@@ -65,12 +68,13 @@ async function refresh() {
}).join("");
} catch (e) { /* 网络抖动时保留上次列表 */ }
}
-document.getElementById("pick").onclick = function () {
- var input = document.getElementById("f");
- input.setAttribute("capture", "environment");
- input.click();
+document.getElementById("pick-camera").onclick = function () {
+ document.getElementById("camera").click();
};
-document.getElementById("f").addEventListener("change", async function (ev) {
+document.getElementById("pick-gallery").onclick = function () {
+ document.getElementById("gallery").click();
+};
+async function uploadFiles(ev) {
var files = Array.prototype.slice.call(ev.target.files || []);
if (!files.length) return;
var done = 0, failed = [];
@@ -90,7 +94,9 @@ document.getElementById("f").addEventListener("change", async function (ev) {
: done + " 个文件已入收件箱";
ev.target.value = "";
refresh();
-});
+}
+document.getElementById("camera").addEventListener("change", uploadFiles);
+document.getElementById("gallery").addEventListener("change", uploadFiles);
refresh();
setInterval(refresh, 5000);
diff --git a/src/upload/server.py b/src/upload/server.py
index af39f09..f7e9579 100644
--- a/src/upload/server.py
+++ b/src/upload/server.py
@@ -13,7 +13,10 @@ from __future__ import annotations
import argparse
import io
+import os
import socket
+import sys
+from datetime import datetime
from pathlib import Path
from typing import Any
@@ -29,10 +32,30 @@ MAX_FILE_BYTES = 25 * 1024 * 1024
MAX_INBOX_FILES = 200
ALLOWED_EXT = {".pdf", ".png", ".jpg", ".jpeg", ".webp", ".bmp"}
DEFAULT_PORT = 5055
+# 进程启动时间:多代进程并存时用来分辨是谁在服务(/api/ping 会带出来)
+_STARTED_AT = datetime.now().isoformat(timespec="seconds")
_store = CaseStore()
+def port_busy(port: int) -> bool:
+ """端口上是否已经有东西在听。
+
+ 重复启动是真踩过的坑:uv 外壳、venv 跳板、真正干活的解释器是三层,杀外层时孙进程会活下来
+ 并继续持有 socket;Windows 又允许第二个进程绑同一端口,于是两个收件器同时在听,手机命中
+ 哪一个全凭运气——改了页面却"没生效"就是这么来的。
+ """
+ probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ probe.settimeout(1.0)
+ try:
+ probe.connect(("127.0.0.1", port))
+ return True
+ except OSError:
+ return False
+ finally:
+ probe.close()
+
+
def lan_ip() -> str:
"""探测默认路由网卡的局域网 IP(二维码里要用,不能写死 127.0.0.1)。"""
probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
@@ -142,6 +165,11 @@ def create_app(pin: str | None = None) -> Flask:
log.info("收到上传 %s -> %s", f.filename, target.name)
return jsonify({"ok": True, "filename": target.name, "bytes": target.stat().st_size})
+ @app.route("/api/ping")
+ def ping() -> Any:
+ """自检:报进程号与启动时间,用来判断"我打到的到底是哪个收件器"。不含任何文件信息。"""
+ return jsonify({"ok": True, "pid": os.getpid(), "started_at": _STARTED_AT, "root": str(_store.root)})
+
@app.route("/api/inbox/
")
def inbox(case_id: str) -> Any:
d, err = resolve_authorized(case_id)
@@ -169,6 +197,15 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument("--pin", default=None, help="PC 首页 PIN;默认取 config.json 的 upload_pin")
args = parser.parse_args(argv)
+ if port_busy(args.port):
+ print("端口 " + str(args.port) + " 上已经有收件器在听,不再重复启动。", file=sys.stderr)
+ print(
+ " 先看它是谁:浏览器开 http://127.0.0.1:" + str(args.port) + "/api/ping 会回 pid 和启动时间",
+ file=sys.stderr,
+ )
+ print(" 要换新的就先停那个进程(netstat -ano | findstr :" + str(args.port) + " 找 PID)。", file=sys.stderr)
+ return 6
+
from ..config import load_config
pin = args.pin or str(load_config().get("upload_pin") or "") or None
diff --git a/tests/test_upload_page.py b/tests/test_upload_page.py
new file mode 100644
index 0000000..4114b40
--- /dev/null
+++ b/tests/test_upload_page.py
@@ -0,0 +1,50 @@
+"""手机上传页的入口形态。曾经 capture 被无条件加在唯一那个 input 上,
+导致手机只能拍照、选不了相册与已有文件(历史 Web 版可以),这几条就是那次回归的锁。"""
+
+from src.upload.page import mobile_page, pc_page
+
+
+def _segment(html: str, anchor: str) -> str:
+ """取某个属性之后的那段标签文本(到最近的尖括号为止)。"""
+ return html.split(anchor, 1)[1].split(">", 1)[0]
+
+
+def test_mobile_page_offers_two_pickers() -> None:
+ html = mobile_page("6月毕业采集", "20261021-case", "tok123")
+ assert html.count('type="file"') == 2
+ assert 'id="pick-camera"' in html and 'id="pick-gallery"' in html
+
+
+def test_capture_only_on_camera_input() -> None:
+ """回归锁:capture 只能出现在拍照那一个 input 上。"""
+ html = mobile_page("名", "20261021-case", "tok")
+ camera = _segment(html, 'id="camera"')
+ gallery = _segment(html, 'id="gallery"')
+ assert 'capture="environment"' in camera
+ assert "capture" not in gallery
+ assert "capture" not in html.split('id="gallery"')[1].split(">", 1)[1]
+
+
+def test_gallery_can_pick_files_and_multiple() -> None:
+ html = mobile_page("名", "20261021-case", "tok")
+ gallery = _segment(html, 'id="gallery"')
+ assert "multiple" in gallery
+ assert ".pdf" in gallery and "image/*" in gallery
+
+
+def test_capture_not_added_by_script() -> None:
+ """曾经是在点击回调里 setAttribute 加上去的,所以连"选文件"也被带进相机。"""
+ html = mobile_page("名", "20261021-case", "tok")
+ assert "setAttribute" not in html
+
+
+def test_both_inputs_share_uploader() -> None:
+ html = mobile_page("名", "20261021-case", "tok")
+ assert html.count('addEventListener("change", uploadFiles)') == 2
+ assert 'fd.append("file", file)' in html # 字段名要和后端 request.files.get("file") 对上
+
+
+def test_pc_page_untouched() -> None:
+ html = pc_page("http://127.0.0.1:5055", [])
+ assert 'type="file"' not in html
+ assert "还没有会话" in html
diff --git a/tests/test_upload_server.py b/tests/test_upload_server.py
new file mode 100644
index 0000000..523ae9e
--- /dev/null
+++ b/tests/test_upload_server.py
@@ -0,0 +1,46 @@
+"""收件器的进程级行为:端口闸门与健康接口、手机页的两个选择入口。"""
+
+import socket
+
+from src.upload.server import create_app, port_busy
+
+
+def test_ping_reports_pid_without_touching_files() -> None:
+ client = create_app(None).test_client()
+ got = client.get("/api/ping")
+ assert got.status_code == 200
+ body = got.get_json()
+ assert body["ok"] is True and isinstance(body["pid"], int)
+ assert body["started_at"] # 多代进程并存时靠它分辨是谁在服务
+ assert "files" not in body and "case_id" not in body # 不泄露任何会话信息
+
+
+def test_port_busy_sees_a_listener() -> None:
+ listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ listener.bind(("127.0.0.1", 0))
+ listener.listen(1)
+ port = listener.getsockname()[1]
+ try:
+ assert port_busy(port) is True
+ finally:
+ listener.close()
+
+
+def test_port_busy_false_after_close() -> None:
+ listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ listener.bind(("127.0.0.1", 0))
+ port = listener.getsockname()[1]
+ listener.close()
+ assert port_busy(port) is False
+
+
+def test_mobile_pages_two_entry_points_in_served_html() -> None:
+ """真实渲染一遍:拍照与选文件是两个 input,capture 只属于拍照那个。"""
+ from src.upload.page import mobile_page
+
+ html = mobile_page("6月毕业采集", "20261021-demo", "tok")
+ camera = html.split('id="camera"', 1)[1].split(">", 1)[0]
+ gallery = html.split('id="gallery"', 1)[1].split(">", 1)[0]
+ assert 'capture="environment"' in camera and "capture" not in gallery
+ assert html.count('type="file"') == 2
+ assert "拍照上传" in html and "从相册或文件选择" in html