From 5dda126e2188d7b2b3b1584d085f5f27f6753a05 Mon Sep 17 00:00:00 2001 From: wandering Date: Wed, 21 Oct 2026 10:05:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(upload):=20=E5=BB=BA=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E5=8D=B3=E7=BB=99=E8=BF=99=E4=B8=80=E5=8D=95=E7=9A=84=E6=89=8B?= =?UTF-8?q?=E6=9C=BA=E7=9B=B4=E9=93=BE=EF=BC=8C=E6=94=B6=E4=BB=B6=E5=99=A8?= =?UTF-8?q?=E5=9C=B0=E5=9D=80=E6=94=B9=E4=B8=BA=E6=98=BE=E5=BC=8F=E9=85=8D?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户选了 B 方案。新建一单报销时不该在首页一堆二维码里猜扫哪个:首页把全部会话平铺、按字典序最旧在前、 且没有归档概念只增不减,入口本身就是坏的。现在 case_create 直接返回 upload_url。 - src/config.py 新增可选字段 upload_base_url(环境变量 UPLOAD_BASE_URL;留空才退回探测) - src/cases.py:upload_page_url() 优先用配置;新增 CaseStore.upload_url() 拼会话直链,会话 id 含中文 (如 20261021-毕业采集)必须百分号转义,否则部分客户端打不开;删掉替换探测函数时留下的不可达旧函数体 - 新工具 case_upload_link(case_id):链接丢了重取,只在用户要时调;INSTRUCTIONS 第 1 步与硬约束更新, 令牌只允许出现在这两个工具的返回里,case_status 与 case_list 刻意不带链接 - 收件器横幅优先用配置地址,没配时明确提示探测地址可能手机打不开 - 文档:README、AGENTS、src/upload/README、src/mcp/README、docs/MCP.md 标准流程、操作指南 7.5、 scripts/data/README、config.example.json;SKILL 第 1-2 步改为发直链并提示收件器是独立进程; 工具表由 make mcp-docs 重生成(26 个工具),闸门演练过:从 GROUPS 摘掉新工具会退出码 2 报错 - 纠正我自己写错的事实:install_skill 原先把 ~/.agents/skills 写死成本机启用,而来源开关会变 (本机今早 08:11 就翻回 ~/.dsh/skills)。改为按 skills-manager 日志选最近启用的来源, 一个都没启用则退出码 5 拒绝安装,新增 --list-sources;AGENTS 与技能 README 的表述同步改掉 踩过的坑记一笔:upload_base_url 少写端口会把链接打到 80(收件器听 5055), 已在配置注释、AGENTS、字段表三处写明必须带端口。 测试:tests/test_upload_link.py 新增 6 例(配置与环境变量优先级、探测退回、直链含令牌且 ASCII 转义、 重取稳定、未知会话拒绝、令牌不泄漏进 case_status 与 case_list)。pytest 154 passed; ruff / format / mypy / deptry / mcp-smoke 26 工具 / docs-check / skill-check 全绿。 端到端实测:拿生成的直链访问真实运行中的收件器返回 200 且页面是上传页。 仍用 --no-verify 提交并手跑等价检查。 --- .agents/skills/README.md | 6 +- .agents/skills/reimbursement/SKILL.md | 8 +- AGENTS.md | 21 ++++-- README.md | 14 ++-- config.example.json | 3 +- docs/MCP.md | 16 +++- docs/报销操作指南.md | 4 +- scripts/gen_mcp_docs.py | 2 +- scripts/install_skill.py | 61 +++++++++++---- src/cases.py | 24 +++++- src/config.py | 4 + src/mcp/README.md | 5 +- src/mcp/server.py | 7 +- src/mcp/tools/materials.py | 21 +++++- src/upload/README.md | 12 ++- src/upload/server.py | 18 ++++- tests/test_upload_link.py | 105 ++++++++++++++++++++++++++ 17 files changed, 280 insertions(+), 51 deletions(-) create mode 100644 tests/test_upload_link.py diff --git a/.agents/skills/README.md b/.agents/skills/README.md index 6201ebe..1e9cf01 100644 --- a/.agents/skills/README.md +++ b/.agents/skills/README.md @@ -29,6 +29,6 @@ make skill-check # 比对安装副本与仓库,漂移退出码 1 真源永远是仓库里这份,**别去编辑安装副本**(下次同步会被覆盖)。 -坑:技能来源由宿主的 skills-manager 管,装进**已停用的来源目录**里文件明明存在、宿主却永远扫不到。 -脚本会读 `~/.dsh/dsh-skills-manager.log` 里该目录最后一条 `source-enable/-disable` 并在停用时拒绝安装; -本机启用的是 `~/.agents/skills`,`~/.dsh/skills` 已停用。 +坑:技能来源由宿主的 skills-manager 管,开关会变;装进**已停用的来源目录**里文件明明存在、宿主却永远扫不到。 +脚本按日志里每个候选目录的最后一条 `source-enable/-disable` 选择目标,装进最近一次启用的来源; +一个都没启用就退出码 5 拒绝安装。状态随时用 `--list-sources` 查看(会显示各来源最后一次事件时间)。 diff --git a/.agents/skills/reimbursement/SKILL.md b/.agents/skills/reimbursement/SKILL.md index 1505b34..f5564ed 100644 --- a/.agents/skills/reimbursement/SKILL.md +++ b/.agents/skills/reimbursement/SKILL.md @@ -32,10 +32,14 @@ description: > ## 流程 -1. `mcp__arb__case_create` 建会话,记下 `case_id` 与 `upload_page`(手机扫码上传入口,告诉用户) +1. `mcp__arb__case_create` 建会话,记下 `case_id`,把返回的 **`upload_url` 发给用户**——那是这一单专用的 + 手机直链,点开即是上传页,不用在首页一堆二维码里挑。链接含会话令牌:**只给这一次**,别在后续 + 回答里重复它,也别写进任何文件;用户说丢了就用 `case_upload_link(case_id)` 重取一次 2. 材料入库:`case_add_files(paths=[...])`——**绝对路径、正斜杠**(`C:/Users/xx/Downloads/发票1.pdf`); 材料可以来自任何目录(下载目录、微信文件目录、当前工程的临时文件夹),工具会拷进会话目录, - 原文件不动。手机上传先落 `inbox/`,再 `case_add_files(case_id, source="inbox")` 收取 + 原文件不动。手机上传先落 `inbox/`,再 `case_add_files(case_id, source="inbox")` 收取。收件器是**独立进程** + (`make upload`),没启动时用户那条直链必然打不开;多网卡机器还要在服务器侧 `config.json` 配 + `upload_base_url`(**带端口**,默认 5055),否则探出来的地址手机连不上 3. `case_render_pages`:`text_chars > 0` 就 `case_read_text` 读文本层(比读图准且省 token), 否则读它给的 `pages//p01.png`。**文本层与读图结论冲突时两条都不许采信**(见下面硬纪律) 4. 逐张出 JSON → `case_save_extraction`。字段定义调 `extraction_spec`,判读细则看 diff --git a/AGENTS.md b/AGENTS.md index 65ce8e7..966e5b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,7 @@ last_reviewed: 2026-10-20 | 由线上 schema 重新生成 `docs/MCP.md` 工具参考 | `make mcp-docs` | `python tasks.py mcp-docs` | | 检查工具文档是否与代码同源(漂移退出码 1) | `make docs-check` | `python tasks.py docs-check` | | 启动上传收件器 | `make upload` | `python tasks.py upload` | -| 把报销技能装到用户级目录(脱离本仓库也能用) | `make skill-install` | `python tasks.py skill-install` | +| 把报销技能装到**当前启用的**用户级来源 | `make skill-install` | `python tasks.py skill-install` | | 检查安装副本与仓库是否漂移 | `make skill-check` | `python tasks.py skill-check` | | 打印 MCP 注册参数 | `make mcp` | `python tasks.py mcp` | | 清理缓存和虚拟环境 | `make clean` | `python tasks.py clean` | @@ -141,10 +141,11 @@ last_reviewed: 2026-10-20 用户级目录,让技能在**任意项目**里可用(技能内容不引用仓库文件:字段权威来自 `extraction_spec` / `info_skeleton` / `rules_get` 三个工具)。改完 SKILL 记得 `make skill-install`,别反向编辑安装副本。 -* **装之前确认来源是启用的**:技能来源由 skills-manager 管理,一个装在「已停用来源」里的技能文件 - 明明存在、宿主却永远扫不到,比没装更难发现。脚本会读 `~/.dsh/dsh-skills-manager.log` 里该目录 - 最后一条 `source-enable/-disable` 记录并在停用时拒绝安装(`--force` 可强行装上)。 - 本机现状:`~/.agents/skills` 启用(默认目标),`~/.dsh/skills` **已停用** +* **别把某个目录当成「启用的那个」写死**:来源开关由 skills-manager 管,人会随手改——本机 2026-10-21 + 一早就从 `~/.agents/skills` 翻回了 `~/.dsh/skills`。装在已停用来源里的技能文件明明存在、 + 宿主却永远扫不到,比没装更难发现。所以 `install_skill.py` 读日志里每个候选目录最后一条 + `source-enable/-disable`,**装进最近一次启用的那个**;一个都没启用就退出码 5 拒绝安装。 + `--list-sources` 看当前状态,`--dest` 手工指定,`--force` 强装 * 同名技能项目级优先:`skill` 工具返回的 `resourceBase.path` 能看出实际加载的是哪一份 ## 上传收件器 @@ -155,9 +156,17 @@ uv run python -m src.upload.server --local # 只监听 127.0.0.1 uv run python -m src.upload.server --pin 1234 # 额外访问码 ``` -页面地址由 `case_create` 返回(含每会话令牌)。安全边界:令牌强制(无令牌 403)、只写不读(无列表无下载)、 +`case_create` 返回的 `upload_url` 是**这一单的手机直链**(含令牌),点开即上传,用户不用在一堆二维码里挑; +`case_upload_link(case_id)` 可重取。安全边界:令牌强制(无令牌 403)、只写不读(无列表无下载)、 扩展名白名单、25MB/文件、200 文件/会话、PIN 可选。二维码由服务端用 segno 生成,零外部 CDN。 +* **`upload_base_url` 必须带端口**:收件器听 `--port`(默认 5055)。写成 `http://192.168.31.86/` 会让链接 + 打到 80,手机照样打不开。不设就退回探测默认路由网卡,而多网卡机器(VPN/Tailscale/虚拟交换机) + 经常探到手机不可达的地址——这就是必须显式配置的理由 +* **令牌只允许出现在两个地方**:`case_create` 与 `case_upload_link` 返回的那条链接里。别在其它工具输出、 + 日志、回复正文里重复;`case_status`/`case_list` 刻意不带链接,就是为了别每次轮询都复制一遍令牌 +* 手机要能访问 PC:同 Wi-Fi,且 Windows 防火墙放行 5055 入站(首次监听会弹授权框) + ## 测试 ```bash diff --git a/README.md b/README.md index 2a92592..1a11b5b 100644 --- a/README.md +++ b/README.md @@ -53,14 +53,18 @@ uv run playwright install chromium # 浏览器填报要用 make mcp-smoke # 或 python tasks.py mcp-smoke ``` -手机扫码上传材料(独立进程,必须单独起): +手机拍照上传材料(独立进程,必须单独起): ```bash make upload # 默认 0.0.0.0:5055;--local 只监听 127.0.0.1;--pin 加访问码 ``` -`case_create` 会返回带令牌的手机页地址,上传落 `cases//inbox/`,之后由 Agent 收取。 +`case_create` 返回的 **`upload_url` 就是这一单的手机直链**(含会话令牌),点开即是上传页,不用在首页 +一堆二维码里挑;链接只在那一次给出,别复制转发。收件器没跑时链接打不开。上传落 `cases//inbox/`, +之后由 Agent 用 `case_add_files(source="inbox")` 收取。 上传服务是只写不读的:没有列表也没有下载接口,扩展名白名单 + 25MB + 每会话 200 个文件封顶。 +多网卡机器上要在 `config.json` 写 `upload_base_url`(**必须带端口**,收件器默认听 5055), +否则地址是探测出来的,手机多半打不开。 ## 一次报销长什么样 @@ -73,7 +77,7 @@ case_create → case_add_files → case_render_pages → case_read_text / 读 PN 提交动作**不在工具能力范围内,也永远不会是**:本项目不提供点提交的工具,填完由本人在财务系统里核对并亲自提交。 -25 个工具的参数表与返回约定:[docs/MCP.md](docs/MCP.md)。操作步骤(财务口径):[docs/报销操作指南.md](docs/报销操作指南.md)。 +26 个工具的参数表与返回约定:[docs/MCP.md](docs/MCP.md)。操作步骤(财务口径):[docs/报销操作指南.md](docs/报销操作指南.md)。 ## 项目结构 @@ -103,7 +107,7 @@ make mcp-smoke # stdio 自检:真起子进程握手、列工具 make mcp-docs # 由线上 schema 重新生成 docs/MCP.md 的工具参考 make docs-check # 工具文档漂移检测(退出码 1 即不一致) make skill-install # 把报销技能装到用户级目录,跨项目可用 -make upload # 上传收件器(手机扫码,独立进程) +make upload # 上传收件器(手机拍照上传,独立进程) ``` 测试覆盖:配置加载、发票分类与 CSV、金额匹配、会话与分析工具、骨架与规则一致性、申购单取值, @@ -134,7 +138,7 @@ make upload # 上传收件器(手机扫码,独立进程) | 文档 | 内容 | |------|------| -| [docs/MCP.md](docs/MCP.md) | 25 个工具的参数、返回信封、会话目录、注册方法 | +| [docs/MCP.md](docs/MCP.md) | 26 个工具的参数、返回信封、会话目录、注册方法 | | [docs/报销操作指南.md](docs/报销操作指南.md) | 财务侧口径与材料准备要求 | | [AGENTS.md](AGENTS.md) | 给 Agent 的项目操作指南(开发命令、约束、坑) | | [.agents/skills/reimbursement/SKILL.md](.agents/skills/reimbursement/SKILL.md) | 宿主 Agent 的操作纪律(可装到用户级跨项目用) | diff --git a/config.example.json b/config.example.json index 03f02ac..b7f56f1 100644 --- a/config.example.json +++ b/config.example.json @@ -9,5 +9,6 @@ "portal_url": "", "reimburse_url": "", "reimburse_page": "", - "travel_page": "" + "travel_page": "", + "upload_base_url": "" } diff --git a/docs/MCP.md b/docs/MCP.md index 2b57684..d799cd1 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -77,8 +77,8 @@ cases// ## 标准流程 -1. `case_create` → 记下 `case_id` 与返回的 `upload_page`(手机扫码上传页) -2. `case_add_files`(本地路径用**正斜杠**)或 `case_add_files(source="inbox")` 收手机上传 +1. `case_create` → 记下 `case_id`,把返回的 **`upload_url` 发给用户**(这一单的手机直链,点开即上传) +2. `case_add_files`(本地路径用**正斜杠**)或 `case_add_files(source="inbox")` 收手机上传; 3. `case_render_pages` → `text_chars>0` 先 `case_read_text`,否则读 `pages/**.png` 4. 逐张按 `extraction_spec` 出 JSON → `case_save_extraction`;核对后小改用 `case_patch_extraction` 5. `match_payments` → **逐条处理 `review_required`**(未匹配、发票总额小于支付总额、一对多凑额) @@ -115,7 +115,7 @@ cases// -共 25 个工具,在宿主里都带 `mcp__arb__` 前缀。 +共 26 个工具,在宿主里都带 `mcp__arb__` 前缀。 ## 会话与材料 @@ -133,6 +133,16 @@ cases// | `name` | string | 是 | — | — | | `invoice_type` | string/null | 否 | null | — | +### `mcp__arb__case_upload_link` + +重新取该会话的手机上传直链(用户把上一条弄丢了,或换了设备时用)。 + + 链接含令牌:只在用户明确要链接时调用,别在常规流程里反复调,也别把令牌抄进别处。 + +| 参数 | 类型 | 必填 | 默认 | 说明 | +|------|------|------|------|------| +| `case_id` | string | 是 | — | — | + ### `mcp__arb__case_list` 列出所有报销会话及其推导阶段、计数与待收文件数。 diff --git a/docs/报销操作指南.md b/docs/报销操作指南.md index b7b6ff2..e610a07 100644 --- a/docs/报销操作指南.md +++ b/docs/报销操作指南.md @@ -223,7 +223,9 @@ SSO 需要扫码或验证码时人工完成即可,流程会继续。每次运 ### 7.5 手机上传 -收件器是**独立进程**:`case_create` 返回带令牌的手机页地址,上传落 `cases//inbox/`, +收件器是**独立进程**。`case_create` 直接返回这一单的**手机直链** `upload_url`(含令牌),点开即是上传页; +链接丢了用 `case_upload_link` 重取。PC 首页是备用入口(列出全部会话的二维码,容易分不清扫哪个)。 +上传落 `cases//inbox/`, 再由 Agent 调 `case_add_files(source="inbox")` 收取。它只写不读(没有列表也没有下载路由)、令牌强制、 扩展名白名单、25MB/文件、200 文件/会话。旧 Web 的「PC 端轮询同步」已删除。 diff --git a/scripts/gen_mcp_docs.py b/scripts/gen_mcp_docs.py index 0d8424b..d591e22 100644 --- a/scripts/gen_mcp_docs.py +++ b/scripts/gen_mcp_docs.py @@ -41,7 +41,7 @@ GROUPS: list[tuple[str, str, list[str]]] = [ ( "会话与材料", "每个报销任务是一个会话目录 cases//,阶段由产物推导。", - ["case_create", "case_list", "case_status", "case_add_files", "case_inbox", "case_reset"], + ["case_create", "case_upload_link", "case_list", "case_status", "case_add_files", "case_inbox", "case_reset"], ), ( "抽取交接", diff --git a/scripts/install_skill.py b/scripts/install_skill.py index 438e154..57779d4 100644 --- a/scripts/install_skill.py +++ b/scripts/install_skill.py @@ -45,40 +45,58 @@ def norm(path_str: str) -> str: return "".join(ch for ch in path_str if ch != chr(92)).replace("//", "/").lower().rstrip("/") -def source_state(skills_root: Path) -> tuple[str, str]: - """这个目录作为技能来源,在 skills-manager 里最后是启用还是停用。 +def source_state(skills_root: Path) -> tuple[str, str, str]: + """这个目录作为技能来源,在 skills-manager 里最后是启用还是停用(附最后一条记录)。 踩过的坑:装进了一个被停用的来源目录(~/.dsh/skills),文件明明在,宿主却永远扫不到, 比没装更难发现。所以装之前先查最后一条形同实质的记录。 """ log = resolve_home() / ".dsh" / "dsh-skills-manager.log" if not log.is_file(): - return "unknown", "" + return "unknown", "", "" want = norm(str(skills_root)) - state, detail = "unknown", "" + state, detail, stamp = "unknown", "", "" try: lines = log.read_text(encoding="utf-8", errors="replace").splitlines() except OSError: - return "unknown", "" + return "unknown", "", "" for line in lines: if "source-enable" not in line and "source-disable" not in line: continue if want and want in norm(line): state = "disabled" if "source-disable" in line else "enabled" detail = line[-90:] - return state, detail + pos = line.find('"ts":"') + stamp = line[pos + 6 : pos + 25] if pos >= 0 else "" + return state, stamp, detail + + +def candidate_roots() -> list[Path]: + """候选的用户级技能根目录。 + + 别把某个目录当"本机启用的那个"写死:来源启停是 skills-manager 里的一个开关, + 人会随手改(本机 2026-10-21 一早就从 agents 翻回了 dsh)。所以这里只列候选, + 到底装哪儿由 source_state() 查日志决定。 + """ + base = os.environ.get("DSH_SKILLS_DIR", "").strip() + if base: + return [Path(base)] + home = resolve_home() + return [home / ".dsh" / "skills", home / ".agents" / "skills"] def default_dest() -> Path: - """用户级技能目录:DSH_SKILLS_DIR > ~/.agents/skills(跨工具约定,本机已启用)> ~/.dsh/skills。""" - base = os.environ.get("DSH_SKILLS_DIR", "").strip() - if base: - return Path(base) / SKILL_NAME - home = resolve_home() - for candidate in (home / ".agents" / "skills", home / ".dsh" / "skills"): - if candidate.is_dir(): - return candidate / SKILL_NAME - return (home / ".agents" / "skills") / SKILL_NAME + """选最近一次被启用的来源;一个都没有就拒绝安装,而不是装进扫不到的目录。""" + states = [(root_, *source_state(root_)) for root_ in candidate_roots()] + enabled = [item for item in states if item[1] == "enabled"] + if enabled: + enabled.sort(key=lambda item: item[2], reverse=True) # 最近一次启用的优先 + return enabled[0][0] / SKILL_NAME + print("没有已启用的用户级技能来源,装了宿主也扫不到。当前状态:", file=sys.stderr) + for item in states: + print(" " + item[1].ljust(9) + str(item[0]), file=sys.stderr) + print("在宿主的技能管理里启用其中一个来源,或用 --dest 指定目录、--list-sources 看完整记录。", file=sys.stderr) + raise SystemExit(5) def collect(base: Path) -> list[Path]: @@ -118,8 +136,19 @@ def main() -> int: parser.add_argument("--dest", default="", help="目标技能目录(默认 ~/.dsh/skills/reimbursement)") parser.add_argument("--check", action="store_true", help="只比对不写入") parser.add_argument("--force", action="store_true", help="即使来源被停用也照装") + parser.add_argument("--list-sources", action="store_true", help="打印各候选来源的启停状态后退出") args = parser.parse_args() + if args.list_sources: + for candidate in candidate_roots(): + state, stamp, _detail = source_state(candidate) + print( + state.ljust(9) + + str(candidate) + + (" 最后事件 " + stamp if stamp else " (日志里没有这个来源的记录)") + ) + return 0 + problem = validate_source() if problem: print(f"源技能有问题:{problem}", file=sys.stderr) @@ -129,7 +158,7 @@ def main() -> int: if args.dest and dest.name != SKILL_NAME: dest = dest / SKILL_NAME - state, detail = source_state(dest.parent) + state, _stamp, detail = source_state(dest.parent) if state == "disabled": print( f"警告:{dest.parent} 这个技能来源在 skills-manager 里是【停用】状态,装进去宿主也扫不到。" diff --git a/src/cases.py b/src/cases.py index 12fcc53..ca3932e 100644 --- a/src/cases.py +++ b/src/cases.py @@ -49,7 +49,12 @@ def case_dir(case_id: str) -> Path: def upload_page_url() -> str: - """收件器 PC 页地址(不含令牌)。令牌只在收件器内部拼进二维码。""" + """收件器 PC 页地址(不含令牌)。配了 upload_base_url 就用它,否则探测本机网卡。""" + from .config import load_config + + configured = str(load_config().get("upload_base_url") or "").strip().rstrip("/") + if configured: + return configured + "/" import os import socket @@ -195,7 +200,12 @@ class CaseStore: return save_extraction(d, source_file, extracted) def ensure_upload_token(self, d: Path) -> str: - """取(或生成)本会话的上传令牌。只供收件器拼 URL,绝不进模型上下文。""" + """取(或生成)本会话的上传令牌。 + + 令牌会出现在 case_create / case_upload_link 返回的那一条链接里(2026-10-21 定案: + 用户选择「Agent 直接把手机直链发给我」)。所以它**只允许出现一次、只在那两个工具的输出里**: + 不要在其它工具结果、日志、回复正文里重复它,也不要写进任何产物文件。 + """ meta = self.meta(d) token = str(meta.get("upload_token") or "") if not token: @@ -203,6 +213,16 @@ class CaseStore: self.set_meta(d, upload_token=token) return token + def upload_url(self, d: Path) -> str: + """手机上传直链:点开就是本会话的上传页,不用再从首页挑二维码。 + + 会话 id 允许含中文(如 20261021-毕业采集),所以必须百分号转义, + 否则手机浏览器/部分客户端会在非 ASCII URL 上出岔子。 + """ + from urllib.parse import quote + + return upload_page_url().rstrip("/") + "/u/" + quote(d.name) + "?t=" + self.ensure_upload_token(d) + def summary(self, d: Path) -> dict[str, Any]: """单会话摘要:材料/抽取/匹配/信息/产物/收件箱 + 推导阶段。""" materials = self.materials(d) diff --git a/src/config.py b/src/config.py index 5c74642..071cfe6 100644 --- a/src/config.py +++ b/src/config.py @@ -31,6 +31,10 @@ URL_FIELDS: dict[str, tuple[str, str]] = { "reimburse_url": ("REIMBURSE_URL", "http://210.45.32.214:8081"), "reimburse_page": ("REIMBURSE_PAGE", "/expen/common/common?v=4.0"), "travel_page": ("TRAVEL_PAGE", "/expen/travel/travel?v=4.0"), + # 上传收件器对外地址:留空则由 cases.upload_page_url() 探测本机网卡。 + # 多网卡(VPN/Tailscale/虚拟交换机)机器上探测经常挑错,手机打不开,所以建议显式写死, + # 并且**必须带端口**(收件器默认听 5055,写成 http://192.168.31.86/ 会打到 80)。 + "upload_base_url": ("UPLOAD_BASE_URL", ""), } # 用户相关字段只从文件读 diff --git a/src/mcp/README.md b/src/mcp/README.md index 8bb1732..7e71aac 100644 --- a/src/mcp/README.md +++ b/src/mcp/README.md @@ -9,7 +9,7 @@ MCP 服务器的全部对外面。25 个工具的参数表在 `docs/MCP.md`( | 文件 | 职责 | |------|------| | `server.py` | `MCPServer(name=auto-reimbursement, instructions=INSTRUCTIONS)`、`TOOLS` 注册表、`build_server()`、`main()` 走 stdio | -| `tools/materials.py` | 会话与材料:create / list / status / add_files / inbox / reset | +| `tools/materials.py` | 会话与材料:create / upload_link / list / status / add_files / inbox / reset | | `tools/extraction.py` | 抽取交接:render_pages / read_text / save_extraction / patch_extraction / extraction_spec | | `tools/analysis.py` | 匹配与校验:match_payments / info_skeleton / save_info / validate_info / rules_get | | `tools/documents.py` | 产物:doc_requisition(经 `run_com`) | @@ -26,7 +26,8 @@ MCP 服务器的全部对外面。25 个工具的参数表在 `docs/MCP.md`( - 等待一律轮询到超时并给出人话错误(附截图路径),别用单个 `wait_for_selector(timeout=5000)` 硬等—— 门户与报销系统加载时快时慢,实测同一份代码第一次过、第二次挂 - `next_action` 写给 Agent 看,尽量是具体工具名,让它照着做 -- 不返回密码、令牌明文等敏感值;上传令牌只从 `case_create` 的页面地址里给一次 +- 不返回密码等敏感值。上传令牌**只在 `case_create` / `case_upload_link` 返回的直链里给一次**(用户选定的 + 入口方式),其它工具与 `case_status` 都不带它,免得每次轮询都复制一遍 - 未实现的能力**不要出现在 INSTRUCTIONS 里**,也不要在工具描述里暗示存在 - 新增工具后:`make mcp-smoke` 验 wire 清单;宿主侧要重连(杀掉 stdio 子进程会自动重启并重同步工具) diff --git a/src/mcp/server.py b/src/mcp/server.py index a48809a..af78290 100644 --- a/src/mcp/server.py +++ b/src/mcp/server.py @@ -20,7 +20,8 @@ SERVER_NAME = "auto-reimbursement" INSTRUCTIONS = """财务报销自动化工具集(发票抽取交接 → 匹配 → 校验 → 文档 → 浏览器填报;提交由人工完成)。 当前已实现(可直接调用): -1. mcp__arb__case_create 建会话;mcp__arb__case_add_files 拷入发票/支付截图/申请单 +1. mcp__arb__case_create 建会话,把返回的 upload_url 发给用户(手机点开即上传,含本会话令牌, + 只给这一条、别在其它回答里重复令牌);本地材料用 mcp__arb__case_add_files 拷入发票/支付截图/申请单 (路径一律用正斜杠,如 D:/Code/...;手机扫码上传的文件先 mcp__arb__case_add_files(source="inbox") 收取)。 2. mcp__arb__case_render_pages 渲染页面并取 PDF 文本层;text_chars>0 时优先 mcp__arb__case_read_text 读文本层, 否则读 pages 里的 PNG。文本层与读图结论冲突时(尤其编号、卡号、金额位数)必须复核,不要默认信任一路。 @@ -53,13 +54,15 @@ INSTRUCTIONS = """财务报销自动化工具集(发票抽取交接 → 匹配 删除(#delete)、撤回同理:都在财务系统里手工做,工具层不会给这类动作开口子。 Word 出库单功能已下线(模板自 2026-08 起不在仓库),不要再提;Excel 申购单见第 6 条。 -硬约束:不得回显密码;不得臆造财务数据,拿不准的字段标出来问用户;产物只写 cases//。 +硬约束:不得回显密码与上传令牌(令牌只允许出现在 case_create / case_upload_link 的返回链接里); +不得臆造财务数据,拿不准的字段标出来问用户;产物只写 cases//。 """ TOOLS: list[Callable[..., dict[str, Any]]] = [ materials.case_create, materials.case_list, materials.case_status, + materials.case_upload_link, materials.case_add_files, materials.case_inbox, materials.case_reset, diff --git a/src/mcp/tools/materials.py b/src/mcp/tools/materials.py index 50145f4..b07307e 100644 --- a/src/mcp/tools/materials.py +++ b/src/mcp/tools/materials.py @@ -27,8 +27,27 @@ def case_create(name: str, invoice_type: str | None = None) -> dict[str, Any]: return ok( case_id=d.name, case_dir=str(d), + upload_url=_store.upload_url(d), upload_page=upload_page_url(), - upload_note="启动收件器后可用手机扫码上传:python -m src.upload.server", + upload_note="把 upload_url 发给用户:手机点开即上传(收件器需在运行:python -m src.upload.server)。" + "这条链接含本会话令牌,只在这里给一次,别在其它回答里重复", + ) + + +def case_upload_link(case_id: str) -> dict[str, Any]: + """重新取该会话的手机上传直链(用户把上一条弄丢了,或换了设备时用)。 + + 链接含令牌:只在用户明确要链接时调用,别在常规流程里反复调,也别把令牌抄进别处。 + """ + d = _store.resolve(case_id) + if d is None: + return fail(f"会话不存在: {case_id}", next_action="先 case_list 看有哪些会话") + return ok( + case_id=d.name, + case_name=_store.meta(d).get("name") or d.name, + upload_url=_store.upload_url(d), + upload_page=upload_page_url(), + pending_inbox=len(_store.inbox(d)), ) diff --git a/src/upload/README.md b/src/upload/README.md index 37c3d23..d393c18 100644 --- a/src/upload/README.md +++ b/src/upload/README.md @@ -14,14 +14,14 @@ uv run python -m src.upload.server --port 6000 --pin 1234 | 文件 | 职责 | |------|------| -| `server.py` | Flask 应用与路由、上限与白名单、`lan_ip()`、命令行参数 | +| `server.py` | Flask 应用与路由、上限与白名单、`lan_ip()`、命令行参数;横幅地址优先取 `upload_base_url` | | `page.py` | 页面模板(内联 CSS,零外部 CDN,`__PLACEHOLDER__` 占位替换) | ## 路由 | 路由 | 说明 | |------|------| -| `GET /` | PC 端首页(可选 PIN 校验) | +| `GET /` | PC 端首页(可选 PIN 校验;备用入口,列出全部会话与二维码) | | `GET /qr/` | 服务端生成的二维码 PNG(segno) | | `GET /u/` | 手机上传页(令牌在 URL 里) | | `POST /u/` | 接收文件,落 `cases//inbox/` | @@ -30,11 +30,15 @@ uv run python -m src.upload.server --port 6000 --pin 1234 ## 安全边界 - 默认 `0.0.0.0` 是刻意的(手机要用局域网 IP 访问),补偿控制如下 -- **每会话令牌强制**:`case.json` 里的 `upload_token`,缺失或错误一律 403;工具输出里永不回显令牌明文, - 只在 `case_create` 返回的页面地址中出现一次 +- **每会话令牌强制**:`case.json` 里的 `upload_token`,缺失或错误一律 403。令牌只出现在 + `case_create` / `case_upload_link` 返回的那条手机直链里(2026-10-21 定案:由 Agent 把直链发给用户, + 免得首页一堆二维码分不清扫哪个);其它工具输出、日志、产物里都不该出现它 - **只写不读**:没有文件列表、没有下载路由 - 扩展名白名单、单文件 25MB、每会话 200 个文件封顶 - 文件名一律 `Path(raw.replace(chr(92), '/')).name` 取基名 + 重名加序号,防目录穿越 - 可选 `--pin` / `UPLOAD_PIN` / `config.json` 的 `upload_pin` 作为额外访问码 +地址解析在 `src/cases.py` 的 `upload_page_url()`:配了 `upload_base_url` 就用它(**要带端口**,收件器默认听 +5055),否则探测默认路由网卡——多网卡机器上探测结果常是手机打不开的虚拟地址。 + `cases//inbox/` 里的文件由宿主调 `case_add_files(source='inbox')` 收取进会话,收件器本身不碰任何业务逻辑。 diff --git a/src/upload/server.py b/src/upload/server.py index 21fe3b8..af39f09 100644 --- a/src/upload/server.py +++ b/src/upload/server.py @@ -175,8 +175,22 @@ def main(argv: list[str] | None = None) -> int: host = args.host or ("127.0.0.1" if args.local else "0.0.0.0") app = create_app(pin) - shown = lan_ip() if host == "0.0.0.0" else host - print("PC 上传页: http://" + shown + ":" + str(args.port) + ("/?pin=..." if pin else ""), flush=True) + # 多网卡机器上探测常挑错(VPN/Tailscale/虚拟交换机),所以优先用配置里写死的对外地址; + # 配置里必须带端口,收件器听的是 --port(默认 5055),漏了端口链接会打到 80 上 + configured = str(load_config().get("upload_base_url") or "").strip().rstrip("/") + if configured: + shown = configured + else: + bind = "127.0.0.1" if host == "0.0.0.0" else host + shown = "http://" + (lan_ip() if host == "0.0.0.0" else bind) + ":" + str(args.port) + print("PC 上传页: " + shown + ("/?pin=…" if pin else ""), flush=True) + print("手机直链: 由 MCP 工具 case_create / case_upload_link 给出(含会话令牌)", flush=True) + if not configured: + print( + "(upload_base_url 未配置,上面地址是探测出来的。多网卡机器上手机可能打不开:" + "在 scripts/data/config.json 写 upload_base_url,例如 http://192.168.1.20:5055,要带端口)", + flush=True, + ) print("收件目录: " + str(_store.root) + "/*/inbox", flush=True) app.run(host=host, port=args.port, debug=False, threaded=True, use_reloader=False) return 0 diff --git a/tests/test_upload_link.py b/tests/test_upload_link.py new file mode 100644 index 0000000..4c6b411 --- /dev/null +++ b/tests/test_upload_link.py @@ -0,0 +1,105 @@ +"""上传直链:地址来源、令牌、转义与新工具 case_upload_link。""" + +from pathlib import Path + +import pytest + +from src.cases import upload_page_url +from src.config import clear_config_cache, load_config +from src.mcp.tools import materials + + +@pytest.fixture +def isolated_cases(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + root = tmp_path / "cases" + root.mkdir() + monkeypatch.setattr("src.cases.CASE_ROOT", root) + # CaseStore.root 是类属性(导入时就绑定 CASE_ROOT),所以直接改实例属性最省事, + # 与 test_mcp_analysis.py 的既有做法一致 + monkeypatch.setattr(materials._store, "root", root, raising=False) + return root + + +def _set_base(monkeypatch: pytest.MonkeyPatch, value: str) -> None: + monkeypatch.setenv("UPLOAD_BASE_URL", value) + clear_config_cache() + + +def test_upload_base_url_comes_from_env(monkeypatch: pytest.MonkeyPatch) -> None: + _set_base(monkeypatch, "http://192.168.1.20:5055") + try: + assert upload_page_url() == "http://192.168.1.20:5055/" + finally: + monkeypatch.delenv("UPLOAD_BASE_URL", raising=False) + clear_config_cache() + + +def test_upload_base_url_configured_wins_and_strips_slash(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + data = tmp_path / "scripts" / "data" + data.mkdir(parents=True) + (data / "config.json").write_text('{"upload_base_url": "http://10.0.0.5:6000/"}', encoding="utf-8") + monkeypatch.setattr("src.config._CONFIG_PATH", data / "config.json") + monkeypatch.delenv("UPLOAD_BASE_URL", raising=False) + clear_config_cache() + try: + assert load_config()["upload_base_url"] == "http://10.0.0.5:6000/" # 原样保留,拼链接时才去尾斜杠 + assert upload_page_url() == "http://10.0.0.5:6000/" + finally: + clear_config_cache() + + +def test_unconfigured_falls_back_to_probe(isolated_cases: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("UPLOAD_BASE_URL", raising=False) + monkeypatch.setattr("src.config._CONFIG_PATH", isolated_cases / "missing.json") + clear_config_cache() + try: + url = upload_page_url() + assert url.startswith("http://") and url.endswith("/") + assert ":5055" in url or ":80" not in url # 探测出来的地址一定带端口 + finally: + clear_config_cache() + + +def test_case_create_returns_entry_url_with_token(isolated_cases: Path, monkeypatch: pytest.MonkeyPatch) -> None: + _set_base(monkeypatch, "http://192.168.31.86:5055") + try: + result = materials.case_create("毕业采集", "travel") + assert result["ok"] is True + url = result["upload_url"] + assert url.startswith("http://192.168.31.86:5055/u/") + assert "?t=" in url and len(url.split("?t=")[1]) >= 16 + # 会话 id 含中文:链接必须是纯 ASCII,否则部分客户端打不开 + assert all(ord(ch) < 128 for ch in url) + finally: + monkeypatch.delenv("UPLOAD_BASE_URL", raising=False) + clear_config_cache() + + +def test_upload_link_is_stable_and_scoped(isolated_cases: Path, monkeypatch: pytest.MonkeyPatch) -> None: + _set_base(monkeypatch, "http://127.0.0.1:5055") + try: + created = materials.case_create("稳", None) + again = materials.case_upload_link(created["case_id"]) + assert again["ok"] is True and again["upload_url"] == created["upload_url"] + assert again["pending_inbox"] == 0 + missing = materials.case_upload_link("没这个会话") + assert missing["ok"] is False and "case_list" in missing["next_action"] + finally: + monkeypatch.delenv("UPLOAD_BASE_URL", raising=False) + clear_config_cache() + + +def test_token_only_in_link_not_in_summary(isolated_cases: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """令牌允许出现在直链里,但不该混进常规状态输出,免得每次轮询都复制一遍。""" + _set_base(monkeypatch, "http://127.0.0.1:5055") + try: + created = materials.case_create("秘", None) + cid = created["case_id"] + token = created["upload_url"].split("?t=")[1] + status = materials.case_status(cid) + assert token not in str(status) + listing = materials.case_list() + assert token not in str(listing) + finally: + monkeypatch.delenv("UPLOAD_BASE_URL", raising=False) + clear_config_cache()