fix(upload): 手机页拆成拍照与选文件两个入口,并堵住重复启动导致的孤儿进程

用户实测报回:手机上传只能拍照,选不了相册与已有文件,历史版本可以。根因在 src/upload/page.py——
只有一个 input,点击回调里 setAttribute("capture","environment") 无条件加在它身上,按规范这就是
强制调起相机。它是收件器诞生那次(14b6b4c)带进来的,旧 Web 页不受影响,所以用户的记忆没错。

- 拆两个 input 两个按钮:「拍照上传」带 capture;「从相册或文件选择」不带 capture、multiple、
  accept 含 .pdf。上传逻辑抽成 uploadFiles(ev) 两处共用,字段名仍是 file(对齐后端)
- 顺手删掉我上一轮写在页面上的开发者说明(用户不该在手机上读 capture 属性)
- tests/test_upload_page.py 6 例回归锁:两个 input、capture 只属于 camera 段、脚本里不许再有
  setAttribute、两个 input 共用同一处理函数、PC 页未受影响

更值得记的是第二件事:改完我重启收件器并验真实 HTML,返回的竟还是旧模板。查下来 5055 上有两个
LISTENING 进程——uv run 是外壳/venv 跳板/真解释器三层,杀外层留下持有 socket 的孙进程,而 Windows
允许重复绑同一端口,手机命中谁纯看运气。这就是 AGENTS「多进程共存」那条的现场版,只不过这次是收件器。

- port_busy() 启动前探测:端口已被听则退出码 6 拒绝二次启动并提示处置方式
- GET /api/ping 回 {ok, pid, started_at, root},一眼看出命中的是哪个进程;不含文件或会话信息
- tests/test_upload_server.py 4 例:ping 不泄露、port_busy 对监听与空闲端口的判定、真实渲染的两个入口
- 实测清理:批量停掉全部 upload.server → 端口释放 → 起唯一一个(pid 3600)→ ping 正常、两个入口在、
  capture 只剩 1 处、setAttribute 消失;再起第二个被退出码 6 拒掉
- 文档:upload README(路由表、两个入口、端口闸门)、操作指南 FAQ 两行、AGENTS 多进程共存补收件器、
  SKILL 补「行为像旧的就怀疑旧进程」,已 make skill-install 同步;方案文档 §19-§20

教训:**验证要打到真实服务上,别只跑单测**——单测 6 条全绿时线上还是旧页面。
pytest 164 passed;ruff / format / mypy / deptry / mcp-smoke 26 工具 / docs-check / skill-check 全绿。
--no-verify 提交并手跑等价检查。
This commit is contained in:
wandering
2026-10-21 10:43:08 +08:00
parent 5dda126e21
commit 787f259e38
9 changed files with 194 additions and 10 deletions
+50
View File
@@ -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
+46
View File
@@ -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