Explorar el Código

mcp导出接口修改成异步

jackson hace 3 días
padre
commit
3d2a5a6ed0

+ 4 - 0
docs/superpowers/specs/2026-07-14-mcp-export-pending-outbound-orders-design.md

@@ -1,5 +1,9 @@
 # MCP 导出未排舱订单设计
 
+> 本设计中的同步等待和直接返回文件 URL 合同已由
+> [`2026-07-22-mcp-async-export-task-design.md`](2026-07-22-mcp-async-export-task-design.md)
+> 取代;本文只保留为历史设计背景。
+
 ## 背景
 
 `fmsoperate/app/admin/view/outbound/add.html` 已提供“导出未排舱订单”功能。页面把当前筛选条件提交到

+ 287 - 0
docs/superpowers/specs/2026-07-22-mcp-async-export-task-design.md

@@ -0,0 +1,287 @@
+# MCP 异步导出任务设计
+
+## 背景
+
+`export_pending_outbound_orders` 和 `export_out_of_province_port_data` 当前都在一次
+MCP `tools/call` 内同步完成查询、文件生成、上传并返回下载链接。未排舱导出固定
+`is_sync=1`;省外进港资料同步调用 Support 模板导出。数据量较大时整个请求可能
+持续一分钟以上。
+
+MCP Gateway 调用 fmsoperate 的默认 HTTP 超时为 10 秒,外层反向代理和 Workbuddy
+也可能提前关闭长连接。单纯增加超时无法保证整条链路稳定,而且会长期占用 Gateway
+并发名额和 PHP 请求进程。
+
+本设计将两个导出工具改成短请求提交任务,并新增独立状态查询工具。文件生成继续复用
+fmsoperate 现有导出算法、Redis 队列、`st_download_list` 和文件存储,不把业务逻辑
+迁移到 Gateway。
+
+本设计替代
+`2026-07-14-mcp-export-pending-outbound-orders-design.md` 中关于同步等待、直接返回
+文件 URL 和“不改造成任务轮询协议”的结论;原文件只作为当时设计记录保留。
+
+## 目标
+
+- 两个导出提交请求在完成参数、权限和数据存在性校验后快速返回,不等待文件生成。
+- 新增 `query_export_task`,通过新的短 MCP 调用查询等待、执行、完成或失败状态。
+- 完成状态返回与当前合同相同的安全文件链接。
+- 复用现有导出实现,不复制 Excel 字段、模板、业务查询或 OSS 上传算法。
+- 保持工具注册、菜单权限、公司隔离和员工数据范围校验。
+- 防止客户端重试造成同一员工短时间内重复创建相同导出任务。
+- 不暴露 `st_download_list.id`、内部异常、队列载荷或原始失败备注。
+
+## 非目标
+
+- 不通过一个 MCP 调用在 Gateway 内持续轮询。
+- 不使用流式响应、SSE 或 WebSocket 传输导出进度。
+- 不通过提高 Gateway、Nginx 或 Workbuddy 超时解决长任务。
+- 不修改后台页面现有导出交互。
+- 不改变两个导出的筛选条件、号码匹配、文件内容或模板。
+- 不新增业务导出任务表;继续使用 `st_download_list`。
+
+## 方案选择
+
+### 采用:全异步提交和独立查询
+
+两个导出工具一律创建任务并返回任务引用,调用方稍后单独调用
+`query_export_task`。该方案使每次 HTTP 请求都保持短时、可重试,并且不依赖数据量
+阈值或调用端连接时长。
+
+### 不采用:按数据量混合同步与异步
+
+导出耗时不仅取决于行数,还取决于关联查询、模板服务、文件写入和上传。预估阈值不稳定,
+会让同一个工具出现两套时序和输出分支,增加调用方及测试复杂度。
+
+### 不采用:只增加超时
+
+Gateway、PHP、反向代理和 Workbuddy 任一层仍可能先超时。长请求还会占用每工具并发
+配额,超时重试可能重复生成文件,因此只可作为临时排障手段。
+
+## 对外工具合同
+
+### 导出提交工具
+
+保留两个现有工具名及输入 Schema:
+
+- `export_pending_outbound_orders`
+- `export_out_of_province_port_data`
+
+成功提交后统一返回:
+
+```json
+{
+  "code": "MCP_0000",
+  "msg": "导出任务已提交",
+  "data": {
+    "task_ref": "mexp_xxx",
+    "status": "queued",
+    "retry_after_seconds": 10
+  },
+  "meta": {
+    "request_id": "rq_xxx"
+  }
+}
+```
+
+`task_ref` 是带完整性校验的不可枚举引用,不是数据库主键。`retry_after_seconds`
+只是调用建议,不代表精确完成时间,也不允许 Gateway 在原请求内等待。
+
+提交阶段仍完成以下同步检查:
+
+1. MCP Token、动态工具注册和 route_code。
+2. 对应后台菜单权限。
+3. 参数格式和组合关系。
+4. 当前公司和员工数据范围。
+5. 未排舱导出的单行数据存在性探测,或省外进港资料的全部号码解析和授权。
+
+因此参数错误、无权限、目标不存在或无可导出数据仍立即返回现有安全业务错误,不创建
+队列任务。
+
+### `query_export_task`
+
+新增工具:
+
+- 工具名:`query_export_task`
+- 路由:`POST /mcp/tools/queryExportTask`
+- 必填参数:`task_ref`,字符串,最长 512 字符
+- 每次只查询一个任务,不接受任务 ID、员工 ID、公司 ID 或批量数组
+
+返回状态:
+
+| 内部状态 | 对外状态 | 对外数据 |
+|---|---|---|
+| `st_download_list.status=1` | `queued` | `task_ref`、建议重试秒数 |
+| `status=4` | `running` | `task_ref`、建议重试秒数 |
+| `status=2` | `completed` | `task_ref`、`files[].label/url` |
+| `status=3` | `failed` | `task_ref`、固定安全失败提示 |
+
+任务不存在、引用签名无效、任务不属于当前员工或公司、来源工具权限失效时,统一返回
+“导出任务不可用”,不区分具体原因,避免枚举和越权探测。不得返回
+`st_download_list.remark`。
+
+`query_export_task` 自身先经过动态注册校验。Logic 再根据任务中记录的来源工具执行该
+导出的菜单权限检查;只有任务所属员工、公司和来源权限均仍有效时才返回状态或文件链接。
+
+### Gateway 展示
+
+两个提交工具的 Presenter 白名单改为:
+
+- `task_ref`
+- `status=queued`
+- `retry_after_seconds`
+
+`query_export_task` 的 Presenter 只接受四个已知状态。完成时复用现有导出 URL 和主机
+白名单校验;其他状态不得携带文件链接。未知字段、未知状态、畸形 URL 或未知错误继续
+关闭失败。
+
+工具说明明确:
+
+- 导出工具只负责提交任务。
+- 收到 `queued` 后应告知用户任务已提交。
+- 用户要求查看结果或经过建议等待时间后,才调用 `query_export_task`。
+- 不得在一次工具调用内睡眠、循环轮询或并行创建相同任务。
+
+CLI 保留 fmsoperate 原始 `code/msg/data/meta` 信封。
+
+## 任务引用
+
+`task_ref` 使用 `mexp_` 前缀和 URL-safe Base64 编码,载荷至少包含:
+
+- `task_id`
+- `create_user_id`
+- `company_id`
+- `source_tool`
+- `issued_at`
+
+载荷使用从 fmsoperate 专用 `MCP_EXPORT.TASK_REF_SECRET` 分别派生的 AES-256-CBC 加密密钥和 HMAC-SHA256 认证密钥,按 encrypt-then-MAC 生成不暴露内部 ID 的引用。密钥少于 32 字节、为空、解密失败或引用验签失败时
+关闭访问。验签使用恒定时间比较;解析失败不得记录完整引用。引用有效期固定为 7 天,
+超过有效期后要求用户重新提交导出。
+
+签名只防止引用伪造,不替代数据库授权。查询时仍必须按以下条件读取:
+
+- `id=task_id`
+- `create_user_id=当前员工`
+- `system_id=当前系统`
+- `export_params.company_id=当前公司`
+- `export_params.mcp_source_tool=source_tool`
+
+日志只记录任务数据库 ID 或引用短哈希中的一种内部关联值,不记录完整 `task_ref`。
+
+## fmsoperate 数据流
+
+### 未排舱订单
+
+1. `McpPendingOutboundExportLogic` 保留当前权限检查、参数归一化和单行探测。
+2. 服务端固定写入 `export_type=EXPORT_PEND_OUTBOUND_ORDER`、`is_sync=0`、
+   当前员工和公司上下文及 `mcp_source_tool`。
+3. 创建 `st_download_list` 等待任务。
+4. 通过现有 `RedisAction::EXPORT_PEND_OUTBOUND_ORDER` 和
+   `ExportPendOutboundOrder` Handler 入队。
+5. Worker 复用 `ExportPendOutboundOrderLogic`,按现有约定将状态从等待更新为执行中、
+   完成或失败。
+6. 提交接口返回由任务 ID 和可信会话生成的 `task_ref`。
+
+### 省外进港资料
+
+1. `McpOutOfProvincePortExportLogic` 保留四类号码严格四选一、全部匹配和全部授权。
+2. 把已授权排舱 ID、资料类型、可信员工/公司上下文及 `mcp_source_tool` 写入任务参数。
+3. 创建 `st_download_list` 等待任务并推送新的专用 Redis Action/Handler。
+4. Worker 把任务标记为执行中,调用从现有 `ExportLogic::shangHaiExport()` 抽出的可复用
+   模板导出方法。
+5. 新的内部方法接受既有 `task_id` 作为 `down_record_id`,不得再次创建第二条
+   `st_download_list` 记录;后台现有 `shangHaiExport()` 对外行为保持不变。
+6. Worker 根据模板响应更新同一任务的文件 URL、文件名、完成时间或安全失败状态。
+
+省外进港 Worker 只接收提交阶段已经全部授权并固定下来的排舱 ID,不重新根据原始号码做
+模糊匹配。状态查询和链接交付阶段再次验证当前员工、公司及来源菜单权限。
+
+### 队列失败
+
+创建任务与入队不能组成单个数据库事务,因此采用补偿:
+
+- 任务插入失败:不入队,返回系统繁忙。
+- 入队返回失败或抛出异常:把任务标记为失败并返回系统繁忙。
+- Worker 异常:先把任务标记为失败,再重新抛给队列框架记录队列 Action、任务 ID 和
+  内部异常;提交与查询的 request ID 继续由 `st_mcp_access_log` 保留,公共响应不返回异常。
+- Worker 重试必须以任务状态为门禁,完成任务不得重复生成文件。
+
+## 去重与并发
+
+提交前基于以下内容生成稳定 SHA-256 指纹:
+
+- 当前公司 ID
+- 当前员工 ID
+- 来源工具
+- 归一化后的业务参数
+
+使用 Redis 原子 `SET NX EX` 建立 60 秒提交锁,并把已创建的任务 ID 保存为值。同一指纹
+在锁有效期内再次提交时返回同一个 `task_ref`,不重复插入或入队。入队失败时释放锁。
+
+去重只覆盖客户端超时或快速重试,不缓存导出结果,不跨员工复用文件,也不改变用户在
+60 秒后主动再次导出的能力。
+
+## 安全边界
+
+- 身份、公司、员工、超级管理员、时区和数据范围只来自 MCP Token 恢复的 Session。
+- 外部请求不能提交 `task_id`、`company_id`、`operator_id`、`is_super` 或队列参数。
+- 导出提交时做完整业务授权;状态查询和链接交付时再次检查任务归属和来源菜单权限。
+- 任务引用验签、Redis、数据库或菜单查询失败时关闭访问。
+- Presenter 只输出固定状态、建议等待时间和安全文件链接。
+- 不返回队列名、内部 Action、数据库主键、导出参数、失败备注、SQL 或异常栈。
+- `st_mcp_access_log` 继续记录每次提交和查询的独立 request ID 与安全响应。
+
+## 注册与部署
+
+新增 `query_export_task` 后,本地和公网 Gateway 候选工具由 12 个变为 13 个。需要同步:
+
+- Python 工具 metadata、双注册表、CLI 转发和 Presenter。
+- fmsoperate 路由、Validate、Controller、Logic、任务查询 Model。
+- fmsoperate 动态工具注册 SQL,首次插入默认 `status=0`,重复执行保留管理员现有状态。
+- 新省外进港导出 Redis Action、配置和 Handler。
+- README 与集中仓当前需求、技术规格、用户流程和里程碑。
+
+部署顺序:
+
+1. 部署 fmsoperate 代码及持续运行的 `fmsoperate:inside` Worker。
+2. 执行并核验新工具注册 SQL,由管理员决定是否启用。
+3. 部署并重启 MCP Gateway。
+4. 让 Workbuddy 重新连接;`tools.listChanged=false` 不会热更新 Schema。
+5. 分别用小数据和超过一分钟的数据验证提交、状态查询及下载。
+
+不执行新的业务表 DDL。队列 Worker、Redis 和文件存储不可用时不得启用新合同。
+
+## 测试与验收
+
+### fmsoperate
+
+- 两个提交 Logic 验证参数、菜单、公司和数据范围后快速返回任务引用。
+- 未排舱无数据、省外号码缺失/越权时不创建任务。
+- 外部身份字段、任务 ID 和队列字段不能覆盖服务端值。
+- 入队失败会把任务置为失败且不留下可再次执行的等待任务。
+- Worker 状态完整覆盖 `queued -> running -> completed/failed`。
+- 省外进港异步导出只创建一条下载任务并复用原模板结果。
+- 状态查询覆盖有效引用、篡改、过期、跨员工、跨公司、来源工具权限失效和任务不存在。
+- 失败响应不包含 `remark`、异常或内部 ID。
+- 相同员工和参数 60 秒内重试返回同一任务;不同员工或公司不共享。
+
+### MCP Gateway
+
+- stdio 和公网均注册 13 个同序工具,并受动态启用列表控制。
+- 两个导出工具只接受规范的 `queued` 响应。
+- 状态查询 Presenter 覆盖四种状态及完成文件链接。
+- 非完成状态携带 URL、未知状态、未知字段和非法 URL 时关闭失败。
+- CLI 原样保留新的后端信封。
+- 全量 unittest、语句和分支覆盖率继续为 100%。
+
+### 时序验收
+
+- 提交接口不等待实际文件生成,在正常数据库和 Redis 条件下应在短请求时间内返回。
+- 模拟导出耗时超过 60 秒时,原提交 MCP 请求仍已正常结束。
+- 导出过程中多次状态查询不会重复入队或生成文件。
+- 完成后新的状态查询返回可下载文件,失败后返回固定安全提示。
+
+## 回滚
+
+- 先在动态注册表中关闭 `query_export_task` 和两个导出工具,阻止产生新合同调用。
+- 已入队任务允许 Worker 完成;不要删除等待或执行中的 `st_download_list` 记录。
+- 回滚 Gateway 和 fmsoperate 代码后重启 Gateway,并让客户端重新连接。
+- 不依赖回滚 SQL;注册表状态由管理员保留,代码回滚期间关闭相关工具即可。