From 787f259e38e49d95b971c66522acf70ff11166fe Mon Sep 17 00:00:00 2001 From: wandering Date: Wed, 21 Oct 2026 10:43:08 +0800 Subject: [PATCH] =?UTF-8?q?fix(upload):=20=E6=89=8B=E6=9C=BA=E9=A1=B5?= =?UTF-8?q?=E6=8B=86=E6=88=90=E6=8B=8D=E7=85=A7=E4=B8=8E=E9=80=89=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E4=B8=A4=E4=B8=AA=E5=85=A5=E5=8F=A3=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E5=A0=B5=E4=BD=8F=E9=87=8D=E5=A4=8D=E5=90=AF=E5=8A=A8=E5=AF=BC?= =?UTF-8?q?=E8=87=B4=E7=9A=84=E5=AD=A4=E5=84=BF=E8=BF=9B=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户实测报回:手机上传只能拍照,选不了相册与已有文件,历史版本可以。根因在 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 提交并手跑等价检查。 --- .../docs/plans/MCP化改造方案-2026-10-19.md | 31 ++++++++++++ .agents/skills/reimbursement/SKILL.md | 2 +- AGENTS.md | 3 ++ docs/报销操作指南.md | 2 + src/upload/README.md | 11 +++- src/upload/page.py | 22 +++++--- src/upload/server.py | 37 ++++++++++++++ tests/test_upload_page.py | 50 +++++++++++++++++++ tests/test_upload_server.py | 46 +++++++++++++++++ 9 files changed, 194 insertions(+), 10 deletions(-) create mode 100644 tests/test_upload_page.py create mode 100644 tests/test_upload_server.py diff --git a/.agents/docs/plans/MCP化改造方案-2026-10-19.md b/.agents/docs/plans/MCP化改造方案-2026-10-19.md index 30786c2..471e6b8 100644 --- a/.agents/docs/plans/MCP化改造方案-2026-10-19.md +++ b/.agents/docs/plans/MCP化改造方案-2026-10-19.md @@ -646,3 +646,34 @@ src/infra/browser/README.md、src/mcp/tools/browser.py 的模块说明与 `brows 验证:安装后 `--check` 报「4 个文件一致」;故意指向停用目录被退出码 3 拦住;ruff 全绿; `skill(reimbursement)` 读回的是新版正文(含「确认工具可用」,已无 `docs/MCP.md` 引用)。 尚未验证的一项:在**另一个项目目录**里开新会话看清单是否出现本技能——需要在别处起会话,交给你顺手验。 + +## 19. 手机上传只能拍照(2026-10-21,用户实测报回) + +现象:手机上传页点「选择文件 / 拍照上传」只开相机,选不了相册与已有文件;用户记得历史 Web 版可以。 + +根因:`src/upload/page.py` 只有一个 ``,点击回调里 `input.setAttribute("capture", + "environment")` **无条件**给唯一 input 加上 capture——按规范这等于强制调起相机,选择器不再给相册/文件。 +它是收件器诞生那次(`14b6b4c`)带进来的,旧 Web 页不受影响,所以"历史版本可以"的记忆是对的。 + +修法:拆成两个 input 与两个按钮,「拍照上传」带 `capture="environment"`,「从相册或文件选择」不带、 +带 `multiple` 且 accept 含 `.pdf`;上传逻辑抽成 `uploadFiles(ev)` 由两个 input 共用,字段名仍是 `file` +(与后端 `request.files.get("file")` 对齐)。回归锁在 `tests/test_upload_page.py`:断言两个 input、 +capture 只出现在 camera 段、脚本里不许再出现 `setAttribute`、两个 input 都挂同一个处理函数。 + +## 20. 修完"没生效"的真相:孤儿进程占着端口(2026-10-21) + +改完页面我重启收件器后**验真实 HTML,返回的还是旧模板**。查下来:5055 上同时有两个 LISTENING 进程 +(9:15 的孤儿 + 10:36 的新进程)。原因是 `uv run` 起进程是三层(uv 外壳 → venv 跳板 → 真正解释器), +杀外层时**孙进程活着并继续持有 socket**,而 Windows 允许第二个进程绑同一端口——手机命中谁纯看运气。 +这正是 AGENTS 里"多进程共存"那条约束的现场版,只不过这次中招的是收件器。 + +对策两条,都进了代码: + +- **启动前端口闸门** `port_busy()`:端口已被听就退出码 6 拒绝二次启动,并提示怎么看是谁在服务 +- **健康接口** `GET /api/ping`:回 `{ok, pid, started_at, root}`,浏览器一开就知道命中的是哪个进程; + 不含任何文件或会话信息,不破坏"只写不读" + +清理与验证:批量停掉全部 `upload.server` 进程 → 端口释放 → 起唯一一个(pid 3600)→ 实测 +`/api/ping` 正常、两个入口都在、capture 只剩 1 处、`setAttribute` 消失;再手动起第二个被退出码 6 拒掉。 + +教训一句话:**验证要打到真实服务上,别只跑单测。** 单测 6 条全绿的同时,线上还是旧页面。 diff --git a/.agents/skills/reimbursement/SKILL.md b/.agents/skills/reimbursement/SKILL.md index f5564ed..59dc992 100644 --- a/.agents/skills/reimbursement/SKILL.md +++ b/.agents/skills/reimbursement/SKILL.md @@ -38,7 +38,7 @@ description: > 2. 材料入库:`case_add_files(paths=[...])`——**绝对路径、正斜杠**(`C:/Users/xx/Downloads/发票1.pdf`); 材料可以来自任何目录(下载目录、微信文件目录、当前工程的临时文件夹),工具会拷进会话目录, 原文件不动。手机上传先落 `inbox/`,再 `case_add_files(case_id, source="inbox")` 收取。收件器是**独立进程** - (`make upload`),没启动时用户那条直链必然打不开;多网卡机器还要在服务器侧 `config.json` 配 + (`make upload`),没启动时用户那条直链必然打不开;用户说"能打开但行为像旧的",多半是旧进程还在听 `upload_base_url`(**带端口**,默认 5055),否则探出来的地址手机连不上 3. `case_render_pages`:`text_chars > 0` 就 `case_read_text` 读文本层(比读图准且省 token), 否则读它给的 `pages//p01.png`。**文本层与读图结论冲突时两条都不许采信**(见下面硬纪律) diff --git a/AGENTS.md b/AGENTS.md index 966e5b5..61beffe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,6 +127,9 @@ last_reviewed: 2026-10-20 弹窗不关),所以缺说明要兜底成「类型:文件名」并在提交后确认弹窗已关;附件清单那张表**不是** `#table`, 按表头里的「附件文件名」找 * **多进程共存**:宿主会保留旧代 stdio 子进程,subagent/teammate 也会各起一条 transport—— observed 4 个 PID 并存。 + 上传收件器同样会多代并存(`uv run` 的外壳/跳板/真解释器三层,杀外层会留下持有 socket 的孙进程, + 而 Windows 允许重复绑同一端口):收件器启动前用 `port_busy()` 拒绝二次启动,并用 `GET /api/ping` + 回自己的 pid 与启动时间,浏览器里一开就知道命中的是谁。 因此任何"独占资源"(浏览器、Office)都要有磁盘锁,别假设只有一个服务器实例 * **配置优先级**:环境变量 > `scripts/data/config.json` > `src/config.py` 内置默认;含 `your-` 的占位值 按未配置处理并告警。改完配置要重连常驻 MCP 子进程才生效(进程内有缓存)。`safe_config()` 才是 diff --git a/docs/报销操作指南.md b/docs/报销操作指南.md index e610a07..04aee9e 100644 --- a/docs/报销操作指南.md +++ b/docs/报销操作指南.md @@ -288,6 +288,8 @@ URL 类字段(`sso_login_url`、`portal_url`、`reimburse_url`、`reimburse_pa | 浏览器起不来 | 没装 Chromium | `uv run playwright install chromium` | | 返回 `owned_by_other_process` | 上一代 MCP 子进程还占着浏览器 | 让持有者 `browser_close`;心跳超 180 秒自动接管 | | 登录一直不跳转 | SSO 要扫码或有验证码 | 看返回的 `need_human` 与截图,人工完成后重调 `browser_login` | +| 手机只能拍照、选不了相册和已有文件 | `capture` 属性挂错位置(已修)。若仍复现,先开 `http://127.0.0.1:5055/api/ping` 看命中的是不是旧进程 | +| 改了收件器页面却"没生效" | 多半是旧的孤儿进程还在听 5055。新代码重复启动会被拒(退出码 6);`netstat -ano` 找 PID 停掉再起 | | 某一步 fill 失败 | 选择器没命中或页面慢 | 看返回 `detail.screenshot` 的截图,人工看一眼再重填该步 | --- diff --git a/src/upload/README.md b/src/upload/README.md index d393c18..07a8296 100644 --- a/src/upload/README.md +++ b/src/upload/README.md @@ -14,7 +14,7 @@ uv run python -m src.upload.server --port 6000 --pin 1234 | 文件 | 职责 | |------|------| -| `server.py` | Flask 应用与路由、上限与白名单、`lan_ip()`、命令行参数;横幅地址优先取 `upload_base_url` | +| `server.py` | Flask 应用与路由、上限与白名单、`lan_ip()`、`port_busy()`、命令行参数;横幅地址优先取 `upload_base_url` | | `page.py` | 页面模板(内联 CSS,零外部 CDN,`__PLACEHOLDER__` 占位替换) | ## 路由 @@ -26,6 +26,7 @@ uv run python -m src.upload.server --port 6000 --pin 1234 | `GET /u/` | 手机上传页(令牌在 URL 里) | | `POST /u/` | 接收文件,落 `cases//inbox/` | | `GET /api/inbox/` | 该会话待收数量(只给数量,不给内容) | +| `GET /api/ping` | 自检:回 pid 与启动时间。多代进程并存时用它在浏览器里直接看"我打到的是哪个收件器" | ## 安全边界 @@ -41,4 +42,12 @@ uv run python -m src.upload.server --port 6000 --pin 1234 地址解析在 `src/cases.py` 的 `upload_page_url()`:配了 `upload_base_url` 就用它(**要带端口**,收件器默认听 5055),否则探测默认路由网卡——多网卡机器上探测结果常是手机打不开的虚拟地址。 +**启动前会探测端口**(`port_busy`):已被占用就退出码 6 拒绝再起。踩过的坑是`uv run` 起的外壳、venv 跳板、 +真正干活的解释器是三层,杀外层时孙进程活着并继续持有 socket,而 Windows 允许第二个进程绑同一端口—— +于是两个收件器同时在听,手机命中谁全凭运气,改完页面看起来"没生效"。 + +手机页有**两个入口**:拍照(``)与从相册/文件选择(无 capture)。 +capture 只能挂在拍照那一个上:挂在唯一的 input 上会让手机只能拍照、选不了已存文件(2026-10-21 修, +回归锁在 `tests/test_upload_page.py`)。 + `cases//inbox/` 里的文件由宿主调 `case_add_files(source='inbox')` 收取进会话,收件器本身不碰任何业务逻辑。 diff --git a/src/upload/page.py b/src/upload/page.py index 432ca81..727fc69 100644 --- a/src/upload/page.py +++ b/src/upload/page.py @@ -18,6 +18,7 @@ body{margin:0;font-family:system-ui,-apple-system,"Microsoft YaHei",sans-serif;b .wrap{max-width:540px;margin:0 auto;padding:16px} .card{background:#fff;border-radius:10px;padding:14px;margin:0 0 12px;box-shadow:0 1px 3px rgba(0,0,0,.08)} .btn{display:block;width:100%;padding:14px;font-size:16px;border:0;border-radius:10px;background:#4f46e5;color:#fff} +.btn2{margin-top:10px;background:#f3e7c9} .btn.sec{background:#eef2ff;color:#3730a3;margin-top:8px} ul{list-style:none;margin:8px 0 0;padding:0} li{display:flex;justify-content:space-between;gap:8px;padding:7px 0;border-bottom:1px solid #f1f5f9;font-size:13px} @@ -41,8 +42,10 @@ MOBILE_BODY = """

__CASE_NAME__

拍照或从相册选择发票、支付截图

- - + + + +
收件箱
  • 加载中…
@@ -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