14 Commits
Author SHA1 Message Date
gitea-admin d796ae16c5 chore: release 1.1.0-rc.20
CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s
2026-09-03 17:15:25 +00:00
gitea-admin 10523f34ec fix: keep attachment contract in client bundle
CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s
2026-09-01 13:06:10 +00:00
gitea-admin b7313ab74d feat: support secure XFT submission attachments
CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s
2026-09-01 03:44:36 +00:00
gitea-admin ba2cfa48cb chore: release 1.1.0-rc.18
CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s
2026-08-29 18:10:54 +00:00
gitea-admin f301cef3e8 fix: list unadapted XFT approval tasks safely 2026-08-29 18:09:29 +00:00
gitea-admin b00c22d74e chore: release 1.1.0-rc.17
CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s
2026-08-26 07:10:04 +00:00
gitea-admin 76a4e0592c fix: repair XFT expense type diagnostics 2026-08-26 05:05:59 +00:00
gitea-admin 2c60c35048 fix: package complete Windows bridge runtime
CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s
2026-08-20 03:53:33 +00:00
gitea-admin ca95c41fdd fix: keep large approval batches observable
CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s
2026-08-20 02:10:37 +00:00
gitea-admin dc86130711 fix: remove XFT use-car submissions 2026-08-20 01:21:59 +00:00
gitea-admin 7fbd13bf20 fix: keep submission previews user-readable 2026-08-20 01:13:36 +00:00
gitea-admin ecb2f570e0 feat: expose readable submission choices 2026-08-20 00:40:36 +00:00
gitea-admin 34c3c03a5f feat: expose user-readable expense types 2026-08-20 00:11:20 +00:00
gitea-admin 5d889d4445 feat: add local approval submission drafts 2026-08-19 15:53:47 +00:00
31 changed files with 3286 additions and 336 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
- This repository implements one lightweight stdio bridge and one unified remote gateway for DingTalk OA and XFT approvals. - This repository implements one lightweight stdio bridge and one unified remote gateway for DingTalk OA and XFT approvals.
- The supported customer architecture uses one gateway OAuth login for both approval sources. Do not reintroduce a local DWS dependency or a second user login without explicit product approval. - The supported customer architecture uses one gateway OAuth login for both approval sources. Do not reintroduce a local DWS dependency or a second user login without explicit product approval.
- The gateway must derive identity from its verified OAuth token. Never accept caller-supplied `userId`, employee ID, owner ID, or equivalent identity selectors. - The gateway must derive identity from its verified OAuth token. Never accept caller-supplied `userId`, employee ID, owner ID, or equivalent identity selectors.
- Approval bodies, expense details, attachments, and upstream schemas must remain real-time and must not be persisted. - The gateway must not persist approval bodies, expense details, attachments, or upstream schemas; they remain real-time. The only exception is a user-explicit local submission draft stored on the Agent machine under the current OS user's private data directory. Local drafts must never be uploaded to the gateway or repository, and every resumed draft must be revalidated against the live template schema before submission.
## Git workflow ## Git workflow
+17 -7
View File
@@ -13,21 +13,23 @@ Agent → 远程 Streamable HTTP MCP / OAuth 统一网关
## 工具 ## 工具
- `approval_login`:检查登录或返回唯一授权链接。 - `approval_login`:检查登录或返回唯一授权链接。
- `approval_list_todos`:实时列出本人待办,签发短期 `approvalId`,并为可安全整组处理的结果签发 `selectionId`。 - `approval_list_todos`:实时列出本人待办;薪福通不按单据类型过滤。签发短期 `approvalId`,并仅为可安全整组处理的结果签发 `selectionId`。
- `approval_list_history`:实时列出本人已经处理过或本人发起的审批;默认最近 30 天,可用 `startDate/endDate` 指定最长 120 天的时间窗口,并签发只能读取详情的短期 `approvalId`。 - `approval_list_history`:实时列出本人已经处理过或本人发起的审批;默认最近 30 天,可用 `startDate/endDate` 指定最长 120 天的时间窗口,并签发只能读取详情的短期 `approvalId`。
- `approval_get_detail`:返回摘要、结构化费用明细或受限 raw 分页。 - `approval_get_detail`:返回摘要、结构化费用明细或受限 raw 分页。
- `approval_get_actions`:检查同意、拒绝与退回能力。 - `approval_get_actions`:检查同意、拒绝与退回能力。
- `approval_process_batch`:一次最多处理 20 条;整批引用预检通过后才逐项执行,并在写后复核。 - `approval_process_batch`:一次最多处理 20 条;整批引用预检通过后才逐项执行,并在写后复核。
- `approval_list_submission_templates`:实时列出当前用户的提单候选模板。 - `approval_list_submission_templates`:实时列出当前用户的提单候选模板。
- `approval_get_submission_schema`:返回结构化可填写字段,不传输巨大原始 Schema。 - `approval_get_submission_schema`:返回结构化可填写字段,不传输巨大原始 Schema。
- `approval_get_submission_options`:查询当前用户可用的薪福通部门和城市编码。 - `approval_get_submission_options`:实时查询当前用户可用的薪福通部门、城市和费用类型;所有选项面向用户只展示业务名称,内部映射正式接口值。
- `approval_prepare_submission`:校验内容,自动查重本人近 30 天跨钉钉/薪福通的发票,并在安全时生成完整预览及短期确认引用;不创建单据。 - `approval_prepare_submission`:校验内容和本机附件元数据,自动查重本人近 30 天跨钉钉/薪福通的发票,并在安全时生成完整预览及短期确认引用;不创建单据,也不上传附件。
- `approval_submit`:用户确认预览后,使用相同内容正式发起审批。 - `approval_submit`:用户确认预览后,签名本地 Bridge 才上传已确认的薪福通附件,并使用相同内容正式发起审批。
- `approval_revoke_submission`:使用提单成功后返回的 `revokeId` 撤销本人刚通过 MCP 发起的钉钉或薪福通审批。 - `approval_revoke_submission`:使用提单成功后返回的 `revokeId` 撤销本人刚通过 MCP 发起的钉钉或薪福通审批。
- `approval_get_submission_status`:只读核验本人已发起薪福通单据的实时状态。 - `approval_get_submission_status`:只读核验本人已发起薪福通单据的实时状态。
待办、历史和处理结果使用统一、精简的领域字段,不向 Agent 暴露钉钉任务 ID、薪福通员工号或上游原始响应。历史查询分为 `handled`(本人已经处理过)和 `submitted`(本人发起),结果没有 `selectionId` 和可执行动作。常见 `approvalId` 使用紧凑的 v4 无状态加密格式:薪福通通常约 60 字符、钉钉 OA 通常约 100 字符,并绑定当前用户和 30 分钟有效期;历史引用还绑定查询语义,只能读取详情。旧版未过期的 v2/v3 引用仍可读取。 待办、历史和处理结果使用统一、精简的领域字段,不向 Agent 暴露钉钉任务 ID、薪福通员工号或上游原始响应。历史查询分为 `handled`(本人已经处理过)和 `submitted`(本人发起),结果没有 `selectionId` 和可执行动作。常见 `approvalId` 使用紧凑的 v4 无状态加密格式:薪福通通常约 60 字符、钉钉 OA 通常约 100 字符,并绑定当前用户和 30 分钟有效期;历史引用还绑定查询语义,只能读取详情。旧版未过期的 v2/v3 引用仍可读取。
薪福通消息中心返回且带稳定任务标识的本人待办都会进入列表。原有费用单据以及能被正式单据接口实时识别的新类型继续支持详情、同意、拒绝和退回;尚未确认接口兼容性的项目返回空 `availableActions` 与 `manualAction.reason=unsupported_xft_task_type`,只允许引导到薪福通客户端查看和处理。此类引用读取详情不会请求未验证的上游接口,动作查询全部返回禁用,批处理在预检阶段以 `provider_not_supported`、`writesStarted=false` 整批零写入;列表包含此类项目时不签发 `selectionId`。新增的能力诊断只给出数量,不额外携带任务 ID、单号、人员或正文。
一次写调用最多处理 20 条。整组同意/拒绝可直接使用 `selectionId`;超过 20 条时,Agent 可从同一次原始列表中按 `approvalId` 分组,不需要 `batchPlan`,也不能重新拉取后把新待办混入原授权。服务器在首个写入前校验整批 `approvalId` 和 `targetId`;任一引用无效时返回 `preflight_failed`、`writesStarted=false`,整批零写入且不自动补偿或重试。普通文本与结构化结果保持同一份精简数据,以兼容不渲染 `structuredContent` 的宿主。 一次写调用最多处理 20 条。整组同意/拒绝可直接使用 `selectionId`;超过 20 条时,Agent 可从同一次原始列表中按 `approvalId` 分组,不需要 `batchPlan`,也不能重新拉取后把新待办混入原授权。服务器在首个写入前校验整批 `approvalId` 和 `targetId`;任一引用无效时返回 `preflight_failed`、`writesStarted=false`,整批零写入且不自动补偿或重试。普通文本与结构化结果保持同一份精简数据,以兼容不渲染 `structuredContent` 的宿主。
Agent 不传 `userId`、员工号或单据所有者。网关从 OAuth Token 确定用户,再分别映射钉钉 OA 与薪福通身份。钉钉提单中的申请人和部门也由网关通过当前登录用户的通讯录档案实时填写;即使用户修改本地桥,也不能冒用他人身份或部门。 Agent 不传 `userId`、员工号或单据所有者。网关从 OAuth Token 确定用户,再分别映射钉钉 OA 与薪福通身份。钉钉提单中的申请人和部门也由网关通过当前登录用户的通讯录档案实时填写;即使用户修改本地桥,也不能冒用他人身份或部门。
@@ -41,15 +43,23 @@ Agent 不传 `userId`、员工号或单据所有者。网关从 OAuth Token 确
模板出现在列表中不等于所有控件都能由开放 API 构造。`approval_get_submission_schema` 会返回 `submissionCapability`,准备阶段也会再次安全校验: 模板出现在列表中不等于所有控件都能由开放 API 构造。`approval_get_submission_schema` 会返回 `submissionCapability`,准备阶段也会再次安全校验:
- 薪福通:白名单中的申请和报销模板均通过正式创建 API 发起,并在创建后实时核验状态。结果可能进入待审批,也可能因企业流程中没有其他有效审批人等规则即时完成;后者返回 `completed`,不会错误地当成接口失败,也不会签发不可用的撤回引用。 - 薪福通:可安全构造的申请和报销模板通过正式创建 API 发起,并在创建后实时核验状态。结果可能进入待审批,也可能因企业流程中没有其他有效审批人等规则即时完成;后者返回 `completed`,不会错误地当成接口失败,也不会签发不可用的撤回引用。用车申请不属于 MCP 发起范围,不会出现在候选模板中。
- 钉钉:当前 6 个候选模板均返回 `client_only`,必须在钉钉客户端发起。其中 5 个包含企业 OpenAPI 无法可靠构造的 `DDBizSuite` 考勤或费控套件;`业务招待申请_北极大`虽是普通明细表,但真实创建时新版企业接口返回系统异常、旧版接口拒绝组件格式,因此同样安全降级。 - 钉钉:当前 6 个候选模板均返回 `client_only`,必须在钉钉客户端发起。其中 5 个包含企业 OpenAPI 无法可靠构造的 `DDBizSuite` 考勤或费控套件;`业务招待申请_北极大`虽是普通明细表,但真实创建时新版企业接口返回系统异常、旧版接口拒绝组件格式,因此同样安全降级。
发起审批固定为“列模板 → 取 Schema → 自动查重/预览 → 用户确认 → 正式提交”。准备阶段从新表单提取发票代码与号码、数电票唯一编号、票据号码、附件哈希;没有唯一编号时,金额、日期与商户组合只作为疑似提示。网关实时刷新当前用户本人近 30 天的钉钉和薪福通已发起审批:原单待审批、已通过或状态未知时默认阻止,原单拒绝、撤回、作废或弱匹配时警告。任一来源查询不完整也不会当成“没有重复”。只有用户看过匹配单号、状态和覆盖情况并明确要求继续后,Agent 才能以 `allowDuplicateInvoices=true` 重新准备。 发起审批固定为“列模板 → 取 Schema → 自动查重/预览 → 用户确认 → 正式提交”。准备阶段从新表单提取发票代码与号码、数电票唯一编号、票据号码、附件哈希;没有唯一编号时,金额、日期与商户组合只作为疑似提示。本机附件在准备阶段只读取并复核普通文件、大小、类型和 SHA-256,网关只收到不含路径的元数据;用户最终确认后才通过签名 Bridge 上传。网关实时刷新当前用户本人近 30 天的钉钉和薪福通已发起审批:原单待审批、已通过或状态未知时默认阻止,原单拒绝、撤回、作废或弱匹配时警告。任一来源查询不完整也不会当成“没有重复”。只有用户看过匹配单号、状态和覆盖情况并明确要求继续后,Agent 才能以 `allowDuplicateInvoices=true` 重新准备。
用户不需要理解薪福通的任何内部选择值。实时 Schema 会把部门、城市、费用类型、出行方式、往返、时间范围、币种和酒店星级等字段标记为名称优先的选择项;Agent 只展示“深圳市”“交通费”“飞机”“往返”“人民币”等业务名称,选定后仅在内部写入正式接口值。动态下拉框也会标准化成 `label/code`,表格子字段同样递归处理。准备阶段的最终确认预览会再次按实时选项反向解析,只返回业务字段名和业务名称,不回显城市码、费用编码、字段号或表格列号;任何值无法可靠反向解析时,不生成 `confirmationId`。无法取得可靠名称映射时安全停止或允许跳过可选项,不退化为向用户索取工号、城市码、组织码、档案编号、数字舱位值或其他内部 ID。
薪福通正式 OpenAPI 当前没有独立的费用类型目录查询。费用类型会先从模板实时字段读取,再检查企业自定义多级数据源;两处都没有可用选项时,MCP 返回具体的实时检查结果并引导在费用管理页面确认或使用薪福通客户端,不会把空目录误报为网络 `fetch` 故障。
人员、企业自定义档案、关联前置单据、分摊及文档未给出可靠名称映射的舱位字段目前标记为 `client_only`。薪福通附件字段则由本机 Bridge 支持:PDF、OFD、JPG、PNG、XML 或 ZIP,单文件及单次提单附件总量均不超过 50MB,最多 10 个。必填附件可以由草稿引用满足;本地路径、文件内容和薪福通附件编号都不会出现在预览或对话中。单据公司、部门、字段引用和行程序号等服务器管理或内部派生值也不会作为用户输入项展示。
服务端临时保留不可逆的用户/发票 HMAC 指纹。首次使用时只为最近 30 天尚未索引的审批读取详情;以后准备提单会刷新精简历史状态,并只为新增审批补建指纹,不会重复逐张下载已索引发票。指纹和最小审批索引最多保留 35 天,过期后自动级联删除;10 分钟提单预留到期即删除。 服务端临时保留不可逆的用户/发票 HMAC 指纹。首次使用时只为最近 30 天尚未索引的审批读取详情;以后准备提单会刷新精简历史状态,并只为新增审批补建指纹,不会重复逐张下载已索引发票。指纹和最小审批索引最多保留 35 天,过期后自动级联删除;10 分钟提单预留到期即删除。
安全准备后,确认引用绑定登录用户、模板版本、完整表单内容、重复风险决定和发票预留,10 分钟有效且只能使用一次;相同发票在另一个有效确认中时不会再签发确认,避免并发重复创建。正文和发票原号不落库。钉钉企业 OpenAPI 支持通过 `POST /v1.0/workflow/processes/forecast` 进行无副作用的流程预测;网关只在预测成功并补齐所有自选处理人后才生成提交确认。薪福通在正式提交时完成最终业务校验,并由网关写后读取 `billSts`:状态 `1(待审批)` 返回 `submitted` 和 `revokeId`,状态 `2(审批通过)` 返回 `completed` 且不提供撤回引用。 安全准备后,确认引用绑定登录用户、模板版本、完整表单内容、重复风险决定和发票预留,10 分钟有效且只能使用一次;相同发票在另一个有效确认中时不会再签发确认,避免并发重复创建。正文和发票原号不落库。钉钉企业 OpenAPI 支持通过 `POST /v1.0/workflow/processes/forecast` 进行无副作用的流程预测;网关只在预测成功并补齐所有自选处理人后才生成提交确认。薪福通在正式提交时完成最终业务校验,并由网关写后读取 `billSts`:状态 `1(待审批)` 返回 `submitted` 和 `revokeId`,状态 `2(审批通过)` 返回 `completed` 且不提供撤回引用。
用户可以让 Agent 把尚未填完的提单保存为本机草稿。它不是钉钉或薪福通的官方草稿:只写入运行 Agent 的当前 OS 用户私有数据目录,不上传网关,也不跨设备同步。草稿保存用户填写值、可选项决定,以及附件所属表单字段、显示名、本地路径、大小、修改时间和 SHA-256;不保存 Token、身份 ID、短期模板/确认/撤回引用、薪福通附件编号或完整上游 Schema,也不复制或预上传文件。恢复时重新实时列模板和 Schema,并重新检查附件普通文件属性、类型、大小与 SHA-256。准备预览只发送不含路径的附件元数据;用户最终确认后,签名 Bridge 才把文件实时转发给薪福通,并把短时、不透明、用户与本次确认绑定的上传引用交给网关创建单据。网关和 Nginx 都不落盘附件。超过 90 天未更新的普通草稿在下次草稿操作时清理;提交结果不确定的草稿会冻结,核验清楚前禁止重试。宿主没有签名本地 Bridge 或安全文件能力时不提供附件上传或持久草稿,也不会改存服务器。
> 发布候选版限制:薪福通 `approve/recall` 撤回接口已经接入并具备本人归属校验与写后复核,但当前企业的真实待审批单仍返回 `XFTSYS9999`,尚无撤回成功样本。因此薪福通撤回属于实验能力;失败或结果不确定时绝不自动重试。查询、处理和发起审批不受此限制。 > 发布候选版限制:薪福通 `approve/recall` 撤回接口已经接入并具备本人归属校验与写后复核,但当前企业的真实待审批单仍返回 `XFTSYS9999`,尚无撤回成功样本。因此薪福通撤回属于实验能力;失败或结果不确定时绝不自动重试。查询、处理和发起审批不受此限制。
## 部署 ## 部署
@@ -89,7 +99,7 @@ https://approval.justconn.top/downloads/dingmcp/install.md
- `approvalId` 是绑定用户、来源和有效期的紧凑不透明加密引用;不包含展示字段或用户 ID 明文。 - `approvalId` 是绑定用户、来源和有效期的紧凑不透明加密引用;不包含展示字段或用户 ID 明文。
- 每批最多 20 条;引用预检失败时首个上游写调用不会发生。 - 每批最多 20 条;引用预检失败时首个上游写调用不会发生。
- 提单的发起人、薪福通员工号和审批状态只能由网关填写。 - 提单的发起人、薪福通员工号和审批状态只能由网关填写。
- 审批正文、费用明细、附件和薪福通 Schema 不落库。 - 网关不保存审批正文、费用明细、附件和薪福通 Schema;用户明确创建的本机草稿是唯一例外,且只位于 Agent 机器的当前用户私有目录。
- 发票查重库只保存当前用户和发票标识的 HMAC、来源、上游业务单号、状态及时间,最多保留 35 天;不保存发票原号、商户原文或附件。 - 发票查重库只保存当前用户和发票标识的 HMAC、来源、上游业务单号、状态及时间,最多保留 35 天;不保存发票原号、商户原文或附件。
- 企业 AppSecret 与薪福通 AuthoritySecret 只保存在服务器。 - 企业 AppSecret 与薪福通 AuthoritySecret 只保存在服务器。
- `failed`、`succeeded` 与 `outcome_uncertain` 有不同重试语义,后者禁止自动重试。 - `failed`、`succeeded` 与 `outcome_uncertain` 有不同重试语义,后者禁止自动重试。
+23
View File
@@ -82,6 +82,29 @@ server {
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
} }
location = /xft/submission-attachments {
client_max_body_size 50m;
proxy_pass http://127.0.0.1:3010;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Authorization $http_authorization;
# Attachment bytes must be forwarded in real time and never spooled
# into Nginx temporary files on the gateway.
proxy_request_buffering off;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header Cache-Control "no-store" always;
add_header Pragma "no-cache" always;
add_header X-Content-Type-Options "nosniff" always;
}
location / { location / {
proxy_pass http://127.0.0.1:3010; proxy_pass http://127.0.0.1:3010;
proxy_http_version 1.1; proxy_http_version 1.1;
@@ -167,7 +167,7 @@ Windows 的 `bridgePath` 和 `mcpConfig...command` 正常应以 `dingtalk-approv
## 步骤 4:安装 Skill 并注册 MCP ## 步骤 4:安装 Skill 并注册 MCP
1. 按当前 Agent 的官方方式安装 `skillPath` 指向的整个目录,而不是只复制 `SKILL.md`。确认目录中同时存在 `SKILL.md` 和 `references/create-approval.md`。 1. 按当前 Agent 的官方方式安装 `skillPath` 指向的整个目录,而不是只复制 `SKILL.md`。确认目录中同时存在 `SKILL.md`、`references/create-approval.md` 和 `references/local-drafts.md`。
2. 使用 `mcpConfig` 只新增或替换名称精确为 `dingtalk-approval` 的连接器。安装器本身不会猜测或修改 Agent 的私有配置。 2. 使用 `mcpConfig` 只新增或替换名称精确为 `dingtalk-approval` 的连接器。安装器本身不会猜测或修改 Agent 的私有配置。
3. 如果 Agent 接受完整 MCP JSON,原样使用整个 `mcpConfig`;如果界面只接受单个服务器配置,原样使用 `mcpConfig.mcpServers.dingtalk-approval`。 3. 如果 Agent 接受完整 MCP JSON,原样使用整个 `mcpConfig`;如果界面只接受单个服务器配置,原样使用 `mcpConfig.mcpServers.dingtalk-approval`。
4. 该连接器必须是本地 `stdio`,`command` 必须是返回的本地 `bridgePath`,`args` 必须为空数组。Windows 中必须保留 JSON 已正确转义的反斜杠。 4. 该连接器必须是本地 `stdio`,`command` 必须是返回的本地 `bridgePath`,`args` 必须为空数组。Windows 中必须保留 JSON 已正确转义的反斜杠。
+19 -3
View File
@@ -3,7 +3,9 @@
## 数据路径 ## 数据路径
```text ```text
用户 ─ Messenger ─ Agent ─ 远程 Streamable HTTP MCP/OAuth ─ 统一审批网关 用户 ─ Messenger ─ Agent
├─ 可选本机私有提单草稿
└─ 远程 Streamable HTTP MCP/OAuth ─ 统一审批网关
├─ 钉钉 OA ├─ 钉钉 OA
└─ 薪福通 └─ 薪福通
``` ```
@@ -22,8 +24,22 @@
Agent 面向统一审批语义,不直接理解钉钉 `processInstanceId/taskId`、薪福通员工号、上游响应或完整 Schema。网关在内部完成来源路由、字段抽取、动作调用和写后复核,只返回审批判断所需的精简字段与统一状态。 Agent 面向统一审批语义,不直接理解钉钉 `processInstanceId/taskId`、薪福通员工号、上游响应或完整 Schema。网关在内部完成来源路由、字段抽取、动作调用和写后复核,只返回审批判断所需的精简字段与统一状态。
薪福通当前待办以消息中心的本人任务为可见性边界,不再用应用码或单据类型白名单过滤。对旧费用类型以及能被正式 `bill-list` 实时识别的未知应用任务,网关标记为 `bill_api` 并沿用详情和审批链路;其他带稳定任务标识的项目标记为 `list_only`,只返回安全列表字段、空动作集合和客户端处理提示。`list_only` 的上游任务标识只进入用户绑定、来源绑定且短期有效的 v4 加密引用,不作为业务编号展示;详情和动作查询不会触发未经确认的上游接口,写请求在整批预检阶段返回 `provider_not_supported` 且零写入。包含此类项目的结果不签发 `selectionId`,诊断仅汇总 `billApiCount/clientOnlyCount`。本次能力分级只作用于当前待办,已处理历史仍只读取正式单据接口可验证的记录。
薪福通所有选择字段按领域选项抽象。部门、城市和费用类型由选项工具返回精简 `label/code` 映射;模板内嵌下拉框被标准化成同样结构,固定枚举通过 `enumLabels` 提供中文名称,表格子字段递归应用相同规则。Agent 只把业务名称展示给用户,正式值仅用于内部构造请求。准备确认时,网关再用实时 Schema 把正式值反向解析为业务名称;预览不包含城市码、费用编码、自定义字段号或表格列号,任一选择无法反向解析时会在签发确认引用前安全失败。无法取得可靠名称映射的人员、档案、关联单据、分摊和部分舱位字段标记为 `client_only`,必填时安全停止、可选时允许省略,绝不转而索取工号或编码。用车申请已从 MCP 发起范围移除,实时模板列表会过滤,旧模板引用也会被网关拒绝。响应不包含完整数据源、内部身份或其他企业档案,选项目录也不持久化。
所有工具以普通 `content.text` 中的精简 JSON 作为跨宿主可见结果,同时保留相同的精简 `structuredContent` 供支持结构化结果的宿主使用;两处都不携带诊断字段或上游原始响应。`raw` 仍是受过滤、分页和硬限额保护的按需诊断视图,不是默认数据通道。 所有工具以普通 `content.text` 中的精简 JSON 作为跨宿主可见结果,同时保留相同的精简 `structuredContent` 供支持结构化结果的宿主使用;两处都不携带诊断字段或上游原始响应。`raw` 仍是受过滤、分页和硬限额保护的按需诊断视图,不是默认数据通道。
## 本机提单草稿
草稿不是网关工具或上游单据状态,而是 Agent 的本地文件工作流。只有用户明确要求起草或保存时,Agent 才在当前 OS 用户的私有应用数据目录写入草稿;Windows 使用 `%LOCALAPPDATA%\DingTalkApproval\drafts`,macOS 使用 `~/Library/Application Support/DingTalkApproval/drafts`,Linux 使用 `${XDG_DATA_HOME:-~/.local/share}/dingtalk-approval/drafts`。POSIX 目录/文件权限分别为 `0700`/`0600`,保存采用同目录临时文件原子替换。宿主没有本地文件能力时明确不支持持久草稿,禁止退化为网关存储。
草稿仅保存用户填写值、来源和模板显示名、可选项决定,以及附件所属表单字段、本地路径、大小、修改时间和 SHA-256。它不保存 Token、当前用户身份、短期 `templateId`/`confirmationId`/`revokeId`、审批动作引用、薪福通附件编号或完整上游 Schema,也不复制或预上传附件。恢复时按来源和模板名重新实时列模板并获取 Schema;模板不唯一、字段变化、附件丢失或哈希变化都必须回到用户确认。普通草稿超过 90 天未更新会在下次草稿操作时清理。
附件准备阶段由签名本地 Bridge 打开用户明确选择的普通文件,拒绝符号链接、读取中变化的文件、伪造扩展名、零字节以及超限内容;支持 PDF、OFD、JPG、PNG、XML 和 ZIP,最多 10 个,单文件及总量均不超过 50MB。Bridge 重新计算 SHA-256,只把字段名称、文件名、大小和哈希发送给 `approval_prepare_submission`,因此预览无上传副作用且不含本地路径。`confirmationId` 绑定这份附件清单;用户最终确认后 Bridge 才把原始字节通过 OAuth 保护的专用 HTTPS 路由发送给网关。Nginx 关闭请求缓冲,网关只在内存中实时转发到薪福通自定义附件上传接口,不写数据库或磁盘。上传返回的薪福通 `fileId` 被封装为用户、本次确认和附件元数据绑定的 15 分钟加密引用;创建单据时才转换成实时 `ACCESSORIES` 字段的 JSON 附件编号数组,Agent 和用户都看不到上游编号。
正式提交仍完整经过网关的身份、模板、字段、发票查重、预览和一次性确认校验。本机文件被修改不能扩大权限。提交成功或即时完成后删除草稿;确定失败时保留并回到编辑;`outcome_uncertain` 时冻结草稿、丢弃短期引用并禁止自动重试,直到本人发起历史或原生客户端证明结果。
单张或部分操作使用 v4 紧凑无状态 `approvalId`。载荷只包含来源、上游标识、用途和 30 分钟有效期,不包含标题、申请人、金额、详情或用户 ID;网关以当前 OAuth 用户派生专属 AES-GCM 密钥,因此同一服务器密钥下其他用户也无法解开。常见薪福通引用约 60 字符,钉钉 OA 引用约 100 字符;长度头可以把宿主截断与普通篡改区分开。待办引用允许进入实时写前校验;`approval_list_history` 签发的 `handled/submitted` 引用只能读取详情,动作查询和批处理会在首次上游写入前拒绝。网关继续接受尚未过期的 v2/v3 待办引用,升级或进程重启不会让同一密钥签发的引用失效。 单张或部分操作使用 v4 紧凑无状态 `approvalId`。载荷只包含来源、上游标识、用途和 30 分钟有效期,不包含标题、申请人、金额、详情或用户 ID;网关以当前 OAuth 用户派生专属 AES-GCM 密钥,因此同一服务器密钥下其他用户也无法解开。常见薪福通引用约 60 字符,钉钉 OA 引用约 100 字符;长度头可以把宿主截断与普通篡改区分开。待办引用允许进入实时写前校验;`approval_list_history` 签发的 `handled/submitted` 引用只能读取详情,动作查询和批处理会在首次上游写入前拒绝。网关继续接受尚未过期的 v2/v3 待办引用,升级或进程重启不会让同一密钥签发的引用失效。
历史列表不与待办混合,也不签发 `selectionId`。未指定时间时默认查询最近 30 天;`startDate/endDate` 接受按 `Asia/Shanghai` 解释的 `YYYY-MM-DD` 或带时区的 ISO 8601 时间,结束日期包含全天,单次跨度最多 120 天。钉钉 `handled` 从本人已完成工作记录取得,`submitted` 在该时间窗口内按当前用户可见模板查询本人发起实例;薪福通 `handled` 从本人的已处理任务取得,`submitted` 使用服务器派生的本人员工号查询单据,再由网关统一按时间窗口过滤。读取历史详情时再次验证当前用户确实处理过该任务或确实是发起人;审批正文、费用明细和附件仍只实时读取、不落库。 历史列表不与待办混合,也不签发 `selectionId`。未指定时间时默认查询最近 30 天;`startDate/endDate` 接受按 `Asia/Shanghai` 解释的 `YYYY-MM-DD` 或带时区的 ISO 8601 时间,结束日期包含全天,单次跨度最多 120 天。钉钉 `handled` 从本人已完成工作记录取得,`submitted` 在该时间窗口内按当前用户可见模板查询本人发起实例;薪福通 `handled` 从本人的已处理任务取得,`submitted` 使用服务器派生的本人员工号查询单据,再由网关统一按时间窗口过滤。读取历史详情时再次验证当前用户确实处理过该任务或确实是发起人;审批正文、费用明细和附件仍只实时读取、不落库。
@@ -82,7 +98,7 @@ Agent 面向统一审批语义,不直接理解钉钉 `processInstanceId/taskId
撤回接口若返回 `XFTSYS*` 服务异常或网络级异常,网关会立即只读复核本人单据状态:已撤回则返回 `revoked`,仍为待审批则返回带 `postWriteVerified` 的 `failed`,无法明确归类才返回 `outcome_uncertain`。任何分支都不会自动重试写操作。 撤回接口若返回 `XFTSYS*` 服务异常或网络级异常,网关会立即只读复核本人单据状态:已撤回则返回 `revoked`,仍为待审批则返回带 `postWriteVerified` 的 `failed`,无法明确归类才返回 `outcome_uncertain`。任何分支都不会自动重试写操作。
截至 `1.1.0-rc.14`,薪福通撤回仍是实验能力:当前企业真实调用 `approve/recall` 会返回 `XFTSYS9999`,写后复核确认单据仍为待审批,尚无成功样本。该限制不影响薪福通查询、审批处理或发起审批;正式版不得把“接口已接通”表述为“撤回已验证成功”。 截至 `1.1.0-rc.15`,薪福通撤回仍是实验能力:当前企业真实调用 `approve/recall` 会返回 `XFTSYS9999`,写后复核确认单据仍为待审批,尚无成功样本。该限制不影响薪福通查询、审批处理或发起审批;正式版不得把“接口已接通”表述为“撤回已验证成功”。
薪福通创建或撤回需要复核时,`approval_get_submission_status` 先用服务器派生的本人员工号和单据详情双重确认目标属于当前用户的已发起单据,再返回状态名称和精简流程历史。只有实时状态仍为 `1(待审批)` 才签发新的短期、用户绑定 `revokeId`;已通过、草稿、拒绝或已撤回单据都不会获得撤回引用。该工具不返回审批正文,也不触发写操作。 薪福通创建或撤回需要复核时,`approval_get_submission_status` 先用服务器派生的本人员工号和单据详情双重确认目标属于当前用户的已发起单据,再返回状态名称和精简流程历史。只有实时状态仍为 `1(待审批)` 才签发新的短期、用户绑定 `revokeId`;已通过、草稿、拒绝或已撤回单据都不会获得撤回引用。该工具不返回审批正文,也不触发写操作。
@@ -94,4 +110,4 @@ Agent 面向统一审批语义,不直接理解钉钉 `processInstanceId/taskId
钉钉企业 OpenAPI 与 DWS 的个人接口不是同一能力面,但两者都支持无副作用的流程预测。网关在准备提单时调用企业接口 `POST /v1.0/workflow/processes/forecast`,发起人身份仍由服务器从已验证 OAuth 用户派生;只有预测成功并补齐全部自选处理人后才签发提交确认。预测请求不会创建审批实例,也不会持久化表单正文。包含 `DDBizSuite` 的考勤/费控模板不会把套件子字段伪装成普通字段提交。`业务招待申请_北极大`虽由普通字段和明细表组成,但真实测试中新版企业创建接口返回系统异常、旧版企业接口拒绝组件格式。当前 6 个白名单钉钉模板因此全部标记为 `client_only`,必须在客户端发起。 钉钉企业 OpenAPI 与 DWS 的个人接口不是同一能力面,但两者都支持无副作用的流程预测。网关在准备提单时调用企业接口 `POST /v1.0/workflow/processes/forecast`,发起人身份仍由服务器从已验证 OAuth 用户派生;只有预测成功并补齐全部自选处理人后才签发提交确认。预测请求不会创建审批实例,也不会持久化表单正文。包含 `DDBizSuite` 的考勤/费控模板不会把套件子字段伪装成普通字段提交。`业务招待申请_北极大`虽由普通字段和明细表组成,但真实测试中新版企业创建接口返回系统异常、旧版企业接口拒绝组件格式。当前 6 个白名单钉钉模板因此全部标记为 `client_only`,必须在客户端发起。
薪福通 Schema 会组合正式创建接口字段与企业实时自定义字段。准备阶段递归校验报销金额、费用明细、表格自定义字段、企业必填字段、当前用户成员映射与员工号。人员字段使用花名册编号数组,部门字段使用组织编码数组,表格字段使用带 `sequence`、`columnValues` 和 `titleCode` 的 JSON 行结构。薪福通没有无副作用的流程预测接口,因此所有开放的创建接口都在写后实时核验结果;申请类流程若按企业配置即时完成,会明确返回 `completed`,而不是误报异常。 薪福通 Schema 会组合正式创建接口字段与企业实时自定义字段。准备阶段递归校验报销金额、费用明细、表格自定义字段、企业必填字段和当前用户身份映射。内部仍按正式接口需要使用组织值、选项值及表格 `titleCode`,但这些只存在于 Agent 与网关的工具请求中;用户只选择名称。人员等没有安全名称查询能力的字段不会接受调用方直接塞入内部值。薪福通没有无副作用的流程预测接口,因此所有开放的创建接口都在写后实时核验结果;申请类流程若按企业配置即时完成,会明确返回 `completed`,而不是误报异常。
+5 -4
View File
@@ -24,12 +24,13 @@ https://approval.justconn.top/oauth/dingtalk/callback
- `xft-member/openapi/xft-member/member/get/by-id`(查询企业成员详情,用于由当前登录身份安全取得本人花名册员工号); - `xft-member/openapi/xft-member/member/get/by-id`(查询企业成员详情,用于由当前登录身份安全取得本人花名册员工号);
- `itrip-gateway/v1/form/query-event-list`; - `itrip-gateway/v1/form/query-event-list`;
- `itrip-gateway/v1/form/customFields/eventSeqId/{eventSeqId}`; - `itrip-gateway/v1/form/customFields/eventSeqId/{eventSeqId}`;
- `itrip-gateway/v1/dataSource/findMultilevelSourcePage`(查询企业费用类型数据源);
- `itrip-gateway/v1/dataSource/getMultiLevelDetail/{sourceSeqId}`(读取费用类型名称与正式提交编码);
- `itrip-gateway/v1/approve/recall`(单据撤回); - `itrip-gateway/v1/approve/recall`(单据撤回);
- `itrip-gateway/v1/bills/apply/travel/create`; - `itrip-gateway/v1/bills/apply/travel/create`;
- `itrip-gateway/v1/bills/apply/expense/create`; - `itrip-gateway/v1/bills/apply/expense/create`;
- `itrip-gateway/v1/bills/apply/entertainment/create`; - `itrip-gateway/v1/bills/apply/entertainment/create`;
- `itrip-gateway/v1/bills/apply/common/create`; - `itrip-gateway/v1/bills/apply/common/create`;
- `itrip-gateway/v1/bills/apply/use-car/create`;
- `itrip-gateway/v1/reimburse/bill/create`; - `itrip-gateway/v1/reimburse/bill/create`;
- `itrip-gateway/v1/reimburse/bill/travel/create`。 - `itrip-gateway/v1/reimburse/bill/travel/create`。
@@ -37,7 +38,7 @@ https://approval.justconn.top/oauth/dingtalk/callback
创建接口必须保持权限校验开启。网关统一提交 `APPRV_PEND`,并在写后实时读取本人单据状态:进入待审批时提供撤回引用;按企业流程规则即时通过时返回 `completed`,不提供撤回引用且禁止重复提交。 创建接口必须保持权限校验开启。网关统一提交 `APPRV_PEND`,并在写后实时读取本人单据状态:进入待审批时提供撤回引用;按企业流程规则即时通过时返回 `completed`,不提供撤回引用且禁止重复提交。
`1.1.0-rc.14` 中薪福通撤回仍标记为实验能力:权限和调用契约已接通,但当前企业真实请求返回 `XFTSYS9999`,尚未取得成功样本。网关会写后复核并禁止自动重试。面向试用用户时必须保留这一说明;若业务要求撤回必须稳定可用,应等待薪福通定位后再升级为正式能力。 `1.1.0-rc.15` 中薪福通撤回仍标记为实验能力:权限和调用契约已接通,但当前企业真实请求返回 `XFTSYS9999`,尚未取得成功样本。网关会写后复核并禁止自动重试。面向试用用户时必须保留这一说明;若业务要求撤回必须稳定可用,应等待薪福通定位后再升级为正式能力。
## 首次凭证配置 ## 首次凭证配置
@@ -119,7 +120,7 @@ https://approval.justconn.top/downloads/dingmcp/install.md
公开 `install.md` 是按回调拓扑选择路径的短安装说明。同机浏览器能够完成宿主回调时,Agent 应按宿主官方方式注册名为 `dingtalk-approval` 的远程 Streamable HTTP MCP,URL 为 `https://approval.justconn.top/mcp`,并由宿主使用标准 OAuth 元数据、动态客户端注册和 PKCE 登录。该路径不下载 Node/npm、Bridge 或 Skill;网关 MCP `instructions` 已提供完整业务规则。登录后只读调用 `approval_list_todos(source=all, limit=1)`,钉钉 OA 与薪福通都为 `ok` 才算验收通过。 公开 `install.md` 是按回调拓扑选择路径的短安装说明。同机浏览器能够完成宿主回调时,Agent 应按宿主官方方式注册名为 `dingtalk-approval` 的远程 Streamable HTTP MCP,URL 为 `https://approval.justconn.top/mcp`,并由宿主使用标准 OAuth 元数据、动态客户端注册和 PKCE 登录。该路径不下载 Node/npm、Bridge 或 Skill;网关 MCP `instructions` 已提供完整业务规则。登录后只读调用 `approval_list_todos(source=all, limit=1)`,钉钉 OA 与薪福通都为 `ok` 才算验收通过。
宿主缺少远程 MCP/标准 OAuth,或 loopback 回调与用户打开授权页的设备不一致时,`install.md` 引导到 `compat-install.md`,无需先尝试一次必然失败的远程登录。兼容页保留签名客户端、平台引导、Skill、stdio MCP、可跨设备的 device-code 登录、更新、卸载和故障恢复步骤。Windows PowerShell 产物必须带 UTF-8 BOM,Node 探测使用外层双引号/内层单引号,避免 PowerShell 5.1 吞掉传给原生程序的双引号。 宿主缺少远程 MCP/标准 OAuth,或 loopback 回调与用户打开授权页的设备不一致时,`install.md` 引导到 `compat-install.md`,无需先尝试一次必然失败的远程登录。兼容页保留签名客户端、平台引导、Skill、stdio MCP、可跨设备的 device-code 登录、更新、卸载和故障恢复步骤。完整 Skill 必须同时包含 `references/create-approval.md` 和 `references/local-drafts.md`。Windows PowerShell 产物必须带 UTF-8 BOM,Node 探测使用外层双引号/内层单引号,避免 PowerShell 5.1 吞掉传给原生程序的双引号。
兼容 Windows 下载优先使用 `curl.exe`,从系统代理变量继承代理,并设置 10 秒连接超时、20 秒低速判定和 90 秒单源总时限;没有 curl 才以 `Invoke-WebRequest -TimeoutSec 90` 兜底。Node 固定版本制品仍按阿里云、npmmirror、官方源顺序尝试并强制校验官方 SHA-256。项目 artifact 由 Ed25519 清单和 SHA-256 保护;来源失败可换源,签名或哈希不符必须立即停止。 兼容 Windows 下载优先使用 `curl.exe`,从系统代理变量继承代理,并设置 10 秒连接超时、20 秒低速判定和 90 秒单源总时限;没有 curl 才以 `Invoke-WebRequest -TimeoutSec 90` 兜底。Node 固定版本制品仍按阿里云、npmmirror、官方源顺序尝试并强制校验官方 SHA-256。项目 artifact 由 Ed25519 清单和 SHA-256 保护;来源失败可换源,签名或哈希不符必须立即停止。
@@ -135,7 +136,7 @@ curl --fail https://approval.justconn.top/downloads/dingmcp/manifest.json
curl --fail https://approval.justconn.top/downloads/dingmcp/manifest.sig curl --fail https://approval.justconn.top/downloads/dingmcp/manifest.sig
``` ```
备份时必须把 `.runtime/auth.sqlite`、同路径派生的 `.runtime/auth.sqlite.invoice-dedup.sqlite` 与 `.secrets/oauth-session.key` 一并迁移。发票查重库只含不可逆 HMAC 和最小状态元数据;审批正文不落库,不需要备份。 备份时必须把 `.runtime/auth.sqlite`、同路径派生的 `.runtime/auth.sqlite.invoice-dedup.sqlite` 与 `.secrets/oauth-session.key` 一并迁移。发票查重库只含不可逆 HMAC 和最小状态元数据;审批正文不落库,不需要备份。本机提单草稿不在服务器上,也不属于服务器备份;用户如需迁移,必须把 Agent 所在机器的当前用户私有草稿目录作为敏感业务数据单独处理。
每个候选版和正式版发布前执行 [发布检查清单](release-checklist.md)。 每个候选版和正式版发布前执行 [发布检查清单](release-checklist.md)。
+1
View File
@@ -17,6 +17,7 @@
- 兼容安装结果的 `loginCommand` 能在 MCP 未加载时刷新登录链接;MCP 已加载时 `approval_login` 完成同一操作。不得因链接过期而重装,首次成功响应只展示一次 onboarding 示例。 - 兼容安装结果的 `loginCommand` 能在 MCP 未加载时刷新登录链接;MCP 已加载时 `approval_login` 完成同一操作。不得因链接过期而重装,首次成功响应只展示一次 onboarding 示例。
- 只读验证待办、本人已处理历史、本人发起历史、历史默认最近 30 天及显式时间窗口、搜索、详情、费用结构化字段、动作、模板 Schema、部门/城市选项和越权拒绝;历史引用必须被动作查询和批处理拒绝。 - 只读验证待办、本人已处理历史、本人发起历史、历史默认最近 30 天及显式时间窗口、搜索、详情、费用结构化字段、动作、模板 Schema、部门/城市选项和越权拒绝;历史引用必须被动作查询和批处理拒绝。
- 发票查重验证钉钉/XFT 跨来源精确匹配、弱匹配、用户隔离、状态更新、来源不可用、历史截断、详情失败、并发确认、35 天过期清理和网关重启恢复;数据库不得出现用户 ID、发票原号、审批正文或附件。重复风险和不完整覆盖未获用户确认时不得签发可提交 `confirmationId`。 - 发票查重验证钉钉/XFT 跨来源精确匹配、弱匹配、用户隔离、状态更新、来源不可用、历史截断、详情失败、并发确认、35 天过期清理和网关重启恢复;数据库不得出现用户 ID、发票原号、审批正文或附件。重复风险和不完整覆盖未获用户确认时不得签发可提交 `confirmationId`。
- 本机草稿验证 Windows/macOS/Linux 私有目录选择、POSIX `0700`/`0600`、原子保存、90 天普通草稿清理、模板和 Schema 实时恢复、附件 SHA-256 变化、提交成功删除、确定失败保留及 `outcome_uncertain` 冻结。附件另验证符号链接/读取中变化/伪造类型/大小与数量上限在零上传前拒绝,prepare 不上传且不向网关发送路径,submit 只在最终确认后上传,multipart 签名不含 `x-alb-digest`,上游 `fileId` 只进入创建请求的实时附件字段。确认 Nginx 请求缓冲关闭,网关数据库、磁盘、日志、发行包和项目仓库都不出现附件字节、草稿正文或本地路径;草稿中不保存 Token、身份 ID、短期模板/确认/撤回引用、上游附件编号或完整 Schema。
- 真实写操作只执行用户明确授权的样本;任何 `outcome_uncertain` 都停止,不自动重试。 - 真实写操作只执行用户明确授权的样本;任何 `outcome_uncertain` 都停止,不自动重试。
- Mock 契约确认网关和 bridge 的批量 Schema 均固定为 20;第 6 个截断引用及任一无效退回目标都会返回 `preflight_failed`、`writesStarted=false`,上游写调用数为 0。 - Mock 契约确认网关和 bridge 的批量 Schema 均固定为 20;第 6 个截断引用及任一无效退回目标都会返回 `preflight_failed`、`writesStarted=false`,上游写调用数为 0。
- 代表性 v4 引用长度满足钉钉 OA 不超过 128 字符、薪福通不超过 80 字符,并通过篡改、过期、跨用户、跨密钥、同密钥重启及 v2/v3 兼容测试。 - 代表性 v4 引用长度满足钉钉 OA 不超过 128 字符、薪福通不超过 80 字符,并通过篡改、过期、跨用户、跨密钥、同密钥重启及 v2/v3 兼容测试。
+5 -5
View File
@@ -6,7 +6,7 @@
|---|---|---|---| |---|---|---|---|
| 薪福通 | 差旅报销单 | 支持,有约束 | 报销总额、币种、费用明细、差旅行程;费用和行程逐项递归校验,企业模板还要求承担部门 | | 薪福通 | 差旅报销单 | 支持,有约束 | 报销总额、币种、费用明细、差旅行程;费用和行程逐项递归校验,企业模板还要求承担部门 |
| 薪福通 | 费用报销单 | 支持,有约束 | 报销总额、币种、费用明细;当前模板要求承担部门和费用说明 | | 薪福通 | 费用报销单 | 支持,有约束 | 报销总额、币种、费用明细;当前模板要求承担部门和费用说明 |
| 薪福通 | 招待费用申请单 | 支持,有约束 | 企业模板类型为 `EXPENSE_APPLY`,使用费用申请创建接口的 `applyExpenses`(费用类型编码、金额等),而不是按模板名称误用招待申请专用 DTO;写后实时核验状态 | | 薪福通 | 招待费用申请单 | 支持,有约束 | 企业模板类型为 `EXPENSE_APPLY`,使用费用申请创建接口的 `applyExpenses`(费用类型、金额等),而不是按模板名称误用招待申请专用 DTO;费用类型名称实时查询、编码内部映射;写后实时核验状态 |
| 薪福通 | 出差费用申请单 | 支持,有约束 | 行程、城市和预计费用递归校验;实测仅有发起节点时流程即时完成并返回 `completed` | | 薪福通 | 出差费用申请单 | 支持,有约束 | 行程、城市和预计费用递归校验;实测仅有发起节点时流程即时完成并返回 `completed` |
| 钉钉 OA | 业务招待申请_北极大 | 仅客户端 | 普通字段和明细表可读取,但真实测试中新版企业创建接口返回系统异常、旧版企业接口拒绝组件格式 | | 钉钉 OA | 业务招待申请_北极大 | 仅客户端 | 普通字段和明细表可读取,但真实测试中新版企业创建接口返回系统异常、旧版企业接口拒绝组件格式 |
| 钉钉 OA | 出差申请单_北极大 | 仅客户端 | `DDBizSuite` 出差套件包含日程、天数、同行人及考勤联动,企业 OpenAPI 没有可靠构造协议 | | 钉钉 OA | 出差申请单_北极大 | 仅客户端 | `DDBizSuite` 出差套件包含日程、天数、同行人及考勤联动,企业 OpenAPI 没有可靠构造协议 |
@@ -15,10 +15,10 @@
| 钉钉 OA | 差旅费用报销_捷康 | 仅客户端 | 钉钉费控报销套件含行程、报销明细、发票、收款账户和企业主体;另有关联出差申请和项目字段 | | 钉钉 OA | 差旅费用报销_捷康 | 仅客户端 | 钉钉费控报销套件含行程、报销明细、发票、收款账户和企业主体;另有关联出差申请和项目字段 |
| 钉钉 OA | 业务招待费用报销_捷康 | 仅客户端 | 钉钉费控报销套件含报销明细、发票、收款账户和付款主体 | | 钉钉 OA | 业务招待费用报销_捷康 | 仅客户端 | 钉钉费控报销套件含报销明细、发票、收款账户和付款主体 |
薪福通自定义字段统一使用 `customFieldValues[].fieldNumber/fieldValue`。日期字段使用 `yyyy-MM-dd HH:mm`,人员字段值是花名册编号 JSON 数组,部门字段值是组织编码 JSON 数组,表格字段值是包含 `sequence`、`columnValues`、`titleCode` 的 JSON 数组字符串,附件字段值是先通过薪福通附件能力取得的附件编号。`approval_get_submission_schema` 会返回实时 `valueFormat`;Agent 不应按字段名称猜编码或结构。 薪福通自定义字段在工具内部统一使用 `customFieldValues[].fieldNumber/fieldValue`,但用户不应看到或提供这些引用。日期字段使用 `yyyy-MM-dd HH:mm`;部门由可读名称选择后内部映射;下拉框与表格子字段使用实时名称映射。人员及其他没有安全名称查询能力的字段标记为 `client_only`,不会要求用户填写花名册编号。附件字段返回 `attachmentCapability.status=supported`:Agent 只把本机草稿中的字段名称和文件引用传给签名 Bridge;prepare 阶段复核文件并只发送不含路径的元数据,最终确认后的 submit 才上传。薪福通附件编号由网关封装并直接写入 `ACCESSORIES` 字段,不能由 Agent 传入 `values.customFieldValues`。`approval_get_submission_schema` 会返回实时 `valueFormat`、`selectionCapability`、`attachmentCapability` 和该模板的精简 `minimalExample`;后者只说明 `values` 形状,所有尖括号占位符都必须由用户业务回答或实时选项替换,不能原样提交。Agent 不得按字段名称猜编码或结构。
薪福通模板要求承担部门或城市编码时,使用同一 `templateId` 调用 `approval_get_submission_options`。`kind=department` 只会返回当前登录用户本人档案中的可选组织;`kind=city` 可用 `query` 按城市名称搜索。城市全量接口较慢,网关给予独立的 45 秒超时并在进程内缓存字典 24 小时,不写入磁盘。响应仅包含可填写的 `code` 和 `label`,不会暴露员工号或原始成员档案。 薪福通模板要求承担部门、城市或费用类型时,使用同一 `templateId` 调用 `approval_get_submission_options`。`kind=department` 只会返回当前登录用户本人档案中的可选组织;`kind=city` 可用 `query` 按城市名称搜索;`kind=expense_type` 先读取模板内嵌选项,再按需读取企业多级费用类型数据源。多级数据源分页接口按正式契约把 `start/limit` 放在 query,并用列表的 `seqId` 读取详情中的 `optionSaveDtoList`;该接口只覆盖企业自定义多级数据源,薪福通正式 OpenAPI 当前没有独立的费用类型目录查询。模板没有内嵌选项且企业多级数据源为空或不含费用类型时,工具会明确返回这两项实时检查结果并引导在费用管理页面确认或使用客户端,不再包装成笼统的获取失败。费用申请、差旅预计费用及报销明细均标记对应选项源。固定枚举提供 `enumLabels`,例如出行方式、单程/往返、上午/下午、人民币和酒店星级;动态 `SELECT` 的 `options` 统一为 `{label, code}`,表格列也递归标准化。Agent 只向用户显示名称并在内部使用正式值,不得向用户索取内部编码。`approval_prepare_submission` 会对标准字段、自定义下拉和表格列做第二次反向解析,确认预览只保留业务名称;无法映射时不签发确认引用。城市全量接口较慢,网关给予独立的 45 秒超时并在进程内缓存字典 24 小时,不写入磁盘;费用类型保持实时且不落盘。响应不会暴露员工号、完整数据源或原始成员档案。
薪福通的自定义字段接口可能同时列出 `billCompanyCode`、`billDepartmentCode` 等标准单据抬头字段,也可能用动态字段号返回名为“承担部门/单据部门”的 `ADV_DEPARTMENT` 控件,但创建接口禁止把它们放进 `customFieldValues`。MCP 会剔除这些服务器管理控件;公司和部门都省略时,薪福通根据当前登录申请人的档案默认填写。若通过标准抬头字段显式指定,则公司与部门必须成对填写。 薪福通的自定义字段接口可能同时列出标准单据抬头控件,也可能用动态字段号返回名为“承担部门/单据部门”的控件,但创建接口禁止把这些标准控件放进 `customFieldValues`。MCP 会剔除服务器管理控件并拒绝调用方显式指定公司或部门;两者省略时由薪福通根据当前登录申请人的档案默认填写。真正的业务部门选择字段则只展示当前用户可用的部门名称并在内部映射。
任一来源模板的 `submissionCapability.status=client_only` 时,`approval_prepare_submission` 会再次拒绝,不能通过修改本地 Skill 或桥绕过。薪福通白名单模板使用正式创建接口,并在写后实时核验:`billSts=1` 为待审批,`billSts=2` 为企业流程规则导致的即时完成。当前列出的 6 个钉钉模板均为 `client_only`;对于以后可由 API 构造的钉钉模板,网关会先调用企业接口 `POST /v1.0/workflow/processes/forecast`,预测成功并补齐自选处理人后才生成提交确认。 任一来源模板的 `submissionCapability.status=client_only` 时,`approval_prepare_submission` 会再次拒绝,不能通过修改本地 Skill 或桥绕过。可安全构造的薪福通模板使用正式创建接口,并在写后实时核验:`billSts=1` 为待审批,`billSts=2` 为企业流程规则导致的即时完成。薪福通用车申请已从 MCP 发起范围移除,不会出现在候选模板中,旧引用也不能继续使用。当前列出的 6 个钉钉模板均为 `client_only`;对于以后可由 API 构造的钉钉模板,网关会先调用企业接口 `POST /v1.0/workflow/processes/forecast`,预测成功并补齐自选处理人后才生成提交确认。
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "dingtalk-approval-mcp", "name": "dingtalk-approval-mcp",
"version": "1.1.0-rc.14", "version": "1.1.0-rc.20",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "dingtalk-approval-mcp", "name": "dingtalk-approval-mcp",
"version": "1.1.0-rc.14", "version": "1.1.0-rc.20",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0", "@modelcontextprotocol/sdk": "^1.30.0",
"express": "^5.2.1", "express": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dingtalk-approval-mcp", "name": "dingtalk-approval-mcp",
"version": "1.1.0-rc.14", "version": "1.1.0-rc.20",
"description": "A lightweight Agent bridge for a unified DingTalk OA and XFT approval gateway.", "description": "A lightweight Agent bridge for a unified DingTalk OA and XFT approval gateway.",
"private": true, "private": true,
"type": "module", "type": "module",
+1 -1
View File
@@ -44,7 +44,7 @@ mkdirSync(out, { recursive: true, mode: 0o755 });
execFileSync("npm", ["run", "build"], { stdio: "inherit", env: npmEnvironment() }); execFileSync("npm", ["run", "build"], { stdio: "inherit", env: npmEnvironment() });
mkdirSync(join(clientStaging, "dist", "src"), { recursive: true }); mkdirSync(join(clientStaging, "dist", "src"), { recursive: true });
for (const name of ["bridge.js", "entrypoint.js", "client-installer.js", "approval-contract.js"]) { for (const name of ["bridge.js", "entrypoint.js", "client-installer.js", "approval-contract.js", "version.js"]) {
cpSync(resolve("dist", "src", name), join(clientStaging, "dist", "src", name)); cpSync(resolve("dist", "src", name), join(clientStaging, "dist", "src", name));
} }
cpSync(resolve("skills/dingtalk-approval"), join(clientStaging, "skill", "dingtalk-approval"), { recursive: true }); cpSync(resolve("skills/dingtalk-approval"), join(clientStaging, "skill", "dingtalk-approval"), { recursive: true });
+21 -4
View File
@@ -2,7 +2,7 @@
import { createHash, createPublicKey, verify as verifySignature } from "node:crypto"; import { createHash, createPublicKey, verify as verifySignature } from "node:crypto";
import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs"; import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { basename, join, resolve, sep } from "node:path"; import { basename, dirname, join, resolve, sep } from "node:path";
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { parseArgs } from "node:util"; import { parseArgs } from "node:util";
@@ -54,6 +54,7 @@ try {
scanTree(clientRoot); scanTree(clientRoot);
const packageMetadata = JSON.parse(readFileSync(join(root, "client", "package", "package.json"), "utf8")); const packageMetadata = JSON.parse(readFileSync(join(root, "client", "package", "package.json"), "utf8"));
if (packageMetadata.name !== "dingtalk-approval-client" || packageMetadata.version !== version) throw new Error("客户端 package.json 名称或版本不一致"); if (packageMetadata.name !== "dingtalk-approval-client" || packageMetadata.version !== version) throw new Error("客户端 package.json 名称或版本不一致");
assertRelativeModuleClosure(join(root, "client", "package", "dist", "src"));
if (JSON.stringify(Object.keys(packageMetadata.bin ?? {}).sort()) !== JSON.stringify(["dingtalk-approval", "dingtalk-approval-bridge"])) { if (JSON.stringify(Object.keys(packageMetadata.bin ?? {}).sort()) !== JSON.stringify(["dingtalk-approval", "dingtalk-approval-bridge"])) {
throw new Error("客户端只能发布安装 CLI 和 Bridge 两个 bin"); throw new Error("客户端只能发布安装 CLI 和 Bridge 两个 bin");
} }
@@ -70,8 +71,9 @@ try {
if (!body.startsWith("#!/usr/bin/env node\n")) throw new Error(`客户端 CLI 缺少 Node shebang:${target}`); if (!body.startsWith("#!/usr/bin/env node\n")) throw new Error(`客户端 CLI 缺少 Node shebang:${target}`);
} }
if (!existsFile(join(root, "client", "package", "skill", "dingtalk-approval", "SKILL.md")) if (!existsFile(join(root, "client", "package", "skill", "dingtalk-approval", "SKILL.md"))
|| !existsFile(join(root, "client", "package", "skill", "dingtalk-approval", "references", "create-approval.md"))) { || !existsFile(join(root, "client", "package", "skill", "dingtalk-approval", "references", "create-approval.md"))
throw new Error("客户端包缺少完整 Skill 与创建审批 reference"); || !existsFile(join(root, "client", "package", "skill", "dingtalk-approval", "references", "local-drafts.md"))) {
throw new Error("客户端包缺少完整 Skill、创建审批或本机草稿 reference");
} }
const offlineRoot = join(root, "offline-install"); const offlineRoot = join(root, "offline-install");
execFileSync("npm", [ execFileSync("npm", [
@@ -80,7 +82,8 @@ try {
], { stdio: "pipe", env: { ...process.env, npm_config_update_notifier: "false" } }); ], { stdio: "pipe", env: { ...process.env, npm_config_update_notifier: "false" } });
if (!existsFile(join(offlineRoot, "node_modules", "dingtalk-approval-client", "dist", "src", "bridge.js")) if (!existsFile(join(offlineRoot, "node_modules", "dingtalk-approval-client", "dist", "src", "bridge.js"))
|| !existsFile(join(offlineRoot, "node_modules", "dingtalk-approval-client", "dist", "src", "approval-contract.js")) || !existsFile(join(offlineRoot, "node_modules", "dingtalk-approval-client", "dist", "src", "approval-contract.js"))
|| !existsFile(join(offlineRoot, "node_modules", "dingtalk-approval-client", "skill", "dingtalk-approval", "references", "create-approval.md"))) { || !existsFile(join(offlineRoot, "node_modules", "dingtalk-approval-client", "skill", "dingtalk-approval", "references", "create-approval.md"))
|| !existsFile(join(offlineRoot, "node_modules", "dingtalk-approval-client", "skill", "dingtalk-approval", "references", "local-drafts.md"))) {
throw new Error("签名客户端无法在空缓存中离线安装"); throw new Error("签名客户端无法在空缓存中离线安装");
} }
const installer = readFileSync(join(releaseDir, artifactName(manifest.artifacts.installer, "installer")), "utf8"); const installer = readFileSync(join(releaseDir, artifactName(manifest.artifacts.installer, "installer")), "utf8");
@@ -152,6 +155,20 @@ function releaseKeyId(value) {
return createHash("sha256").update(der).digest("hex"); return createHash("sha256").update(der).digest("hex");
} }
function existsFile(path) { try { return statSync(path).isFile(); } catch { return false; } } function existsFile(path) { try { return statSync(path).isFile(); } catch { return false; } }
function assertRelativeModuleClosure(rootPath) {
const prefix = `${resolve(rootPath)}${sep}`;
for (const path of walk(rootPath)) {
if (!path.endsWith(".js")) continue;
const body = readFileSync(path, "utf8");
const imports = body.matchAll(/(?:\bfrom\s*|\bimport\s*)["'](\.\.?\/[^"']+\.js)["']/g);
for (const match of imports) {
const target = resolve(dirname(path), match[1]);
if (!target.startsWith(prefix) || !existsFile(target)) {
throw new Error(`客户端模块依赖缺失:${match[1]}(由 ${path.slice(prefix.length)} 导入)`);
}
}
}
}
function scanTree(rootPath) { function scanTree(rootPath) {
for (const path of walk(rootPath)) { for (const path of walk(rootPath)) {
if (statSync(path).size > 2_000_000) continue; if (statSync(path).size > 2_000_000) continue;
+4 -2
View File
@@ -1,6 +1,6 @@
--- ---
name: dingtalk-approval name: dingtalk-approval
description: Use the protected DingTalk Approval MCP to list, inspect, summarize, approve, reject, return, or create the signed-in user's DingTalk OA and XFT approvals, including approvals the user handled or submitted. Trigger for approval todos or history, expense review, reimbursement details, explicit approval actions, or requests to start and submit a new approval. description: Use the protected DingTalk Approval MCP to list, inspect, summarize, approve, reject, return, create, or locally draft the signed-in user's DingTalk OA and XFT approvals, including approvals the user handled or submitted. Trigger for approval todos or history, expense review, reimbursement details, explicit approval actions, requests to start or submit a new approval, or requests to save, continue, list, or delete a local approval draft.
--- ---
# DingTalk Approval # DingTalk Approval
@@ -30,4 +30,6 @@ Call `approval_list_history` with `scope=handled` for approvals the current user
## Create an approval ## Create an approval
Before creating, submitting, revoking, or reconciling a newly initiated approval, read `references/create-approval.md` completely and follow it. Do not use that reference for ordinary todo review, history lookup, or processing someone else's pending approval. When the user asks to draft, save, continue, list, or delete a submission draft, read `references/local-drafts.md` completely and follow it. A draft exists only on the Agent machine and never on the gateway or upstream service. Do not expose its UUID or local path.
Before preparing, submitting, revoking, or reconciling a newly initiated approval—including one resumed from a local draft—read `references/create-approval.md` completely and follow it. Do not use that reference for ordinary todo review, history lookup, or processing someone else's pending approval.
@@ -4,18 +4,21 @@ Create only XFT approvals and these DingTalk templates: `出差申请单_北极
1. Call `approval_list_submission_templates` and use only its returned `templateId`. 1. Call `approval_list_submission_templates` and use only its returned `templateId`.
2. Call `approval_get_submission_schema`. Compare every required field in `fields` and `inputContract` with information the user has already supplied. Never guess a missing value. 2. Call `approval_get_submission_schema`. Compare every required field in `fields` and `inputContract` with information the user has already supplied. Never guess a missing value.
If `submissionCapability.status` is `client_only`, stop API construction and tell the user to start that template in its native client. All six current DingTalk candidates are client-only: five contain unsupported business suites, while live enterprise-API tests rejected the ordinary table form in `业务招待申请_北极大`. XFT whitelist templates use their formal create APIs and are verified against live status after submission. Use `inputContract.values.minimalExample` only as the exact JSON shape for `values`. Replace every angle-bracket placeholder from the user's business answer or the selected live option; never submit a placeholder literally. Continue to enforce the recursive `standardFields` constraints even when the example omits optional fields.
For an XFT department or city code, call `approval_get_submission_options` with the same `templateId`; use only a returned `code`. Department results are restricted to the current signed-in user's member record. Never invent or request an internal code when the tool can resolve it. If `submissionCapability.status` is `client_only`, stop API construction and tell the user to start that template in its native client. All six current DingTalk candidates are client-only: five contain unsupported business suites, while live enterprise-API tests rejected the ordinary table form in `业务招待申请_北极大`. Supported XFT templates use their formal create APIs and are verified against live status after submission. XFT use-car applications are outside the MCP scope: they must not appear in the candidate list, and an old reference must be treated as unsupported rather than reconstructed.
Treat every XFT selection as a label-only user interaction. For a field with `optionSource`, call `approval_get_submission_options` with the same `templateId` and show only returned `label` values. After the user chooses a label, copy that exact item's `code` into the corresponding `values` field for prepare/submit; never put the label itself into a code-valued field. Keep its `code` only inside tool arguments and never repeat it in conversation. Apply the same rule to inline `options`; for an inline enum show only `enumLabels` values and internally use the matching enum key. Department results are restricted to the current signed-in user's member record. Never show, explain, or ask the user for a field code, city code, organization code, employee number, archive number, numeric cabin value, `expenseNumber`, `expenseTypeNumber`, `expTypeCode`, or other internal ID. If `selectionCapability.status=client_only`, do not ask for the internal value or guess it: a required field stops MCP creation and requires the native client; an optional field may be skipped, or the user may choose native-client completion. If a required option source cannot return readable labels, stop and offer retry or native-client completion.
For DingTalk, do not ask for or fill applicant and top-level department controls marked `serverManaged`; keep `deptId=-1` unless the user selected a department that the gateway can verify belongs to them. The gateway derives the default department from the authenticated user's live directory record. For DingTalk, do not ask for or fill applicant and top-level department controls marked `serverManaged`; keep `deptId=-1` unless the user selected a department that the gateway can verify belongs to them. The gateway derives the default department from the authenticated user's live directory record.
3. While any required field is missing or ambiguous, ask the user for the missing information and repeat the check after each answer. Prefer one concise question at a time, grouping fields only when they naturally belong together. Do not call `approval_prepare_submission` until all required fields are complete. 3. While any required field is missing or ambiguous, ask the user for the missing information and repeat the check after each answer. Prefer one concise question at a time, grouping fields only when they naturally belong together. Do not call `approval_prepare_submission` until all required fields are complete.
4. Explicitly show the remaining optional fields with short explanations and ask whether the user wants to add any. The user may choose to skip all optional fields; record that choice instead of silently omitting them. 4. Explicitly show the remaining user-visible, API-supported optional business fields with short explanations and ask whether the user wants to add any. Do not present fields marked `userVisible=false`, `serverManaged`, `internallyDerived`, or `selectionCapability.status=client_only` as ordinary questions. Briefly mention client-only optional capabilities only when they are relevant to the user's request. The user may choose to skip all optional fields; record that choice instead of silently omitting them.
5. Call `approval_prepare_submission` with `allowDuplicateInvoices=false`. It automatically checks invoice identifiers and expense signatures against the signed-in user's submitted DingTalk and XFT history for the most recent 30 days. If it reports another missing, invalid, unsupported, or selection-required field, return to the collection loop and ask until validation succeeds. Never work around validation. 5. Call `approval_prepare_submission` with `allowDuplicateInvoices=false`. For an XFT field with `attachmentCapability.status=supported`, pass its user-selected local file in the Bridge-only `attachments` array, not in `values`; the Bridge validates the regular file and sends only field name, file name, size, and SHA-256 for the no-upload preview. It automatically checks invoice identifiers, attachment hashes, and expense signatures against the signed-in user's submitted DingTalk and XFT history for the most recent 30 days. If it reports another missing, invalid, unsupported, or selection-required field, return to the collection loop and ask until validation succeeds. Never work around validation.
6. If preparation returns `duplicate_confirmation_required` or `duplicate_check_unavailable`, no submit-capable `confirmationId` was created. Show the duplicate check's masked invoice evidence, matching source/business number/status, and coverage warning. Ask whether the user still wants to submit. Never infer consent from the original creation request. Only after the user explicitly accepts that exact risk, call prepare again with the same content and `allowDuplicateInvoices=true`. A rejected, revoked, or void prior bill is still a warning; a pending, approved, or unknown prior bill is blocking by default. If another active preparation reserved the same invoice, wait for or finish that preparation rather than overriding it. 6. If preparation returns `duplicate_confirmation_required` or `duplicate_check_unavailable`, no submit-capable `confirmationId` was created. Show the duplicate check's masked invoice evidence, matching source/business number/status, and coverage warning. Ask whether the user still wants to submit. Never infer consent from the original creation request. Only after the user explicitly accepts that exact risk, call prepare again with the same content and `allowDuplicateInvoices=true`. A rejected, revoked, or void prior bill is still a warning; a pending, approved, or unknown prior bill is blocking by default. If another active preparation reserved the same invoice, wait for or finish that preparation rather than overriding it.
7. Show the successful returned preview, including `duplicateCheck`, and obtain explicit final confirmation to create the approval. Do not treat answers to field questions, the choice to skip optional fields, or the duplicate-risk acknowledgement as this final submission confirmation. 7. Show the successful returned preview, including `duplicateCheck`, and obtain explicit final confirmation to create the approval. The preview is already converted back to business labels; repeat those labels exactly and never replace them with values retained from Schema construction. If the gateway cannot map any selection back to a current label, it will refuse to create a `confirmationId`; refresh the live options instead of exposing the unresolved value. Do not treat answers to field questions, the choice to skip optional fields, or the duplicate-risk acknowledgement as this final submission confirmation.
8. After final confirmation, call `approval_submit` once with the same `templateId`, `values`, department, selections, `allowDuplicateInvoices`, and returned `confirmationId`. Never modify the content or retry automatically. 8. After final confirmation, call `approval_submit` once with the same `templateId`, `values`, local `attachments`, department, selections, `allowDuplicateInvoices`, and returned `confirmationId`. The signed Bridge revalidates and uploads attachments only at this point; the gateway never receives local paths and does not persist file bytes. Never modify the content or retry automatically.
If the user explicitly asks to undo a DingTalk or XFT approval just created through this MCP, call `approval_revoke_submission` once with the returned `revokeId`. Never use revoke to reject another person's todo, never accept a raw user ID, employee ID, or bill ID, and never automatically retry `failed` or `outcome_uncertain`. XFT recall is experimental in this release candidate: the current enterprise has returned `XFTSYS9999` for a real pending bill and no successful XFT recall has been observed. State this limitation when relevant and never report a recall as successful unless the tool returns `revoked` after live reconciliation. If the user explicitly asks to undo a DingTalk or XFT approval just created through this MCP, call `approval_revoke_submission` once with the returned `revokeId`. Never use revoke to reject another person's todo, never accept a raw user ID, employee ID, or bill ID, and never automatically retry `failed` or `outcome_uncertain`. XFT recall is experimental in this release candidate: the current enterprise has returned `XFTSYS9999` for a real pending bill and no successful XFT recall has been observed. State this limitation when relevant and never report a recall as successful unless the tool returns `revoked` after live reconciliation.
For an XFT submission or recall result that needs reconciliation, call `approval_get_submission_status` with the `businessId` returned by `approval_submit`. It is read-only and revalidates that the bill belongs to the signed-in user's initiated bills. Report `statusName` and the compact flow history. `completed` means the workflow finished immediately under the enterprise's approval rules (for example, no other effective approver); do not retry it. A fresh short-lived `revokeId` is returned only while the live bill status is pending approval; it still requires the user's explicit authorization before one recall attempt. Never treat status lookup alone as permission to repeat a write, and never retry `completed`, `upstream_state_mismatch`, or `outcome_uncertain`. For an XFT submission or recall result that needs reconciliation, call `approval_get_submission_status` with the `businessId` returned by `approval_submit`. It is read-only and revalidates that the bill belongs to the signed-in user's initiated bills. Report `statusName` and the compact flow history. `completed` means the workflow finished immediately under the enterprise's approval rules (for example, no other effective approver); do not retry it. A fresh short-lived `revokeId` is returned only while the live bill status is pending approval; it still requires the user's explicit authorization before one recall attempt. Never treat status lookup alone as permission to repeat a write, and never retry `completed`, `upstream_state_mismatch`, or `outcome_uncertain`.
Never ask for or pass an applicant user ID, employee number, owner, or approval status. The gateway derives them from the authenticated user. For XFT custom fields, use the live `fieldNumber` and its declared `valueFormat`; people use roster-number arrays, departments use organization-code arrays, and tables use `sequence`/`columnValues`/`titleCode`. If a required field type is unsupported or XFT reports a missing employee profile or permission, explain the requirement and stop. Never ask for or pass an applicant user ID, employee number, owner, or approval status. The gateway derives them from the authenticated user. For XFT custom fields, keep live `fieldNumber`, option codes, table `titleCode`, and serialization details internal to the tool call. Departments must be selected by returned department names; person fields currently require the native client because the MCP has no safe name lookup. If a required field type is unsupported or XFT reports a missing employee profile or permission, explain the business requirement without exposing internal identifiers and stop.
Local file paths and file bytes are not valid `values` or `customFieldValues`. Use the signed Bridge `attachments` argument only when the live XFT Schema reports `attachmentCapability.status=supported`; prepare validates without uploading, and submit uploads only after final confirmation. If the host lacks the signed Bridge or the field does not advertise attachment support, preserve any local draft and direct the user to complete that form in the native client. Never pass base64 content or an invented attachment number.
@@ -0,0 +1,87 @@
# Local submission drafts
Use a local draft only when the user explicitly asks to draft, save, continue, list, or delete an approval submission. A local draft is not an upstream DingTalk or XFT draft. Store it only on the machine running the Agent, never on the approval gateway, in the repository, in chat memory, or in another cloud service.
## Storage contract
Use the current OS user's private application-data directory:
- Windows: `%LOCALAPPDATA%\DingTalkApproval\drafts`
- macOS: `~/Library/Application Support/DingTalkApproval/drafts`
- Linux: `${XDG_DATA_HOME:-~/.local/share}/dingtalk-approval/drafts`
On POSIX, create the directory with mode `0700` and each file with mode `0600`. On Windows, retain a private current-user profile ACL and never grant access to other users. Do not follow symlinks in the draft directory. Use a random UUID plus `.json` as the filename, write a temporary file in the same directory, then atomically rename it. If the host cannot safely read and write local files, tell the user persistent local drafts are unavailable; do not silently fall back to gateway storage.
On every create, save, list, resume, delete, or submit operation, remove ordinary `editing` drafts whose `updatedAt` is more than 90 days old. Never age-delete a `submit_uncertain` draft until its submission outcome is reconciled or the user explicitly deletes it after seeing the warning.
Store schema version 1 in this shape:
```json
{
"schemaVersion": 1,
"kind": "dingtalk-approval-local-draft",
"draftId": "<uuid>",
"title": "上海客户拜访差旅报销",
"source": "xft",
"templateName": "差旅报销单",
"status": "editing",
"values": {},
"optionalFieldDecision": {
"skipped": []
},
"attachments": [
{
"fieldName": "invoiceAttachments",
"displayName": "invoice.pdf",
"localPath": "<absolute local path>",
"size": 12345,
"modifiedAt": "<ISO-8601>",
"sha256": "<64 lowercase hex>"
}
],
"createdAt": "<ISO-8601>",
"updatedAt": "<ISO-8601>"
}
```
Allow only `editing` and `submit_uncertain` status. Treat every file as untrusted local input. Reject malformed JSON, unexpected `kind`/`schemaVersion`, invalid UUID/timestamps, or an oversized file instead of guessing. Do not expose `draftId`, the filename, or local paths to the user; identify drafts by business title, template, and update time.
Store only user-provided business values, the source and template display name, explicit optional-field decisions, and local attachment references. Never store OAuth tokens, cookies, current-user IDs, employee IDs used only for authentication, `templateId`, `confirmationId`, `revokeId`, `approvalId`, `selectionId`, `targetId`, or a complete upstream schema. Never copy or upload an attachment merely to save a draft.
## Create and update
When starting a local draft, call `approval_list_submission_templates` and `approval_get_submission_schema` to establish that the requested template is supported and to collect its current fields. Save after each user-approved change while the draft remains active. Keep attachment fields out of `values`; record each local file in `attachments` with its field name, byte size, modification time, and SHA-256.
Tell the user once that the draft is local to this Agent machine and will not appear in DingTalk or XFT or synchronize to another device. Afterwards, discuss it simply as a draft unless that distinction matters.
## List, resume, and delete
List only concise business metadata: title, template, source, status, and local update time. If several drafts could match the user's words, ask them to choose using those business fields, not internal IDs or paths.
To resume:
1. Re-read and validate the local JSON without trusting stored field definitions.
2. Call `approval_list_submission_templates` and locate the live template by exact source and display name. If none or more than one matches, ask the user; never reuse a stored short-lived ID.
3. Call `approval_get_submission_schema` again. Reconcile stored values against the live fields. Ask about newly required, changed, ambiguous, or removed fields and never silently drop a value.
4. For each attachment, verify the regular file still exists and recompute SHA-256. If it is missing or its hash changed, show the attachment display name and require the user to select or confirm the file again. A changed modification time with the same hash may update metadata without changing the business content.
5. Continue the required and optional field collection from `create-approval.md`.
Treat every stored selection value as an internal form value. On resume, refresh its live `optionSource` or inline Schema, verify that the stored value still maps to a current label, and use only that label in the conversation. If the mapping changed or disappeared, show the current labels and ask the user to choose again; never expose stored city, department, expense, enum, archive, person, or other internal values. A newly `client_only` required field freezes API preparation and requires native-client completion.
Delete only the selected local file. Confirm deletion in business terms. Deleting a local draft never calls an upstream revoke or approval API.
## Prepare and submit
Before preparing a draft, read `create-approval.md` completely and follow its live validation, optional-field, duplicate-invoice, preview, and final-confirmation workflow. Recompute attachment hashes immediately before `approval_prepare_submission`. The server remains authoritative: local file modification cannot bypass identity, template, field, invoice-duplicate, or confirmation checks.
For each live XFT field whose `attachmentCapability.status` is `supported`, pass the draft attachment to the local Bridge `attachments` argument using exactly `fieldName`, `localPath`, `displayName`, `size`, `modifiedAt`, and `sha256` from the validated draft. Keep attachment fields out of `values.customFieldValues`. The signed Bridge opens the ordinary file without following a final symlink, checks its type and size, recomputes SHA-256, and sends only field name, file name, size, and hash to the gateway during `approval_prepare_submission`; it does not upload then. Supported local files are PDF, OFD, JPG/JPEG, PNG, XML, and ZIP, with at most 10 files and 50MB total. Show the returned attachment preview using business field and display names only; never reveal a local path, full hash, upload reference, or XFT file ID.
After the user explicitly confirms the complete preview, call `approval_submit` with the same local `attachments` array. The signed Bridge revalidates every file before any upload, uploads only after that final confirmation, and replaces paths with short-lived opaque upload references before calling the gateway tool. A local path, base64 content, or invented upstream attachment number is never a valid `values` field. If `attachmentCapability` is absent/client-only, the host has no signed Bridge, or local validation fails, keep the draft and explain the concrete file issue or require native-client completion.
Never persist the returned `templateId`, `confirmationId`, or `revokeId`. After the user's final confirmation, call `approval_submit` once with the in-memory live values and confirmation:
- On `submitted`, `succeeded`, or `completed`, delete the local draft after the tool result confirms the write.
- On a confirmed `failed` result, keep the draft as `editing`, discard all short-lived references, and explain the fixable business error.
- On `outcome_uncertain`, atomically mark the draft `submit_uncertain`, discard all short-lived write references, and freeze it. Never prepare or submit it again automatically. First query the signed-in user's live submitted history and, when a returned XFT business number permits it, use `approval_get_submission_status`. If the outcome cannot be proved, keep it frozen and ask the user to verify in the native client.
When reconciliation proves that the approval was created, delete the draft. When it proves that no approval was created, return it to `editing` only after telling the user what was checked; a later submit still requires a fresh schema, preparation, preview, duplicate check, and explicit confirmation.
+3
View File
@@ -1 +1,4 @@
export const APPROVAL_BATCH_LIMIT = 20; export const APPROVAL_BATCH_LIMIT = 20;
export const XFT_ATTACHMENT_MAX_FILE_BYTES = 50 * 1024 * 1024;
export const XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION = 10;
export const XFT_ATTACHMENT_MAX_TOTAL_BYTES = 50 * 1024 * 1024;
+265 -16
View File
@@ -1,15 +1,25 @@
#!/usr/bin/env node #!/usr/bin/env node
import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { createHash } from "node:crypto";
import {
chmodSync, closeSync, constants as fsConstants, cpSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync,
readFileSync, renameSync, rmSync, writeFileSync,
} from "node:fs";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { basename, dirname, join, resolve } from "node:path"; import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod"; import { z } from "zod";
import { APPROVAL_BATCH_LIMIT } from "./approval-contract.js"; import {
APPROVAL_BATCH_LIMIT,
XFT_ATTACHMENT_MAX_FILE_BYTES,
XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION,
XFT_ATTACHMENT_MAX_TOTAL_BYTES,
} from "./approval-contract.js";
import { isDirectEntrypoint } from "./entrypoint.js"; import { isDirectEntrypoint } from "./entrypoint.js";
import { APPLICATION_VERSION } from "./version.js";
interface BridgeState { interface BridgeState {
client?: { client_id: string; client_secret?: string }; client?: { client_id: string; client_secret?: string };
@@ -36,6 +46,25 @@ export const APPROVAL_ONBOARDING_EXAMPLES = [
"先列出待办,我确认后同意指定的几张", "先列出待办,我确认后同意指定的几张",
] as const; ] as const;
const REMOTE_WRITE_TIMEOUT_MS = 45_000;
interface LocalSubmissionAttachment {
fieldName: string;
localPath: string;
displayName?: string;
size?: number;
modifiedAt?: string;
sha256?: string;
}
interface InspectedLocalAttachment {
fieldName: string;
fileName: string;
size: number;
sha256: string;
content: Buffer;
}
class AuthRequired extends Error { class AuthRequired extends Error {
constructor(readonly challenge: NonNullable<BridgeState["device"]>) { super("需要钉钉登录"); } constructor(readonly challenge: NonNullable<BridgeState["device"]>) { super("需要钉钉登录"); }
} }
@@ -82,7 +111,14 @@ export class RemoteBridge {
return this.withCallSlot(async () => { return this.withCallSlot(async () => {
try { try {
const client = await this.remoteClient(); const client = await this.remoteClient();
return this.appendOnboarding(await client.callTool({ name, arguments: args })); try {
const options = name === "approval_process_batch" ? { timeout: REMOTE_WRITE_TIMEOUT_MS } : undefined;
return this.appendOnboarding(await client.callTool({ name, arguments: args }, undefined, options));
} catch (error) {
const uncertain = bridgeWriteOutcomeUncertainResult(name, args);
if (uncertain) return uncertain;
throw error;
}
} catch (error) { } catch (error) {
if (error instanceof AuthRequired) return this.authResult(error.challenge); if (error instanceof AuthRequired) return this.authResult(error.challenge);
throw error; throw error;
@@ -90,6 +126,32 @@ export class RemoteBridge {
}); });
} }
async prepareSubmission(args: Record<string, unknown>): Promise<any> {
const attachments = inspectLocalSubmissionAttachments(args.attachments);
return this.call("approval_prepare_submission", {
...args,
attachments: attachments.map(localAttachmentMetadata),
});
}
async submitApproval(args: Record<string, unknown>): Promise<any> {
const attachments = inspectLocalSubmissionAttachments(args.attachments);
if (!attachments.length) return this.call("approval_submit", { ...args, attachments: [] });
const confirmationId = typeof args.confirmationId === "string" ? args.confirmationId : "";
if (!confirmationId) throw new Error("附件提交缺少最终确认,请重新预览并取得用户确认");
try {
const uploaded = [];
for (const attachment of attachments) {
const result = await this.uploadSubmissionAttachment(attachment, confirmationId);
uploaded.push({ ...localAttachmentMetadata(attachment), attachmentRef: result.attachmentRef });
}
return this.call("approval_submit", { ...args, attachments: uploaded });
} catch (error) {
if (error instanceof AuthRequired) return this.authResult(error.challenge);
throw error;
}
}
private async withCallSlot<T>(work: () => Promise<T>): Promise<T> { private async withCallSlot<T>(work: () => Promise<T>): Promise<T> {
if (this.activeCalls < 3) this.activeCalls += 1; if (this.activeCalls < 3) this.activeCalls += 1;
else await new Promise<void>((resolve) => this.callWaiters.push(resolve)); else await new Promise<void>((resolve) => this.callWaiters.push(resolve));
@@ -101,6 +163,53 @@ export class RemoteBridge {
} }
} }
private async uploadSubmissionAttachment(
attachment: InspectedLocalAttachment,
confirmationId: string,
): Promise<{ attachmentRef: string }> {
await this.tryCompleteDeviceLogin();
if (!this.usableAccessToken()) await this.refreshAccessToken();
if (!this.usableAccessToken()) throw new AuthRequired(await this.startDeviceLogin());
let response: Response;
try {
response = await this.fetchFn(new URL("/xft/submission-attachments", this.origin), {
method: "POST",
headers: {
authorization: `Bearer ${this.state.tokens!.access_token}`,
"content-type": "application/octet-stream",
"content-length": String(attachment.size),
"x-dingmcp-field-name": encodeURIComponent(attachment.fieldName),
"x-dingmcp-file-name": encodeURIComponent(attachment.fileName),
"x-dingmcp-file-sha256": attachment.sha256,
"x-dingmcp-confirmation-id": confirmationId,
},
body: attachment.content.buffer.slice(
attachment.content.byteOffset,
attachment.content.byteOffset + attachment.content.byteLength,
) as ArrayBuffer,
signal: AbortSignal.timeout(Math.max(REMOTE_WRITE_TIMEOUT_MS, 120_000)),
});
} catch (error) {
throw new Error(`附件“${attachment.fileName}”上传到审批网关时连接失败;审批尚未创建,请勿自动重试`, { cause: error });
}
let value: Record<string, unknown>;
try { value = await response.json() as Record<string, unknown>; }
catch { throw new Error(`附件“${attachment.fileName}”上传失败:审批网关返回了无法解析的响应`); }
if (!response.ok || typeof value.attachmentRef !== "string") {
const detail = typeof value.error === "string" ? value.error : `HTTP ${response.status}`;
throw new Error(`附件“${attachment.fileName}”上传失败:${detail}`);
}
if (
value.fieldName !== attachment.fieldName
|| value.fileName !== attachment.fileName
|| value.size !== attachment.size
|| value.sha256 !== attachment.sha256
) {
throw new Error(`附件“${attachment.fileName}”上传响应与本地文件不一致,审批尚未创建`);
}
return { attachmentRef: value.attachmentRef };
}
private async remoteClient(): Promise<Client> { private async remoteClient(): Promise<Client> {
if (this.client && this.usableAccessToken()) return this.client; if (this.client && this.usableAccessToken()) return this.client;
if (this.client) { if (this.client) {
@@ -117,7 +226,7 @@ export class RemoteBridge {
await this.tryCompleteDeviceLogin(); await this.tryCompleteDeviceLogin();
if (!this.usableAccessToken()) await this.refreshAccessToken(); if (!this.usableAccessToken()) await this.refreshAccessToken();
if (!this.usableAccessToken()) throw new AuthRequired(await this.startDeviceLogin()); if (!this.usableAccessToken()) throw new AuthRequired(await this.startDeviceLogin());
const client = new Client({ name: "dingtalk-approval-stdio-bridge", version: "1.1.0-rc.13" }); const client = new Client({ name: "dingtalk-approval-stdio-bridge", version: APPLICATION_VERSION });
const transport = new StreamableHTTPClientTransport(this.endpoint, { const transport = new StreamableHTTPClientTransport(this.endpoint, {
requestInit: { headers: { authorization: `Bearer ${this.state.tokens!.access_token}` } }, requestInit: { headers: { authorization: `Bearer ${this.state.tokens!.access_token}` } },
}); });
@@ -361,6 +470,40 @@ export function bridgeAuthToolResult(result: BridgeLoginResult) {
return { content, structuredContent: { result } }; return { content, structuredContent: { result } };
} }
export function bridgeWriteOutcomeUncertainResult(name: string, args: Record<string, unknown>) {
if (name !== "approval_process_batch") return undefined;
const requestedItems = Array.isArray(args.items)
? args.items.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === "object" && !Array.isArray(item))
: [];
const selectionUsed = requestedItems.length === 0 && typeof args.selectionId === "string";
const message = "审批写请求超过 bridge 等待时间或连接中断,操作可能已经到达网关;禁止自动重试,请重新列出实时待办核对。";
const results = requestedItems.map((item, index) => ({
index: index + 1,
...(typeof item.approvalId === "string" ? { approvalId: item.approvalId } : {}),
status: "outcome_uncertain",
outcomeUncertain: true,
success: false,
message,
}));
const result = {
action: args.action,
status: "outcome_uncertain",
outcomeUncertain: true,
total: selectionUsed ? null : requestedItems.length,
uncertain: selectionUsed ? null : requestedItems.length,
...(selectionUsed ? { targetCount: "unknown" } : {}),
succeeded: 0,
failed: 0,
results,
requiresRelist: true,
retryable: false,
};
return {
content: [{ type: "text" as const, text: `${message}\n${JSON.stringify(result)}` }],
structuredContent: { result },
};
}
function isAuthenticationFailure(error: unknown): boolean { function isAuthenticationFailure(error: unknown): boolean {
const record = error && typeof error === "object" ? error as Record<string, unknown> : {}; const record = error && typeof error === "object" ? error as Record<string, unknown> : {};
const status = Number(record.status ?? record.statusCode ?? record.code); const status = Number(record.status ?? record.statusCode ?? record.code);
@@ -376,6 +519,101 @@ function isExplicitOAuthInvalidation(status: number, value: Record<string, unkno
&& ["invalid_grant", "invalid_client", "unauthorized_client", "access_denied", "expired_token"].includes(code); && ["invalid_grant", "invalid_client", "unauthorized_client", "access_denied", "expired_token"].includes(code);
} }
function inspectLocalSubmissionAttachments(value: unknown): InspectedLocalAttachment[] {
if (value === undefined) return [];
if (!Array.isArray(value)) throw new Error("attachments 必须是本机附件数组");
if (value.length > XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION) {
throw new Error(`一次薪福通提单最多选择 ${XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION} 个附件`);
}
const attachments = value.map((item) => inspectLocalSubmissionAttachment(item));
const total = attachments.reduce((sum, attachment) => sum + attachment.size, 0);
if (total > XFT_ATTACHMENT_MAX_TOTAL_BYTES) throw new Error("一次薪福通提单的附件总大小不能超过 50MB");
return attachments;
}
function inspectLocalSubmissionAttachment(value: unknown): InspectedLocalAttachment {
const attachment = isObject(value) ? value as unknown as LocalSubmissionAttachment : undefined;
if (!attachment || typeof attachment.fieldName !== "string" || !attachment.fieldName.trim()) {
throw new Error("附件缺少实时表单中的字段名称");
}
if (typeof attachment.localPath !== "string" || !attachment.localPath.trim()) {
throw new Error("附件缺少用户明确选择的本机文件");
}
if (!isAbsolute(attachment.localPath)) throw new Error("附件必须使用本机绝对路径,请重新选择文件");
const resolvedPath = resolve(attachment.localPath);
const fileName = basename(resolvedPath);
if (attachment.displayName !== undefined && attachment.displayName !== fileName) {
throw new Error(`附件“${attachment.displayName}”与当前选择的文件名不一致,请重新选择`);
}
if (!fileName || fileName.length > 240 || /[\r\n\0"]/u.test(fileName)) throw new Error("附件文件名无效,请重新命名后选择");
let before;
let fd: number | undefined;
try {
before = lstatSync(resolvedPath);
if (!before.isFile() || before.isSymbolicLink()) throw new Error("not a regular file");
fd = openSync(resolvedPath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
const opened = fstatSync(fd);
if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino) throw new Error("file changed while opening");
if (opened.size <= 0 || opened.size > XFT_ATTACHMENT_MAX_FILE_BYTES) throw new Error("invalid file size");
const content = readFileSync(fd);
const after = fstatSync(fd);
if (after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || content.length !== opened.size) {
throw new Error("file changed while reading");
}
validateLocalInvoiceAttachment(fileName, content);
const sha256 = createHash("sha256").update(content).digest("hex");
if (attachment.size !== undefined && attachment.size !== content.length) {
throw new Error("stored size changed");
}
if (attachment.sha256 !== undefined && attachment.sha256 !== sha256) {
throw new Error("stored hash changed");
}
return {
fieldName: attachment.fieldName.trim(),
fileName,
size: content.length,
sha256,
content,
};
} catch (error) {
const reason = error instanceof Error ? error.message : "file validation failed";
if (reason === "invalid file type") throw new Error(`附件“${fileName}”不是支持的 PDF、OFD、JPG、PNG、XML 或 ZIP 文件`);
if (reason === "invalid file size") throw new Error(`附件“${fileName}”必须大于 0 字节且单个不超过 50MB`);
if (reason === "stored hash changed" || reason === "stored size changed" || reason === "file changed while reading") {
throw new Error(`附件“${fileName}”自保存草稿或预览后发生变化,请重新选择并确认`);
}
throw new Error(`无法安全读取附件“${fileName}”;请选择未经过符号链接的普通文件`);
} finally {
if (fd !== undefined) closeSync(fd);
}
}
function validateLocalInvoiceAttachment(fileName: string, content: Buffer): void {
const extension = extname(fileName).toLowerCase();
const zip = content.length >= 4 && content[0] === 0x50 && content[1] === 0x4b
&& [[0x03, 0x04], [0x05, 0x06], [0x07, 0x08]].some(([a, b]) => content[2] === a && content[3] === b);
const valid = extension === ".pdf" ? content.subarray(0, 5).toString("ascii") === "%PDF-"
: extension === ".png" ? content.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
: extension === ".jpg" || extension === ".jpeg" ? content[0] === 0xff && content[1] === 0xd8 && content[2] === 0xff
: extension === ".xml" ? content.subarray(0, 1024).toString("utf8").replace(/^\uFEFF?\s*/u, "").startsWith("<")
: extension === ".ofd" || extension === ".zip" ? zip
: false;
if (!valid) throw new Error("invalid file type");
}
function localAttachmentMetadata(attachment: InspectedLocalAttachment) {
return {
fieldName: attachment.fieldName,
fileName: attachment.fileName,
size: attachment.size,
sha256: attachment.sha256,
};
}
function isObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function normalizeEndpoint(value: string): URL { function normalizeEndpoint(value: string): URL {
const url = new URL(value); const url = new URL(value);
if (url.protocol !== "https:" || url.pathname !== "/mcp") throw new Error("远程 MCP 必须是 HTTPS /mcp 地址"); if (url.protocol !== "https:" || url.pathname !== "/mcp") throw new Error("远程 MCP 必须是 HTTPS /mcp 地址");
@@ -388,8 +626,8 @@ function parseEndpoint(argv: string[]): string {
} }
export function createBridgeMcpServer(bridge: RemoteBridge): McpServer { export function createBridgeMcpServer(bridge: RemoteBridge): McpServer {
const server = new McpServer({ name: "dingtalk-approval-stdio-bridge", version: "1.1.0-rc.13" }, { const server = new McpServer({ name: "dingtalk-approval-stdio-bridge", version: APPLICATION_VERSION }, {
instructions: "首次使用或登录链接过期时调用approval_login;每次调用会检查登录并在需要时生成有效的新链接,绝不因链接过期而重新安装。工具不会通过其他消息通道自动发送链接,必须把content.text中的登录URL原样写入当前面向用户的回复,不得只声称已生成或已推送。审批引用、调用容量和内部分批不得向用户解释;查看时只回答业务信息,用户明确授权后在授权范围内自动处理。写前校验失败时零写入,任何不确定结果都不得自动重试。", instructions: "首次使用或登录链接过期时调用approval_login;每次调用会检查登录并在需要时生成有效的新链接,绝不因链接过期而重新安装。工具不会通过其他消息通道自动发送链接,必须把content.text中的登录URL原样写入当前面向用户的回复,不得只声称已生成或已推送。审批引用、调用容量和内部分批不得向用户解释;查看时只回答业务信息,用户明确授权后在授权范围内自动处理。薪福通待办不按单据类型过滤;availableActions=[]且manualAction.reason=unsupported_xft_task_type的项目仍要展示,但只能引导薪福通客户端查看处理,禁止尝试MCP写入。写前校验失败时零写入,任何不确定结果都不得自动重试。提单草稿仅可保存在 Agent 所在机器的当前用户私有目录,不得上传网关;恢复草稿后必须重新读取实时模板 Schema,短期提交引用不得写入草稿。草稿附件保存字段名称、本地路径、大小、修改时间和SHA-256;prepare只读取并复核本地文件、把不含路径的元数据用于预览,最终确认后的submit才上传,网关不保存文件。薪福通所有选择型字段只向用户展示业务名称:optionSource 选项展示label,内嵌枚举展示enumLabels;用户选定label后,values中必须填同一项的code而不能填label,code只存在于工具调用内部。不得向用户索取或展示城市码、组织码、工号、档案编号、数字舱位值或其他内部ID;selectionCapability=client_only时停止构造该必填项或允许跳过该可选项。",
}); });
server.registerTool("approval_login", { server.registerTool("approval_login", {
title: "登录审批服务", description: "检查登录;未登录或旧链接已过期时生成新的钉钉授权链接。同一身份同时用于钉钉OA和薪福通,无需重新安装。", inputSchema: {}, title: "登录审批服务", description: "检查登录;未登录或旧链接已过期时生成新的钉钉授权链接。同一身份同时用于钉钉OA和薪福通,无需重新安装。", inputSchema: {},
@@ -399,7 +637,7 @@ export function createBridgeMcpServer(bridge: RemoteBridge): McpServer {
return bridgeAuthToolResult(result); return bridgeAuthToolResult(result);
}); });
server.registerTool("approval_list_todos", { server.registerTool("approval_list_todos", {
title: "列出我的审批待办", description: "实时列出当前登录用户本人的钉钉OA和薪福通待办。", inputSchema: { title: "列出我的审批待办", description: "实时列出当前登录用户本人的钉钉OA和薪福通待办;薪福通不按单据类型过滤,未适配项目会明确标记为仅可在薪福通客户端查看处理。", inputSchema: {
source: z.enum(["all", "dingtalk", "xft"]).default("all"), query: z.string().max(200).default(""), source: z.enum(["all", "dingtalk", "xft"]).default("all"), query: z.string().max(200).default(""),
limit: z.number().int().min(1).max(200).default(50), limit: z.number().int().min(1).max(200).default(50),
}, annotations: { readOnlyHint: true, destructiveHint: false }, }, annotations: { readOnlyHint: true, destructiveHint: false },
@@ -440,35 +678,44 @@ export function createBridgeMcpServer(bridge: RemoteBridge): McpServer {
}, annotations: { readOnlyHint: true, destructiveHint: false }, }, annotations: { readOnlyHint: true, destructiveHint: false },
}, async (args) => bridge.call("approval_list_submission_templates", args)); }, async (args) => bridge.call("approval_list_submission_templates", args));
server.registerTool("approval_get_submission_schema", { server.registerTool("approval_get_submission_schema", {
title: "获取发起审批表单结构", description: "实时读取模板字段、必填项、复杂字段格式和 API 构造能力。", inputSchema: { title: "获取发起审批表单结构", description: "实时读取模板字段、必填项、可读选择名称、minimalExample、复杂字段格式和 API 构造能力;minimalExample的尖括号必须替换,client_only字段不得让用户提供内部值。", inputSchema: {
templateId: z.string().min(20).max(4096), templateId: z.string().min(20).max(4096),
}, annotations: { readOnlyHint: true, destructiveHint: false }, }, annotations: { readOnlyHint: true, destructiveHint: false },
}, async (args) => bridge.call("approval_get_submission_schema", args)); }, async (args) => bridge.call("approval_get_submission_schema", args));
server.registerTool("approval_get_submission_options", { server.registerTool("approval_get_submission_options", {
title: "查询发起审批可用选项", description: "实时查询当前登录用户本人的薪福通承担部门,或搜索城市编码。", inputSchema: { title: "查询发起审批可用选项", description: "实时查询当前登录用户本人的薪福通承担部门,或按名称搜索城市和费用类型。所有选项只向用户展示label;values必须填所选项的code而不能填label,code仅供工具内部提交。", inputSchema: {
templateId: z.string().min(20).max(4096), kind: z.enum(["department", "city"]), templateId: z.string().min(20).max(4096), kind: z.enum(["department", "city", "expense_type"]),
query: z.string().max(100).default(""), limit: z.number().int().min(1).max(100).default(50), query: z.string().max(100).default(""), limit: z.number().int().min(1).max(100).default(50),
}, annotations: { readOnlyHint: true, destructiveHint: false }, }, annotations: { readOnlyHint: true, destructiveHint: false },
}, async (args) => bridge.call("approval_get_submission_options", args)); }, async (args) => bridge.call("approval_get_submission_options", args));
const submissionInputSchema = { const submissionInputSchema = {
templateId: z.string().min(20).max(4096), templateId: z.string().min(20).max(4096),
values: z.record(z.string().min(1).max(200), z.unknown()), values: z.record(z.string().min(1).max(200), z.unknown()).describe("选择项必须使用所选label对应的code而不是label;code不得展示给用户"),
deptId: z.number().int().default(-1).describe("钉钉发起部门;默认-1,由网关按当前登录用户实时解析并验证"), deptId: z.number().int().default(-1).describe("钉钉发起部门;默认-1,由网关按当前登录用户实时解析并验证"),
targetSelectActioners: z.array(z.object({ targetSelectActioners: z.array(z.object({
actionerKey: z.string().min(1).max(500), actionerKey: z.string().min(1).max(500),
actionerStaffIds: z.array(z.string().min(1).max(500)).min(1).max(50), actionerStaffIds: z.array(z.string().min(1).max(500)).min(1).max(50),
})).max(20).default([]), })).max(20).default([]),
allowDuplicateInvoices: z.boolean().default(false).describe("仅在网关已提示重复风险或查重不完整、且用户明确确认仍要提交后设为 true"), allowDuplicateInvoices: z.boolean().default(false).describe("仅在网关已提示重复风险或查重不完整、且用户明确确认仍要提交后设为 true"),
attachments: z.array(z.object({
fieldName: z.string().min(1).max(200).describe("实时 Schema 中的附件字段业务名称"),
localPath: z.string().min(1).max(4096).describe("用户明确选择的本机绝对文件路径;只由签名 Bridge 读取,不发送给网关"),
displayName: z.string().min(1).max(240).optional(),
size: z.number().int().min(1).max(XFT_ATTACHMENT_MAX_FILE_BYTES).optional(),
modifiedAt: z.string().datetime().optional(),
sha256: z.string().regex(/^[0-9a-f]{64}$/).optional(),
})).max(XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION).default([])
.describe("本机草稿附件引用;prepare复核文件且不上传,用户最终确认后的submit才上传"),
}; };
server.registerTool("approval_prepare_submission", { server.registerTool("approval_prepare_submission", {
title: "校验并预览新审批", description: "无副作用校验全部字段并自动查重本人近30天钉钉/薪福通发票;有重复风险或查重不完整时要求用户明确确认,不会直接生成提交引用。", inputSchema: submissionInputSchema, title: "校验并预览新审批", description: "无副作用校验全部字段并自动查重本人近30天钉钉/薪福通发票;选择项在values中填所选label对应的code,成功预览只显示业务名称;有重复风险或查重不完整时要求用户明确确认,不会直接生成提交引用。", inputSchema: submissionInputSchema,
annotations: { readOnlyHint: true, destructiveHint: false }, annotations: { readOnlyHint: true, destructiveHint: false },
}, async (args) => bridge.call("approval_prepare_submission", args)); }, async (args) => bridge.prepareSubmission(args));
server.registerTool("approval_submit", { server.registerTool("approval_submit", {
title: "正式发起审批", description: "仅在用户看过最终预览并明确确认后,使用完全相同内容和重复风险决定发起审批;禁止自动重试。", inputSchema: { title: "正式发起审批", description: "仅在用户看过最终预览并明确确认后,使用完全相同内容和重复风险决定发起审批;禁止自动重试。", inputSchema: {
confirmationId: z.string().min(20).max(4096), ...submissionInputSchema, confirmationId: z.string().min(20).max(4096), ...submissionInputSchema,
}, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false }, }, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
}, async (args) => bridge.call("approval_submit", args)); }, async (args) => bridge.submitApproval(args));
server.registerTool("approval_revoke_submission", { server.registerTool("approval_revoke_submission", {
title: "撤销本人发起的审批", description: "使用提单成功后返回的 revokeId 撤销本人发起的审批;禁止自动重试。薪福通 recall 当前为实验能力,必须以写后状态复核为准。", inputSchema: { title: "撤销本人发起的审批", description: "使用提单成功后返回的 revokeId 撤销本人发起的审批;禁止自动重试。薪福通 recall 当前为实验能力,必须以写后状态复核为准。", inputSchema: {
revokeId: z.string().min(20).max(4096), remark: z.string().max(1000).default(""), revokeId: z.string().min(20).max(4096), remark: z.string().max(1000).default(""),
@@ -502,7 +749,9 @@ export function syncBundledSkillForLegacyUpdate(
): void { ): void {
const resolvedState = resolve(statePath); const resolvedState = resolve(statePath);
if (basename(resolvedState) !== "session.json" || basename(dirname(resolvedState)) !== "state") return; if (basename(resolvedState) !== "session.json" || basename(dirname(resolvedState)) !== "state") return;
if (!existsSync(join(bundled, "SKILL.md")) || !existsSync(join(bundled, "references", "create-approval.md"))) return; if (!existsSync(join(bundled, "SKILL.md"))
|| !existsSync(join(bundled, "references", "create-approval.md"))
|| !existsSync(join(bundled, "references", "local-drafts.md"))) return;
const target = resolve(dirname(resolvedState), "../skill/dingtalk-approval"); const target = resolve(dirname(resolvedState), "../skill/dingtalk-approval");
const parent = dirname(target); const parent = dirname(target);
mkdirSync(parent, { recursive: true, mode: 0o700 }); mkdirSync(parent, { recursive: true, mode: 0o700 });
+5 -2
View File
@@ -808,8 +808,11 @@ function mcpConfig(paths: ClientPaths): Record<string, unknown> {
} }
function installBundledSkill(paths: ClientPaths, bundledSkillDir: string, skillBody: Buffer): void { function installBundledSkill(paths: ClientPaths, bundledSkillDir: string, skillBody: Buffer): void {
const reference = join(bundledSkillDir, "references", "create-approval.md"); const createReference = join(bundledSkillDir, "references", "create-approval.md");
if (!existsSync(join(bundledSkillDir, "SKILL.md")) || !existsSync(reference)) { const draftReference = join(bundledSkillDir, "references", "local-drafts.md");
if (!existsSync(join(bundledSkillDir, "SKILL.md"))
|| !existsSync(createReference)
|| !existsSync(draftReference)) {
throw new Error("客户端包缺少完整 Skill 资源"); throw new Error("客户端包缺少完整 Skill 资源");
} }
const parent = dirname(paths.skillDir); const parent = dirname(paths.skillDir);
+121 -17
View File
@@ -9,9 +9,13 @@ import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middlew
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod"; import { z } from "zod";
import { APPROVAL_BATCH_LIMIT } from "./approval-contract.js"; import {
APPROVAL_BATCH_LIMIT,
XFT_ATTACHMENT_MAX_FILE_BYTES,
XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION,
} from "./approval-contract.js";
import { ApprovalService } from "./approval-service.js"; import { ApprovalService } from "./approval-service.js";
import { errorMessage } from "./errors.js"; import { ApprovalSafetyError, XftApiError, errorMessage } from "./errors.js";
import type { AppConfig } from "./config.js"; import type { AppConfig } from "./config.js";
import { DingTalkClient } from "./dingtalk.js"; import { DingTalkClient } from "./dingtalk.js";
import type { XftApprovalProvider } from "./xft.js"; import type { XftApprovalProvider } from "./xft.js";
@@ -21,6 +25,8 @@ import type { OfficialOaReturnClient } from "./unified-approval.js";
import { DINGTALK_OAUTH_AUTHORIZE_ORIGIN, DingTalkMcpOAuthProvider } from "./oauth.js"; import { DINGTALK_OAUTH_AUTHORIZE_ORIGIN, DingTalkMcpOAuthProvider } from "./oauth.js";
import type { AuthenticatedDingTalkUser } from "./xft.js"; import type { AuthenticatedDingTalkUser } from "./xft.js";
import { InvoiceDeduplicationService, InvoiceDedupStore, invoiceDedupDatabasePath } from "./invoice-dedup.js"; import { InvoiceDeduplicationService, InvoiceDedupStore, invoiceDedupDatabasePath } from "./invoice-dedup.js";
import { APPLICATION_VERSION } from "./version.js";
import { SignedReferenceCodec } from "./signed-reference.js";
type TransportEntry = type TransportEntry =
| { kind: "streamable"; transport: StreamableHTTPServerTransport; userId: string; createdAt: number; lastSeenAt: number } | { kind: "streamable"; transport: StreamableHTTPServerTransport; userId: string; createdAt: number; lastSeenAt: number }
@@ -32,7 +38,7 @@ const MCP_SESSION_MAX_PER_USER = 8;
const MCP_SESSION_MAX_TOTAL = 1000; const MCP_SESSION_MAX_TOTAL = 1000;
export const APPROVAL_MCP_INSTRUCTIONS = [ export const APPROVAL_MCP_INSTRUCTIONS = [
"先调用 approval_list_todos 获取当前待办。用户只是在查看时,仅回答标题、申请人、金额、时间等业务信息,不主动讨论审批接口、批处理方式或索取写操作授权。用户明确要求处理后,在其授权范围内内部选择整组引用或自动分批;技术分批不得造成重复确认。", "先调用 approval_list_todos 获取当前待办。薪福通待办不按单据类型过滤;availableActions=[] 且 manualAction.reason=unsupported_xft_task_type 的项目仍要展示给用户,但只能引导到薪福通客户端查看和处理,禁止尝试 MCP 写入。用户只是在查看时,仅回答标题、申请人、金额、时间等业务信息,不主动讨论审批接口、批处理方式或索取写操作授权。用户明确要求处理后,在其授权范围内内部选择整组引用或自动分批;技术分批不得造成重复确认。",
"approvalId、selectionId、targetId、单次条数限制、分批方式以及内部引用是否返回均属于 Agent 与 MCP 的内部调用细节,绝不向用户展示、解释或要求用户操作。面向用户只说明目标审批、业务动作、成功结果和需要用户决策的业务失败;不要说引用是否签发、是否失效、是否存在一键全选或如何分组。", "approvalId、selectionId、targetId、单次条数限制、分批方式以及内部引用是否返回均属于 Agent 与 MCP 的内部调用细节,绝不向用户展示、解释或要求用户操作。面向用户只说明目标审批、业务动作、成功结果和需要用户决策的业务失败;不要说引用是否签发、是否失效、是否存在一键全选或如何分组。",
"查询本人处理过或本人发起的审批时调用 approval_list_history;默认最近30天,可用 startDate/endDate 指定最长120天的时间窗口。用其只读 approvalId 获取详情;历史引用不能用于 approval_get_actions 或 approval_process_batch,也不会返回 selectionId。", "查询本人处理过或本人发起的审批时调用 approval_list_history;默认最近30天,可用 startDate/endDate 指定最长120天的时间窗口。用其只读 approvalId 获取详情;历史引用不能用于 approval_get_actions 或 approval_process_batch,也不会返回 selectionId。",
"费用复核优先调用 approval_get_detail(view=expense);summary 用于一般详情,raw 仅用于分页补充字段。", "费用复核优先调用 approval_get_detail(view=expense);summary 用于一般详情,raw 仅用于分页补充字段。",
@@ -40,7 +46,8 @@ export const APPROVAL_MCP_INSTRUCTIONS = [
"只有用户明确要求处理审批时,才调用 approval_process_batch;写操作不会二次确认。开始写入前会先校验整批 approvalId 和 targetId;任一引用无效则整批零写入。重新拉取待办后必须重新确认目标,禁止自动补偿或自动重试。", "只有用户明确要求处理审批时,才调用 approval_process_batch;写操作不会二次确认。开始写入前会先校验整批 approvalId 和 targetId;任一引用无效则整批零写入。重新拉取待办后必须重新确认目标,禁止自动补偿或自动重试。",
"钉钉开放接口无法预检部分模板的签字等业务规则;要求手写签名时返回客户端处理链接。处理结果中 failed 表示操作被拒绝且未生效;除非用户看过原因后再次明确要求,否则不要重试。succeeded 表示节点提交成功;completed 才表示无需当前用户继续审批。uncertain/outcome_uncertain 表示已经提交但未确认,禁止自动重试,应稍后重新列出待办。followUpRequired 大于 0 时应重新列出待办并告知用户。", "钉钉开放接口无法预检部分模板的签字等业务规则;要求手写签名时返回客户端处理链接。处理结果中 failed 表示操作被拒绝且未生效;除非用户看过原因后再次明确要求,否则不要重试。succeeded 表示节点提交成功;completed 才表示无需当前用户继续审批。uncertain/outcome_uncertain 表示已经提交但未确认,禁止自动重试,应稍后重新列出待办。followUpRequired 大于 0 时应重新列出待办并告知用户。",
"出现 authChallenge 时,工具不会通过其他消息通道自动发送内容。必须把 directUrl 原样写入当前面向用户的回复;有二维码图片时直接作为当前回复附件,禁止转成 ASCII,也不得只声称已生成或已推送。", "出现 authChallenge 时,工具不会通过其他消息通道自动发送内容。必须把 directUrl 原样写入当前面向用户的回复;有二维码图片时直接作为当前回复附件,禁止转成 ASCII,也不得只声称已生成或已推送。",
"发起审批范围仅包括薪福通已支持模板,以及 6 个钉钉候选模板:北极大的出差申请、外出、业务招待申请,捷康的差旅费用报销、业务招待费用报销、业务招待申请。禁止绕过范围限制。固定使用 approval_list_submission_templates → approval_get_submission_schema → approval_prepare_submission。薪福通部门或城市编码必须用 approval_get_submission_options 实时查询,不得猜测。必须先检查 submissionCapability;client_only 时停止 API 构造并引导钉钉客户端。读取 Schema 后,逐项核对用户已提供的信息;必填字段缺失或含糊时必须继续询问且不得猜测,直到全部完整。必须列出尚未填写的非必填字段并询问用户是否补充,用户可以明确跳过。prepare 若返回缺失、无效或需自选处理人的字段,继续询问后重新 prepare。prepare 成功后必须向用户展示完整预览并另行取得明确确认;确认后才用完全相同的内容调用 approval_submit。不要让用户或 Agent 提供发起人 userId、薪福通员工号或审批状态。", "发起审批范围仅包括薪福通已支持模板,以及 6 个钉钉候选模板:北极大的出差申请、外出、业务招待申请,捷康的差旅费用报销、业务招待费用报销、业务招待申请。禁止绕过范围限制。固定使用 approval_list_submission_templates → approval_get_submission_schema → approval_prepare_submission。所有选择型字段只向用户展示业务名称:optionSource 指向的部门、城市或费用类型必须用 approval_get_submission_options 实时查询;内嵌 options/enumLabels 只展示 label 或中文名称。用户选择 label 后,Agent 必须在 values 对应字段中填入同一选项的 code,不能把 label 当作正式值;code 只存在于工具调用内部,绝不出现在用户对话。绝不向用户展示、解释或索取字段编码、城市码、组织码、工号、档案编号、数字舱位值或其他内部 ID。selectionCapability.status=client_only 的字段不得猜测或构造;必填时停止并引导薪福通客户端,可选时只说明客户端可补充且允许用户跳过。必须先检查 submissionCapability;client_only 时停止 API 构造并引导原生客户端。读取 Schema 后,逐项核对用户已提供的信息;必填字段缺失或含糊时必须继续询问且不得猜测,直到全部完整。只列出 userVisible!=false 且非 client_only 的尚未填写非必填字段,使用业务名称说明并询问用户是否补充;用户可以明确跳过。prepare 若返回缺失、无效或需自选处理人的字段,继续询问后重新 prepare。prepare 成功后必须向用户展示完整预览并另行取得明确确认;确认后才用完全相同的内容调用 approval_submit。不要让用户或 Agent 提供发起人 userId、薪福通员工号或审批状态。",
"用户明确要求起草、保存、继续、列出或删除提单草稿时,草稿只能由 Agent 保存在运行机器的当前 OS 用户私有数据目录:Windows %LOCALAPPDATA%\\DingTalkApproval\\drafts、macOS ~/Library/Application Support/DingTalkApproval/drafts、Linux ${XDG_DATA_HOME:-~/.local/share}/dingtalk-approval/drafts;POSIX 目录/文件权限为 0700/0600并用同目录临时文件原子替换。不得上传网关、写入项目仓库或声称已在钉钉/薪福通创建草稿。宿主没有本地文件能力时明确说明无法持久保存。每个 UUID.json 使用 schemaVersion=1、kind=dingtalk-approval-local-draft、draftId、title、source、templateName、status(editing或submit_uncertain)、values、optionalFieldDecision、attachments、createdAt、updatedAt;只保存用户填写值和附件本地路径/大小/mtime/SHA-256,不得保存 Token、身份 ID、短期 templateId/confirmationId/revokeId 或完整上游 Schema。恢复时必须重新列模板并实时读取 Schema,附件必须在正式准备前重新校验。签名本地 Bridge 会把路径转换为不含本地路径的附件元数据参与预览,并只在最终确认后上传;网关不保存文件。每次草稿操作自动清理超过 90 天未更新的普通草稿。正式提交成功或即时完成后删除;明确失败时保留;outcome_uncertain 时冻结且禁止自动重试,先实时核验本人发起记录。",
"approval_prepare_submission 会自动检查当前用户近30天本人发起的钉钉与薪福通审批是否使用了相同发票。若返回 duplicate_confirmation_required 或 duplicate_check_unavailable,本次没有生成 confirmationId;必须展示匹配单号、状态和查重覆盖情况并询问用户。只有用户明确表示知晓重复或查重不完整仍要提交后,才可将 allowDuplicateInvoices=true 原样重新 prepare。不得自行代替用户确认。", "approval_prepare_submission 会自动检查当前用户近30天本人发起的钉钉与薪福通审批是否使用了相同发票。若返回 duplicate_confirmation_required 或 duplicate_check_unavailable,本次没有生成 confirmationId;必须展示匹配单号、状态和查重覆盖情况并询问用户。只有用户明确表示知晓重复或查重不完整仍要提交后,才可将 allowDuplicateInvoices=true 原样重新 prepare。不得自行代替用户确认。",
"approval_submit 发起后会返回实时核验结果;仍在审批中的单据会带 revokeId,按企业规则即时完成的薪福通流程返回 completed 且不可撤回。只有用户明确要求撤销该本人审批时才调用 approval_revoke_submission;禁止用撤销代替拒绝别人的待办,禁止自动重试 completed、failed 或 outcome_uncertain。薪福通创建或撤回需要复核时使用只读 approval_get_submission_status,它会实时校验本人已发起单据归属,但不会再次执行写操作。", "approval_submit 发起后会返回实时核验结果;仍在审批中的单据会带 revokeId,按企业规则即时完成的薪福通流程返回 completed 且不可撤回。只有用户明确要求撤销该本人审批时才调用 approval_revoke_submission;禁止用撤销代替拒绝别人的待办,禁止自动重试 completed、failed 或 outcome_uncertain。薪福通创建或撤回需要复核时使用只读 approval_get_submission_status,它会实时校验本人已发起单据归属,但不会再次执行写操作。",
].join("\n"); ].join("\n");
@@ -56,7 +63,7 @@ export function createApprovalMcpServer(
invoiceDedup?: InvoiceDeduplicationService, invoiceDedup?: InvoiceDeduplicationService,
): McpServer { ): McpServer {
const server = new McpServer( const server = new McpServer(
{ name: "dingtalk-approval-mcp", version: "1.1.0-rc.13" }, { name: "dingtalk-approval-mcp", version: APPLICATION_VERSION },
{ instructions: APPROVAL_MCP_INSTRUCTIONS }, { instructions: APPROVAL_MCP_INSTRUCTIONS },
); );
const unified = new UnifiedApprovalService(config, service, xft, xftChallenges, officialOaReturns, invoiceDedup); const unified = new UnifiedApprovalService(config, service, xft, xftChallenges, officialOaReturns, invoiceDedup);
@@ -65,7 +72,7 @@ export function createApprovalMcpServer(
server.registerTool("approval_list_todos", { server.registerTool("approval_list_todos", {
title: "列出我的审批待办", title: "列出我的审批待办",
description: "审批操作的入口。通过同一登录身份实时列出当前用户在钉钉OA和薪福通中的待办,并取得后续接口必须使用的approvalId;列表尽可能直接包含金额和带时区的提交时间。", description: "审批操作的入口。通过同一登录身份实时列出当前用户在钉钉OA和薪福通中的待办;薪福通不按单据类型静默过滤。已确认正式接口兼容的项目取得可用approvalId,未适配项目也会显示但以availableActions=[]和manualAction明确要求到薪福通客户端查看处理。列表尽可能直接包含金额和带时区的提交时间。",
inputSchema: { inputSchema: {
source: z.enum(["all", "dingtalk", "xft"]).default("all"), source: z.enum(["all", "dingtalk", "xft"]).default("all"),
query: z.string().max(200).default(""), query: z.string().max(200).default(""),
@@ -170,7 +177,7 @@ export function createApprovalMcpServer(
server.registerTool("approval_get_submission_schema", { server.registerTool("approval_get_submission_schema", {
title: "获取发起审批表单结构", title: "获取发起审批表单结构",
description: "使用模板列表返回的templateId实时读取结构化字段、必填状态和填写约束。Agent必须持续询问直至必填字段齐全,并主动提醒可选字段;不会返回超大的上游原始Schema。", description: "使用模板列表返回的templateId实时读取结构化字段、必填状态、可读选项、填写约束和minimalExample。minimalExample只说明values形状,尖括号必须替换;选择项只向用户展示label/enumLabels;client_only字段不得索取内部值。Agent必须持续询问直至必填字段齐全,并主动提醒可选字段中能安全填写的业务项;不会返回超大的上游原始Schema。",
inputSchema: { inputSchema: {
templateId: z.string().min(20).max(4096).describe("必须原样使用 approval_list_submission_templates 返回的短期签名引用"), templateId: z.string().min(20).max(4096).describe("必须原样使用 approval_list_submission_templates 返回的短期签名引用"),
}, },
@@ -182,10 +189,10 @@ export function createApprovalMcpServer(
server.registerTool("approval_get_submission_options", { server.registerTool("approval_get_submission_options", {
title: "查询发起审批可用选项", title: "查询发起审批可用选项",
description: "使用薪福通templateId实时查询当前登录用户本人的承担部门,或按名称搜索城市编码。只返回可填写的code/label,不返回成员档案、员工号或原始上游数据。", description: "使用薪福通templateId实时查询当前登录用户本人的承担部门,或按名称搜索城市和费用类型。所有结果只向用户展示label;用户选择后,values对应字段必须填同一项的code,不能填label。code仅留在工具调用内部,禁止向用户索取或展示。只返回可填写的code/label,不返回成员档案、员工号或原始上游数据。",
inputSchema: { inputSchema: {
templateId: z.string().min(20).max(4096), templateId: z.string().min(20).max(4096),
kind: z.enum(["department", "city"]), kind: z.enum(["department", "city", "expense_type"]),
query: z.string().max(100).default(""), query: z.string().max(100).default(""),
limit: z.number().int().min(1).max(100).default(50), limit: z.number().int().min(1).max(100).default(50),
}, },
@@ -197,7 +204,7 @@ export function createApprovalMcpServer(
const submissionInputSchema = { const submissionInputSchema = {
templateId: z.string().min(20).max(4096).describe("模板列表返回的短期签名引用"), templateId: z.string().min(20).max(4096).describe("模板列表返回的短期签名引用"),
values: z.record(z.string().min(1).max(200), z.unknown()).describe("按模板Schema填写的表单业务字段;不得包含发起人身份、员工号或审批状态"), values: z.record(z.string().min(1).max(200), z.unknown()).describe("按模板Schema填写的表单业务字段;选择项必须使用所选label对应的code而不是label,code不得展示给用户;不得包含发起人身份、员工号或审批状态"),
deptId: z.number().int().default(-1).describe("仅钉钉OA使用;默认传-1,由服务器实时解析并验证当前登录用户所属部门"), deptId: z.number().int().default(-1).describe("仅钉钉OA使用;默认传-1,由服务器实时解析并验证当前登录用户所属部门"),
targetSelectActioners: z.array(z.object({ targetSelectActioners: z.array(z.object({
actionerKey: z.string().min(1).max(500), actionerKey: z.string().min(1).max(500),
@@ -205,14 +212,32 @@ export function createApprovalMcpServer(
})).max(20).default([]), })).max(20).default([]),
allowDuplicateInvoices: z.boolean().default(false).describe("仅当 prepare 已展示重复/疑似重复或查重覆盖不完整,且用户明确确认仍要提交时设为 true;不得由 Agent 自行开启"), allowDuplicateInvoices: z.boolean().default(false).describe("仅当 prepare 已展示重复/疑似重复或查重覆盖不完整,且用户明确确认仍要提交时设为 true;不得由 Agent 自行开启"),
}; };
const attachmentMetadataSchema = z.object({
fieldName: z.string().min(1).max(200).describe("实时 Schema 中的附件字段业务名称"),
fileName: z.string().min(1).max(240),
size: z.number().int().min(1).max(XFT_ATTACHMENT_MAX_FILE_BYTES),
sha256: z.string().regex(/^[0-9a-f]{64}$/),
});
const prepareSubmissionInputSchema = {
...submissionInputSchema,
attachments: z.array(attachmentMetadataSchema).max(XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION).default([])
.describe("薪福通本机附件元数据;不含本地路径、文件内容或预上传引用,prepare 不上传"),
};
const submitSubmissionInputSchema = {
...submissionInputSchema,
attachments: z.array(attachmentMetadataSchema.extend({
attachmentRef: z.string().min(20).max(4096).describe("仅最终提交时由签名本地 Bridge 上传后填写"),
})).max(XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION).default([])
.describe("薪福通附件元数据和绑定本次确认的短期上传引用;不含本地路径和文件内容"),
};
server.registerTool("approval_prepare_submission", { server.registerTool("approval_prepare_submission", {
title: "校验并预览新审批", title: "校验并预览新审批",
description: "仅在用户已补齐所有必填字段并选择是否填写可选字段后调用。实时复核模板和字段构造能力,并自动查重本人近30天钉钉/薪福通发票。若返回缺失或无效字段,Agent必须继续询问;若为 client_only 则停止 API 构造。重复风险或查重不完整时不生成confirmationId,必须展示结果并询问用户;只有用户明确接受后才以allowDuplicateInvoices=true重试准备。成功预览仍需另行获得最终提交确认。", description: "仅在用户已补齐所有必填字段并选择是否填写可选字段后调用。选择项在values中使用所选label对应的code,不能直接填label;code不得展示给用户。实时复核模板和字段构造能力,并自动查重本人近30天钉钉/薪福通发票。若返回缺失或无效字段,Agent必须继续询问;若为 client_only 则停止 API 构造。重复风险或查重不完整时不生成confirmationId,必须展示结果并询问用户;只有用户明确接受后才以allowDuplicateInvoices=true重试准备。成功预览会反向显示业务名称,仍需另行获得最终提交确认。",
inputSchema: submissionInputSchema, inputSchema: prepareSubmissionInputSchema,
annotations: { readOnlyHint: true, destructiveHint: false }, annotations: { readOnlyHint: true, destructiveHint: false },
}, async ({ templateId, values, deptId, targetSelectActioners, allowDuplicateInvoices }) => { }, async ({ templateId, values, deptId, targetSelectActioners, allowDuplicateInvoices, attachments }) => {
const result = await unified.prepareSubmission(user, { templateId, values, deptId, targetSelectActioners, allowDuplicateInvoices }); const result = await unified.prepareSubmission(user, { templateId, values, deptId, targetSelectActioners, allowDuplicateInvoices, attachments });
const summary = result.confirmationId const summary = result.confirmationId
? "审批尚未提交;已生成待用户确认的完整预览。" ? "审批尚未提交;已生成待用户确认的完整预览。"
: "审批尚未提交;发票查重需要用户决定,本次未生成提交确认。"; : "审批尚未提交;发票查重需要用户决定,本次未生成提交确认。";
@@ -224,12 +249,12 @@ export function createApprovalMcpServer(
description: "仅在用户看过approval_prepare_submission最终预览并明确确认后调用。必须原样重传相同表单内容、allowDuplicateInvoices决定和一次性confirmationId;服务端再次校验当前登录身份、模板和发票预留。薪福通流程可能进入待审批,也可能按企业规则即时完成并返回completed;两者都禁止自动重试。", description: "仅在用户看过approval_prepare_submission最终预览并明确确认后调用。必须原样重传相同表单内容、allowDuplicateInvoices决定和一次性confirmationId;服务端再次校验当前登录身份、模板和发票预留。薪福通流程可能进入待审批,也可能按企业规则即时完成并返回completed;两者都禁止自动重试。",
inputSchema: { inputSchema: {
confirmationId: z.string().min(20).max(4096).describe("approval_prepare_submission返回的一次性确认引用"), confirmationId: z.string().min(20).max(4096).describe("approval_prepare_submission返回的一次性确认引用"),
...submissionInputSchema, ...submitSubmissionInputSchema,
}, },
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false }, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
}, async ({ confirmationId, templateId, values, deptId, targetSelectActioners, allowDuplicateInvoices }) => { }, async ({ confirmationId, templateId, values, deptId, targetSelectActioners, allowDuplicateInvoices, attachments }) => {
try { try {
const result = await unified.submitApproval(user, confirmationId, { templateId, values, deptId, targetSelectActioners, allowDuplicateInvoices }); const result = await unified.submitApproval(user, confirmationId, { templateId, values, deptId, targetSelectActioners, allowDuplicateInvoices, attachments });
audit?.(user.userId, "submit_approval", confirmationId, "submitted"); audit?.(user.userId, "submit_approval", confirmationId, "submitted");
return compactResult( return compactResult(
result.status === "completed" ? "审批已发起并按企业流程规则即时完成。" result.status === "completed" ? "审批已发起并按企业流程规则即时完成。"
@@ -398,6 +423,52 @@ export async function startHttpServer(config: AppConfig): Promise<void> {
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpUrl), resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpUrl),
}) : undefined; }) : undefined;
const attachmentSigner = new SignedReferenceCodec(config.approvalRefSecret);
const attachmentUploadHandler = async (req: Request, res: Response) => {
try {
if (!xft?.uploadSubmissionAttachment) throw new ApprovalSafetyError("服务器未启用薪福通附件上传");
const user = requestUser(config, oauth, req.auth);
const fieldName = encodedHeader(req, "x-dingmcp-field-name");
const fileName = encodedHeader(req, "x-dingmcp-file-name");
const sha256 = headerString(req.headers["x-dingmcp-file-sha256"]);
const confirmationId = headerString(req.headers["x-dingmcp-confirmation-id"]);
if (!fieldName || !fileName || !sha256 || !confirmationId || !Buffer.isBuffer(req.body)) {
throw new ApprovalSafetyError("附件上传请求缺少字段名称、文件名、SHA-256、提单确认或二进制内容");
}
assertAttachmentConfirmation(attachmentSigner, user, confirmationId, {
fieldName, fileName, size: req.body.length, sha256,
});
const uploaded = await xft.uploadSubmissionAttachment(user, {
fieldName,
fileName,
size: req.body.length,
sha256,
content: req.body,
confirmationId,
});
res.setHeader("Cache-Control", "no-store");
res.json({
fieldName: uploaded.fieldName,
fileName: uploaded.fileName,
size: uploaded.size,
sha256: uploaded.sha256,
attachmentRef: uploaded.attachmentRef,
});
} catch (error) {
const status = error instanceof ApprovalSafetyError ? 400 : error instanceof XftApiError ? 502 : 500;
res.setHeader("Cache-Control", "no-store");
res.status(status).json({
error: errorMessage(error),
...(error instanceof XftApiError && error.code ? { code: error.code } : {}),
});
}
};
const attachmentBody = express.raw({ type: "application/octet-stream", limit: `${XFT_ATTACHMENT_MAX_FILE_BYTES}b` });
app.post(
"/xft/submission-attachments",
...(authMiddleware ? [authMiddleware, attachmentBody, attachmentUploadHandler] : [attachmentBody, attachmentUploadHandler]),
);
const transports = new Map<string, TransportEntry>(); const transports = new Map<string, TransportEntry>();
let pendingTransportSessions = 0; let pendingTransportSessions = 0;
const pendingTransportSessionsByUser = new Map<string, number>(); const pendingTransportSessionsByUser = new Map<string, number>();
@@ -585,6 +656,39 @@ export function isLoopbackAddress(value: string | undefined): boolean {
} }
function headerString(value: string | string[] | undefined): string | undefined { return Array.isArray(value) ? value[0] : value; } function headerString(value: string | string[] | undefined): string | undefined { return Array.isArray(value) ? value[0] : value; }
function encodedHeader(req: Request, name: string): string | undefined {
const value = headerString(req.headers[name]);
if (!value || value.length > 1000) return undefined;
try { return decodeURIComponent(value); } catch { return undefined; }
}
export function assertAttachmentConfirmation(
signer: SignedReferenceCodec,
user: AuthenticatedDingTalkUser,
confirmationId: string,
attachment: { fieldName: string; fileName: string; size: number; sha256: string },
): void {
let confirmation: Record<string, unknown>;
try { confirmation = signer.verify<Record<string, unknown>>(confirmationId); }
catch { throw new ApprovalSafetyError("提单确认无效或已过期,请重新预览后再上传附件"); }
if (
confirmation.kind !== "submission_confirmation"
|| confirmation.source !== "xft"
|| confirmation.userId !== user.userId
|| typeof confirmation.exp !== "number"
|| confirmation.exp < Date.now()
) {
throw new ApprovalSafetyError("提单确认无效、已过期或不属于当前用户,请重新预览后再上传附件");
}
const manifest = Array.isArray(confirmation.attachmentManifest) ? confirmation.attachmentManifest : [];
if (!manifest.some((item) => item && typeof item === "object" && !Array.isArray(item)
&& (item as Record<string, unknown>).fieldName === attachment.fieldName
&& (item as Record<string, unknown>).fileName === attachment.fileName
&& (item as Record<string, unknown>).size === attachment.size
&& (item as Record<string, unknown>).sha256 === attachment.sha256)) {
throw new ApprovalSafetyError(`附件“${attachment.fileName}”不在用户刚刚确认的提单预览中,请重新预览`);
}
}
export function compactResult(summary: string, value: unknown, images?: XftChallengeProvider) { export function compactResult(summary: string, value: unknown, images?: XftChallengeProvider) {
const visibleValue = agentVisibleResult(value); const visibleValue = agentVisibleResult(value);
const serialized = JSON.stringify(visibleValue); const serialized = JSON.stringify(visibleValue);
+62
View File
@@ -0,0 +1,62 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
import { deflateRawSync, inflateRawSync } from "node:zlib";
export class SignedReferenceCodec {
private readonly key: Buffer;
constructor(secret: string) {
this.key = createHash("sha256").update(secret).digest();
}
sign(value: object): string {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", this.key, iv);
const compressed = deflateRawSync(Buffer.from(JSON.stringify(value), "utf8"), { level: 9 });
const payload = Buffer.concat([cipher.update(compressed), cipher.final()]);
return `v3.${iv.toString("base64url")}.${payload.toString("base64url")}.${cipher.getAuthTag().toString("base64url")}`;
}
verify<T>(value: string): T {
const parts = value.split(".");
if (parts.length !== 4) throw new SignedReferenceFormatError();
const [version, encodedIv, encodedPayload, encodedTag] = parts;
if (!version || !["v2", "v3"].includes(version) || !encodedIv || !encodedPayload || !encodedTag) {
throw new SignedReferenceFormatError();
}
let iv: Buffer;
let payload: Buffer;
let tag: Buffer;
try {
iv = decodeCanonicalBase64Url(encodedIv);
payload = decodeCanonicalBase64Url(encodedPayload);
tag = decodeCanonicalBase64Url(encodedTag);
if (iv.length !== 12 || tag.length !== 16 || payload.length === 0) throw new Error("invalid encrypted reference lengths");
} catch {
throw new SignedReferenceFormatError();
}
try {
const decipher = createDecipheriv("aes-256-gcm", this.key, iv);
decipher.setAuthTag(tag);
const decrypted = Buffer.concat([decipher.update(payload), decipher.final()]);
const plaintext = version === "v3" ? inflateRawSync(decrypted, { maxOutputLength: 64 * 1024 }) : decrypted;
return JSON.parse(plaintext.toString("utf8")) as T;
} catch {
throw new SignedReferenceIntegrityError();
}
}
}
export class SignedReferenceFormatError extends Error {
constructor() { super("引用格式不完整,可能在传输中被截断,请重新获取最新引用"); }
}
export class SignedReferenceIntegrityError extends Error {
constructor() { super("引用无效或已损坏,请重新获取最新引用"); }
}
function decodeCanonicalBase64Url(value: string): Buffer {
if (!/^[A-Za-z0-9_-]+$/.test(value)) throw new Error("invalid base64url");
const decoded = Buffer.from(value, "base64url");
if (decoded.toString("base64url") !== value) throw new Error("non-canonical base64url");
return decoded;
}
+528 -94
View File
@@ -1,13 +1,32 @@
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "node:crypto"; import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "node:crypto";
import { deflateRawSync, inflateRawSync } from "node:zlib"; import {
import { APPROVAL_BATCH_LIMIT } from "./approval-contract.js"; APPROVAL_BATCH_LIMIT,
XFT_ATTACHMENT_MAX_FILE_BYTES,
XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION,
XFT_ATTACHMENT_MAX_TOTAL_BYTES,
} from "./approval-contract.js";
import type { AppConfig } from "./config.js"; import type { AppConfig } from "./config.js";
import { ApprovalSafetyError, XftSessionExpiredError, errorMessage } from "./errors.js"; import { ApprovalSafetyError, XftSessionExpiredError, errorMessage } from "./errors.js";
import { ApprovalService, type ApprovalHistoryItem, type ProcessApprovalItem, type TodoItem } from "./approval-service.js"; import { ApprovalService, type ApprovalHistoryItem, type ProcessApprovalItem, type TodoItem } from "./approval-service.js";
import type { ApprovalHistoryRange, ApprovalHistoryScope, AuthenticatedDingTalkUser, XftApprovalProvider, XftHistoryItem, XftSubmissionField, XftSubmissionKind, XftSubmissionOptionKind, XftSubmissionTemplate, XftTodoItem } from "./xft.js"; import {
type ApprovalHistoryRange,
type ApprovalHistoryScope,
type AuthenticatedDingTalkUser,
type XftApprovalProvider,
type XftHistoryItem,
type XftResolvedSubmissionAttachment,
type XftSubmissionAttachment,
type XftSubmissionField,
type XftSubmissionKind,
type XftSubmissionOptionKind,
type XftSubmissionTemplate,
type XftTodoItem,
} from "./xft.js";
import { browseRawDetail, compactApprovalDetail, type ApprovalDetailOptions, type ApprovalDetailView } from "./approval-detail.js"; import { browseRawDetail, compactApprovalDetail, type ApprovalDetailOptions, type ApprovalDetailView } from "./approval-detail.js";
import { sensitiveLabel } from "./approval-metadata.js"; import { sensitiveLabel } from "./approval-metadata.js";
import { isRecord } from "./json.js";
import type { InvoiceDeduplicationService } from "./invoice-dedup.js"; import type { InvoiceDeduplicationService } from "./invoice-dedup.js";
import { SignedReferenceCodec } from "./signed-reference.js";
export type UnifiedSource = "dingtalk" | "xft"; export type UnifiedSource = "dingtalk" | "xft";
export type UnifiedAction = "approve" | "reject" | "return"; export type UnifiedAction = "approve" | "reject" | "return";
@@ -18,6 +37,17 @@ const DINGTALK_SUBMISSION_TEMPLATES = {
"捷康_费用报销": ["差旅费用报销_捷康", "业务招待费用报销_捷康", "业务招待申请_捷康"], "捷康_费用报销": ["差旅费用报销_捷康", "业务招待费用报销_捷康", "业务招待申请_捷康"],
} as const; } as const;
const DINGTALK_SUBMISSION_CATEGORIES = Object.keys(DINGTALK_SUBMISSION_TEMPLATES); const DINGTALK_SUBMISSION_CATEGORIES = Object.keys(DINGTALK_SUBMISSION_TEMPLATES);
const ALLOWED_XFT_SUBMISSION_KINDS = new Set<XftSubmissionKind>([
"travel_apply", "expense_apply", "entertainment_apply", "common_apply",
"common_reimburse", "travel_reimburse",
]);
function assertXftSubmissionTemplateAllowed(template: XftSubmissionTemplate): void {
if (!ALLOWED_XFT_SUBMISSION_KINDS.has(template.kind)) {
throw new ApprovalSafetyError("该薪福通模板不在 MCP 支持的发起范围内,请重新列出可发起模板");
}
}
function xftSubmissionCapability(kind: XftSubmissionKind): Record<string, unknown> { function xftSubmissionCapability(kind: XftSubmissionKind): Record<string, unknown> {
return { return {
status: "api_supported_with_constraints", status: "api_supported_with_constraints",
@@ -44,6 +74,7 @@ interface SubmissionConfirmationReference {
nonce: string; nonce: string;
serviceEpoch: string; serviceEpoch: string;
invoiceReservationId?: string; invoiceReservationId?: string;
attachmentManifest?: Array<{ fieldName: string; fileName: string; size: number; sha256: string }>;
exp: number; exp: number;
processCode?: string; processCode?: string;
xftTemplate?: XftSubmissionTemplate; xftTemplate?: XftSubmissionTemplate;
@@ -143,6 +174,7 @@ export interface SubmissionInput {
deptId?: number; deptId?: number;
targetSelectActioners?: Array<{ actionerKey: string; actionerStaffIds: string[] }>; targetSelectActioners?: Array<{ actionerKey: string; actionerStaffIds: string[] }>;
allowDuplicateInvoices?: boolean; allowDuplicateInvoices?: boolean;
attachments?: XftSubmissionAttachment[];
} }
export interface OfficialOaReturnClient { export interface OfficialOaReturnClient {
@@ -224,7 +256,12 @@ export class UnifiedApprovalService {
if (!this.xft) sourceStatus.xft = { status: "disabled" }; if (!this.xft) sourceStatus.xft = { status: "disabled" };
else tasks.push(this.xft.listMyTodos(user, limit, query).then((result) => { else tasks.push(this.xft.listMyTodos(user, limit, query).then((result) => {
xftItems = result.items.map((item) => this.normalizeXftTodo(user, item)); xftItems = result.items.map((item) => this.normalizeXftTodo(user, item));
sourceStatus.xft = { status: "ok", count: result.items.length }; sourceStatus.xft = {
status: "ok",
count: result.items.length,
...(typeof result.diagnostics?.billApiCount === "number" ? { billApiCount: result.diagnostics.billApiCount } : {}),
...(typeof result.diagnostics?.clientOnlyCount === "number" ? { clientOnlyCount: result.diagnostics.clientOnlyCount } : {}),
};
}).catch(async (error) => { }).catch(async (error) => {
if (error instanceof XftSessionExpiredError && this.xftChallenges) { if (error instanceof XftSessionExpiredError && this.xftChallenges) {
try { sourceStatus.xft = { status: "auth_required", authChallenge: await this.xftChallenges.getOrStart(user) }; } try { sourceStatus.xft = { status: "auth_required", authChallenge: await this.xftChallenges.getOrStart(user) }; }
@@ -240,10 +277,15 @@ export class UnifiedApprovalService {
: interleave(dingTalkItems, xftItems); : interleave(dingTalkItems, xftItems);
const returnedItems = items.slice(0, limit); const returnedItems = items.slice(0, limit);
const sourcesHealthy = requestedSourcesHealthy(source, sourceStatus); const sourcesHealthy = requestedSourcesHealthy(source, sourceStatus);
const containsClientOnly = returnedItems.some((item) => isRecord(item)
&& item.source === "xft"
&& isRecord(item.manualAction)
&& item.manualAction.reason === "unsupported_xft_task_type");
const selectionUnavailableReason = !issueSelection ? "selection_not_requested" const selectionUnavailableReason = !issueSelection ? "selection_not_requested"
: returnedItems.length === 0 ? "empty_result" : returnedItems.length === 0 ? "empty_result"
: !sourcesHealthy ? "requested_source_unhealthy" : !sourcesHealthy ? "requested_source_unhealthy"
: returnedItems.length > APPROVAL_BATCH_LIMIT ? "result_exceeds_batch_limit" : returnedItems.length > APPROVAL_BATCH_LIMIT ? "result_exceeds_batch_limit"
: containsClientOnly ? "contains_client_only_item"
: undefined; : undefined;
const selectionAvailable = selectionUnavailableReason === undefined; const selectionAvailable = selectionUnavailableReason === undefined;
return { return {
@@ -365,7 +407,8 @@ export class UnifiedApprovalService {
if (source !== "dingtalk") { if (source !== "dingtalk") {
if (!this.xft) sourceStatus.xft = { status: "disabled" }; if (!this.xft) sourceStatus.xft = { status: "disabled" };
else tasks.push(this.xft.listSubmissionTemplates(user, limit, query).then((templates) => { else tasks.push(this.xft.listSubmissionTemplates(user, limit, query).then((templates) => {
xftItems = templates.map((template) => ({ const supported = templates.filter((template) => ALLOWED_XFT_SUBMISSION_KINDS.has(template.kind));
xftItems = supported.map((template) => ({
templateId: this.signer.sign({ templateId: this.signer.sign({
kind: "submission_template", userId: user.userId, source: "xft", xftTemplate: template, kind: "submission_template", userId: user.userId, source: "xft", xftTemplate: template,
exp: expiresInMinutes(30), exp: expiresInMinutes(30),
@@ -375,7 +418,7 @@ export class UnifiedApprovalService {
kind: template.kind, kind: template.kind,
eventType: template.eventType, eventType: template.eventType,
})); }));
sourceStatus.xft = { status: "ok", count: templates.length }; sourceStatus.xft = { status: "ok", count: supported.length };
}).catch((error) => { sourceStatus.xft = { status: "unavailable", message: errorMessage(error) }; })); }).catch((error) => { sourceStatus.xft = { status: "unavailable", message: errorMessage(error) }; }));
} }
await Promise.all(tasks); await Promise.all(tasks);
@@ -389,11 +432,17 @@ export class UnifiedApprovalService {
const template = this.decodeSubmissionTemplate(user, templateId); const template = this.decodeSubmissionTemplate(user, templateId);
if (template.source === "xft") { if (template.source === "xft") {
if (!this.xft || !template.xftTemplate) throw new ApprovalSafetyError("薪福通提单源未启用"); if (!this.xft || !template.xftTemplate) throw new ApprovalSafetyError("薪福通提单源未启用");
const capability = xftSubmissionCapability(template.xftTemplate.kind); assertXftSubmissionTemplateAllowed(template.xftTemplate);
const schema = await this.xft.getSubmissionSchema(user, template.xftTemplate);
const requiredClientOnly = schema.fields.filter(requiredXftFieldNeedsNativeClient);
const capability = requiredClientOnly.length ? {
status: "client_only",
reason: `包含当前 MCP 无法按业务名称安全填写的必填项:${requiredClientOnly.map((field) => field.name).join("、")}`,
} : xftSubmissionCapability(template.xftTemplate.kind);
return { return {
templateId, templateId,
source: "xft", source: "xft",
...(await this.xft.getSubmissionSchema(user, template.xftTemplate)), ...schema,
submissionCapability: capability, submissionCapability: capability,
}; };
} }
@@ -435,6 +484,7 @@ export class UnifiedApprovalService {
if (template.source !== "xft" || !this.xft || !template.xftTemplate) { if (template.source !== "xft" || !this.xft || !template.xftTemplate) {
throw new ApprovalSafetyError("实时提单选项查询当前仅用于薪福通模板"); throw new ApprovalSafetyError("实时提单选项查询当前仅用于薪福通模板");
} }
assertXftSubmissionTemplateAllowed(template.xftTemplate);
return { return {
templateId, templateId,
source: "xft" as const, source: "xft" as const,
@@ -454,19 +504,23 @@ export class UnifiedApprovalService {
let fingerprint: string; let fingerprint: string;
if (template.source === "xft") { if (template.source === "xft") {
if (!this.xft || !template.xftTemplate) throw new ApprovalSafetyError("薪福通提单源未启用"); if (!this.xft || !template.xftTemplate) throw new ApprovalSafetyError("薪福通提单源未启用");
assertXftSubmissionTemplateAllowed(template.xftTemplate);
const schema = await this.xft.getSubmissionSchema(user, template.xftTemplate); const schema = await this.xft.getSubmissionSchema(user, template.xftTemplate);
validateXftSubmissionValues(template.xftTemplate.kind, input.values, schema.fields); const attachments = validateXftSubmissionAttachments(schema.fields, input.attachments ?? [], false);
validateXftSubmissionValues(template.xftTemplate.kind, input.values, schema.fields, attachments);
const identityValidation = await this.xft.validateSubmissionIdentity(user); const identityValidation = await this.xft.validateSubmissionIdentity(user);
preview = { preview = {
source: "xft", template: schema.template, source: "xft", template: schema.template,
applicant: "当前登录用户(由服务器映射,不能由 Agent 指定)", applicant: "当前登录用户(由服务器映射,不能由 Agent 指定)",
identityValidation, identityValidation,
submissionStatus: "APPRV_PEND", submissionStatus: "APPRV_PEND",
formSummary: summarizeValues(input.values), formSummary: await readableXftSubmissionSummary(this.xft, user, schema.template, schema.fields, schema.inputContract, input.values),
...(attachments.length ? { attachments: attachmentPreview(attachments) } : {}),
processForecast: { supported: false, message: "薪福通正式接口不提供无副作用的流程预测;提交时由上游完成最终业务校验" }, processForecast: { supported: false, message: "薪福通正式接口不提供无副作用的流程预测;提交时由上游完成最终业务校验" },
}; };
fingerprint = hashJson({ template: schema.template, fields: schema.fields }); fingerprint = hashJson({ template: schema.template, fields: schema.fields });
} else { } else {
if ((input.attachments ?? []).length) throw new ApprovalSafetyError("本地附件上传当前仅支持薪福通模板");
if (!template.processCode) throw new ApprovalSafetyError("钉钉模板引用缺少 processCode"); if (!template.processCode) throw new ApprovalSafetyError("钉钉模板引用缺少 processCode");
const current = await this.dingTalk.getSubmissionSchema(template.processCode); const current = await this.dingTalk.getSubmissionSchema(template.processCode);
assertDingTalkSubmissionTemplateAllowed(current.template); assertDingTalkSubmissionTemplateAllowed(current.template);
@@ -492,9 +546,10 @@ export class UnifiedApprovalService {
const expiresAt = Date.now() + 10 * 60_000; const expiresAt = Date.now() + 10 * 60_000;
const invoiceEvidenceExpected = template.source === "xft" const invoiceEvidenceExpected = template.source === "xft"
&& (template.xftTemplate?.kind === "common_reimburse" || template.xftTemplate?.kind === "travel_reimburse"); && (template.xftTemplate?.kind === "common_reimburse" || template.xftTemplate?.kind === "travel_reimburse");
const deduplicationValues = submissionValuesForInvoiceDeduplication(input);
const deduplication = this.invoiceDedup const deduplication = this.invoiceDedup
? await this.invoiceDedup.prepare( ? await this.invoiceDedup.prepare(
user, input.values, input.allowDuplicateInvoices === true, expiresAt, invoiceEvidenceExpected, user, deduplicationValues, input.allowDuplicateInvoices === true, expiresAt, invoiceEvidenceExpected,
) )
: undefined; : undefined;
if (deduplication?.duplicateCheck.requiresUserConfirmation) { if (deduplication?.duplicateCheck.requiresUserConfirmation) {
@@ -514,6 +569,7 @@ export class UnifiedApprovalService {
templateFingerprint: fingerprint, requestHash: hashJson(normalized), nonce, templateFingerprint: fingerprint, requestHash: hashJson(normalized), nonce,
serviceEpoch: this.submissionServiceEpoch, serviceEpoch: this.submissionServiceEpoch,
...(deduplication?.reservationId ? { invoiceReservationId: deduplication.reservationId } : {}), ...(deduplication?.reservationId ? { invoiceReservationId: deduplication.reservationId } : {}),
...((input.attachments ?? []).length ? { attachmentManifest: submissionAttachmentManifest(input) } : {}),
...(template.processCode ? { processCode: template.processCode } : {}), ...(template.processCode ? { processCode: template.processCode } : {}),
...(template.xftTemplate ? { xftTemplate: template.xftTemplate } : {}), ...(template.xftTemplate ? { xftTemplate: template.xftTemplate } : {}),
exp: expiresAt, exp: expiresAt,
@@ -543,23 +599,32 @@ export class UnifiedApprovalService {
if (hashJson(normalizedSubmissionInput(input)) !== confirmation.requestHash) { if (hashJson(normalizedSubmissionInput(input)) !== confirmation.requestHash) {
throw new ApprovalSafetyError("提单内容与用户确认时不一致,请重新准备并确认"); throw new ApprovalSafetyError("提单内容与用户确认时不一致,请重新准备并确认");
} }
const deduplicationValues = submissionValuesForInvoiceDeduplication(input);
if (confirmation.invoiceReservationId) this.invoiceDedup?.assertReservation(user, confirmation.invoiceReservationId); if (confirmation.invoiceReservationId) this.invoiceDedup?.assertReservation(user, confirmation.invoiceReservationId);
this.consumedSubmissionNonces.set(confirmation.nonce, confirmation.exp); this.consumedSubmissionNonces.set(confirmation.nonce, confirmation.exp);
try { try {
if (confirmation.source === "xft") { if (confirmation.source === "xft") {
if (!this.xft || !confirmation.xftTemplate) throw new ApprovalSafetyError("薪福通提单源未启用"); if (!this.xft || !confirmation.xftTemplate) throw new ApprovalSafetyError("薪福通提单源未启用");
assertXftSubmissionTemplateAllowed(confirmation.xftTemplate);
const schema = await this.xft.getSubmissionSchema(user, confirmation.xftTemplate); const schema = await this.xft.getSubmissionSchema(user, confirmation.xftTemplate);
if (hashJson({ template: schema.template, fields: schema.fields }) !== confirmation.templateFingerprint) { if (hashJson({ template: schema.template, fields: schema.fields }) !== confirmation.templateFingerprint) {
throw new ApprovalSafetyError("薪福通表单在确认后发生变化,请重新准备并确认"); throw new ApprovalSafetyError("薪福通表单在确认后发生变化,请重新准备并确认");
} }
validateXftSubmissionValues(confirmation.xftTemplate.kind, input.values, schema.fields); const attachments = validateXftSubmissionAttachments(schema.fields, input.attachments ?? [], true);
const submitted = await this.xft.submitApproval(user, confirmation.xftTemplate, input.values); validateXftSubmissionValues(confirmation.xftTemplate.kind, input.values, schema.fields, attachments);
const submitted = await this.xft.submitApproval(
user,
confirmation.xftTemplate,
input.values,
attachments,
confirmationId,
);
let liveStatus; let liveStatus;
try { try {
liveStatus = await this.xft.getMySubmissionStatus(user, submitted.billId); liveStatus = await this.xft.getMySubmissionStatus(user, submitted.billId);
} catch { } catch {
const duplicateIndexUpdate = await this.invoiceDedup?.recordSubmission( const duplicateIndexUpdate = await this.invoiceDedup?.recordSubmission(
user, confirmation.invoiceReservationId, input.values, "xft", submitted.billId, "outcome_uncertain", user, confirmation.invoiceReservationId, deduplicationValues, "xft", submitted.billId, "outcome_uncertain",
); );
return { return {
source: "xft" as const, source: "xft" as const,
@@ -572,7 +637,7 @@ export class UnifiedApprovalService {
const completedImmediately = /^(?:2|APPRV)$/i.test(liveStatus.status); const completedImmediately = /^(?:2|APPRV)$/i.test(liveStatus.status);
if (completedImmediately) { if (completedImmediately) {
const duplicateIndexUpdate = await this.invoiceDedup?.recordSubmission( const duplicateIndexUpdate = await this.invoiceDedup?.recordSubmission(
user, confirmation.invoiceReservationId, input.values, "xft", submitted.billId, "approved", user, confirmation.invoiceReservationId, deduplicationValues, "xft", submitted.billId, "approved",
); );
return { return {
source: "xft" as const, source: "xft" as const,
@@ -587,7 +652,7 @@ export class UnifiedApprovalService {
} }
if (!liveStatus.revokeAllowed) { if (!liveStatus.revokeAllowed) {
const duplicateIndexUpdate = await this.invoiceDedup?.recordSubmission( const duplicateIndexUpdate = await this.invoiceDedup?.recordSubmission(
user, confirmation.invoiceReservationId, input.values, "xft", submitted.billId, liveStatus.status, user, confirmation.invoiceReservationId, deduplicationValues, "xft", submitted.billId, liveStatus.status,
); );
return { return {
source: "xft" as const, source: "xft" as const,
@@ -604,7 +669,7 @@ export class UnifiedApprovalService {
exp: expiresInMinutes(7 * 24 * 60), exp: expiresInMinutes(7 * 24 * 60),
} satisfies SubmissionRevokeReference); } satisfies SubmissionRevokeReference);
const duplicateIndexUpdate = await this.invoiceDedup?.recordSubmission( const duplicateIndexUpdate = await this.invoiceDedup?.recordSubmission(
user, confirmation.invoiceReservationId, input.values, "xft", submitted.billId, liveStatus.status, user, confirmation.invoiceReservationId, deduplicationValues, "xft", submitted.billId, liveStatus.status,
); );
return { return {
source: "xft", ...submitted, revokeId, source: "xft", ...submitted, revokeId,
@@ -632,7 +697,7 @@ export class UnifiedApprovalService {
exp: expiresInMinutes(7 * 24 * 60), exp: expiresInMinutes(7 * 24 * 60),
} satisfies SubmissionRevokeReference); } satisfies SubmissionRevokeReference);
const duplicateIndexUpdate = await this.invoiceDedup?.recordSubmission( const duplicateIndexUpdate = await this.invoiceDedup?.recordSubmission(
user, confirmation.invoiceReservationId, input.values, "dingtalk", processInstanceId, "pending", user, confirmation.invoiceReservationId, deduplicationValues, "dingtalk", processInstanceId, "pending",
); );
return { return {
source: "dingtalk", status: "submitted", processInstanceId, revokeId, source: "dingtalk", status: "submitted", processInstanceId, revokeId,
@@ -689,6 +754,20 @@ export class UnifiedApprovalService {
const ref = this.decodeApproval(user, approvalId); const ref = this.decodeApproval(user, approvalId);
const view = options.view ?? "summary"; const view = options.view ?? "summary";
if (ref.provider === "xft") { if (ref.provider === "xft") {
if (ref.clientOnly) {
return {
approvalId,
source: "xft" as const,
sourceType: "unadapted_xft_task",
view,
status: "client_required",
detailCapability: {
status: "client_only",
message: "该薪福通待办已在列表中显示,但尚未确认正式详情接口兼容性,请在薪福通客户端查看详情",
},
diagnostics: { realtime: true, persistedBusinessData: false, upstreamDetailRequested: false },
};
}
if (!this.xft || !ref.billId) throw new ApprovalSafetyError("薪福通审批源未启用"); if (!this.xft || !ref.billId) throw new ApprovalSafetyError("薪福通审批源未启用");
try { try {
const raw = redactSensitive(ref.historyScope const raw = redactSensitive(ref.historyScope
@@ -751,6 +830,18 @@ export class UnifiedApprovalService {
}; };
} }
if (ref.provider === "official_oa") return this.getOfficialOaActions(user, approvalId, ref); if (ref.provider === "official_oa") return this.getOfficialOaActions(user, approvalId, ref);
if (ref.clientOnly) {
const message = "该薪福通待办已列出,但尚未确认正式审批接口兼容性,请在薪福通客户端处理";
return {
approvalId,
actions: [
{ action: "approve" as const, allowed: false, clientRequired: true, unavailableReason: "provider_not_supported", message },
{ action: "reject" as const, allowed: false, clientRequired: true, unavailableReason: "provider_not_supported", message },
{ action: "return" as const, allowed: false, clientRequired: true, unavailableReason: "provider_not_supported", message },
],
manualAction: { required: true, reason: "unsupported_xft_task_type", message },
};
}
if (!this.xft || !ref.billId) throw new ApprovalSafetyError("薪福通审批源未启用"); if (!this.xft || !ref.billId) throw new ApprovalSafetyError("薪福通审批源未启用");
let returned; let returned;
try { returned = await this.xft.listReturnNodes(user, ref.billId); } try { returned = await this.xft.listReturnNodes(user, ref.billId); }
@@ -1039,6 +1130,12 @@ export class UnifiedApprovalService {
if (ref.historyScope) { if (ref.historyScope) {
throw new ApprovalReferenceValidationError("history_reference_read_only", "历史审批引用仅用于读取,不能提交审批动作"); throw new ApprovalReferenceValidationError("history_reference_read_only", "历史审批引用仅用于读取,不能提交审批动作");
} }
if (ref.provider === "xft" && ref.clientOnly) {
throw new ApprovalReferenceValidationError(
"provider_not_supported",
"该薪福通待办尚未确认正式审批接口兼容性,不能通过 MCP 处理,请在薪福通客户端完成",
);
}
if (ref.provider === "xft" && action === "approve" && ref.allowApprove === false) { if (ref.provider === "xft" && action === "approve" && ref.allowApprove === false) {
throw new ApprovalReferenceValidationError("action_not_allowed", "该薪福通待办当前不允许同意"); throw new ApprovalReferenceValidationError("action_not_allowed", "该薪福通待办当前不允许同意");
} }
@@ -1173,19 +1270,24 @@ export class UnifiedApprovalService {
} }
private normalizeXftTodo(user: AuthenticatedDingTalkUser, item: XftTodoItem) { private normalizeXftTodo(user: AuthenticatedDingTalkUser, item: XftTodoItem) {
const billApiSupported = item.capability !== "list_only" && Boolean(item.billId);
const referenceId = billApiSupported ? item.billId : item.taskId;
if (!referenceId) throw new ApprovalSafetyError("薪福通待办缺少安全引用标识");
const ref: ApprovalReference = { const ref: ApprovalReference = {
kind: "approval", kind: "approval",
userId: user.userId, userId: user.userId,
provider: "xft", provider: "xft",
billId: item.billId, billId: billApiSupported ? item.billId : `task:${referenceId}`,
...(item.allowPass !== undefined ? { allowApprove: item.allowPass } : {}), ...(billApiSupported ? {} : { clientOnly: true }),
...(item.allowReject !== undefined ? { allowReject: item.allowReject } : {}), ...(billApiSupported && item.allowPass !== undefined ? { allowApprove: item.allowPass } : { allowApprove: false }),
...(billApiSupported && item.allowReject !== undefined ? { allowReject: item.allowReject } : { allowReject: false }),
exp: expiresInMinutes(30), exp: expiresInMinutes(30),
}; };
const clientOnlyMessage = "该薪福通待办已列出,但尚未确认正式详情和审批接口兼容性,请在薪福通客户端查看和处理";
return { return {
approvalId: this.signPortableApprovalReference(ref), approvalId: this.signPortableApprovalReference(ref),
source: "xft", source: "xft",
businessNo: item.billId, ...(billApiSupported ? { businessNo: item.billId } : {}),
title: item.title ?? "薪福通审批", title: item.title ?? "薪福通审批",
applicant: { applicant: {
...(item.applicantName ? { name: item.applicantName } : {}), ...(item.applicantName ? { name: item.applicantName } : {}),
@@ -1194,11 +1296,18 @@ export class UnifiedApprovalService {
...(item.applyTime ? { submittedAt: localTime(item.applyTime) } : {}), ...(item.applyTime ? { submittedAt: localTime(item.applyTime) } : {}),
...(item.amount !== undefined ? { amount: { value: item.amount, currency: item.currency ?? "CNY" } } : {}), ...(item.amount !== undefined ? { amount: { value: item.amount, currency: item.currency ?? "CNY" } } : {}),
...(item.reason ? { summary: item.reason } : {}), ...(item.reason ? { summary: item.reason } : {}),
availableActions: [ availableActions: billApiSupported ? [
...(item.allowPass === false ? [] : ["approve"]), ...(item.allowPass === false ? [] : ["approve"]),
...(item.allowReject === false ? [] : ["reject"]), ...(item.allowReject === false ? [] : ["reject"]),
"return", "return",
], ] : [],
...(billApiSupported ? {} : {
manualAction: {
required: true,
reason: "unsupported_xft_task_type",
message: clientOnlyMessage,
},
}),
}; };
} }
@@ -1430,49 +1539,6 @@ export class UnifiedApprovalService {
} }
} }
class SignedReferenceCodec {
private readonly key: Buffer;
constructor(secret: string) {
this.key = createHash("sha256").update(secret).digest();
}
sign(value: object): string {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", this.key, iv);
const compressed = deflateRawSync(Buffer.from(JSON.stringify(value), "utf8"), { level: 9 });
const payload = Buffer.concat([cipher.update(compressed), cipher.final()]);
return `v3.${iv.toString("base64url")}.${payload.toString("base64url")}.${cipher.getAuthTag().toString("base64url")}`;
}
verify<T>(value: string): T {
const parts = value.split(".");
if (parts.length !== 4) throw new ReferenceFormatError();
const [version, encodedIv, encodedPayload, encodedTag] = parts;
if (!version || !["v2", "v3"].includes(version) || !encodedIv || !encodedPayload || !encodedTag) throw new ReferenceFormatError();
let iv: Buffer;
let payload: Buffer;
let tag: Buffer;
try {
iv = decodeCanonicalBase64Url(encodedIv);
payload = decodeCanonicalBase64Url(encodedPayload);
tag = decodeCanonicalBase64Url(encodedTag);
if (iv.length !== 12 || tag.length !== 16 || payload.length === 0) throw new Error("invalid encrypted reference lengths");
} catch {
throw new ReferenceFormatError();
}
try {
const decipher = createDecipheriv("aes-256-gcm", this.key, iv);
decipher.setAuthTag(tag);
const decrypted = Buffer.concat([decipher.update(payload), decipher.final()]);
const plaintext = version === "v3" ? inflateRawSync(decrypted, { maxOutputLength: 64 * 1024 }) : decrypted;
return JSON.parse(plaintext.toString("utf8")) as T;
} catch {
throw new ReferenceIntegrityError();
}
}
}
const COMPACT_APPROVAL_AAD = Buffer.from("dingmcp:approval:v4", "utf8"); const COMPACT_APPROVAL_AAD = Buffer.from("dingmcp:approval:v4", "utf8");
const MAX_COMPACT_FIELD_BYTES = 8 * 1024; const MAX_COMPACT_FIELD_BYTES = 8 * 1024;
@@ -2015,6 +2081,7 @@ function validateXftSubmissionValues(
kind: XftSubmissionKind, kind: XftSubmissionKind,
values: Record<string, unknown>, values: Record<string, unknown>,
customFields: XftSubmissionField[], customFields: XftSubmissionField[],
attachments: XftResolvedSubmissionAttachment[] = [],
): void { ): void {
const required: Record<XftSubmissionKind, string[]> = { const required: Record<XftSubmissionKind, string[]> = {
travel_apply: ["businessTrip"], expense_apply: ["applyExpenses"], travel_apply: ["businessTrip"], expense_apply: ["applyExpenses"],
@@ -2022,25 +2089,34 @@ function validateXftSubmissionValues(
common_reimburse: ["reimAmount", "reimAmountCurrencyCode", "expenseList"], common_reimburse: ["reimAmount", "reimAmountCurrencyCode", "expenseList"],
travel_reimburse: ["reimAmount", "reimAmountCurrencyCode", "businessTrip", "expenseList"], travel_reimburse: ["reimAmount", "reimAmountCurrencyCode", "businessTrip", "expenseList"],
}; };
assertNoClientOnlyStandardValues(values);
const requiredUnsupported = customFields.filter(requiredXftFieldNeedsNativeClient);
if (requiredUnsupported.length) {
throw new ApprovalSafetyError(`该薪福通模板包含当前 MCP 无法安全填写的必填项:${requiredUnsupported.map((field) => field.name).join("、")},请在薪福通客户端发起`);
}
const missingBusiness = required[kind].filter((key) => isBlankSubmissionValue(values[key])); const missingBusiness = required[kind].filter((key) => isBlankSubmissionValue(values[key]));
const missingCustom = missingRequiredXftCustomFields(customFields, values.customFieldValues); const missingCustom = missingRequiredXftCustomFields(customFields, values.customFieldValues, attachments);
if (missingBusiness.length || missingCustom.length) { if (missingBusiness.length || missingCustom.length) {
const businessLabels: Record<string, string> = {
businessTrip: "差旅行程", applyExpenses: "事前申请费用", entertainmentApplyApiCreateDTO: "招待信息",
useCarInfo: "用车信息", reimAmount: "报销总金额", reimAmountCurrencyCode: "报销币种", expenseList: "费用明细",
};
const parts = [ const parts = [
...(missingBusiness.length ? [`业务字段:${missingBusiness.join("、")}`] : []), ...(missingBusiness.length ? [`业务字段:${missingBusiness.map((key) => businessLabels[key] ?? key).join("、")}`] : []),
...(missingCustom.length ? [`自定义字段:${missingCustom.map((field) => `${field.name}(${field.fieldNumber})`).join("、")}`] : []), ...(missingCustom.length ? [`自定义字段:${missingCustom.map((field) => field.name).join("、")}`] : []),
]; ];
throw new ApprovalSafetyError(`薪福通提单缺少必填${parts.join(";")}`); throw new ApprovalSafetyError(`薪福通提单缺少必填${parts.join(";")}`);
} }
const hasBillCompany = !isBlankSubmissionValue(values.billCompanyCode); const hasBillCompany = !isBlankSubmissionValue(values.billCompanyCode);
const hasBillDepartment = !isBlankSubmissionValue(values.billDepartmentCode); const hasBillDepartment = !isBlankSubmissionValue(values.billDepartmentCode);
if (hasBillCompany !== hasBillDepartment) { if (hasBillCompany || hasBillDepartment) {
throw new ApprovalSafetyError("薪福通单据公司 billCompanyCode 和单据部门 billDepartmentCode 必须同时填写或同时省略"); throw new ApprovalSafetyError("单据公司和部门由薪福通按当前登录用户填写,不应向用户索取或由 Agent 指定");
} }
validateRequiredXftCustomFields(customFields, values.customFieldValues); validateRequiredXftCustomFields(customFields, values.customFieldValues, attachments);
validateXftCustomFieldValues(customFields, values.customFieldValues); validateXftCustomFieldValues(customFields, values.customFieldValues);
if (kind === "common_reimburse" || kind === "travel_reimburse") { if (kind === "common_reimburse" || kind === "travel_reimburse") {
requirePositiveDecimal(values.reimAmount, "报销总金额 reimAmount"); requirePositiveDecimal(values.reimAmount, "报销总金额 reimAmount");
if (values.reimAmountCurrencyCode !== "CNY") throw new ApprovalSafetyError("报销币种 reimAmountCurrencyCode 目前只支持 CNY"); if (values.reimAmountCurrencyCode !== "CNY") throw new ApprovalSafetyError("报销币种目前只支持人民币");
validateXftExpenses(values.expenseList); validateXftExpenses(values.expenseList);
} }
if (kind === "travel_apply" || kind === "travel_reimburse") validateXftBusinessTrip(values.businessTrip); if (kind === "travel_apply" || kind === "travel_reimburse") validateXftBusinessTrip(values.businessTrip);
@@ -2048,11 +2124,45 @@ function validateXftSubmissionValues(
if (kind === "entertainment_apply") validateXftEntertainment(values.entertainmentApplyApiCreateDTO); if (kind === "entertainment_apply") validateXftEntertainment(values.entertainmentApplyApiCreateDTO);
} }
function requiredXftFieldNeedsNativeClient(field: XftSubmissionField): boolean {
if (!field.required) return false;
if (field.selectionCapability?.status === "client_only") return true;
return (field.itemFields ?? []).some((item) => item.required === true
&& isSubmissionRecord(item.selectionCapability)
&& item.selectionCapability.status === "client_only");
}
function assertNoClientOnlyStandardValues(values: Record<string, unknown>): void {
const reject = (record: Record<string, unknown>, key: string, label: string) => {
if (!isBlankSubmissionValue(record[key])) {
throw new ApprovalSafetyError(`${label}当前无法通过 MCP 按业务名称安全填写;不要提供内部编码或 ID,请省略该可选项或改用薪福通客户端`);
}
};
reject(values, "relatedBillId", "关联申请单");
reject(values, "peerEmpNumbers", "同行人员");
reject(values, "billCollectionInfo", "收款信息");
reject(values, "billLendPayRelList", "核销借款");
if (isSubmissionRecord(values.businessTrip) && Array.isArray(values.businessTrip.businessTripDetails)) {
values.businessTrip.businessTripDetails.filter(isSubmissionRecord).forEach((trip) => {
reject(trip, "customArchiveOptionNumbers", "行程自定义档案");
if (Array.isArray(trip.estimateExpenses)) trip.estimateExpenses.filter(isSubmissionRecord)
.forEach((expense) => reject(expense, "spreadInfo", "预计费用分摊"));
});
}
if (Array.isArray(values.expenseList)) values.expenseList.filter(isSubmissionRecord).forEach((expense) => {
reject(expense, "participants", "费用参与人员");
reject(expense, "aircraftCabin", "飞机舱位");
reject(expense, "trainCabin", "火车席别");
reject(expense, "shipCabin", "轮船舱位");
reject(expense, "spreadInfo", "费用分摊");
});
}
function validateXftExpenseApplication(value: unknown): void { function validateXftExpenseApplication(value: unknown): void {
if (!Array.isArray(value) || !value.length) throw new ApprovalSafetyError("事前申请费用 applyExpenses 至少需要 1 条"); if (!Array.isArray(value) || !value.length) throw new ApprovalSafetyError("事前申请费用 applyExpenses 至少需要 1 条");
value.forEach((item, index) => { value.forEach((item, index) => {
if (!isSubmissionRecord(item)) throw new ApprovalSafetyError(`事前申请费用第 ${index + 1} 条必须是对象`); if (!isSubmissionRecord(item)) throw new ApprovalSafetyError(`事前申请费用第 ${index + 1} 条必须是对象`);
if (isBlankSubmissionValue(item.expenseNumber)) throw new ApprovalSafetyError(`事前申请费用第 ${index + 1} 条缺少费用类型编码 expenseNumber`); if (isBlankSubmissionValue(item.expenseNumber)) throw new ApprovalSafetyError(`事前申请费用第 ${index + 1} 条缺少费用类型,请先查询并选择可用名称`);
requirePositiveDecimal(item.amount, `事前申请费用第 ${index + 1} 条申请金额 amount`); requirePositiveDecimal(item.amount, `事前申请费用第 ${index + 1} 条申请金额 amount`);
if (!isBlankSubmissionValue(item.participantNum) if (!isBlankSubmissionValue(item.participantNum)
&& (!Number.isSafeInteger(Number(item.participantNum)) || Number(item.participantNum) <= 0)) { && (!Number.isSafeInteger(Number(item.participantNum)) || Number(item.participantNum) <= 0)) {
@@ -2077,21 +2187,26 @@ function validateXftEntertainment(value: unknown): void {
function validateRequiredXftCustomFields( function validateRequiredXftCustomFields(
fields: XftSubmissionField[], fields: XftSubmissionField[],
value: unknown, value: unknown,
attachments: XftResolvedSubmissionAttachment[] = [],
): void { ): void {
const missing = missingRequiredXftCustomFields(fields, value); const missing = missingRequiredXftCustomFields(fields, value, attachments);
if (missing.length) { if (missing.length) {
throw new ApprovalSafetyError(`薪福通提单缺少必填自定义字段:${missing.map((field) => `${field.name}(${field.fieldNumber})`).join("、")}`); throw new ApprovalSafetyError(`薪福通提单缺少必填自定义字段:${missing.map((field) => field.name).join("、")}`);
} }
} }
function missingRequiredXftCustomFields( function missingRequiredXftCustomFields(
fields: XftSubmissionField[], fields: XftSubmissionField[],
value: unknown, value: unknown,
attachments: XftResolvedSubmissionAttachment[] = [],
): XftSubmissionField[] { ): XftSubmissionField[] {
const entries = Array.isArray(value) ? value.filter(isSubmissionRecord) : []; const entries = Array.isArray(value) ? value.filter(isSubmissionRecord) : [];
return fields.filter((field) => field.required).filter((field) => !entries.some((entry) => const attachmentFields = new Set(attachments.map((attachment) => attachment.fieldNumber));
String(entry.fieldNumber ?? "") === field.fieldNumber && !isBlankSubmissionValue(entry.fieldValue), return fields.filter((field) => field.required).filter((field) => {
)); if (isXftAttachmentField(field) && attachmentFields.has(field.fieldNumber)) return false;
return !entries.some((entry) => String(entry.fieldNumber ?? "") === field.fieldNumber
&& !isBlankSubmissionValue(entry.fieldValue));
});
} }
function validateXftCustomFieldValues(fields: XftSubmissionField[], value: unknown): void { function validateXftCustomFieldValues(fields: XftSubmissionField[], value: unknown): void {
@@ -2106,22 +2221,82 @@ function validateXftCustomFieldValues(fields: XftSubmissionField[], value: unkno
if (!field) throw new ApprovalSafetyError(`薪福通自定义字段编码“${fieldNumber}”不在当前实时表单中`); if (!field) throw new ApprovalSafetyError(`薪福通自定义字段编码“${fieldNumber}”不在当前实时表单中`);
if (seen.has(fieldNumber)) throw new ApprovalSafetyError(`薪福通自定义字段“${field.name}(${fieldNumber})”重复`); if (seen.has(fieldNumber)) throw new ApprovalSafetyError(`薪福通自定义字段“${field.name}(${fieldNumber})”重复`);
seen.add(fieldNumber); seen.add(fieldNumber);
if (field.selectionCapability?.status === "client_only") {
throw new ApprovalSafetyError(`“${field.name}”当前无法通过 MCP 按业务名称安全填写,请在薪福通客户端完成`);
}
if (isXftAttachmentField(field)) {
throw new ApprovalSafetyError(`附件字段“${field.name}”必须通过 attachments 使用本机文件,不能直接提供附件编号`);
}
if (typeof raw.fieldValue !== "string") throw new ApprovalSafetyError(`薪福通自定义字段“${field.name}”的 fieldValue 必须是字符串;数组和表格需传 JSON 字符串`); if (typeof raw.fieldValue !== "string") throw new ApprovalSafetyError(`薪福通自定义字段“${field.name}”的 fieldValue 必须是字符串;数组和表格需传 JSON 字符串`);
const type = field.type.toUpperCase(); const type = field.type.toUpperCase();
if (type.includes("TABLE")) validateXftTableField(field, raw.fieldValue); if (type.includes("TABLE")) validateXftTableField(field, raw.fieldValue);
if (type === "DATE" && !/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(raw.fieldValue)) { if (type === "DATE" && !/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(raw.fieldValue)) {
throw new ApprovalSafetyError(`薪福通自定义字段“${field.name}”必须使用 yyyy-MM-dd HH:mm 日期时间格式`); throw new ApprovalSafetyError(`薪福通自定义字段“${field.name}”必须使用 yyyy-MM-dd HH:mm 日期时间格式`);
} }
if (type.includes("PERSON") || type.includes("EMP") || type.includes("DEPARTMENT") || type.includes("ORG")) { if (type.includes("DEPARTMENT") || type.includes("ORG")) {
let parsed: unknown; let parsed: unknown;
try { parsed = JSON.parse(raw.fieldValue); } catch { throw new ApprovalSafetyError(`薪福通自定义字段“${field.name}”必须是 JSON 数组字符串`); } try { parsed = JSON.parse(raw.fieldValue); } catch { throw new ApprovalSafetyError(`薪福通字段“${field.name}”的部门选择格式无效,请重新查询并选择部门名称`); }
if (!Array.isArray(parsed) || parsed.some((item) => typeof item !== "string" || !item.trim())) { if (!Array.isArray(parsed) || parsed.some((item) => typeof item !== "string" || !item.trim())) {
throw new ApprovalSafetyError(`薪福通自定义字段“${field.name}”必须是非空编码组成的 JSON 数组字符串`); throw new ApprovalSafetyError(`薪福通字段“${field.name}”的部门选择格式无效,请重新查询并选择部门名称`);
} }
} }
}); });
} }
function validateXftSubmissionAttachments(
fields: XftSubmissionField[],
attachments: XftSubmissionAttachment[],
requireUploadReference: boolean,
): XftResolvedSubmissionAttachment[] {
if (!Array.isArray(attachments)) throw new ApprovalSafetyError("attachments 必须是数组");
if (attachments.length > XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION) {
throw new ApprovalSafetyError(`一次薪福通提单最多上传 ${XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION} 个附件`);
}
const seen = new Set<string>();
let totalBytes = 0;
return attachments.map((attachment) => {
if (!attachment || typeof attachment !== "object") throw new ApprovalSafetyError("附件信息格式无效");
if (!attachment.fieldName || attachment.fieldName.length > 200) throw new ApprovalSafetyError("附件缺少有效的表单字段名称");
if (!attachment.fileName || attachment.fileName.length > 240 || /[\\/\r\n\0"]/u.test(attachment.fileName)) {
throw new ApprovalSafetyError("附件文件名无效,请重新选择本机普通文件");
}
if (!Number.isSafeInteger(attachment.size) || attachment.size <= 0 || attachment.size > XFT_ATTACHMENT_MAX_FILE_BYTES) {
throw new ApprovalSafetyError(`附件“${attachment.fileName}”必须大于 0 字节且单个不超过 50MB`);
}
totalBytes += attachment.size;
if (!Number.isSafeInteger(totalBytes) || totalBytes > XFT_ATTACHMENT_MAX_TOTAL_BYTES) {
throw new ApprovalSafetyError("一次薪福通提单的附件总大小不能超过 50MB");
}
if (!/^[0-9a-f]{64}$/.test(attachment.sha256)) throw new ApprovalSafetyError(`附件“${attachment.fileName}”的 SHA-256 格式无效`);
const matches = fields.filter((field) => field.name === attachment.fieldName && isXftAttachmentField(field));
if (matches.length !== 1) {
throw new ApprovalSafetyError(matches.length === 0
? `实时薪福通表单中没有名为“${attachment.fieldName}”的附件字段,请重新读取表单`
: `实时薪福通表单中“${attachment.fieldName}”附件字段不唯一,请重新选择`);
}
if (requireUploadReference && !attachment.attachmentRef) {
throw new ApprovalSafetyError(`附件“${attachment.fileName}”尚未由本机 Bridge 上传,请重新执行最终提交`);
}
if (!requireUploadReference && attachment.attachmentRef) {
throw new ApprovalSafetyError("预览阶段不得预上传附件,请移除上传引用后重新准备");
}
const field = matches[0]!;
const duplicateKey = `${field.fieldNumber}\0${attachment.sha256}`;
if (seen.has(duplicateKey)) throw new ApprovalSafetyError(`附件“${attachment.fileName}”在同一字段中重复`);
seen.add(duplicateKey);
return {
...attachment,
fieldNumber: field.fieldNumber,
...(requireUploadReference ? { attachmentRef: attachment.attachmentRef! } : { attachmentRef: "" }),
};
});
}
function isXftAttachmentField(field: XftSubmissionField): boolean {
const type = field.type.toUpperCase();
return type.includes("ATTACH") || type.includes("ACCESSOR");
}
function validateXftTableField(field: XftSubmissionField, value: string): void { function validateXftTableField(field: XftSubmissionField, value: string): void {
let rows: unknown; let rows: unknown;
try { rows = JSON.parse(value); } catch { throw new ApprovalSafetyError(`薪福通表格字段“${field.name}”必须是 JSON 数组字符串`); } try { rows = JSON.parse(value); } catch { throw new ApprovalSafetyError(`薪福通表格字段“${field.name}”必须是 JSON 数组字符串`); }
@@ -2134,6 +2309,10 @@ function validateXftTableField(field: XftSubmissionField, value: string): void {
if (!isSubmissionRecord(column) || isBlankSubmissionValue(column.titleCode) || isBlankSubmissionValue(column.value)) { if (!isSubmissionRecord(column) || isBlankSubmissionValue(column.titleCode) || isBlankSubmissionValue(column.value)) {
throw new ApprovalSafetyError(`薪福通表格字段“${field.name}”第 ${index + 1} 行的列必须包含 titleCode 和 value`); throw new ApprovalSafetyError(`薪福通表格字段“${field.name}”第 ${index + 1} 行的列必须包含 titleCode 和 value`);
} }
const itemField = (field.itemFields ?? []).find((item) => String(item.titleCode ?? "") === String(column.titleCode ?? ""));
if (itemField && isSubmissionRecord(itemField.selectionCapability) && itemField.selectionCapability.status === "client_only") {
throw new ApprovalSafetyError(`薪福通表格字段“${field.name}”中的“${String(itemField.label ?? "选项")}”当前无法通过 MCP 安全填写,请在薪福通客户端完成`);
}
} }
}); });
} }
@@ -2142,10 +2321,10 @@ function validateXftExpenses(value: unknown): void {
if (!Array.isArray(value) || value.length === 0) throw new ApprovalSafetyError("费用明细 expenseList 至少需要一笔"); if (!Array.isArray(value) || value.length === 0) throw new ApprovalSafetyError("费用明细 expenseList 至少需要一笔");
value.forEach((item, index) => { value.forEach((item, index) => {
if (!isSubmissionRecord(item)) throw new ApprovalSafetyError(`第 ${index + 1} 笔费用必须是对象`); if (!isSubmissionRecord(item)) throw new ApprovalSafetyError(`第 ${index + 1} 笔费用必须是对象`);
for (const key of ["expTypeCode", "currencyCode", "originalCurrencyReimAmt"] as const) { if (isBlankSubmissionValue(item.expTypeCode)) throw new ApprovalSafetyError(`第 ${index + 1} 笔费用缺少费用类型,请先查询并选择可用名称`);
if (isBlankSubmissionValue(item[key])) throw new ApprovalSafetyError(`第 ${index + 1} 笔费用缺少 ${key}`); if (isBlankSubmissionValue(item.currencyCode)) throw new ApprovalSafetyError(`第 ${index + 1} 笔费用缺少费用币种`);
} if (isBlankSubmissionValue(item.originalCurrencyReimAmt)) throw new ApprovalSafetyError(`第 ${index + 1} 笔费用缺少费用金额`);
if (item.currencyCode !== "CNY") throw new ApprovalSafetyError(`第 ${index + 1} 笔费用币种目前只支持 CNY`); if (item.currencyCode !== "CNY") throw new ApprovalSafetyError(`第 ${index + 1} 笔费用币种目前只支持人民币`);
requirePositiveDecimal(item.originalCurrencyReimAmt, `第 ${index + 1} 笔费用金额 originalCurrencyReimAmt`); requirePositiveDecimal(item.originalCurrencyReimAmt, `第 ${index + 1} 笔费用金额 originalCurrencyReimAmt`);
if (item.wrTim !== undefined) requireDateString(item.wrTim, `第 ${index + 1} 笔消费日期 wrTim`, /^\d{4}-\d{2}-\d{2}$/); if (item.wrTim !== undefined) requireDateString(item.wrTim, `第 ${index + 1} 笔消费日期 wrTim`, /^\d{4}-\d{2}-\d{2}$/);
}); });
@@ -2157,27 +2336,27 @@ function validateXftBusinessTrip(value: unknown): void {
} }
value.businessTripDetails.forEach((item, index) => { value.businessTripDetails.forEach((item, index) => {
if (!isSubmissionRecord(item)) throw new ApprovalSafetyError(`第 ${index + 1} 段行程必须是对象`); if (!isSubmissionRecord(item)) throw new ApprovalSafetyError(`第 ${index + 1} 段行程必须是对象`);
for (const key of ["departCityCode", "destinationCityCode", "endTime"] as const) { if (isBlankSubmissionValue(item.departCityCode)) throw new ApprovalSafetyError(`第 ${index + 1} 段行程缺少出发城市`);
if (isBlankSubmissionValue(item[key])) throw new ApprovalSafetyError(`第 ${index + 1} 段行程缺少 ${key}`); if (isBlankSubmissionValue(item.destinationCityCode)) throw new ApprovalSafetyError(`第 ${index + 1} 段行程缺少目的城市`);
} if (isBlankSubmissionValue(item.endTime)) throw new ApprovalSafetyError(`第 ${index + 1} 段行程缺少结束时间`);
const beginTime = item.beginTime ?? item.startTime; const beginTime = item.beginTime ?? item.startTime;
if (isBlankSubmissionValue(beginTime)) throw new ApprovalSafetyError(`第 ${index + 1} 段行程缺少 beginTime(官方示例别名 startTime)`); if (isBlankSubmissionValue(beginTime)) throw new ApprovalSafetyError(`第 ${index + 1} 段行程缺少开始时间`);
requireDateString(beginTime, `第 ${index + 1} 段出发时间`, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/); requireDateString(beginTime, `第 ${index + 1} 段出发时间`, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/);
requireDateString(item.endTime, `第 ${index + 1} 段结束时间`, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/); requireDateString(item.endTime, `第 ${index + 1} 段结束时间`, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/);
if (String(beginTime) > String(item.endTime)) throw new ApprovalSafetyError(`第 ${index + 1} 段行程结束时间不能早于出发时间`); if (String(beginTime) > String(item.endTime)) throw new ApprovalSafetyError(`第 ${index + 1} 段行程结束时间不能早于出发时间`);
if (item.roundTrip !== undefined && !["N", "Y"].includes(String(item.roundTrip))) { if (item.roundTrip !== undefined && !["N", "Y"].includes(String(item.roundTrip))) {
throw new ApprovalSafetyError(`第 ${index + 1} 段行程 roundTrip 只能是 N 或 Y`); throw new ApprovalSafetyError(`第 ${index + 1} 段行程的往返选择无效,请重新选择“单程”或“往返”`);
} }
if (item.travelMode !== undefined) { if (item.travelMode !== undefined) {
const modes = String(item.travelMode).split(",").map((mode) => mode.trim()).filter(Boolean); const modes = String(item.travelMode).split(",").map((mode) => mode.trim()).filter(Boolean);
const allowed = new Set(["Train", "Bus", "Ship", "Hotel", "RideHailing", "AirPlane", "Other"]); const allowed = new Set(["Train", "Bus", "Ship", "Hotel", "RideHailing", "AirPlane", "Other"]);
if (!modes.length || modes.some((mode) => !allowed.has(mode))) throw new ApprovalSafetyError(`第 ${index + 1} 段行程 travelMode 含不支持的出行方式`); if (!modes.length || modes.some((mode) => !allowed.has(mode))) throw new ApprovalSafetyError(`第 ${index + 1} 段行程含不支持的出行方式,请从 Schema 的中文名称中重新选择`);
} }
if (item.estimateExpenses !== undefined) { if (item.estimateExpenses !== undefined) {
if (!Array.isArray(item.estimateExpenses)) throw new ApprovalSafetyError(`第 ${index + 1} 段行程 estimateExpenses 必须是数组`); if (!Array.isArray(item.estimateExpenses)) throw new ApprovalSafetyError(`第 ${index + 1} 段行程 estimateExpenses 必须是数组`);
item.estimateExpenses.forEach((expense, expenseIndex) => { item.estimateExpenses.forEach((expense, expenseIndex) => {
if (!isSubmissionRecord(expense) || isBlankSubmissionValue(expense.expenseTypeNumber) || isBlankSubmissionValue(expense.applyAmount)) { if (!isSubmissionRecord(expense) || isBlankSubmissionValue(expense.expenseTypeNumber) || isBlankSubmissionValue(expense.applyAmount)) {
throw new ApprovalSafetyError(`第 ${index + 1} 段行程第 ${expenseIndex + 1} 笔预计费用缺少 expenseTypeNumber 或 applyAmount`); throw new ApprovalSafetyError(`第 ${index + 1} 段行程第 ${expenseIndex + 1} 笔预计费用缺少费用类型或申请金额`);
} }
requirePositiveDecimal(expense.applyAmount, `第 ${index + 1} 段行程第 ${expenseIndex + 1} 笔预计费用 applyAmount`); requirePositiveDecimal(expense.applyAmount, `第 ${index + 1} 段行程第 ${expenseIndex + 1} 笔预计费用 applyAmount`);
}); });
@@ -2344,9 +2523,32 @@ function normalizedSubmissionInput(input: SubmissionInput): Record<string, unkno
deptId: input.deptId ?? -1, deptId: input.deptId ?? -1,
targetSelectActioners: input.targetSelectActioners ?? [], targetSelectActioners: input.targetSelectActioners ?? [],
allowDuplicateInvoices: input.allowDuplicateInvoices === true, allowDuplicateInvoices: input.allowDuplicateInvoices === true,
attachments: submissionAttachmentManifest(input),
}; };
} }
function submissionAttachmentManifest(input: SubmissionInput) {
return (input.attachments ?? []).map(({ fieldName, fileName, size, sha256 }) => ({ fieldName, fileName, size, sha256 }));
}
function submissionValuesForInvoiceDeduplication(input: SubmissionInput): Record<string, unknown> {
if (!(input.attachments ?? []).length) return input.values;
return {
...input.values,
invoiceAttachments: input.attachments!.map((attachment) => ({ sha256: attachment.sha256 })),
};
}
function attachmentPreview(attachments: XftResolvedSubmissionAttachment[]): Array<Record<string, unknown>> {
return attachments.map((attachment) => ({
field: attachment.fieldName,
fileName: attachment.fileName,
size: attachment.size,
sha256: `…${attachment.sha256.slice(-8)}`,
uploadStatus: "将在最终确认后上传",
}));
}
function summarizeValues(values: Record<string, unknown>): Record<string, unknown> { function summarizeValues(values: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.entries(values).slice(0, 200).map(([key, value]) => [key, summarizeValue(value)])); return Object.fromEntries(Object.entries(values).slice(0, 200).map(([key, value]) => [key, summarizeValue(value)]));
} }
@@ -2359,6 +2561,238 @@ function summarizeValue(value: unknown): unknown {
return String(value); return String(value);
} }
interface ReadableXftSummaryContext {
provider: XftApprovalProvider;
user: AuthenticatedDingTalkUser;
template: XftSubmissionTemplate;
customFields: XftSubmissionField[];
optionLabels: Map<string, string>;
}
async function readableXftSubmissionSummary(
provider: XftApprovalProvider,
user: AuthenticatedDingTalkUser,
template: XftSubmissionTemplate,
customFields: XftSubmissionField[],
inputContract: Record<string, unknown>,
values: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const valueContract = isSubmissionRecord(inputContract.values) ? inputContract.values : undefined;
const standardFields = Array.isArray(valueContract?.standardFields)
? valueContract.standardFields.filter(isSubmissionRecord)
: [];
const context: ReadableXftSummaryContext = {
provider, user, template, customFields, optionLabels: new Map(),
};
if (!standardFields.length) {
const compatibilitySummary: Record<string, unknown> = {
"预览说明": "当前实时模板未提供可读字段定义;已完成结构校验,但内部表单值不会在预览中展示",
"已填写业务区块数": Object.keys(values).length,
};
if (customFields.length && Object.hasOwn(values, "customFieldValues")) {
const custom = await readableXftCustomFields(values.customFieldValues, context, "表单自定义字段");
if (custom.length) compatibilitySummary["表单自定义字段"] = custom;
}
// Some third-party/test providers still implement the older contract. Never
// fall back to echoing their opaque values, because those may be codes or IDs.
return compatibilitySummary;
}
const output = await readableXftRecord(values, standardFields, context, "表单", true);
const known = new Set(standardFields.flatMap((field) => [
String(field.key ?? ""),
...(Array.isArray(field.aliases) ? field.aliases.map(String) : []),
]).filter(Boolean));
const standardHasCustomFields = standardFields.some((field) => String(field.key ?? "") === "customFieldValues");
if (!standardHasCustomFields && Object.hasOwn(values, "customFieldValues")) {
const custom = await readableXftCustomFields(values.customFieldValues, context, "表单自定义字段");
if (custom.length) output["表单自定义字段"] = custom;
}
known.add("customFieldValues");
const supplementary = Object.keys(values).filter((key) => !known.has(key));
if (supplementary.length) {
const invoiceOnly = supplementary.every((key) => /invoice|发票|票据/i.test(key));
if (!invoiceOnly) {
throw new ApprovalSafetyError("当前实时模板无法为部分已填写内容生成不含内部值的完整预览,请刷新模板后重新填写");
}
output["发票查重资料"] = "已提供;唯一标识仅用于查重,并在确认预览中脱敏展示";
}
return output;
}
async function readableXftRecord(
record: Record<string, unknown>,
descriptors: Array<Record<string, unknown>>,
context: ReadableXftSummaryContext,
path: string,
allowUnknownKeys = false,
): Promise<Record<string, unknown>> {
const output: Record<string, unknown> = {};
const knownKeys = new Set<string>();
for (const descriptor of descriptors) {
const key = String(descriptor.key ?? descriptor.fieldNumber ?? descriptor.titleCode ?? "");
if (!key) continue;
const aliases = Array.isArray(descriptor.aliases) ? descriptor.aliases.map(String) : [];
[key, ...aliases].forEach((candidate) => knownKeys.add(candidate));
const matchedKey = [key, ...aliases].find((candidate) => Object.hasOwn(record, candidate));
if (!matchedKey || isBlankSubmissionValue(record[matchedKey])) continue;
if (key === "customFieldValues") {
const custom = await readableXftCustomFields(record[matchedKey], context, `${path}自定义字段`);
if (custom.length) output["自定义字段"] = custom;
continue;
}
if (descriptor.userVisible === false || descriptor.serverManaged === true || descriptor.internallyDerived === true) continue;
const label = String(descriptor.label ?? descriptor.name ?? "业务字段");
output[label] = await readableXftValue(record[matchedKey], descriptor, context, `${path}“${label}”`);
}
if (!allowUnknownKeys && Object.keys(record).some((key) => !knownKeys.has(key))) {
throw new ApprovalSafetyError(`${path}包含当前实时模板未定义的内容,无法生成不含内部值的完整预览`);
}
return output;
}
async function readableXftValue(
value: unknown,
descriptor: Record<string, unknown>,
context: ReadableXftSummaryContext,
path: string,
): Promise<unknown> {
const selection = isSubmissionRecord(descriptor.selectionCapability) ? descriptor.selectionCapability : undefined;
if (selection?.status === "client_only") {
throw new ApprovalSafetyError(`${path}当前无法通过 MCP 按业务名称安全预览,请省略该可选项或在薪福通客户端完成`);
}
if (String(descriptor.key ?? "") === "customFieldValues") {
return readableXftCustomFields(value, context, path);
}
const type = String(descriptor.type ?? "").toUpperCase();
if (type.includes("TABLE")) return readableXftTable(value, descriptor, context, path);
const enumLabels = isSubmissionRecord(descriptor.enumLabels) ? descriptor.enumLabels : undefined;
const inlineOptions = Array.isArray(descriptor.options) ? descriptor.options.filter(isSubmissionRecord) : [];
const optionSource = isSubmissionRecord(descriptor.optionSource) ? descriptor.optionSource : undefined;
if (enumLabels || inlineOptions.length || optionSource) {
const selected = type === "COMMA_SEPARATED_ENUM" && typeof value === "string"
? value.split(",").map((item) => item.trim()).filter(Boolean)
: normalizeXftSelectedValues(value, type);
const labels = await Promise.all(selected.map((item) => readableXftOptionLabel(
item, descriptor, enumLabels, inlineOptions, optionSource, context, path,
)));
return labels.length === 1 && descriptor.multiple !== true && type !== "COMMA_SEPARATED_ENUM" ? labels[0] : labels;
}
const objectFields = Array.isArray(descriptor.fields) ? descriptor.fields.filter(isSubmissionRecord) : [];
if (objectFields.length) {
if (!isSubmissionRecord(value)) throw new ApprovalSafetyError(`${path}无法生成安全的业务预览,请重新填写`);
return readableXftRecord(value, objectFields, context, path);
}
const itemFields = Array.isArray(descriptor.itemFields) ? descriptor.itemFields.filter(isSubmissionRecord) : [];
if (type === "ARRAY" || Array.isArray(value)) {
if (!Array.isArray(value)) throw new ApprovalSafetyError(`${path}无法生成安全的业务预览,请重新填写`);
if (!itemFields.length) {
if (value.some((item) => item !== null && typeof item === "object")) {
throw new ApprovalSafetyError(`${path}缺少可读字段定义,无法安全展示`);
}
return value.map((item) => summarizeValue(item));
}
return Promise.all(value.map(async (item, index) => {
if (!isSubmissionRecord(item)) throw new ApprovalSafetyError(`${path}第 ${index + 1} 项无法生成安全的业务预览`);
return readableXftRecord(item, itemFields, context, `${path}第 ${index + 1} 项`);
}));
}
if (isSubmissionRecord(value)) {
throw new ApprovalSafetyError(`${path}缺少可读字段定义,无法安全展示`);
}
return summarizeValue(value);
}
function normalizeXftSelectedValues(value: unknown, type: string): string[] {
if (Array.isArray(value)) return value.map(String);
if (typeof value !== "string") return [String(value)];
if (type.includes("DEPARTMENT") || type.includes("ORG") || value.trim().startsWith("[")) {
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) return parsed.map(String);
} catch {
// A non-JSON option is handled as one selected value below.
}
}
return [value];
}
async function readableXftOptionLabel(
code: string,
descriptor: Record<string, unknown>,
enumLabels: Record<string, unknown> | undefined,
inlineOptions: Array<Record<string, unknown>>,
optionSource: Record<string, unknown> | undefined,
context: ReadableXftSummaryContext,
path: string,
): Promise<string> {
const fixed = typeof enumLabels?.[code] === "string" ? String(enumLabels[code]) : undefined;
if (fixed) return fixed;
const inline = inlineOptions.find((item) => String(item.code ?? "") === code);
if (inline && typeof inline.label === "string" && inline.label.trim()) return inline.label.trim();
const kind = optionSource?.kind;
if (kind === "department" || kind === "city" || kind === "expense_type") {
const cacheKey = `${kind}\u0000${code}`;
const cached = context.optionLabels.get(cacheKey);
if (cached) return cached;
const queried = await context.provider.getSubmissionOptions(context.user, context.template, kind, code, 200);
const matched = queried.items.find((item) => item.code === code && item.label.trim());
if (matched) {
context.optionLabels.set(cacheKey, matched.label.trim());
return matched.label.trim();
}
}
throw new ApprovalSafetyError(`${path}的当前选择已失效或无法映射为业务名称,请重新查询可用名称后选择`);
}
async function readableXftCustomFields(
value: unknown,
context: ReadableXftSummaryContext,
path: string,
): Promise<Array<Record<string, unknown>>> {
if (value === undefined) return [];
if (!Array.isArray(value)) throw new ApprovalSafetyError(`${path}无法生成安全的业务预览`);
const known = new Map(context.customFields.map((field) => [field.fieldNumber, field]));
return Promise.all(value.map(async (item, index) => {
if (!isSubmissionRecord(item)) throw new ApprovalSafetyError(`${path}第 ${index + 1} 项无法生成安全的业务预览`);
const field = known.get(String(item.fieldNumber ?? ""));
if (!field) throw new ApprovalSafetyError(`${path}包含当前实时模板无法识别的字段,请刷新模板后重试`);
const descriptor: Record<string, unknown> = {
...field,
key: field.fieldNumber,
label: field.name,
};
return { [field.name]: await readableXftValue(item.fieldValue, descriptor, context, `${path}“${field.name}”`) };
}));
}
async function readableXftTable(
value: unknown,
descriptor: Record<string, unknown>,
context: ReadableXftSummaryContext,
path: string,
): Promise<Array<Record<string, unknown>>> {
let rows = value;
if (typeof rows === "string") {
try { rows = JSON.parse(rows); } catch { throw new ApprovalSafetyError(`${path}无法生成安全的业务预览`); }
}
if (!Array.isArray(rows)) throw new ApprovalSafetyError(`${path}无法生成安全的业务预览`);
const itemFields = Array.isArray(descriptor.itemFields) ? descriptor.itemFields.filter(isSubmissionRecord) : [];
return Promise.all(rows.map(async (row, index) => {
if (!isSubmissionRecord(row) || !Array.isArray(row.columnValues)) {
throw new ApprovalSafetyError(`${path}第 ${index + 1} 行无法生成安全的业务预览`);
}
const output: Record<string, unknown> = {};
for (const column of row.columnValues) {
if (!isSubmissionRecord(column)) throw new ApprovalSafetyError(`${path}第 ${index + 1} 行无法生成安全的业务预览`);
const columnDescriptor = itemFields.find((item) => String(item.titleCode ?? "") === String(column.titleCode ?? ""));
if (!columnDescriptor) throw new ApprovalSafetyError(`${path}包含当前实时模板无法识别的列,请刷新模板后重试`);
const label = String(columnDescriptor.label ?? "业务字段");
output[label] = await readableXftValue(column.value, columnDescriptor, context, `${path}第 ${index + 1} 行“${label}”`);
}
return output;
}));
}
function submissionStringValue(value: unknown): string { function submissionStringValue(value: unknown): string {
if (typeof value === "string") return value; if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value); if (typeof value === "number" || typeof value === "boolean") return String(value);
+1
View File
@@ -0,0 +1 @@
export const APPLICATION_VERSION = "1.1.0-rc.20";
+865 -131
View File
File diff suppressed because it is too large Load Diff
+62 -4
View File
@@ -6,8 +6,10 @@ export interface AuthenticatedDingTalkUser {
export interface XftTodoItem { export interface XftTodoItem {
source: "xft"; source: "xft";
billId: string; billId?: string;
taskId?: string; taskId?: string;
capability?: "bill_api" | "list_only";
unavailableReason?: "unsupported_task_type" | "missing_bill_identifier" | "bill_lookup_unavailable";
eventSeqId?: string; eventSeqId?: string;
title?: string; title?: string;
applicantName?: string; applicantName?: string;
@@ -28,7 +30,8 @@ export interface ApprovalHistoryRange {
endTime: number; endTime: number;
} }
export interface XftHistoryItem extends Omit<XftTodoItem, "allowPass" | "allowReject"> { export interface XftHistoryItem extends Omit<XftTodoItem, "allowPass" | "allowReject" | "billId" | "capability" | "unavailableReason"> {
billId: string;
historyScope: ApprovalHistoryScope; historyScope: ApprovalHistoryScope;
status?: string; status?: string;
statusName?: string; statusName?: string;
@@ -77,19 +80,66 @@ export interface XftSubmissionField {
required: boolean; required: boolean;
description?: string; description?: string;
multiple?: boolean; multiple?: boolean;
options?: unknown[]; options?: XftSubmissionOption[];
enumLabels?: Record<string, string>;
moduleType?: string; moduleType?: string;
valueFormat?: string; valueFormat?: string;
itemFields?: Array<Record<string, unknown>>; itemFields?: Array<Record<string, unknown>>;
optionSource?: {
tool: "approval_get_submission_options";
kind: XftSubmissionOptionKind;
};
selectionCapability?: XftSubmissionSelectionCapability;
attachmentCapability?: {
status: "supported";
input: "local_file";
uploadWhen: "after_final_confirmation";
maxFileBytes: number;
maxFilesPerSubmission: number;
};
} }
export type XftSubmissionOptionKind = "department" | "city"; export type XftSubmissionOptionKind = "department" | "city" | "expense_type";
export interface XftSubmissionOption { export interface XftSubmissionOption {
code: string; code: string;
label: string; label: string;
} }
export interface XftSubmissionAttachment {
fieldName: string;
fileName: string;
size: number;
sha256: string;
attachmentRef?: string;
}
export interface XftResolvedSubmissionAttachment extends XftSubmissionAttachment {
fieldNumber: string;
attachmentRef: string;
}
export interface XftSubmissionAttachmentUpload {
fieldName: string;
fileName: string;
size: number;
sha256: string;
content: Buffer;
confirmationId: string;
}
export type XftSubmissionSelectionCapability =
| {
status: "supported";
userInput: "label";
submitValue: "code";
exposeInternalValue: false;
}
| {
status: "client_only";
reason: string;
};
export interface XftApprovalProvider { export interface XftApprovalProvider {
authStatus(user: AuthenticatedDingTalkUser): Promise<{ authStatus(user: AuthenticatedDingTalkUser): Promise<{
authenticated: true; authenticated: true;
@@ -159,16 +209,24 @@ export interface XftApprovalProvider {
): Promise<{ ): Promise<{
kind: XftSubmissionOptionKind; kind: XftSubmissionOptionKind;
items: XftSubmissionOption[]; items: XftSubmissionOption[];
total?: number;
hasMore?: boolean;
diagnostics: Record<string, unknown>; diagnostics: Record<string, unknown>;
}>; }>;
validateSubmissionIdentity(user: AuthenticatedDingTalkUser): Promise<{ validateSubmissionIdentity(user: AuthenticatedDingTalkUser): Promise<{
validated: true; validated: true;
diagnostics: Record<string, unknown>; diagnostics: Record<string, unknown>;
}>; }>;
uploadSubmissionAttachment?(
user: AuthenticatedDingTalkUser,
attachment: XftSubmissionAttachmentUpload,
): Promise<XftSubmissionAttachment & { attachmentRef: string }>;
submitApproval( submitApproval(
user: AuthenticatedDingTalkUser, user: AuthenticatedDingTalkUser,
template: XftSubmissionTemplate, template: XftSubmissionTemplate,
values: Record<string, unknown>, values: Record<string, unknown>,
attachments?: XftResolvedSubmissionAttachment[],
confirmationId?: string,
): Promise<{ billId: string; status: "submitted"; diagnostics: Record<string, unknown> }>; ): Promise<{ billId: string; status: "submitted"; diagnostics: Record<string, unknown> }>;
recallSubmission( recallSubmission(
user: AuthenticatedDingTalkUser, user: AuthenticatedDingTalkUser,
+139 -2
View File
@@ -1,15 +1,136 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; import { createHash } from "node:crypto";
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, statSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import test from "node:test"; import test from "node:test";
import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { import {
APPROVAL_ONBOARDING_EXAMPLES, RemoteBridge, bridgeAuthToolResult, createBridgeMcpServer, APPROVAL_ONBOARDING_EXAMPLES, RemoteBridge, bridgeAuthToolResult, bridgeWriteOutcomeUncertainResult, createBridgeMcpServer,
syncBundledSkillForLegacyUpdate, syncBundledSkillForLegacyUpdate,
} from "../src/bridge.js"; } from "../src/bridge.js";
test("bridge write timeout fallback remains structured and forbids retries", () => {
const result = bridgeWriteOutcomeUncertainResult("approval_process_batch", {
action: "approve",
items: [{ approvalId: "approval-one" }, { approvalId: "approval-two" }],
});
assert.equal(result?.structuredContent.result.status, "outcome_uncertain");
assert.equal(result?.structuredContent.result.uncertain, 2);
assert.equal(result?.structuredContent.result.retryable, false);
assert.equal(result?.structuredContent.result.requiresRelist, true);
assert.equal(result?.structuredContent.result.results.length, 2);
assert.match(result?.content[0]?.text ?? "", /禁止自动重试/);
assert.equal(bridgeWriteOutcomeUncertainResult("approval_list_todos", {}), undefined);
});
test("stdio bridge converts a remote batch timeout into a structured uncertain outcome", async () => {
const root = mkdtempSync(join(tmpdir(), "ding-bridge-write-timeout-"));
const statePath = join(root, "session.json");
writeFileSync(statePath, JSON.stringify({
client: { client_id: "bridge-client" },
tokens: { access_token: "fresh-access", savedAt: Date.now(), expires_in: 900 },
}));
const bridge = new RemoteBridge("https://approval.example.test/mcp", statePath, async () => {
throw new Error("network should not be used by the injected client");
});
(bridge as unknown as { client: { callTool(): Promise<unknown>; close(): Promise<void> } }).client = {
async callTool() { throw new Error("Request timed out"); },
async close() {},
};
const result = await bridge.call("approval_process_batch", {
action: "approve",
items: [{ approvalId: "approval-one" }],
});
assert.equal(result.structuredContent.result.status, "outcome_uncertain");
assert.equal(result.structuredContent.result.uncertain, 1);
assert.equal(result.structuredContent.result.retryable, false);
assert.match(result.content[0].text, /重新列出实时待办核对/);
});
test("stdio bridge keeps draft paths local, previews attachment metadata, and uploads only after final confirmation", async () => {
const root = mkdtempSync(join(tmpdir(), "ding-bridge-attachment-"));
const statePath = join(root, "session.json");
const filePath = join(root, "invoice.pdf");
const content = Buffer.from("%PDF-1.7\nmock invoice", "utf8");
const sha256 = createHash("sha256").update(content).digest("hex");
writeFileSync(filePath, content);
writeFileSync(statePath, JSON.stringify({
client: { client_id: "bridge-client" },
tokens: { access_token: "fresh-access", savedAt: Date.now(), expires_in: 900 },
}));
const uploads: Array<{ headers: Headers; body: Buffer }> = [];
const bridge = new RemoteBridge("https://approval.example.test/mcp", statePath, async (input, init) => {
assert.equal(new URL(String(input)).pathname, "/xft/submission-attachments");
uploads.push({ headers: new Headers(init?.headers), body: Buffer.from(init?.body as ArrayBuffer) });
return Response.json({
fieldName: "发票附件", fileName: "invoice.pdf", size: content.length, sha256,
attachmentRef: "opaque-upload-reference-12345",
});
});
const calls: Array<{ name: string; arguments: Record<string, unknown> }> = [];
(bridge as unknown as { client: { callTool(request: any): Promise<unknown>; close(): Promise<void> } }).client = {
async callTool(request: any) {
calls.push(request);
return { content: [{ type: "text", text: "ok" }], structuredContent: { result: { status: "ok" } } };
},
async close() {},
};
const localAttachment = {
fieldName: "发票附件", displayName: "invoice.pdf", localPath: filePath,
size: content.length, modifiedAt: new Date().toISOString(), sha256,
};
await bridge.prepareSubmission({ templateId: "template-reference-12345", values: {}, attachments: [localAttachment] });
assert.equal(uploads.length, 0, "preview must have no upstream upload side effect");
const preparedArgs = calls[0]!.arguments;
assert.deepEqual(preparedArgs.attachments, [{
fieldName: "发票附件", fileName: "invoice.pdf", size: content.length, sha256,
}]);
assert.doesNotMatch(JSON.stringify(preparedArgs), new RegExp(root.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
await bridge.submitApproval({
confirmationId: "confirmation-reference-12345", templateId: "template-reference-12345",
values: {}, attachments: [localAttachment],
});
assert.equal(uploads.length, 1);
assert.deepEqual(uploads[0]!.body, content);
assert.equal(uploads[0]!.headers.get("authorization"), "Bearer fresh-access");
assert.equal(decodeURIComponent(uploads[0]!.headers.get("x-dingmcp-field-name")!), "发票附件");
assert.equal(decodeURIComponent(uploads[0]!.headers.get("x-dingmcp-file-name")!), "invoice.pdf");
assert.equal(uploads[0]!.headers.get("x-dingmcp-file-sha256"), sha256);
const submittedArgs = calls[1]!.arguments;
assert.deepEqual(submittedArgs.attachments, [{
fieldName: "发票附件", fileName: "invoice.pdf", size: content.length, sha256,
attachmentRef: "opaque-upload-reference-12345",
}]);
assert.doesNotMatch(JSON.stringify(submittedArgs), new RegExp(root.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
});
test("stdio bridge rejects symlinked or changed draft attachments before any upload", async () => {
const root = mkdtempSync(join(tmpdir(), "ding-bridge-attachment-link-"));
const statePath = join(root, "session.json");
const realPath = join(root, "invoice.pdf");
const linkPath = join(root, "invoice-link.pdf");
writeFileSync(realPath, "%PDF-1.7\nmock invoice");
symlinkSync(realPath, linkPath);
writeFileSync(statePath, JSON.stringify({
client: { client_id: "bridge-client" },
tokens: { access_token: "fresh-access", savedAt: Date.now(), expires_in: 900 },
}));
let networkCalls = 0;
const bridge = new RemoteBridge("https://approval.example.test/mcp", statePath, async () => {
networkCalls += 1;
throw new Error("network must not be called");
});
await assert.rejects(bridge.prepareSubmission({
templateId: "template-reference-12345", values: {},
attachments: [{ fieldName: "发票附件", localPath: linkPath }],
}), /无法安全读取附件.*符号链接/);
assert.equal(networkCalls, 0);
});
test("legacy updater post-install syncs the complete bundled Skill", () => { test("legacy updater post-install syncs the complete bundled Skill", () => {
const root = mkdtempSync(join(tmpdir(), "ding-bridge-skill-sync-")); const root = mkdtempSync(join(tmpdir(), "ding-bridge-skill-sync-"));
const bundled = join(root, "package", "skill", "dingtalk-approval"); const bundled = join(root, "package", "skill", "dingtalk-approval");
@@ -19,12 +140,14 @@ test("legacy updater post-install syncs the complete bundled Skill", () => {
mkdirSync(target, { recursive: true }); mkdirSync(target, { recursive: true });
writeFileSync(join(bundled, "SKILL.md"), "new skill"); writeFileSync(join(bundled, "SKILL.md"), "new skill");
writeFileSync(join(bundled, "references", "create-approval.md"), "create rules"); writeFileSync(join(bundled, "references", "create-approval.md"), "create rules");
writeFileSync(join(bundled, "references", "local-drafts.md"), "draft rules");
writeFileSync(join(target, "SKILL.md"), "old skill"); writeFileSync(join(target, "SKILL.md"), "old skill");
syncBundledSkillForLegacyUpdate(statePath, bundled); syncBundledSkillForLegacyUpdate(statePath, bundled);
assert.equal(readFileSync(join(target, "SKILL.md"), "utf8"), "new skill"); assert.equal(readFileSync(join(target, "SKILL.md"), "utf8"), "new skill");
assert.equal(readFileSync(join(target, "references", "create-approval.md"), "utf8"), "create rules"); assert.equal(readFileSync(join(target, "references", "create-approval.md"), "utf8"), "create rules");
assert.equal(readFileSync(join(target, "references", "local-drafts.md"), "utf8"), "draft rules");
assert.deepEqual(readdirSync(join(root, "client", "skill")), ["dingtalk-approval"]); assert.deepEqual(readdirSync(join(root, "client", "skill")), ["dingtalk-approval"]);
}); });
@@ -245,6 +368,9 @@ test("MCP approval_login returns both portable text and a standard resource link
const instructions = client.getInstructions() ?? ""; const instructions = client.getInstructions() ?? "";
assert.match(instructions, /登录链接过期.*approval_login.*绝不.*重新安装/); assert.match(instructions, /登录链接过期.*approval_login.*绝不.*重新安装/);
assert.match(instructions, /审批引用.*调用容量.*内部分批.*不得向用户解释/); assert.match(instructions, /审批引用.*调用容量.*内部分批.*不得向用户解释/);
assert.match(instructions, /提单草稿仅可保存在 Agent 所在机器.*不得上传网关.*实时模板 Schema/s);
assert.match(instructions, /所有选择型字段只向用户展示业务名称.*optionSource.*label.*enumLabels.*城市码.*组织码.*工号.*client_only/s);
assert.match(instructions, /用户选定label.*values.*code.*不能填label.*工具调用内部/s);
assert.doesNotMatch(instructions, /最多.*20|selectionId|approvalId|targetId/); assert.doesNotMatch(instructions, /最多.*20|selectionId|approvalId|targetId/);
const history = tools.tools.find((tool) => tool.name === "approval_list_history")!; const history = tools.tools.find((tool) => tool.name === "approval_list_history")!;
assert.equal((history.inputSchema as any).properties.startDate.default, ""); assert.equal((history.inputSchema as any).properties.startDate.default, "");
@@ -252,6 +378,17 @@ test("MCP approval_login returns both portable text and a standard resource link
const batch = tools.tools.find((tool) => tool.name === "approval_process_batch")!; const batch = tools.tools.find((tool) => tool.name === "approval_process_batch")!;
assert.equal((batch.inputSchema as any).properties.items.maxItems, 20); assert.equal((batch.inputSchema as any).properties.items.maxItems, 20);
assert.match(batch.description ?? "", /Schema容量.*不向用户解释.*任一无效则零写入/s); assert.match(batch.description ?? "", /Schema容量.*不向用户解释.*任一无效则零写入/s);
const options = tools.tools.find((tool) => tool.name === "approval_get_submission_options")!;
assert.deepEqual((options.inputSchema as any).properties.kind.enum, ["department", "city", "expense_type"]);
assert.match(options.description ?? "", /所有选项只向用户展示label.*values.*code.*不能填label.*工具内部/s);
const submissionSchema = tools.tools.find((tool) => tool.name === "approval_get_submission_schema")!;
assert.match(submissionSchema.description ?? "", /minimalExample.*尖括号必须替换/);
for (const toolName of ["approval_prepare_submission", "approval_submit"]) {
const submission = tools.tools.find((tool) => tool.name === toolName)!;
const attachmentItem = (submission.inputSchema as any).properties.attachments.items;
assert.ok(attachmentItem.properties.localPath, `${toolName} must accept Bridge-local paths`);
assert.equal(attachmentItem.properties.attachmentRef, undefined);
}
} finally { } finally {
await client.close(); await client.close();
await server.close(); await server.close();
+2
View File
@@ -389,6 +389,7 @@ test("generic installer preserves identity across safe updates, rollback, downgr
assert.equal(existsSync(paths.bridgeCommand), true); assert.equal(existsSync(paths.bridgeCommand), true);
assert.equal(existsSync(paths.skillFile), true); assert.equal(existsSync(paths.skillFile), true);
assert.equal(existsSync(join(paths.skillDir, "references", "create-approval.md")), true); assert.equal(existsSync(join(paths.skillDir, "references", "create-approval.md")), true);
assert.equal(existsSync(join(paths.skillDir, "references", "local-drafts.md")), true);
const state = JSON.parse(readFileSync(paths.stateFile, "utf8")); const state = JSON.parse(readFileSync(paths.stateFile, "utf8"));
assert.equal(state.version, "1.1.0-rc.5"); assert.equal(state.version, "1.1.0-rc.5");
assert.deepEqual(state.manifestMirrors, [`${mirrorBase}manifest.json`]); assert.deepEqual(state.manifestMirrors, [`${mirrorBase}manifest.json`]);
@@ -654,6 +655,7 @@ const skill = join(prefix, "node_modules", "dingtalk-approval-client", "skill",
mkdirSync(join(skill, "references"), { recursive: true }); mkdirSync(join(skill, "references"), { recursive: true });
writeFileSync(join(skill, "SKILL.md"), "---\\nname: dingtalk-approval\\ndescription: Bundled test\\n---\\n"); writeFileSync(join(skill, "SKILL.md"), "---\\nname: dingtalk-approval\\ndescription: Bundled test\\n---\\n");
writeFileSync(join(skill, "references", "create-approval.md"), "# Create approval\\n"); writeFileSync(join(skill, "references", "create-approval.md"), "# Create approval\\n");
writeFileSync(join(skill, "references", "local-drafts.md"), "# Local drafts\\n");
`); `);
if (process.platform === "win32") { if (process.platform === "win32") {
writeFileSync(join(bin, "npm.cmd"), "@echo off\r\nexit /b 99\r\n"); writeFileSync(join(bin, "npm.cmd"), "@echo off\r\nexit /b 99\r\n");
+24
View File
@@ -36,6 +36,7 @@ test("customer distribution prefers remote MCP OAuth and keeps a generic compati
assert.match(compatibilityInstaller, /90 秒[\s\S]*stderr[\s\S]*stdout/); assert.match(compatibilityInstaller, /90 秒[\s\S]*stderr[\s\S]*stdout/);
assert.match(compatibilityInstaller, /登录链接过期.*绝不能重新安装.*approval_login.*loginCommand/s); assert.match(compatibilityInstaller, /登录链接过期.*绝不能重新安装.*approval_login.*loginCommand/s);
assert.match(compatibilityInstaller, /sourceStatus[\s\S]*dingtalk\.status[\s\S]*xft\.status[\s\S]*都必须是 `ok`/); assert.match(compatibilityInstaller, /sourceStatus[\s\S]*dingtalk\.status[\s\S]*xft\.status[\s\S]*都必须是 `ok`/);
assert.match(compatibilityInstaller, /references\/local-drafts\.md/);
assert.match(compatibilityInstaller, /更新[\s\S]*update --json[\s\S]*卸载[\s\S]*uninstall --yes --json/); assert.match(compatibilityInstaller, /更新[\s\S]*update --json[\s\S]*卸载[\s\S]*uninstall --yes --json/);
assert.doesNotMatch(compatibilityInstaller, /TimeoutSec 300|--max-time 300|--timeout=300|至少 10 分钟超时/); assert.doesNotMatch(compatibilityInstaller, /TimeoutSec 300|--max-time 300|--timeout=300|至少 10 分钟超时/);
assert.doesNotMatch(compatibilityInstaller, /--host|WorkBuddy|Codex|Claude/); assert.doesNotMatch(compatibilityInstaller, /--host|WorkBuddy|Codex|Claude/);
@@ -80,7 +81,9 @@ test("customer distribution prefers remote MCP OAuth and keeps a generic compati
const skill = readFileSync("skills/dingtalk-approval/SKILL.md", "utf8"); const skill = readFileSync("skills/dingtalk-approval/SKILL.md", "utf8");
const createApproval = readFileSync("skills/dingtalk-approval/references/create-approval.md", "utf8"); const createApproval = readFileSync("skills/dingtalk-approval/references/create-approval.md", "utf8");
const localDrafts = readFileSync("skills/dingtalk-approval/references/local-drafts.md", "utf8");
assert.match(skill, /read `references\/create-approval\.md` completely/); assert.match(skill, /read `references\/create-approval\.md` completely/);
assert.match(skill, /read `references\/local-drafts\.md` completely/);
assert.doesNotMatch(skill, /XFT recall is experimental/); assert.doesNotMatch(skill, /XFT recall is experimental/);
assert.match(createApproval, /XFT recall is experimental.*XFTSYS9999/s); assert.match(createApproval, /XFT recall is experimental.*XFTSYS9999/s);
assert.match(skill, /native remote-MCP OAuth flow when the user can complete its registered callback/); assert.match(skill, /native remote-MCP OAuth flow when the user can complete its registered callback/);
@@ -90,6 +93,22 @@ test("customer distribution prefers remote MCP OAuth and keeps a generic compati
assert.match(skill, /authChallenge\.directUrl.*current reply/s); assert.match(skill, /authChallenge\.directUrl.*current reply/s);
assert.match(skill, /onboarding\.firstRun=true.*Do not execute an example automatically/s); assert.match(skill, /onboarding\.firstRun=true.*Do not execute an example automatically/s);
assert.match(skill, /link expires.*approval_login.*Never reinstall/s); assert.match(skill, /link expires.*approval_login.*Never reinstall/s);
assert.match(localDrafts, /%LOCALAPPDATA%\\DingTalkApproval\\drafts/);
assert.match(localDrafts, /Library\/Application Support\/DingTalkApproval\/drafts/);
assert.match(localDrafts, /\.local\/share.*dingtalk-approval\/drafts/s);
assert.match(localDrafts, /mode `0700`.*mode `0600`/s);
assert.match(localDrafts, /atomically rename/);
assert.match(localDrafts, /more than 90 days old/);
assert.match(localDrafts, /SHA-256/);
assert.match(localDrafts, /Never store[\s\S]*templateId[\s\S]*confirmationId[\s\S]*complete upstream schema/);
assert.match(localDrafts, /outcome_uncertain[\s\S]*freeze[\s\S]*Never prepare or submit it again automatically/);
assert.match(localDrafts, /never on the approval gateway/);
assert.match(localDrafts, /localPath.*signed Bridge.*does not upload.*final confirmation.*native-client completion/s);
assert.match(createApproval, /Local file paths.*not valid `values` or `customFieldValues`.*signed Bridge `attachments`.*submit uploads only after final confirmation.*native client/s);
assert.match(createApproval, /every XFT selection.*label-only.*optionSource.*returned `label` values.*`code` only inside/s);
assert.match(createApproval, /Never show, explain, or ask.*city code.*organization code.*employee number.*numeric cabin value.*`expenseNumber`.*`expTypeCode`/s);
assert.match(createApproval, /selectionCapability\.status=client_only.*required field stops.*optional field may be skipped/s);
assert.match(localDrafts, /every stored selection value.*internal form value.*only that label.*never expose stored city.*department.*expense.*internal values/s);
const builder = readFileSync("scripts/build-release.mjs", "utf8"); const builder = readFileSync("scripts/build-release.mjs", "utf8");
assert.match(builder, /schemaVersion: 3,\s+version,/s); assert.match(builder, /schemaVersion: 3,\s+version,/s);
@@ -103,9 +122,14 @@ test("customer distribution prefers remote MCP OAuth and keeps a generic compati
assert.match(builder, /utf8Bom: true/); assert.match(builder, /utf8Bom: true/);
assert.match(builder, /validateNodeRuntime/); assert.match(builder, /validateNodeRuntime/);
assert.match(builder, /cpSync\(resolve\("skills\/dingtalk-approval"\).*recursive: true/s); assert.match(builder, /cpSync\(resolve\("skills\/dingtalk-approval"\).*recursive: true/s);
assert.match(builder, /"approval-contract\.js", "version\.js"/);
assert.doesNotMatch(builder, /codexPlugin|codex-plugin|\.codex-plugin|openai\.yaml/); assert.doesNotMatch(builder, /codexPlugin|codex-plugin|\.codex-plugin|openai\.yaml/);
assert.doesNotMatch(builder, /dingtalk-workspace-cli|DWS_URL|DWS_SHA256/); assert.doesNotMatch(builder, /dingtalk-workspace-cli|DWS_URL|DWS_SHA256/);
const releaseVerifier = readFileSync("scripts/verify-release.mjs", "utf8");
assert.match(releaseVerifier, /assertRelativeModuleClosure\(join\(root, "client", "package", "dist", "src"\)\)/);
assert.match(releaseVerifier, /客户端模块依赖缺失/);
const deploy = readFileSync("deploy/install.sh", "utf8"); const deploy = readFileSync("deploy/install.sh", "utf8");
assert.match(deploy, /--download-base-url "\$\{gitea_release_base_url\}"[\s\S]*--download-mirror-base-url "\$\{public_download_base_url\}"/s); assert.match(deploy, /--download-base-url "\$\{gitea_release_base_url\}"[\s\S]*--download-mirror-base-url "\$\{public_download_base_url\}"/s);
assert.match(deploy, /refs\/tags\/\$\{release_tag\}[\s\S]*release_created_at_commit="\$\{release_tag_commit:-\$\{release_commit\}\}"/s); assert.match(deploy, /refs\/tags\/\$\{release_tag\}[\s\S]*release_created_at_commit="\$\{release_tag_commit:-\$\{release_commit\}\}"/s);
+51 -2
View File
@@ -5,7 +5,8 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import type { AppConfig } from "../src/config.js"; import type { AppConfig } from "../src/config.js";
import type { ApprovalService } from "../src/approval-service.js"; import type { ApprovalService } from "../src/approval-service.js";
import type { XftApprovalProvider } from "../src/xft.js"; import type { XftApprovalProvider } from "../src/xft.js";
import { approvalBatchSummary, APPROVAL_MCP_INSTRUCTIONS, compactResult, createApprovalMcpServer, isLoopbackAddress, mcpAllowedHosts, oauthHtmlContentSecurityPolicy } from "../src/server.js"; import { SignedReferenceCodec } from "../src/signed-reference.js";
import { approvalBatchSummary, APPROVAL_MCP_INSTRUCTIONS, assertAttachmentConfirmation, compactResult, createApprovalMcpServer, isLoopbackAddress, mcpAllowedHosts, oauthHtmlContentSecurityPolicy } from "../src/server.js";
function config(): AppConfig { function config(): AppConfig {
return { return {
@@ -46,6 +47,16 @@ test("MCP initialization teaches the workflow while tools/list stays compact", a
assert.match(client.getInstructions() ?? "", /单次条数限制.*内部调用细节.*绝不向用户.*一键全选.*如何分组/s); assert.match(client.getInstructions() ?? "", /单次条数限制.*内部调用细节.*绝不向用户.*一键全选.*如何分组/s);
assert.match(client.getInstructions() ?? "", /用户只是在查看.*业务信息.*不主动讨论.*自动分批.*不得造成重复确认/s); assert.match(client.getInstructions() ?? "", /用户只是在查看.*业务信息.*不主动讨论.*自动分批.*不得造成重复确认/s);
assert.match(client.getInstructions() ?? "", /近30天.*allowDuplicateInvoices=true/s); assert.match(client.getInstructions() ?? "", /近30天.*allowDuplicateInvoices=true/s);
assert.match(client.getInstructions() ?? "", /草稿只能由 Agent 保存在.*当前 OS 用户私有数据目录/s);
assert.match(client.getInstructions() ?? "", /schemaVersion=1.*dingtalk-approval-local-draft.*submit_uncertain/s);
assert.match(client.getInstructions() ?? "", /不得上传网关.*不得.*完整上游 Schema/s);
assert.match(client.getInstructions() ?? "", /恢复时.*实时读取 Schema/s);
assert.match(client.getInstructions() ?? "", /自动清理超过 90 天/s);
assert.match(client.getInstructions() ?? "", /outcome_uncertain.*冻结.*禁止自动重试/s);
assert.match(client.getInstructions() ?? "", /所有选择型字段只向用户展示业务名称.*optionSource.*options\/enumLabels.*values.*正式值/s);
assert.match(client.getInstructions() ?? "", /用户选择 label.*values.*同一选项的 code.*不能把 label.*code 只存在于工具调用内部/s);
assert.match(client.getInstructions() ?? "", /绝不向用户展示、解释或索取.*城市码.*组织码.*工号.*档案编号.*数字舱位值.*内部 ID/s);
assert.match(client.getInstructions() ?? "", /selectionCapability\.status=client_only.*必填时停止.*可选时.*跳过/s);
const tools = await client.listTools(); const tools = await client.listTools();
assert.deepEqual(tools.tools.map((tool) => tool.name), [ assert.deepEqual(tools.tools.map((tool) => tool.name), [
@@ -77,11 +88,26 @@ test("MCP initialization teaches the workflow while tools/list stays compact", a
assert.match(descriptions.approval_process_batch ?? "", /Schema容量.*不得转述给用户.*任一无效.*零写入/s); assert.match(descriptions.approval_process_batch ?? "", /Schema容量.*不得转述给用户.*任一无效.*零写入/s);
assert.match(descriptions.approval_prepare_submission ?? "", /明确确认|确认/); assert.match(descriptions.approval_prepare_submission ?? "", /明确确认|确认/);
assert.match(descriptions.approval_get_submission_schema ?? "", /必填字段齐全.*可选字段/); assert.match(descriptions.approval_get_submission_schema ?? "", /必填字段齐全.*可选字段/);
assert.match(descriptions.approval_get_submission_options ?? "", /承担部门.*城市编码/); assert.match(descriptions.approval_get_submission_schema ?? "", /minimalExample.*values形状.*尖括号必须替换/);
assert.match(descriptions.approval_get_submission_options ?? "", /承担部门.*城市.*费用类型.*只向用户展示label.*values.*code.*不能填label/s);
const optionSchema = tools.tools.find((tool) => tool.name === "approval_get_submission_options")?.inputSchema as any;
assert.deepEqual(optionSchema.properties.kind.enum, ["department", "city", "expense_type"]);
assert.match(descriptions.approval_prepare_submission ?? "", /继续询问/); assert.match(descriptions.approval_prepare_submission ?? "", /继续询问/);
assert.match(descriptions.approval_prepare_submission ?? "", /查重.*allowDuplicateInvoices/s); assert.match(descriptions.approval_prepare_submission ?? "", /查重.*allowDuplicateInvoices/s);
assert.match(descriptions.approval_prepare_submission ?? "", /values.*code.*不能直接填label.*预览.*业务名称/s);
const prepareSchema = tools.tools.find((tool) => tool.name === "approval_prepare_submission")?.inputSchema as any; const prepareSchema = tools.tools.find((tool) => tool.name === "approval_prepare_submission")?.inputSchema as any;
assert.equal(prepareSchema.properties.allowDuplicateInvoices.default, false); assert.equal(prepareSchema.properties.allowDuplicateInvoices.default, false);
for (const toolName of ["approval_prepare_submission", "approval_submit"]) {
const submission = tools.tools.find((tool) => tool.name === toolName)!;
const attachmentItem = (submission.inputSchema as any).properties.attachments.items;
assert.ok(attachmentItem.properties.fileName, `${toolName} must accept validated attachment metadata`);
assert.ok(attachmentItem.properties.sha256);
assert.equal(attachmentItem.properties.localPath, undefined, "the gateway must never accept local paths");
}
const prepareAttachmentItem = prepareSchema.properties.attachments.items;
assert.equal(prepareAttachmentItem.properties.attachmentRef, undefined, "prepare must not accept pre-uploaded references");
const submitSchema = tools.tools.find((tool) => tool.name === "approval_submit")?.inputSchema as any;
assert.ok(submitSchema.properties.attachments.items.properties.attachmentRef);
assert.match(descriptions.approval_list_submission_templates ?? "", /出差申请.*外出.*差旅费用报销.*业务招待费用报销/); assert.match(descriptions.approval_list_submission_templates ?? "", /出差申请.*外出.*差旅费用报销.*业务招待费用报销/);
assert.match(descriptions.approval_submit ?? "", /禁止自动重试/); assert.match(descriptions.approval_submit ?? "", /禁止自动重试/);
assert.match(descriptions.approval_revoke_submission ?? "", /禁止自动重试.*薪福通.*实验能力/); assert.match(descriptions.approval_revoke_submission ?? "", /禁止自动重试.*薪福通.*实验能力/);
@@ -125,6 +151,29 @@ test("OAuth consent CSP permits only the DingTalk login redirect after same-orig
assert.doesNotMatch(policy, /form-action[^;]*\*/); assert.doesNotMatch(policy, /form-action[^;]*\*/);
}); });
test("attachment uploads are bound to the signed-in user and the exact confirmed preview", () => {
const signer = new SignedReferenceCodec("fixed-reference-secret-for-tests");
const attachment = {
fieldName: "发票附件", fileName: "invoice.pdf", size: 551,
sha256: "a".repeat(64),
};
const confirmationId = signer.sign({
kind: "submission_confirmation",
source: "xft",
userId: "user-1",
attachmentManifest: [attachment],
exp: Date.now() + 60_000,
});
assert.doesNotThrow(() => assertAttachmentConfirmation(signer, { userId: "user-1" }, confirmationId, attachment));
assert.throws(() => assertAttachmentConfirmation(
signer, { userId: "user-1" }, confirmationId, { ...attachment, fileName: "unconfirmed.pdf" },
), /不在用户刚刚确认的提单预览中/);
assert.throws(() => assertAttachmentConfirmation(
signer, { userId: "another-user" }, confirmationId, attachment,
), /不属于当前用户/);
});
test("remote gateway lists DingTalk OA and XFT through one authenticated MCP session", async () => { test("remote gateway lists DingTalk OA and XFT through one authenticated MCP session", async () => {
const dingTalk = { const dingTalk = {
async listMyTodos() { async listMyTodos() {
+352 -2
View File
@@ -5,7 +5,7 @@ import { deflateRawSync } from "node:zlib";
import type { AppConfig } from "../src/config.js"; import type { AppConfig } from "../src/config.js";
import { UnifiedApprovalService } from "../src/unified-approval.js"; import { UnifiedApprovalService } from "../src/unified-approval.js";
import type { ApprovalService } from "../src/approval-service.js"; import type { ApprovalService } from "../src/approval-service.js";
import type { XftApprovalProvider, XftSubmissionTemplate } from "../src/xft.js"; import type { XftApprovalProvider, XftSubmissionField, XftSubmissionTemplate } from "../src/xft.js";
function config(): AppConfig { function config(): AppConfig {
return { return {
@@ -388,6 +388,65 @@ test("XFT action restrictions are enforced during zero-write preflight", async (
assert.equal(writes, 0); assert.equal(writes, 0);
}); });
test("unadapted XFT todos stay visible but detail and every write action remain client-only", async () => {
let detailCalls = 0;
let writes = 0;
const ding = {
async listMyTodos() {
return { items: [{
source: "official_oa", processInstanceId: "mixed-instance", taskId: "mixed-task", title: "钉钉审批", raw: {},
}] };
},
} as unknown as ApprovalService;
const xft = {
async listMyTodos() {
return {
items: [{
source: "xft", taskId: "provider-task-secret", capability: "list_only", unavailableReason: "unsupported_task_type",
title: "薪福通其他审批", allowPass: false, allowReject: false,
}],
diagnostics: { billApiCount: 0, clientOnlyCount: 1 },
};
},
async getTodoDetail() { detailCalls += 1; return {}; },
async listReturnNodes() { detailCalls += 1; return []; },
async processApprovalBatch() { writes += 1; return { results: [{ success: true }] }; },
};
const service = new UnifiedApprovalService(config(), ding, xft as never);
const user = { userId: "user-1" };
const listed = await service.listTodos(user, "all", 10);
assert.equal(listed.returned, 2);
assert.equal("selectionId" in listed, false, "a mixed result containing client-only XFT tasks cannot become a batch selection");
assert.equal(listed.sourceStatus.xft?.billApiCount, 0);
assert.equal(listed.sourceStatus.xft?.clientOnlyCount, 1);
const item = listed.items.find((candidate) => candidate.title === "薪福通其他审批")!;
assert.equal("businessNo" in item, false);
assert.deepEqual(item.availableActions, []);
assert.equal((item.manualAction as Record<string, unknown>).reason, "unsupported_xft_task_type");
const approvalId = String(item.approvalId);
assert.match(approvalId, /^v4\./);
assert.doesNotMatch(approvalId, /provider-task-secret/);
const detail = await service.getDetail(user, approvalId) as unknown as Record<string, unknown>;
assert.equal(detail.status, "client_required");
assert.deepEqual(detail.diagnostics, { realtime: true, persistedBusinessData: false, upstreamDetailRequested: false });
assert.equal(detailCalls, 0, "client-only detail must not call an unverified upstream detail API");
const actions = await service.getActions(user, approvalId);
assert.deepEqual(actions.actions.map((action) => [action.action, action.allowed]), [
["approve", false], ["reject", false], ["return", false],
]);
assert.equal((actions.manualAction as Record<string, unknown>).reason, "unsupported_xft_task_type");
assert.equal(detailCalls, 0, "client-only action discovery must not call an unverified upstream action API");
const processed = await service.processBatch(user, "approve", "同意", [{ approvalId }]);
assert.equal(processed.status, "preflight_failed");
assert.equal(processed.writesStarted, false);
assert.equal(processed.invalidItems?.[0]?.code, "provider_not_supported");
assert.equal(writes, 0);
});
test("approval reference expiry is checked once before the batch starts", async () => { test("approval reference expiry is checked once before the batch starts", async () => {
const originalNow = Date.now; const originalNow = Date.now;
let now = originalNow(); let now = originalNow();
@@ -1020,6 +1079,68 @@ test("Xft travel reimbursement preparation validates nested and custom required
}), /第 1 段行程缺少/); }), /第 1 段行程缺少/);
}); });
test("Xft local attachment metadata satisfies required fields, is preview-bound, and never exposes a path", async () => {
const template = {
eventSeqId: "1", eventNumber: "XFT-A", eventName: "费用报销单",
eventType: "COMMON_REIM", kind: "common_reimburse" as const,
};
const submittedAttachments: unknown[] = [];
const xft = {
async listSubmissionTemplates() { return [template]; },
async getSubmissionSchema() { return {
template,
fields: [{
fieldNumber: "ACCESSORIES_1", name: "发票附件", type: "ACCESSORIES", required: true,
attachmentCapability: {
status: "supported", input: "local_file", uploadWhen: "after_final_confirmation",
maxFileBytes: 50 * 1024 * 1024, maxFilesPerSubmission: 10,
},
}],
inputContract: {}, diagnostics: {},
}; },
async getSubmissionOptions() { return { kind: "expense_type", items: [{ code: "MEAL", label: "业务招待费" }], diagnostics: {} }; },
async validateSubmissionIdentity() { return { validated: true, diagnostics: {} }; },
async submitApproval(_user: unknown, _template: unknown, _values: unknown, attachments: unknown[]) {
submittedAttachments.push(...attachments);
return { billId: "2026090100000001", status: "submitted", diagnostics: {} };
},
async getMySubmissionStatus() { return {
billId: "2026090100000001", status: "1", statusName: "待审批", revoked: false, revokeAllowed: true,
}; },
};
const service = new UnifiedApprovalService(config(), {} as ApprovalService, xft as never);
const user = { userId: "user-1" };
const templateId = String((await service.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
const values = {
reimAmount: "551.00", reimAmountCurrencyCode: "CNY",
expenseList: [{ expTypeCode: "MEAL", currencyCode: "CNY", originalCurrencyReimAmt: "551.00" }],
};
await assert.rejects(service.prepareSubmission(user, { templateId, values }), /发票附件/);
const metadata = {
fieldName: "发票附件", fileName: "invoice.pdf", size: 21,
sha256: "a".repeat(64),
};
const prepared = await service.prepareSubmission(user, { templateId, values, attachments: [metadata] });
assert.equal(prepared.status, "confirmation_required");
assert.match(JSON.stringify(prepared.preview), /invoice\.pdf/);
assert.doesNotMatch(JSON.stringify(prepared.preview), /a{32}|localPath|attachmentRef/);
const submitted = await service.submitApproval(user, prepared.confirmationId!, {
templateId,
values,
attachments: [{ ...metadata, attachmentRef: "opaque-upload-reference-12345" }],
});
assert.equal(submitted.status, "submitted");
assert.deepEqual(submittedAttachments, [{
...metadata, fieldNumber: "ACCESSORIES_1", attachmentRef: "opaque-upload-reference-12345",
}]);
await assert.rejects(service.prepareSubmission(user, {
templateId,
values: { ...values, customFieldValues: [{ fieldNumber: "ACCESSORIES_1", fieldValue: "invented-file-id" }] },
attachments: [metadata],
}), /必须通过 attachments.*不能直接提供附件编号/);
});
test("DingTalk submission rejects required client-only generated fields", async () => { test("DingTalk submission rejects required client-only generated fields", async () => {
const ding = { const ding = {
async listSubmissionTemplates() { return [{ processCode: "PROC-1", name: "外出_北极大", dirName: "北极大_人事假勤" }]; }, async listSubmissionTemplates() { return [{ processCode: "PROC-1", name: "外出_北极大", dirName: "北极大_人事假勤" }]; },
@@ -1108,6 +1229,175 @@ test("Xft application templates support API submission with live post-write stat
assert.equal(travelPrepared.status, "confirmation_required"); assert.equal(travelPrepared.status, "confirmation_required");
}); });
test("Xft preparation preview resolves every selection to user-readable business names", async () => {
const template: XftSubmissionTemplate = {
eventSeqId: "event-travel", eventNumber: "XFT-TRAVEL", eventName: "差旅报销单",
eventType: "TRAVEL_REIM", kind: "travel_reimburse",
};
const fields: XftSubmissionField[] = [
{
fieldNumber: "C-TOPIC", name: "申请事项", type: "SELECT", required: true,
options: [{ code: "PROCUREMENT", label: "采购事项" }],
selectionCapability: { status: "supported", userInput: "label", submitValue: "code", exposeInternalValue: false },
},
{
fieldNumber: "C-TABLE", name: "补充明细", type: "TABLE", required: true,
itemFields: [{
titleCode: "T-TYPE", label: "明细类型", type: "SELECT", required: true,
options: [{ code: "MARKETING", label: "市场活动" }],
selectionCapability: { status: "supported", userInput: "label", submitValue: "code", exposeInternalValue: false },
}],
},
];
const standardFields = [
{ key: "reimAmount", label: "报销总金额", type: "decimal" },
{ key: "reimAmountCurrencyCode", label: "报销币种", type: "string", enumLabels: { CNY: "人民币" } },
{
key: "expenseList", label: "费用明细", type: "array", itemFields: [
{ key: "expTypeCode", label: "费用类型", type: "string", optionSource: { kind: "expense_type" } },
{ key: "currencyCode", label: "费用币种", type: "string", enumLabels: { CNY: "人民币" } },
{ key: "originalCurrencyReimAmt", label: "费用金额", type: "decimal" },
{ key: "payCityCode", label: "消费城市", type: "string", optionSource: { kind: "city" } },
],
},
{
key: "businessTrip", label: "差旅行程", type: "object", fields: [{
key: "businessTripDetails", label: "行程明细", type: "array", itemFields: [
{ key: "departCityCode", label: "出发城市", type: "string", optionSource: { kind: "city" } },
{ key: "destinationCityCode", label: "目的城市", type: "string", optionSource: { kind: "city" } },
{ key: "beginTime", label: "出发时间", type: "datetime" },
{ key: "endTime", label: "结束时间", type: "datetime" },
{ key: "travelMode", label: "出行方式", type: "comma_separated_enum", multiple: true, enumLabels: { AirPlane: "飞机", Train: "火车" } },
{ key: "roundTrip", label: "是否往返", type: "string", enumLabels: { Y: "往返", N: "单程" } },
],
}],
},
{ key: "customFieldValues", userVisible: false, type: "array" },
];
const optionCalls: Array<{ kind: string; query: string }> = [];
const xft = {
async listSubmissionTemplates() { return [template]; },
async getSubmissionSchema() {
return { template, fields, inputContract: { values: { standardFields } }, diagnostics: {} };
},
async getSubmissionOptions(_user: unknown, _template: unknown, kind: string, query: string) {
optionCalls.push({ kind, query });
const labels: Record<string, string> = {
"440300": "深圳市", "310100": "上海市", MEAL: "餐饮费",
};
return {
kind,
items: labels[query] ? [{ code: query, label: labels[query] }] : [],
diagnostics: {},
};
},
async validateSubmissionIdentity() { return { validated: true, diagnostics: {} }; },
};
const service = new UnifiedApprovalService(config(), {} as ApprovalService, xft as never);
const user = { userId: "user-1" };
const templateId = String((await service.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
const prepared = await service.prepareSubmission(user, {
templateId,
values: {
reimAmount: "100.00",
reimAmountCurrencyCode: "CNY",
expenseList: [{
expTypeCode: "MEAL", currencyCode: "CNY", originalCurrencyReimAmt: "100.00", payCityCode: "440300",
}],
businessTrip: { businessTripDetails: [{
departCityCode: "440300", destinationCityCode: "310100",
beginTime: "2026-08-20 09:00", endTime: "2026-08-21 18:00",
travelMode: "AirPlane,Train", roundTrip: "Y",
}] },
customFieldValues: [
{ fieldNumber: "C-TOPIC", fieldValue: "PROCUREMENT" },
{ fieldNumber: "C-TABLE", fieldValue: JSON.stringify([{ sequence: 1, columnValues: [{ titleCode: "T-TYPE", value: "MARKETING" }] }]) },
],
},
});
assert.equal(prepared.status, "confirmation_required");
const summary = JSON.stringify(prepared.preview.formSummary);
for (const label of ["深圳市", "上海市", "餐饮费", "人民币", "飞机", "火车", "往返", "采购事项", "市场活动"]) {
assert.match(summary, new RegExp(label));
}
for (const internal of ["440300", "310100", "MEAL", "C-TOPIC", "PROCUREMENT", "CNY", "T-TYPE", "MARKETING"]) {
assert.doesNotMatch(summary, new RegExp(internal));
}
assert.deepEqual(optionCalls, [
{ kind: "expense_type", query: "MEAL" },
{ kind: "city", query: "440300" },
{ kind: "city", query: "310100" },
]);
});
test("Xft preparation fails before confirmation when a selected code has no readable mapping", async () => {
const template: XftSubmissionTemplate = {
eventSeqId: "event-expense", eventNumber: "XFT-EXPENSE", eventName: "费用申请单",
eventType: "EXPENSE_APPLY", kind: "expense_apply",
};
const xft = {
async listSubmissionTemplates() { return [template]; },
async getSubmissionSchema() {
return {
template,
fields: [],
inputContract: { values: { standardFields: [{
key: "applyExpenses", label: "事前申请费用列表", type: "array", itemFields: [
{ key: "expenseNumber", label: "费用类型", type: "string", optionSource: { kind: "expense_type" } },
{ key: "amount", label: "申请金额", type: "decimal" },
],
}] } },
diagnostics: {},
};
},
async getSubmissionOptions() { return { kind: "expense_type", items: [], diagnostics: {} }; },
async validateSubmissionIdentity() { return { validated: true, diagnostics: {} }; },
};
const service = new UnifiedApprovalService(config(), {} as ApprovalService, xft as never);
const user = { userId: "user-1" };
const templateId = String((await service.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
await assert.rejects(
service.prepareSubmission(user, {
templateId, values: { applyExpenses: [{ expenseNumber: "UNKNOWN", amount: "10.00" }] },
}),
/无法映射为业务名称.*重新查询可用名称/,
);
});
test("Xft use-car application is omitted and old signed references are rejected", async () => {
const useCarTemplate: XftSubmissionTemplate = {
eventSeqId: "event-car", eventNumber: "XFT-CAR", eventName: "用车申请单",
eventType: "USE_CAR_APPLY", kind: "use_car_apply",
};
const expenseTemplate: XftSubmissionTemplate = {
eventSeqId: "event-expense", eventNumber: "XFT-EXPENSE", eventName: "费用申请单",
eventType: "EXPENSE_APPLY", kind: "expense_apply",
};
let schemaReads = 0;
const xft = {
async listSubmissionTemplates() { return [useCarTemplate, expenseTemplate]; },
async getSubmissionSchema() {
schemaReads += 1;
return { template: useCarTemplate, fields: [], inputContract: { values: { standardFields: [] } }, diagnostics: {} };
},
};
const service = new UnifiedApprovalService(config(), {} as ApprovalService, xft as never);
const user = { userId: "user-1" };
const listed = await service.listSubmissionTemplates(user, "xft", 10);
assert.deepEqual(listed.items.map((item) => item.name), ["费用申请单"]);
assert.equal(listed.sourceStatus.xft?.count, 1);
const templateId = (service as any).signer.sign({
kind: "submission_template", userId: user.userId, source: "xft",
xftTemplate: useCarTemplate, exp: Date.now() + 60_000,
});
await assert.rejects(service.getSubmissionSchema(user, templateId), /不在 MCP 支持的发起范围/);
await assert.rejects(
service.prepareSubmission(user, { templateId, values: { useCarInfo: { purpose: "客户拜访" } } }),
/不在 MCP 支持的发起范围/,
);
assert.equal(schemaReads, 0);
});
test("Xft preparation reports standard and live custom required fields together", async () => { test("Xft preparation reports standard and live custom required fields together", async () => {
const template: XftSubmissionTemplate = { const template: XftSubmissionTemplate = {
eventSeqId: "1", eventNumber: "XFT-E", eventName: "招待费用申请单", eventSeqId: "1", eventNumber: "XFT-E", eventName: "招待费用申请单",
@@ -1131,10 +1421,70 @@ test("Xft preparation reports standard and live custom required fields together"
const templateId = String((await service.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId); const templateId = String((await service.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
await assert.rejects( await assert.rejects(
service.prepareSubmission(user, { templateId, values: {} }), service.prepareSubmission(user, { templateId, values: {} }),
/业务字段:applyExpenses;自定义字段:招待事由(C1)、招待日期(C2)/, /业务字段:事前申请费用;自定义字段:招待事由、招待日期/,
); );
}); });
test("Xft preparation blocks fields whose user-readable choices cannot be resolved", async () => {
const template: XftSubmissionTemplate = {
eventSeqId: "1", eventNumber: "XFT-E", eventName: "费用申请单",
eventType: "EXPENSE_APPLY", kind: "expense_apply",
};
let fields: XftSubmissionField[] = [{
fieldNumber: "P1", name: "参与人员", type: "PERSON", required: true,
selectionCapability: { status: "client_only", reason: "没有按姓名查询人员的安全接口" },
}];
const xft = {
async listSubmissionTemplates() { return [template]; },
async getSubmissionSchema() { return { template, fields, inputContract: {}, diagnostics: {} }; },
async validateSubmissionIdentity() { return { validated: true, diagnostics: {} }; },
};
const service = new UnifiedApprovalService(config(), {} as ApprovalService, xft as never);
const user = { userId: "user-1" };
const templateId = String((await service.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
const schema = await service.getSubmissionSchema(user, templateId);
assert.equal(schema.submissionCapability.status, "client_only");
assert.match(String(schema.submissionCapability.reason), /参与人员/);
await assert.rejects(service.prepareSubmission(user, {
templateId, values: { applyExpenses: [{ expenseNumber: "MEAL", amount: 10 }] },
}), /无法安全填写的必填项:参与人员.*薪福通客户端/);
fields = [{
fieldNumber: "P1", name: "参与人员", type: "PERSON", required: false,
selectionCapability: { status: "client_only", reason: "没有按姓名查询人员的安全接口" },
}];
await assert.rejects(service.prepareSubmission(user, {
templateId,
values: {
applyExpenses: [{ expenseNumber: "MEAL", amount: 10 }],
customFieldValues: [{ fieldNumber: "P1", fieldValue: "[\"EMP-1\"]" }],
},
}), /参与人员.*无法通过 MCP.*薪福通客户端/);
});
test("Xft preparation refuses optional internal-value fields instead of asking the user for codes", async () => {
const template: XftSubmissionTemplate = {
eventSeqId: "1", eventNumber: "XFT-T", eventName: "出差申请单",
eventType: "TRAVEL_APPLY", kind: "travel_apply",
};
const xft = {
async listSubmissionTemplates() { return [template]; },
async getSubmissionSchema() { return { template, fields: [], inputContract: {}, diagnostics: {} }; },
};
const service = new UnifiedApprovalService(config(), {} as ApprovalService, xft as never);
const user = { userId: "user-1" };
const templateId = String((await service.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
await assert.rejects(service.prepareSubmission(user, {
templateId,
values: {
businessTrip: { businessTripDetails: [{
departCityCode: "1", destinationCityCode: "2", beginTime: "2026-08-12 09:00", endTime: "2026-08-12 18:00",
}] },
peerEmpNumbers: ["EMP-1"],
},
}), /同行人员.*不要提供内部编码或 ID/);
});
test("submission rejects caller-supplied applicant identity", async () => { test("submission rejects caller-supplied applicant identity", async () => {
const ding = { const ding = {
async listSubmissionTemplates() { return [{ processCode: "PROC-1", name: "出差申请单_北极大", dirName: "北极大_人事假勤" }]; }, async listSubmissionTemplates() { return [{ processCode: "PROC-1", name: "出差申请单_北极大", dirName: "北极大_人事假勤" }]; },
+14
View File
@@ -0,0 +1,14 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { APPLICATION_VERSION } from "../src/version.js";
test("package, gateway, and bridge share one application version", () => {
const packageMetadata = JSON.parse(readFileSync("package.json", "utf8")) as { version: string };
assert.equal(APPLICATION_VERSION, packageMetadata.version);
for (const path of ["src/server.ts", "src/bridge.ts"]) {
const source = readFileSync(path, "utf8");
assert.match(source, /version: APPLICATION_VERSION/);
assert.doesNotMatch(source, /version: "\d+\.\d+\.\d+(?:-rc\.\d+)?"/);
}
});
+586 -20
View File
@@ -44,6 +44,31 @@ function decodedBody(init?: RequestInit): Record<string, unknown> {
return JSON.parse(String(sm4.decrypt(envelope.secretMsg, privateKey.slice(0, 32)))) as Record<string, unknown>; return JSON.parse(String(sm4.decrypt(envelope.secretMsg, privateKey.slice(0, 32)))) as Record<string, unknown>;
} }
function findRecordByKey(value: unknown, key: string): Record<string, unknown> | undefined {
if (Array.isArray(value)) {
for (const item of value) {
const match = findRecordByKey(item, key);
if (match) return match;
}
return undefined;
}
if (!value || typeof value !== "object") return undefined;
const record = value as Record<string, unknown>;
if (record.key === key) return record;
for (const child of Object.values(record)) {
const match = findRecordByKey(child, key);
if (match) return match;
}
return undefined;
}
function findRecordsByKey(value: unknown, key: string): Array<Record<string, unknown>> {
if (Array.isArray(value)) return value.flatMap((item) => findRecordsByKey(item, key));
if (!value || typeof value !== "object") return [];
const record = value as Record<string, unknown>;
return [...(record.key === key ? [record] : []), ...Object.values(record).flatMap((item) => findRecordsByKey(item, key))];
}
test("Xft OpenAPI maps the authenticated DingTalk user and signs encrypted requests", async () => { test("Xft OpenAPI maps the authenticated DingTalk user and signs encrypted requests", async () => {
const calls: Array<{ path: string; body: Record<string, unknown>; headers: Headers; url: URL }> = []; const calls: Array<{ path: string; body: Record<string, unknown>; headers: Headers; url: URL }> = [];
const fakeFetch: typeof fetch = async (input, init) => { const fakeFetch: typeof fetch = async (input, init) => {
@@ -161,30 +186,83 @@ test("Xft OpenAPI does not turn missing or blank amounts into zero", async () =>
} }
}); });
test("Xft OpenAPI continues past an irrelevant full first todo page", async () => { test("Xft OpenAPI keeps unadapted personal todos visible instead of filtering them", async () => {
const pages: number[] = []; const pages: number[] = [];
const irrelevant = Array.from({ length: 100 }, (_, index) => ({
todoTaskId: `OTHER-${index}`, groupId: `OTHER-${index}`, appCode: "other-app",
}));
const client = new XftOpenApiClient(config(), async (input, init) => { const client = new XftOpenApiClient(config(), async (input, init) => {
const path = new URL(String(input)).pathname; const path = new URL(String(input)).pathname;
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } }); if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
if (path.endsWith("todo-task/page/by-user")) { if (path.endsWith("todo-task/page/by-user")) {
const page = Number(decodedBody(init).currentPage); const page = Number(decodedBody(init).currentPage);
pages.push(page); pages.push(page);
return encrypted({ code: "0", data: { records: page === 1 ? irrelevant : [{ return encrypted({ code: "0", data: { records: [{
todoTaskId: "TD-TARGET", groupId: "2026081400000001", appCode: "xft-itrip", todoTaskId: "OTHER-1", groupId: "OTHER-1", appCode: "other-app", title: "其他审批",
}] } }); }], totalSize: 1 } });
} }
if (path.endsWith("todo-task/detail/by-ids")) return encrypted({ code: "0", data: [] }); if (path.endsWith("todo-task/detail/by-ids")) return encrypted({ code: "0", data: [] });
throw new Error(`unexpected ${path}`);
});
const result = await client.listMyTodos({ userId: "ding-u1" }, 10);
assert.equal(result.items[0]?.title, "其他审批");
assert.equal(result.items[0]?.capability, "list_only");
assert.equal(result.items[0]?.unavailableReason, "missing_bill_identifier");
assert.equal(result.diagnostics.billApiCount, 0);
assert.equal(result.diagnostics.clientOnlyCount, 1);
assert.deepEqual(pages, [1]);
});
test("Xft OpenAPI upgrades an unknown app todo when the official bill API recognizes it", async () => {
const targetBillId = "2026081400000001";
const client = new XftOpenApiClient(config(), async (input) => {
const path = new URL(String(input)).pathname;
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
if (path.endsWith("todo-task/page/by-user")) return encrypted({ code: "0", data: { records: [{
todoTaskId: "TD-INVITATION", groupId: targetBillId, appCode: "new-approval-app",
}], totalSize: 1 } });
if (path.endsWith("todo-task/detail/by-ids")) return encrypted({ code: "0", data: [] });
if (path.endsWith("bill-list")) return encrypted({ code: "0", data: { list: [{ if (path.endsWith("bill-list")) return encrypted({ code: "0", data: { list: [{
billId: "2026081400000001", eventType: "COMMON_REIM", title: "目标费用单", billId: targetBillId, eventType: "INVITATION_APPLY", title: "招待费用申请单",
}] } }); }] } });
throw new Error(`unexpected ${path}`); throw new Error(`unexpected ${path}`);
}); });
const result = await client.listMyTodos({ userId: "ding-u1" }, 1);
assert.equal(result.items[0]?.billId, "2026081400000001"); const result = await client.listMyTodos({ userId: "ding-u1" }, 10);
assert.deepEqual(pages, [1, 2]); assert.equal(result.items[0]?.billId, targetBillId);
assert.equal(result.items[0]?.title, "招待费用申请单");
assert.equal(result.items[0]?.capability, "bill_api");
assert.equal(result.diagnostics.billApiCount, 1);
assert.equal(result.diagnostics.clientOnlyCount, 0);
});
test("Xft OpenAPI degrades a failed unknown bill probe without hiding known expense todos", async () => {
const knownBillId = "2026081400000001";
const unknownBillId = "2026081400000002";
const client = new XftOpenApiClient(config(), async (input, init) => {
const path = new URL(String(input)).pathname;
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
if (path.endsWith("todo-task/page/by-user")) return encrypted({ code: "0", data: { records: [{
todoTaskId: "TD-KNOWN", groupId: knownBillId, appCode: "xft-itrip",
}, {
todoTaskId: "TD-UNKNOWN", groupId: unknownBillId, appCode: "new-approval-app", title: "新类型审批",
}], totalSize: 2 } });
if (path.endsWith("todo-task/detail/by-ids")) return encrypted({ code: "0", data: [] });
if (path.endsWith("bill-list")) {
const ids = (decodedBody(init).billIds as Array<string | number>).map(String);
if (ids.includes(unknownBillId)) return encrypted({ code: "BIZ_LOOKUP_FAILED", message: "lookup unavailable" });
return encrypted({ code: "0", data: { list: [{ billId: knownBillId, eventType: "COMMON_REIM", title: "费用报销单" }] } });
}
throw new Error(`unexpected ${path}`);
});
const result = await client.listMyTodos({ userId: "ding-u1" }, 10);
assert.equal(result.items.length, 2);
assert.equal(result.items[0]?.billId, knownBillId);
assert.equal(result.items[0]?.capability, "bill_api");
assert.equal(result.items[1]?.title, "新类型审批");
assert.equal(result.items[1]?.billId, undefined);
assert.equal(result.items[1]?.capability, "list_only");
assert.equal(result.items[1]?.unavailableReason, "bill_lookup_unavailable");
assert.equal(result.diagnostics.billApiCount, 1);
assert.equal(result.diagnostics.clientOnlyCount, 1);
}); });
test("Xft write transport failures are uncertain while deterministic rejections stay failed", async () => { test("Xft write transport failures are uncertain while deterministic rejections stay failed", async () => {
@@ -248,10 +326,37 @@ test("Xft OpenAPI rejects non-exact, missing-identity, and ambiguous user mappin
}); });
test("Xft OpenAPI surfaces XFT framework business errors", async () => { test("Xft OpenAPI surfaces XFT framework business errors", async () => {
const client = new XftOpenApiClient(config(), async () => encrypted({ const client = new XftOpenApiClient(config(), async () => new Response(
String(sm4.encrypt(JSON.stringify({
SYCOMRETZ: [{ ERRCOD: "SYOPA16", ERRMSG: "应用状态错误" }], SYCOMRETZ: [{ ERRCOD: "SYOPA16", ERRMSG: "应用状态错误" }],
})); }), privateKey.slice(0, 32))),
await assert.rejects(client.authStatus({ userId: "ding-u1" }), /应用状态错误/); { status: 403 },
));
await assert.rejects(
client.authStatus({ userId: "ding-u1" }),
/应用状态错误(操作:用户身份映射;HTTP 403;薪福通错误码 SYOPA16)/,
);
});
test("Xft OpenAPI exposes safe transport diagnostics without leaking the native error message", async () => {
for (const [cause, expected] of [
[Object.assign(new Error("connect ECONNRESET https://user:password@private.invalid/?token=secret"), { code: "ECONNRESET" }),
/用户身份映射请求失败:网络连接被重置或中断(底层错误码 ECONNRESET;超时上限 2000ms)/],
[Object.assign(new Error("getaddrinfo ENOTFOUND private.invalid"), { code: "ENOTFOUND" }),
/用户身份映射请求失败:DNS 解析失败(底层错误码 ENOTFOUND;超时上限 2000ms)/],
[Object.assign(new Error("connect timed out"), { code: "UND_ERR_CONNECT_TIMEOUT", name: "ConnectTimeoutError" }),
/用户身份映射请求失败:连接或等待响应超时(底层错误码 UND_ERR_CONNECT_TIMEOUT;错误类型 ConnectTimeoutError;超时上限 2000ms)/],
] as const) {
const client = new XftOpenApiClient(config(), async () => {
throw new TypeError("fetch failed", { cause });
});
await assert.rejects(client.authStatus({ userId: "ding-u1" }), (error: unknown) => {
assert.ok(error instanceof Error);
assert.match(error.message, expected);
assert.doesNotMatch(error.message, /password|private\.invalid|token=secret|fetch failed/);
return true;
});
}
}); });
test("Xft OpenAPI preserves official GET endpoint query parameters in the signed target", async () => { test("Xft OpenAPI preserves official GET endpoint query parameters in the signed target", async () => {
@@ -304,6 +409,59 @@ test("Xft OpenAPI derives empId server-side and validates ownership before and a
assert.equal(actionBodies[0]?.empId, "EMP-1"); assert.equal(actionBodies[0]?.empId, "EMP-1");
}); });
test("Xft approval batches revalidate each write and share one post-write todo scan", async () => {
const billIds = Array.from({ length: 20 }, (_, index) => String(10_000_000_100 + index));
const pending = new Set(billIds);
let todoQueries = 0;
let actionCalls = 0;
const client = new XftOpenApiClient(config(), async (input, init) => {
const path = new URL(String(input)).pathname;
const body = init?.body ? decodedBody(init) : {};
if (path.includes("user-mapping")) {
return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
}
if (path.endsWith("todo-task/page/by-user")) {
todoQueries += 1;
return encrypted({ code: "0", data: { records: [...pending].map((billId) => ({
todoTaskId: `TD-${billId}`, groupId: billId, appCode: "xft-itrip",
})) } });
}
if (path.endsWith("todo-task/detail/by-ids")) {
const taskIds = Array.isArray(body.todoTaskIds) ? body.todoTaskIds : [];
return encrypted({ code: "0", data: taskIds.map((taskId) => ({
todoTaskId: taskId, businessId: String(taskId).replace(/^TD-/, ""), appCode: "xft-itrip",
})) });
}
if (path.endsWith("bill-list")) {
const ids = Array.isArray(body.billIds) ? body.billIds.map(String) : [];
return encrypted({ code: "0", data: { list: ids.map((billId) => ({ billId, eventType: "COMMON_REIM" })) } });
}
if (path.endsWith("approve/pass")) {
actionCalls += 1;
const businessList = Array.isArray(body.businessList) ? body.businessList : [];
const businessId = businessList[0] && typeof businessList[0] === "object"
? String((businessList[0] as Record<string, unknown>).businessId)
: "";
pending.delete(businessId);
return encrypted({ code: "0", data: {} });
}
throw new Error(`unexpected ${path}`);
});
const result = await client.processApprovalBatch(
{ userId: "ding-u1" },
"agree",
"同意",
billIds.map((billId) => ({ billId })),
);
assert.equal(result.succeeded, 20);
assert.equal(result.failed, 0);
assert.equal(actionCalls, 20);
assert.equal(todoQueries, 21, "each write needs a live ownership scan, followed by one shared post-write scan");
assert.deepEqual(result.results.map((item) => item.billId), billIds);
});
test("Xft submission discovers live templates and derives protected create fields server-side", async () => { test("Xft submission discovers live templates and derives protected create fields server-side", async () => {
const createBodies: Record<string, unknown>[] = []; const createBodies: Record<string, unknown>[] = [];
const fakeFetch: typeof fetch = async (input, init) => { const fakeFetch: typeof fetch = async (input, init) => {
@@ -329,6 +487,8 @@ test("Xft submission discovers live templates and derives protected create field
fieldNumber: "billDepartmentCode", name: "承担部门", type: "ADV_DEPARTMENT", required: true, options: [], fieldNumber: "billDepartmentCode", name: "承担部门", type: "ADV_DEPARTMENT", required: true, options: [],
}, { }, {
fieldNumber: "C-DYNAMIC-DEPT", name: "承担部门", type: "ADV_DEPARTMENT", required: true, options: [], fieldNumber: "C-DYNAMIC-DEPT", name: "承担部门", type: "ADV_DEPARTMENT", required: true, options: [],
}, {
fieldNumber: "ACCESSORIES_1", name: "发票附件", type: "ACCESSORIES", required: false, options: [],
}] }); }] });
if (path.endsWith("reimburse/bill/create")) { if (path.endsWith("reimburse/bill/create")) {
createBodies.push(body); createBodies.push(body);
@@ -341,12 +501,27 @@ test("Xft submission discovers live templates and derives protected create field
assert.equal(templates[0]?.kind, "common_reimburse"); assert.equal(templates[0]?.kind, "common_reimburse");
const schema = await client.getSubmissionSchema({ userId: "ding-u1" }, templates[0]!); const schema = await client.getSubmissionSchema({ userId: "ding-u1" }, templates[0]!);
assert.equal(schema.fields[0]?.fieldNumber, "C1"); assert.equal(schema.fields[0]?.fieldNumber, "C1");
assert.equal(schema.fields.length, 1); assert.equal(schema.fields.length, 2);
assert.deepEqual(schema.fields.find((field) => field.fieldNumber === "ACCESSORIES_1")?.attachmentCapability, {
status: "supported",
input: "local_file",
uploadWhen: "after_final_confirmation",
maxFileBytes: 50 * 1024 * 1024,
maxFilesPerSubmission: 10,
});
const standardFields = (schema.inputContract.values as Record<string, unknown>).standardFields as Array<Record<string, unknown>>; const standardFields = (schema.inputContract.values as Record<string, unknown>).standardFields as Array<Record<string, unknown>>;
const minimalExample = (schema.inputContract.values as Record<string, unknown>).minimalExample as Record<string, unknown>;
assert.deepEqual(Object.keys(minimalExample), ["reimAmount", "reimAmountCurrencyCode", "expenseList"]);
assert.match(JSON.stringify(minimalExample), /报销总金额.*费用类型选项.*本笔费用金额/);
const expenseList = standardFields.find((field) => field.key === "expenseList"); const expenseList = standardFields.find((field) => field.key === "expenseList");
assert.deepEqual((expenseList?.itemFields as Array<Record<string, unknown>>).filter((field) => field.required).map((field) => field.key), [ const expenseItemFields = expenseList?.itemFields as Array<Record<string, unknown>>;
assert.deepEqual(expenseItemFields.filter((field) => field.required).map((field) => field.key), [
"expTypeCode", "currencyCode", "originalCurrencyReimAmt", "expTypeCode", "currencyCode", "originalCurrencyReimAmt",
]); ]);
assert.deepEqual(expenseItemFields.find((field) => field.key === "expTypeCode")?.optionSource, {
tool: "approval_get_submission_options", kind: "expense_type",
});
assert.equal(expenseItemFields.find((field) => field.key === "expTypeCode")?.label, "费用类型");
const identityCheck = await client.validateSubmissionIdentity({ userId: "ding-u1" }); const identityCheck = await client.validateSubmissionIdentity({ userId: "ding-u1" });
assert.equal(identityCheck.validated, true); assert.equal(identityCheck.validated, true);
assert.doesNotMatch(JSON.stringify(identityCheck), /E001|EMP-1/); assert.doesNotMatch(JSON.stringify(identityCheck), /E001|EMP-1/);
@@ -360,13 +535,80 @@ test("Xft submission discovers live templates and derives protected create field
assert.equal(createBodies[0]?.billApplyAuthCheckType, "Y"); assert.equal(createBodies[0]?.billApplyAuthCheckType, "Y");
}); });
test("Xft submission options expose only the current user's departments and searchable city codes", async () => { test("Xft attachment upload follows the official multipart signing mode and injects opaque file IDs only at create", async () => {
const file = Buffer.from("%PDF-1.7\nmock invoice", "utf8");
const sha256 = (await import("node:crypto")).createHash("sha256").update(file).digest("hex");
let uploadHeaders: Headers | undefined;
let uploadBody: Buffer | undefined;
let createBody: Record<string, unknown> | undefined;
const fakeFetch: typeof fetch = async (input, init) => {
const url = new URL(String(input));
const path = url.pathname;
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{
thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1",
}] } });
if (path.endsWith("member/get/by-id")) return encrypted({ returnCode: "SUC0000", body: {
number: "E001", idRelation: { enterpriseUserId: "EMP-1" },
} });
if (path.endsWith("form/query-event-list")) return encrypted({ code: "0", data: { list: [{
eventSeqId: "1", eventNumber: "FORM-1", eventName: "费用报销单", eventStatus: "1", eventType: "COMMON_REIM",
}] } });
if (path.endsWith("accessory/custom-field/upload")) {
uploadHeaders = new Headers(init?.headers);
uploadBody = Buffer.from(init?.body as ArrayBuffer);
assert.equal(url.searchParams.get("uploaderNumber"), "E001");
return encrypted({ code: "SUC0000", body: { fileId: "file-secret-1" } });
}
if (path.endsWith("reimburse/bill/create")) {
createBody = decodedBody(init);
return encrypted({ code: "SUC0000", body: 2026090100000001 });
}
throw new Error(`unexpected ${path}`);
};
const client = new XftOpenApiClient(config(), fakeFetch);
const confirmationId = "confirmation-bound-to-user-preview";
const uploaded = await client.uploadSubmissionAttachment({ userId: "ding-u1" }, {
fieldName: "发票附件", fileName: "invoice.pdf", size: file.length, sha256, content: file, confirmationId,
});
assert.equal(uploadHeaders?.has("x-alb-digest"), false, "official SDK omits body digest for multipart uploads");
assert.equal(uploadHeaders?.get("x-alb-verify"), "sm3withsm2");
assert.equal(uploadHeaders?.get("apisign")?.length, 128);
assert.match(uploadHeaders?.get("content-type") ?? "", /^multipart\/form-data;.*boundary=/);
assert.match(uploadBody?.toString("utf8") ?? "", /name="file"; filename="invoice\.pdf"/);
assert.ok(uploadBody?.includes(file));
assert.doesNotMatch(uploaded.attachmentRef, /file-secret-1/);
const template = {
eventSeqId: "1", eventNumber: "FORM-1", eventName: "费用报销单",
eventType: "COMMON_REIM", kind: "common_reimburse" as const,
};
await client.submitApproval({ userId: "ding-u1" }, template, {
reimAmount: "551.00", reimAmountCurrencyCode: "CNY", expenseList: [{ expTypeCode: "MEAL" }],
}, [{
fieldNumber: "ACCESSORIES_1", fieldName: "发票附件", fileName: "invoice.pdf",
size: file.length, sha256, attachmentRef: uploaded.attachmentRef,
}], confirmationId);
assert.deepEqual(createBody?.customFieldValues, [{
fieldNumber: "ACCESSORIES_1", fieldValue: JSON.stringify(["file-secret-1"]),
}]);
assert.doesNotMatch(JSON.stringify(createBody), /confirmation-bound|attachmentRef|sha256/);
await assert.rejects(client.submitApproval({ userId: "ding-u1" }, template, {}, [{
fieldNumber: "ACCESSORIES_1", fieldName: "发票附件", fileName: "invoice.pdf",
size: file.length, sha256, attachmentRef: uploaded.attachmentRef,
}], "another-confirmation"), /与本次用户确认.*不一致/);
});
test("Xft submission options expose only departments, cities, and the matching expense data source", async () => {
const templateRecord = { const templateRecord = {
eventSeqId: 1, eventNumber: "TRAVEL-1", eventName: "出差费用申请单", eventStatus: "1", eventType: "TRAVEL_APPLY", eventSeqId: 1, eventNumber: "TRAVEL-1", eventName: "出差费用申请单", eventStatus: "1", eventType: "TRAVEL_APPLY",
}; };
let cityRequests = 0; let cityRequests = 0;
let expenseSourceRequests = 0;
let unrelatedSourceRequests = 0;
const client = new XftOpenApiClient(config(), async (input, init) => { const client = new XftOpenApiClient(config(), async (input, init) => {
const path = new URL(String(input)).pathname; const url = new URL(String(input));
const path = url.pathname;
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } }); if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
if (path.endsWith("form/query-event-list")) return encrypted({ code: "0", data: { list: [templateRecord] } }); if (path.endsWith("form/query-event-list")) return encrypted({ code: "0", data: { list: [templateRecord] } });
if (path.endsWith("member/get/by-id")) { if (path.endsWith("member/get/by-id")) {
@@ -386,6 +628,35 @@ test("Xft submission options expose only the current user's departments and sear
regions: [{ cityCode: "440300", cityName: "深圳市" }, { cityCode: "441900", cityName: "东莞市" }], regions: [{ cityCode: "440300", cityName: "深圳市" }, { cityCode: "441900", cityName: "东莞市" }],
} }); } });
} }
if (path.includes("form/customFields/eventSeqId")) return encrypted({ code: "0", data: [] });
if (path.endsWith("dataSource/findMultilevelSourcePage")) {
assert.equal(init?.method, "POST");
assert.equal(url.searchParams.get("start"), "0");
assert.equal(url.searchParams.get("limit"), "100");
assert.deepEqual(decodedBody(init), {});
return encrypted({ success: true, message: "调用成功!", totalCount: 2, list: [
{ seqId: 150, id: "DS-EXP", selectSourceName: "企业费用类型" },
{ seqId: 151, id: "DS-PROJECT", selectSourceName: "项目档案" },
] });
}
if (path.endsWith("dataSource/getMultiLevelDetail/150")) {
expenseSourceRequests += 1;
return encrypted({ success: true, message: "调用成功!", data: {
id: "DS-EXP",
selectSourceName: "企业费用类型",
optionSaveDtoList: [
{ seqId: 601, selectOptionName: "交通费", optionSaveDtoList: null },
{ seqId: 602, selectOptionName: "住宿费", optionSaveDtoList: null },
{ seqId: 603, selectOptionName: "停用费用", optionSaveDtoList: null, selectable: false },
],
} });
}
if (path.endsWith("dataSource/getMultiLevelDetail/151")) {
unrelatedSourceRequests += 1;
return encrypted({ success: true, message: "调用成功!", data: {
optionSaveDtoList: [{ seqId: 701, selectOptionName: "项目一", optionSaveDtoList: null }],
} });
}
throw new Error(`unexpected ${path}`); throw new Error(`unexpected ${path}`);
}); });
const template = (await client.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!; const template = (await client.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!;
@@ -400,6 +671,301 @@ test("Xft submission options expose only the current user's departments and sear
const cachedCities = await client.getSubmissionOptions({ userId: "ding-u1" }, template, "city", "东莞", 10); const cachedCities = await client.getSubmissionOptions({ userId: "ding-u1" }, template, "city", "东莞", 10);
assert.deepEqual(cachedCities.items, [{ code: "441900", label: "东莞市" }]); assert.deepEqual(cachedCities.items, [{ code: "441900", label: "东莞市" }]);
assert.equal(cityRequests, 1); assert.equal(cityRequests, 1);
const expenseTypes = await client.getSubmissionOptions({ userId: "ding-u1" }, template, "expense_type", "住宿", 10);
assert.deepEqual(expenseTypes.items, [{ code: "602", label: "住宿费" }]);
assert.equal(expenseTypes.total, 1);
assert.equal(expenseTypes.hasMore, false);
assert.equal(expenseSourceRequests, 1);
assert.equal(unrelatedSourceRequests, 0);
});
test("all formal Xft expense fields declare a user-readable expense type option source", async () => {
const records = [
{ eventSeqId: 11, eventNumber: "EXP-11", eventName: "费用申请", eventStatus: "1", eventType: "EXPENSE_APPLY" },
{ eventSeqId: 12, eventNumber: "TRIP-12", eventName: "出差费用申请", eventStatus: "1", eventType: "TRAVEL_APPLY" },
{ eventSeqId: 13, eventNumber: "REIM-13", eventName: "费用报销", eventStatus: "1", eventType: "COMMON_REIM" },
];
const client = new XftOpenApiClient(config(), async (input) => {
const path = new URL(String(input)).pathname;
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
if (path.endsWith("form/query-event-list")) return encrypted({ code: "0", data: { list: records } });
if (path.includes("form/customFields/eventSeqId")) return encrypted({ code: "0", data: [] });
throw new Error(`unexpected ${path}`);
});
const templates = await client.listSubmissionTemplates({ userId: "ding-u1" }, 10);
const expected = ["expenseNumber", "expenseTypeNumber", "expTypeCode"];
for (const [index, fieldKey] of expected.entries()) {
const schema = await client.getSubmissionSchema({ userId: "ding-u1" }, templates[index]!);
const field = findRecordByKey(schema.inputContract, fieldKey);
assert.equal(field?.label, "费用类型");
assert.deepEqual(field?.optionSource, { tool: "approval_get_submission_options", kind: "expense_type" });
assert.deepEqual(field?.selectionCapability, {
status: "supported", userInput: "label", submitValue: "code", exposeInternalValue: false,
});
assert.doesNotMatch(String(field?.description ?? ""), /要求用户.*编码/);
}
});
test("all formal Xft city and enum choices expose readable labels while unresolved references fail closed", async () => {
const records = [
{ eventSeqId: 21, eventNumber: "EXP-21", eventName: "费用申请", eventStatus: "1", eventType: "EXPENSE_APPLY" },
{ eventSeqId: 22, eventNumber: "ENT-22", eventName: "招待申请", eventStatus: "1", eventType: "ENTERTAINMENT_APPLY" },
{ eventSeqId: 23, eventNumber: "TRIP-23", eventName: "差旅申请", eventStatus: "1", eventType: "TRAVEL_APPLY" },
{ eventSeqId: 24, eventNumber: "REIM-24", eventName: "费用报销", eventStatus: "1", eventType: "COMMON_REIM" },
{ eventSeqId: 25, eventNumber: "TR-25", eventName: "差旅报销", eventStatus: "1", eventType: "TRAVEL_REIM" },
];
const client = new XftOpenApiClient(config(), async (input) => {
const path = new URL(String(input)).pathname;
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
if (path.endsWith("form/query-event-list")) return encrypted({ code: "0", data: { list: records } });
if (path.includes("form/customFields/eventSeqId")) return encrypted({ code: "0", data: [] });
throw new Error(`unexpected ${path}`);
});
const templates = await client.listSubmissionTemplates({ userId: "ding-u1" }, 20);
const schemas = await Promise.all(templates.map((template) => client.getSubmissionSchema({ userId: "ding-u1" }, template)));
const combined = schemas.map((schema) => schema.inputContract);
for (const schema of schemas) {
const values = schema.inputContract.values as Record<string, unknown>;
assert.ok(values.minimalExample && typeof values.minimalExample === "object");
assert.match(JSON.stringify(values.minimalExample), /</);
}
for (const key of ["payCityCode", "entertainmentCityCode", "departCityCode", "destinationCityCode", "departureCode", "destinationCode"]) {
const matches = findRecordsByKey(combined, key);
assert.ok(matches.length > 0, `${key} should exist`);
for (const field of matches) {
assert.deepEqual(field.optionSource, { tool: "approval_get_submission_options", kind: "city" });
assert.equal((field.selectionCapability as Record<string, unknown>)?.exposeInternalValue, false);
assert.doesNotMatch(String(field.label), /编码/);
}
}
const travelMode = findRecordByKey(combined, "travelMode");
assert.equal((travelMode?.enumLabels as Record<string, string>)?.AirPlane, "飞机");
assert.equal((travelMode?.enumLabels as Record<string, string>)?.RideHailing, "网约车");
assert.equal((findRecordByKey(combined, "roundTrip")?.enumLabels as Record<string, string>)?.Y, "往返");
assert.equal((findRecordByKey(combined, "beginTimePrecision")?.enumLabels as Record<string, string>)?.AM, "上午");
assert.equal((findRecordByKey(combined, "currencyCode")?.enumLabels as Record<string, string>)?.CNY, "人民币");
assert.equal((findRecordByKey(combined, "hotelStar")?.enumLabels as Record<string, string>)?.["1"], "五星");
for (const key of ["peerEmpNumbers", "participants", "customArchiveOptionNumbers", "aircraftCabin", "trainCabin", "shipCabin", "relatedBillId"]) {
const field = findRecordByKey(combined, key);
assert.equal((field?.selectionCapability as Record<string, unknown>)?.status, "client_only", key);
assert.doesNotMatch(String(field?.label ?? ""), /工号|编码| ID/);
}
});
test("live Xft custom choices normalize labels recursively and unsupported selectors are explicit", async () => {
const templateRecord = {
eventSeqId: 31, eventNumber: "CUSTOM-31", eventName: "费用申请", eventStatus: "1", eventType: "EXPENSE_APPLY",
};
const client = new XftOpenApiClient(config(), async (input) => {
const path = new URL(String(input)).pathname;
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
if (path.endsWith("form/query-event-list")) return encrypted({ code: "0", data: { list: [templateRecord] } });
if (path.includes("form/customFields/eventSeqId")) return encrypted({ code: "0", data: [
{ fieldNumber: "S1", name: "餐别", type: "SELECT", required: true, options: [
{ optionCode: "LUNCH", optionName: "午餐" }, { optionCode: "DINNER", optionName: "晚餐" },
] },
{ fieldNumber: "S2", name: "无名称映射", type: "SELECT", required: false, options: ["1", "2"] },
{ fieldNumber: "P1", name: "参与人员", type: "PERSON", required: true },
{ fieldNumber: "D1", name: "成本部门", type: "ADV_DEPARTMENT", required: true },
{ fieldNumber: "T1", name: "明细", type: "TABLE", required: true, columns: [
{ titleCode: "TS", name: "用餐类型", type: "SELECT", required: true, options: [{ code: "TEAM", label: "团队餐" }] },
{ titleCode: "TP", name: "同行人", type: "PERSON", required: false },
] },
] });
throw new Error(`unexpected ${path}`);
});
const template = (await client.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!;
const fields = (await client.getSubmissionSchema({ userId: "ding-u1" }, template)).fields;
const select = fields.find((field) => field.fieldNumber === "S1")!;
assert.deepEqual(select.options, [{ code: "LUNCH", label: "午餐" }, { code: "DINNER", label: "晚餐" }]);
assert.equal(select.selectionCapability?.status, "supported");
assert.equal(fields.find((field) => field.fieldNumber === "S2")?.selectionCapability?.status, "client_only");
assert.equal(fields.find((field) => field.fieldNumber === "P1")?.selectionCapability?.status, "client_only");
assert.deepEqual(fields.find((field) => field.fieldNumber === "D1")?.optionSource, {
tool: "approval_get_submission_options", kind: "department",
});
const tableFields = fields.find((field) => field.fieldNumber === "T1")?.itemFields ?? [];
assert.deepEqual(tableFields.find((field) => field.titleCode === "TS")?.options, [{ code: "TEAM", label: "团队餐" }]);
assert.equal((tableFields.find((field) => field.titleCode === "TP")?.selectionCapability as Record<string, unknown>)?.status, "client_only");
});
test("Xft expense types prefer user-readable live template options and fail closed when unavailable", async () => {
const templateRecord = {
eventSeqId: 8, eventNumber: "EXP-8", eventName: "招待费用申请单", eventStatus: "1", eventType: "EXPENSE_APPLY",
};
let dataSourceRequests = 0;
const client = new XftOpenApiClient(config(), async (input) => {
const path = new URL(String(input)).pathname;
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
if (path.endsWith("form/query-event-list")) return encrypted({ code: "0", data: { list: [templateRecord] } });
if (path.includes("form/customFields/eventSeqId")) return encrypted({ code: "0", data: [{
fieldNumber: "expenseType", name: "费用类型", type: "SELECT", required: true,
options: [
{ optionCode: "MEAL", optionName: "客户招待费" },
{ optionCode: "CAR", optionName: "市内交通费" },
{ optionCode: "其他费", optionName: "其他费" },
],
}] });
if (path.includes("dataSource/")) {
dataSourceRequests += 1;
throw new Error("data source must not be called");
}
throw new Error(`unexpected ${path}`);
});
const template = (await client.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!;
const schema = await client.getSubmissionSchema({ userId: "ding-u1" }, template);
assert.deepEqual(schema.fields[0]?.optionSource, {
tool: "approval_get_submission_options", kind: "expense_type",
});
const result = await client.getSubmissionOptions({ userId: "ding-u1" }, template, "expense_type", "招待", 10);
assert.deepEqual(result.items, [{ code: "MEAL", label: "客户招待费" }]);
const sameCodeAndLabel = await client.getSubmissionOptions({ userId: "ding-u1" }, template, "expense_type", "其他", 10);
assert.deepEqual(sameCodeAndLabel.items, [{ code: "其他费", label: "其他费" }]);
assert.equal(dataSourceRequests, 0);
const unavailable = new XftOpenApiClient(config(), async (input) => {
const path = new URL(String(input)).pathname;
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
if (path.endsWith("form/query-event-list")) return encrypted({ code: "0", data: { list: [templateRecord] } });
if (path.includes("form/customFields/eventSeqId")) return encrypted({ code: "0", data: [] });
if (path.endsWith("dataSource/findMultilevelSourcePage")) return encrypted({
success: true, message: "调用成功!", totalCount: 1, list: [
{ seqId: 100, id: "DS-PROJECT", selectSourceName: "项目档案" },
],
});
throw new Error(`unexpected ${path}`);
});
const unavailableTemplate = (await unavailable.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!;
await assert.rejects(
unavailable.getSubmissionOptions({ userId: "ding-u1" }, unavailableTemplate, "expense_type"),
/提单实时字段未提供内嵌选项,且企业多级数据源共返回 1 项,但没有费用类型数据源.*正式 OpenAPI 未提供独立的费用类型目录查询/,
);
});
test("Xft expense type lookup explains a successful empty official data-source response", async () => {
const templateRecord = {
eventSeqId: 16, eventNumber: "EXP-16", eventName: "费用报销单", eventStatus: "1", eventType: "COMMON_REIM",
};
const client = new XftOpenApiClient(config(), async (input) => {
const path = new URL(String(input)).pathname;
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
if (path.endsWith("form/query-event-list")) return encrypted({ code: "0", data: { list: [templateRecord] } });
if (path.includes("form/customFields/eventSeqId")) return encrypted({ code: "0", data: [] });
if (path.endsWith("dataSource/findMultilevelSourcePage")) return encrypted({
success: true, message: "调用成功!", totalCount: 0, list: [],
});
throw new Error(`unexpected ${path}`);
});
const template = (await client.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!;
await assert.rejects(
client.getSubmissionOptions({ userId: "ding-u1" }, template, "expense_type"),
/提单实时字段未提供内嵌选项,且企业多级数据源列表为空.*请在费用管理页面确认配置或使用薪福通客户端完成/,
);
});
test("Xft expense type lookup follows the official data-source paging and detail contract", async () => {
const templateRecord = {
eventSeqId: 17, eventNumber: "EXP-17", eventName: "费用报销单", eventStatus: "1", eventType: "COMMON_REIM",
};
let listRequests = 0;
let detailRequests = 0;
const client = new XftOpenApiClient(config(), async (input, init) => {
const url = new URL(String(input));
const path = url.pathname;
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
if (path.endsWith("form/query-event-list")) return encrypted({ code: "0", data: { list: [templateRecord] } });
if (path.includes("form/customFields/eventSeqId")) return encrypted({ code: "0", data: [] });
if (path.endsWith("dataSource/findMultilevelSourcePage")) {
listRequests += 1;
assert.equal(init?.method, "POST");
assert.equal(url.searchParams.get("start"), "0");
assert.equal(url.searchParams.get("limit"), "100");
assert.deepEqual(decodedBody(init), {});
return encrypted({
success: true, message: "调用成功!", totalCount: 1, list: [
{ seqId: 150, id: "SOUR01", selectSourceName: "企业费用类型" },
],
});
}
if (path.endsWith("dataSource/getMultiLevelDetail/150")) {
detailRequests += 1;
assert.equal(init?.method, "GET");
assert.equal(init?.body, undefined);
return encrypted({
success: true,
message: "调用成功!",
data: {
id: "SOUR01",
selectSourceName: "企业费用类型",
optionSaveDtoList: [{
seqId: 1214876,
selectOptionName: "交通费",
optionSaveDtoList: [{ seqId: 1214877, selectOptionName: "出租车费", optionSaveDtoList: null }],
}],
},
});
}
throw new Error(`unexpected ${path}`);
});
const template = (await client.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!;
const result = await client.getSubmissionOptions({ userId: "ding-u1" }, template, "expense_type");
assert.equal(listRequests, 1);
assert.equal(detailRequests, 1);
assert.deepEqual(result.items, [
{ code: "1214876", label: "交通费" },
{ code: "1214877", label: "出租车费" },
]);
});
test("Xft expense type lookup preserves data-source API errors instead of replacing them with an empty result", async () => {
const templateRecord = {
eventSeqId: 18, eventNumber: "EXP-18", eventName: "费用报销单", eventStatus: "1", eventType: "COMMON_REIM",
};
const client = new XftOpenApiClient(config(), async (input) => {
const path = new URL(String(input)).pathname;
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
if (path.endsWith("form/query-event-list")) return encrypted({ code: "0", data: { list: [templateRecord] } });
if (path.includes("form/customFields/eventSeqId")) return encrypted({ code: "0", data: [] });
if (path.endsWith("dataSource/findMultilevelSourcePage")) {
return new Response(String(sm4.encrypt(JSON.stringify({
code: "NO_PERMISSION", message: "未开通多级数据源查询权限",
}), privateKey.slice(0, 32))), { status: 403 });
}
throw new Error(`unexpected ${path}`);
});
const template = (await client.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!;
await assert.rejects(
client.getSubmissionOptions({ userId: "ding-u1" }, template, "expense_type"),
/未开通多级数据源查询权限(操作:费用类型数据源列表;HTTP 403;薪福通错误码 NO_PERMISSION)/,
);
});
test("Xft expense type lookup preserves a failed matching data-source detail", async () => {
const templateRecord = {
eventSeqId: 19, eventNumber: "EXP-19", eventName: "费用报销单", eventStatus: "1", eventType: "COMMON_REIM",
};
const client = new XftOpenApiClient(config(), async (input) => {
const path = new URL(String(input)).pathname;
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
if (path.endsWith("form/query-event-list")) return encrypted({ code: "0", data: { list: [templateRecord] } });
if (path.includes("form/customFields/eventSeqId")) return encrypted({ code: "0", data: [] });
if (path.endsWith("dataSource/findMultilevelSourcePage")) return encrypted({
success: true, message: "调用成功!", totalCount: 1, list: [
{ seqId: 151, id: "SOUR02", selectSourceName: "企业费用类型" },
],
});
if (path.endsWith("dataSource/getMultiLevelDetail/151")) {
throw new TypeError("fetch failed", {
cause: Object.assign(new Error("socket closed with internal address"), { code: "UND_ERR_SOCKET" }),
});
}
throw new Error(`unexpected ${path}`);
});
const template = (await client.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!;
await assert.rejects(
client.getSubmissionOptions({ userId: "ding-u1" }, template, "expense_type"),
/费用类型数据源详情请求失败:网络连接被重置或中断(底层错误码 UND_ERR_SOCKET;超时上限 2000ms)/,
);
}); });
test("Xft recall derives empId from the authenticated user and validates the upstream success key", async () => { test("Xft recall derives empId from the authenticated user and validates the upstream success key", async () => {
@@ -564,7 +1130,8 @@ test("Xft submission routes every supported template kind to its documented crea
throw new Error(`unexpected ${path}`); throw new Error(`unexpected ${path}`);
}); });
const templates = await client.listSubmissionTemplates({ userId: "ding-u1" }, 20); const templates = await client.listSubmissionTemplates({ userId: "ding-u1" }, 20);
assert.equal(templates.length, 8); assert.equal(templates.length, 7);
assert.equal(templates.some((item) => item.eventNumber === "T5"), false);
assert.equal(templates.find((item) => item.eventNumber === "T8")?.kind, "expense_apply"); assert.equal(templates.find((item) => item.eventNumber === "T8")?.kind, "expense_apply");
for (const template of templates) await client.submitApproval({ userId: "ding-u1" }, template, {}); for (const template of templates) await client.submitApproval({ userId: "ding-u1" }, template, {});
assert.deepEqual(createPaths.sort(), [ assert.deepEqual(createPaths.sort(), [
@@ -573,7 +1140,6 @@ test("Xft submission routes every supported template kind to its documented crea
"/itrip/xft-api/v1/bills/apply/expense/create", "/itrip/xft-api/v1/bills/apply/expense/create",
"/itrip/xft-api/v1/bills/apply/expense/create", "/itrip/xft-api/v1/bills/apply/expense/create",
"/itrip/xft-api/v1/bills/apply/travel/create", "/itrip/xft-api/v1/bills/apply/travel/create",
"/itrip/xft-api/v1/bills/apply/use-car/create",
"/itrip/xft-api/v1/reimburse/bill/create", "/itrip/xft-api/v1/reimburse/bill/create",
"/itrip/xft-api/v1/reimburse/bill/travel/create", "/itrip/xft-api/v1/reimburse/bill/travel/create",
]); ]);