feat(upload): 建会话即给这一单的手机直链,收件器地址改为显式配置
用户选了 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 提交并手跑等价检查。
This commit is contained in:
+22
-2
@@ -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)
|
||||
|
||||
@@ -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", ""),
|
||||
}
|
||||
|
||||
# 用户相关字段只从文件读
|
||||
|
||||
+3
-2
@@ -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 子进程会自动重启并重同步工具)
|
||||
|
||||
|
||||
+5
-2
@@ -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_id>/。
|
||||
硬约束:不得回显密码与上传令牌(令牌只允许出现在 case_create / case_upload_link 的返回链接里);
|
||||
不得臆造财务数据,拿不准的字段标出来问用户;产物只写 cases/<case_id>/。
|
||||
"""
|
||||
|
||||
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,
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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/<case_id>` | 服务端生成的二维码 PNG(segno) |
|
||||
| `GET /u/<case_id>` | 手机上传页(令牌在 URL 里) |
|
||||
| `POST /u/<case_id>` | 接收文件,落 `cases/<id>/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/<id>/inbox/` 里的文件由宿主调 `case_add_files(source='inbox')` 收取进会话,收件器本身不碰任何业务逻辑。
|
||||
|
||||
+16
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user