# 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;注册表状态由管理员保留,代码回滚期间关闭相关工具即可。