Author SHA1 Message Date
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
28 changed files with 1936 additions and 210 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.
- 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.
- 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
+11 -3
View File
@@ -20,7 +20,7 @@ Agent → 远程 Streamable HTTP MCP / OAuth 统一网关
- `approval_process_batch`:一次最多处理 20 条;整批引用预检通过后才逐项执行,并在写后复核。
- `approval_list_submission_templates`:实时列出当前用户的提单候选模板。
- `approval_get_submission_schema`:返回结构化可填写字段,不传输巨大原始 Schema。
- `approval_get_submission_options`:查询当前用户可用的薪福通部门和城市编码。
- `approval_get_submission_options`:实时查询当前用户可用的薪福通部门、城市和费用类型;所有选项面向用户只展示业务名称,内部映射正式接口值。
- `approval_prepare_submission`:校验内容,自动查重本人近 30 天跨钉钉/薪福通的发票,并在安全时生成完整预览及短期确认引用;不创建单据。
- `approval_submit`:用户确认预览后,使用相同内容正式发起审批。
- `approval_revoke_submission`:使用提单成功后返回的 `revokeId` 撤销本人刚通过 MCP 发起的钉钉或薪福通审批。
@@ -41,15 +41,23 @@ Agent 不传 `userId`、员工号或单据所有者。网关从 OAuth Token 确
模板出现在列表中不等于所有控件都能由开放 API 构造。`approval_get_submission_schema` 会返回 `submissionCapability`,准备阶段也会再次安全校验:
- 薪福通:白名单中的申请和报销模板均通过正式创建 API 发起,并在创建后实时核验状态。结果可能进入待审批,也可能因企业流程中没有其他有效审批人等规则即时完成;后者返回 `completed`,不会错误地当成接口失败,也不会签发不可用的撤回引用。
- 薪福通:可安全构造的申请和报销模板通过正式创建 API 发起,并在创建后实时核验状态。结果可能进入待审批,也可能因企业流程中没有其他有效审批人等规则即时完成;后者返回 `completed`,不会错误地当成接口失败,也不会签发不可用的撤回引用。用车申请不属于 MCP 发起范围,不会出现在候选模板中。
- 钉钉:当前 6 个候选模板均返回 `client_only`,必须在钉钉客户端发起。其中 5 个包含企业 OpenAPI 无法可靠构造的 `DDBizSuite` 考勤或费控套件;`业务招待申请_北极大`虽是普通明细表,但真实创建时新版企业接口返回系统异常、旧版接口拒绝组件格式,因此同样安全降级。
发起审批固定为“列模板 → 取 Schema → 自动查重/预览 → 用户确认 → 正式提交”。准备阶段从新表单提取发票代码与号码、数电票唯一编号、票据号码、附件哈希;没有唯一编号时,金额、日期与商户组合只作为疑似提示。网关实时刷新当前用户本人近 30 天的钉钉和薪福通已发起审批:原单待审批、已通过或状态未知时默认阻止,原单拒绝、撤回、作废或弱匹配时警告。任一来源查询不完整也不会当成“没有重复”。只有用户看过匹配单号、状态和覆盖情况并明确要求继续后,Agent 才能以 `allowDuplicateInvoices=true` 重新准备。
用户不需要理解薪福通的任何内部选择值。实时 Schema 会把部门、城市、费用类型、出行方式、往返、时间范围、币种和酒店星级等字段标记为名称优先的选择项;Agent 只展示“深圳市”“交通费”“飞机”“往返”“人民币”等业务名称,选定后仅在内部写入正式接口值。动态下拉框也会标准化成 `label/code`,表格子字段同样递归处理。准备阶段的最终确认预览会再次按实时选项反向解析,只返回业务字段名和业务名称,不回显城市码、费用编码、字段号或表格列号;任何值无法可靠反向解析时,不生成 `confirmationId`。无法取得可靠名称映射时安全停止或允许跳过可选项,不退化为向用户索取工号、城市码、组织码、档案编号、数字舱位值或其他内部 ID。
薪福通正式 OpenAPI 当前没有独立的费用类型目录查询。费用类型会先从模板实时字段读取,再检查企业自定义多级数据源;两处都没有可用选项时,MCP 返回具体的实时检查结果并引导在费用管理页面确认或使用薪福通客户端,不会把空目录误报为网络 `fetch` 故障。
人员、企业自定义档案、关联前置单据、分摊及文档未给出可靠名称映射的舱位字段目前标记为 `client_only`。必填时不生成提交预览并引导薪福通客户端;可选时不会作为普通问题打扰用户。单据公司、部门、字段引用和行程序号等服务器管理或内部派生值也不会作为用户输入项展示。
服务端临时保留不可逆的用户/发票 HMAC 指纹。首次使用时只为最近 30 天尚未索引的审批读取详情;以后准备提单会刷新精简历史状态,并只为新增审批补建指纹,不会重复逐张下载已索引发票。指纹和最小审批索引最多保留 35 天,过期后自动级联删除;10 分钟提单预留到期即删除。
安全准备后,确认引用绑定登录用户、模板版本、完整表单内容、重复风险决定和发票预留,10 分钟有效且只能使用一次;相同发票在另一个有效确认中时不会再签发确认,避免并发重复创建。正文和发票原号不落库。钉钉企业 OpenAPI 支持通过 `POST /v1.0/workflow/processes/forecast` 进行无副作用的流程预测;网关只在预测成功并补齐所有自选处理人后才生成提交确认。薪福通在正式提交时完成最终业务校验,并由网关写后读取 `billSts`:状态 `1(待审批)` 返回 `submitted` 和 `revokeId`,状态 `2(审批通过)` 返回 `completed` 且不提供撤回引用。
用户可以让 Agent 把尚未填完的提单保存为本机草稿。它不是钉钉或薪福通的官方草稿:只写入运行 Agent 的当前 OS 用户私有数据目录,不上传网关,也不跨设备同步。草稿保存用户填写值、可选项决定以及附件的本地路径和 SHA-256,不保存 Token、身份 ID、短期模板/确认/撤回引用或完整上游 Schema;附件在正式准备前重新验哈希。恢复时重新实时列模板和读取 Schema,超过 90 天未更新的普通草稿在下次草稿操作时清理。提交结果不确定的草稿会冻结,核验清楚前禁止重试。宿主没有本地文件能力时不提供持久草稿,也不会改存服务器。当前 MCP 不提供附件上传工具;若实时 Schema 要求上游附件编号,本机路径不能代替,需保留草稿并在原生客户端完成。
> 发布候选版限制:薪福通 `approve/recall` 撤回接口已经接入并具备本人归属校验与写后复核,但当前企业的真实待审批单仍返回 `XFTSYS9999`,尚无撤回成功样本。因此薪福通撤回属于实验能力;失败或结果不确定时绝不自动重试。查询、处理和发起审批不受此限制。
## 部署
@@ -89,7 +97,7 @@ https://approval.justconn.top/downloads/dingmcp/install.md
- `approvalId` 是绑定用户、来源和有效期的紧凑不透明加密引用;不包含展示字段或用户 ID 明文。
- 每批最多 20 条;引用预检失败时首个上游写调用不会发生。
- 提单的发起人、薪福通员工号和审批状态只能由网关填写。
- 审批正文、费用明细、附件和薪福通 Schema 不落库。
- 网关不保存审批正文、费用明细、附件和薪福通 Schema;用户明确创建的本机草稿是唯一例外,且只位于 Agent 机器的当前用户私有目录。
- 发票查重库只保存当前用户和发票标识的 HMAC、来源、上游业务单号、状态及时间,最多保留 35 天;不保存发票原号、商户原文或附件。
- 企业 AppSecret 与薪福通 AuthoritySecret 只保存在服务器。
- `failed`、`succeeded` 与 `outcome_uncertain` 有不同重试语义,后者禁止自动重试。
@@ -167,7 +167,7 @@ Windows 的 `bridgePath` 和 `mcpConfig...command` 正常应以 `dingtalk-approv
## 步骤 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 的私有配置。
3. 如果 Agent 接受完整 MCP JSON,原样使用整个 `mcpConfig`;如果界面只接受单个服务器配置,原样使用 `mcpConfig.mcpServers.dingtalk-approval`。
4. 该连接器必须是本地 `stdio`,`command` 必须是返回的本地 `bridgePath`,`args` 必须为空数组。Windows 中必须保留 JSON 已正确转义的反斜杠。
+17 -5
View File
@@ -3,9 +3,11 @@
## 数据路径
```text
用户 ─ Messenger ─ Agent ─ 远程 Streamable HTTP MCP/OAuth ─ 统一审批网关
├─ 钉钉 OA
└─ 薪福通
用户 ─ Messenger ─ Agent
├─ 可选本机私有提单草稿
└─ 远程 Streamable HTTP MCP/OAuth ─ 统一审批网关
├─ 钉钉 OA
└─ 薪福通
```
支持远程 MCP 和标准 OAuth,且用户能完成宿主所注册回调的场景直接连接网关,不安装客户端运行时。若宿主使用 `127.0.0.1`/`localhost` 回调,授权页必须在 Agent 所在设备打开。宿主缺少远程传输/OAuth 能力,或用户通过 SSH、无头终端、Messenger 等方式跨设备交互而无法完成本机回调时,使用本地 Bridge 把 stdio 转换为远程 Streamable HTTP,并通过可跨设备的 device-code 登录。Bridge 只保存用户自己的短期 access/refresh token,不包含企业密钥、不读取审批上游,也不监听端口。
@@ -22,8 +24,18 @@
Agent 面向统一审批语义,不直接理解钉钉 `processInstanceId/taskId`、薪福通员工号、上游响应或完整 Schema。网关在内部完成来源路由、字段抽取、动作调用和写后复核,只返回审批判断所需的精简字段与统一状态。
薪福通所有选择字段按领域选项抽象。部门、城市和费用类型由选项工具返回精简 `label/code` 映射;模板内嵌下拉框被标准化成同样结构,固定枚举通过 `enumLabels` 提供中文名称,表格子字段递归应用相同规则。Agent 只把业务名称展示给用户,正式值仅用于内部构造请求。准备确认时,网关再用实时 Schema 把正式值反向解析为业务名称;预览不包含城市码、费用编码、自定义字段号或表格列号,任一选择无法反向解析时会在签发确认引用前安全失败。无法取得可靠名称映射的人员、档案、关联单据、分摊和部分舱位字段标记为 `client_only`,必填时安全停止、可选时允许省略,绝不转而索取工号或编码。用车申请已从 MCP 发起范围移除,实时模板列表会过滤,旧模板引用也会被网关拒绝。响应不包含完整数据源、内部身份或其他企业档案,选项目录也不持久化。
所有工具以普通 `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;模板不唯一、字段变化、附件丢失或哈希变化都必须回到用户确认。当前 MCP 没有附件上传工具,本机路径或文件内容不能作为上游附件编号提交;实时 Schema 要求附件编号时只能保留草稿并转到原生客户端。普通草稿超过 90 天未更新会在下次草稿操作时清理。
正式提交仍完整经过网关的身份、模板、字段、发票查重、预览和一次性确认校验。本机文件被修改不能扩大权限。提交成功或即时完成后删除草稿;确定失败时保留并回到编辑;`outcome_uncertain` 时冻结草稿、丢弃短期引用并禁止自动重试,直到本人发起历史或原生客户端证明结果。
单张或部分操作使用 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` 使用服务器派生的本人员工号查询单据,再由网关统一按时间窗口过滤。读取历史详情时再次验证当前用户确实处理过该任务或确实是发起人;审批正文、费用明细和附件仍只实时读取、不落库。
@@ -82,7 +94,7 @@ Agent 面向统一审批语义,不直接理解钉钉 `processInstanceId/taskId
撤回接口若返回 `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`;已通过、草稿、拒绝或已撤回单据都不会获得撤回引用。该工具不返回审批正文,也不触发写操作。
@@ -94,4 +106,4 @@ Agent 面向统一审批语义,不直接理解钉钉 `processInstanceId/taskId
钉钉企业 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`(查询企业成员详情,用于由当前登录身份安全取得本人花名册员工号);
- `itrip-gateway/v1/form/query-event-list`;
- `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/bills/apply/travel/create`;
- `itrip-gateway/v1/bills/apply/expense/create`;
- `itrip-gateway/v1/bills/apply/entertainment/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/travel/create`。
@@ -37,7 +38,7 @@ https://approval.justconn.top/oauth/dingtalk/callback
创建接口必须保持权限校验开启。网关统一提交 `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` 才算验收通过。
宿主缺少远程 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 保护;来源失败可换源,签名或哈希不符必须立即停止。
@@ -135,7 +136,7 @@ curl --fail https://approval.justconn.top/downloads/dingmcp/manifest.json
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)。
+1
View File
@@ -17,6 +17,7 @@
- 兼容安装结果的 `loginCommand` 能在 MCP 未加载时刷新登录链接;MCP 已加载时 `approval_login` 完成同一操作。不得因链接过期而重装,首次成功响应只展示一次 onboarding 示例。
- 只读验证待办、本人已处理历史、本人发起历史、历史默认最近 30 天及显式时间窗口、搜索、详情、费用结构化字段、动作、模板 Schema、部门/城市选项和越权拒绝;历史引用必须被动作查询和批处理拒绝。
- 发票查重验证钉钉/XFT 跨来源精确匹配、弱匹配、用户隔离、状态更新、来源不可用、历史截断、详情失败、并发确认、35 天过期清理和网关重启恢复;数据库不得出现用户 ID、发票原号、审批正文或附件。重复风险和不完整覆盖未获用户确认时不得签发可提交 `confirmationId`。
- 本机草稿验证 Windows/macOS/Linux 私有目录选择、POSIX `0700`/`0600`、原子保存、90 天普通草稿清理、模板和 Schema 实时恢复、附件 SHA-256 变化、提交成功删除、确定失败保留及 `outcome_uncertain` 冻结。确认网关数据库、日志、发行包和项目仓库都不出现草稿正文,草稿中不保存 Token、身份 ID、短期模板/确认/撤回引用或完整 Schema。
- 真实写操作只执行用户明确授权的样本;任何 `outcome_uncertain` 都停止,不自动重试。
- Mock 契约确认网关和 bridge 的批量 Schema 均固定为 20;第 6 个截断引用及任一无效退回目标都会返回 `preflight_failed`、`writesStarted=false`,上游写调用数为 0。
- 代表性 v4 引用长度满足钉钉 OA 不超过 128 字符、薪福通不超过 80 字符,并通过篡改、过期、跨用户、跨密钥、同密钥重启及 v2/v3 兼容测试。
+5 -5
View File
@@ -6,7 +6,7 @@
|---|---|---|---|
| 薪福通 | 差旅报销单 | 支持,有约束 | 报销总额、币种、费用明细、差旅行程;费用和行程逐项递归校验,企业模板还要求承担部门 |
| 薪福通 | 费用报销单 | 支持,有约束 | 报销总额、币种、费用明细;当前模板要求承担部门和费用说明 |
| 薪福通 | 招待费用申请单 | 支持,有约束 | 企业模板类型为 `EXPENSE_APPLY`,使用费用申请创建接口的 `applyExpenses`(费用类型编码、金额等),而不是按模板名称误用招待申请专用 DTO;写后实时核验状态 |
| 薪福通 | 招待费用申请单 | 支持,有约束 | 企业模板类型为 `EXPENSE_APPLY`,使用费用申请创建接口的 `applyExpenses`(费用类型、金额等),而不是按模板名称误用招待申请专用 DTO;费用类型名称实时查询、编码内部映射;写后实时核验状态 |
| 薪福通 | 出差费用申请单 | 支持,有约束 | 行程、城市和预计费用递归校验;实测仅有发起节点时流程即时完成并返回 `completed` |
| 钉钉 OA | 业务招待申请_北极大 | 仅客户端 | 普通字段和明细表可读取,但真实测试中新版企业创建接口返回系统异常、旧版企业接口拒绝组件格式 |
| 钉钉 OA | 出差申请单_北极大 | 仅客户端 | `DDBizSuite` 出差套件包含日程、天数、同行人及考勤联动,企业 OpenAPI 没有可靠构造协议 |
@@ -15,10 +15,10 @@
| 钉钉 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`,不会要求用户填写花名册编号或附件编号。`approval_get_submission_schema` 会返回实时 `valueFormat`、`selectionCapability` 和该模板的精简 `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",
"version": "1.1.0-rc.14",
"version": "1.1.0-rc.17",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dingtalk-approval-mcp",
"version": "1.1.0-rc.14",
"version": "1.1.0-rc.17",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0",
"express": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dingtalk-approval-mcp",
"version": "1.1.0-rc.14",
"version": "1.1.0-rc.17",
"description": "A lightweight Agent bridge for a unified DingTalk OA and XFT approval gateway.",
"private": true,
"type": "module",
+1 -1
View File
@@ -44,7 +44,7 @@ mkdirSync(out, { recursive: true, mode: 0o755 });
execFileSync("npm", ["run", "build"], { stdio: "inherit", env: npmEnvironment() });
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("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 { mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
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 { parseArgs } from "node:util";
@@ -54,6 +54,7 @@ try {
scanTree(clientRoot);
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 名称或版本不一致");
assertRelativeModuleClosure(join(root, "client", "package", "dist", "src"));
if (JSON.stringify(Object.keys(packageMetadata.bin ?? {}).sort()) !== JSON.stringify(["dingtalk-approval", "dingtalk-approval-bridge"])) {
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 (!existsFile(join(root, "client", "package", "skill", "dingtalk-approval", "SKILL.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", "create-approval.md"))
|| !existsFile(join(root, "client", "package", "skill", "dingtalk-approval", "references", "local-drafts.md"))) {
throw new Error("客户端包缺少完整 Skill、创建审批或本机草稿 reference");
}
const offlineRoot = join(root, "offline-install");
execFileSync("npm", [
@@ -80,7 +82,8 @@ try {
], { stdio: "pipe", env: { ...process.env, npm_config_update_notifier: "false" } });
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", "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("签名客户端无法在空缓存中离线安装");
}
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");
}
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) {
for (const path of walk(rootPath)) {
if (statSync(path).size > 2_000_000) continue;
+4 -2
View File
@@ -1,6 +1,6 @@
---
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
@@ -30,4 +30,6 @@ Call `approval_list_history` with `scope=handled` for approvals the current user
## 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`.
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.
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.
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.
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.
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.
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.
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`.
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 attachment field values. When the live Schema requires an upstream attachment number but the MCP has no attachment-upload tool, preserve any local draft and direct the user to complete that form in the native client. Never pass a local path, base64 content, or an invented attachment number to `approval_prepare_submission`.
@@ -0,0 +1,85 @@
# 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.
A local attachment path or file bytes are never a valid upstream attachment field value. If the live schema requires an upstream attachment number and the MCP exposes no attachment-upload tool, keep the draft and explain that this form must be completed in the native client; never send a local path, base64 file, or invented attachment number to the gateway.
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.
+56 -10
View File
@@ -10,6 +10,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import { z } from "zod";
import { APPROVAL_BATCH_LIMIT } from "./approval-contract.js";
import { isDirectEntrypoint } from "./entrypoint.js";
import { APPLICATION_VERSION } from "./version.js";
interface BridgeState {
client?: { client_id: string; client_secret?: string };
@@ -36,6 +37,8 @@ export const APPROVAL_ONBOARDING_EXAMPLES = [
"先列出待办,我确认后同意指定的几张",
] as const;
const REMOTE_WRITE_TIMEOUT_MS = 45_000;
class AuthRequired extends Error {
constructor(readonly challenge: NonNullable<BridgeState["device"]>) { super("需要钉钉登录"); }
}
@@ -82,7 +85,14 @@ export class RemoteBridge {
return this.withCallSlot(async () => {
try {
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) {
if (error instanceof AuthRequired) return this.authResult(error.challenge);
throw error;
@@ -117,7 +127,7 @@ export class RemoteBridge {
await this.tryCompleteDeviceLogin();
if (!this.usableAccessToken()) await this.refreshAccessToken();
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, {
requestInit: { headers: { authorization: `Bearer ${this.state.tokens!.access_token}` } },
});
@@ -361,6 +371,40 @@ export function bridgeAuthToolResult(result: BridgeLoginResult) {
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 {
const record = error && typeof error === "object" ? error as Record<string, unknown> : {};
const status = Number(record.status ?? record.statusCode ?? record.code);
@@ -388,8 +432,8 @@ function parseEndpoint(argv: string[]): string {
}
export function createBridgeMcpServer(bridge: RemoteBridge): McpServer {
const server = new McpServer({ name: "dingtalk-approval-stdio-bridge", version: "1.1.0-rc.13" }, {
instructions: "首次使用或登录链接过期时调用approval_login;每次调用会检查登录并在需要时生成有效的新链接,绝不因链接过期而重新安装。工具不会通过其他消息通道自动发送链接,必须把content.text中的登录URL原样写入当前面向用户的回复,不得只声称已生成或已推送。审批引用、调用容量和内部分批不得向用户解释;查看时只回答业务信息,用户明确授权后在授权范围内自动处理。写前校验失败时零写入,任何不确定结果都不得自动重试。",
const server = new McpServer({ name: "dingtalk-approval-stdio-bridge", version: APPLICATION_VERSION }, {
instructions: "首次使用或登录链接过期时调用approval_login;每次调用会检查登录并在需要时生成有效的新链接,绝不因链接过期而重新安装。工具不会通过其他消息通道自动发送链接,必须把content.text中的登录URL原样写入当前面向用户的回复,不得只声称已生成或已推送。审批引用、调用容量和内部分批不得向用户解释;查看时只回答业务信息,用户明确授权后在授权范围内自动处理。写前校验失败时零写入,任何不确定结果都不得自动重试。提单草稿仅可保存在 Agent 所在机器的当前用户私有目录,不得上传网关;恢复草稿后必须重新读取实时模板 Schema,短期提交引用不得写入草稿。薪福通所有选择型字段只向用户展示业务名称:optionSource 选项展示label,内嵌枚举展示enumLabels;用户选定label后,values中必须填同一项的code而不能填label,code只存在于工具调用内部。不得向用户索取或展示城市码、组织码、工号、档案编号、数字舱位值或其他内部ID;selectionCapability=client_only时停止构造该必填项或允许跳过该可选项。",
});
server.registerTool("approval_login", {
title: "登录审批服务", description: "检查登录;未登录或旧链接已过期时生成新的钉钉授权链接。同一身份同时用于钉钉OA和薪福通,无需重新安装。", inputSchema: {},
@@ -440,19 +484,19 @@ export function createBridgeMcpServer(bridge: RemoteBridge): McpServer {
}, annotations: { readOnlyHint: true, destructiveHint: false },
}, async (args) => bridge.call("approval_list_submission_templates", args));
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),
}, annotations: { readOnlyHint: true, destructiveHint: false },
}, async (args) => bridge.call("approval_get_submission_schema", args));
server.registerTool("approval_get_submission_options", {
title: "查询发起审批可用选项", description: "实时查询当前登录用户本人的薪福通承担部门,或搜索城市编码。", inputSchema: {
templateId: z.string().min(20).max(4096), kind: z.enum(["department", "city"]),
title: "查询发起审批可用选项", description: "实时查询当前登录用户本人的薪福通承担部门,或按名称搜索城市和费用类型。所有选项只向用户展示label;values必须填所选项的code而不能填label,code仅供工具内部提交。", inputSchema: {
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),
}, annotations: { readOnlyHint: true, destructiveHint: false },
}, async (args) => bridge.call("approval_get_submission_options", args));
const submissionInputSchema = {
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,由网关按当前登录用户实时解析并验证"),
targetSelectActioners: z.array(z.object({
actionerKey: z.string().min(1).max(500),
@@ -461,7 +505,7 @@ export function createBridgeMcpServer(bridge: RemoteBridge): McpServer {
allowDuplicateInvoices: z.boolean().default(false).describe("仅在网关已提示重复风险或查重不完整、且用户明确确认仍要提交后设为 true"),
};
server.registerTool("approval_prepare_submission", {
title: "校验并预览新审批", description: "无副作用校验全部字段并自动查重本人近30天钉钉/薪福通发票;有重复风险或查重不完整时要求用户明确确认,不会直接生成提交引用。", inputSchema: submissionInputSchema,
title: "校验并预览新审批", description: "无副作用校验全部字段并自动查重本人近30天钉钉/薪福通发票;选择项在values中填所选label对应的code,成功预览只显示业务名称;有重复风险或查重不完整时要求用户明确确认,不会直接生成提交引用。", inputSchema: submissionInputSchema,
annotations: { readOnlyHint: true, destructiveHint: false },
}, async (args) => bridge.call("approval_prepare_submission", args));
server.registerTool("approval_submit", {
@@ -502,7 +546,9 @@ export function syncBundledSkillForLegacyUpdate(
): void {
const resolvedState = resolve(statePath);
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 parent = dirname(target);
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 {
const reference = join(bundledSkillDir, "references", "create-approval.md");
if (!existsSync(join(bundledSkillDir, "SKILL.md")) || !existsSync(reference)) {
const createReference = join(bundledSkillDir, "references", "create-approval.md");
const draftReference = join(bundledSkillDir, "references", "local-drafts.md");
if (!existsSync(join(bundledSkillDir, "SKILL.md"))
|| !existsSync(createReference)
|| !existsSync(draftReference)) {
throw new Error("客户端包缺少完整 Skill 资源");
}
const parent = dirname(paths.skillDir);
+9 -7
View File
@@ -21,6 +21,7 @@ import type { OfficialOaReturnClient } from "./unified-approval.js";
import { DINGTALK_OAUTH_AUTHORIZE_ORIGIN, DingTalkMcpOAuthProvider } from "./oauth.js";
import type { AuthenticatedDingTalkUser } from "./xft.js";
import { InvoiceDeduplicationService, InvoiceDedupStore, invoiceDedupDatabasePath } from "./invoice-dedup.js";
import { APPLICATION_VERSION } from "./version.js";
type TransportEntry =
| { kind: "streamable"; transport: StreamableHTTPServerTransport; userId: string; createdAt: number; lastSeenAt: number }
@@ -40,7 +41,8 @@ export const APPROVAL_MCP_INSTRUCTIONS = [
"只有用户明确要求处理审批时,才调用 approval_process_batch;写操作不会二次确认。开始写入前会先校验整批 approvalId 和 targetId;任一引用无效则整批零写入。重新拉取待办后必须重新确认目标,禁止自动补偿或自动重试。",
"钉钉开放接口无法预检部分模板的签字等业务规则;要求手写签名时返回客户端处理链接。处理结果中 failed 表示操作被拒绝且未生效;除非用户看过原因后再次明确要求,否则不要重试。succeeded 表示节点提交成功;completed 才表示无需当前用户继续审批。uncertain/outcome_uncertain 表示已经提交但未确认,禁止自动重试,应稍后重新列出待办。followUpRequired 大于 0 时应重新列出待办并告知用户。",
"出现 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,附件必须在正式准备前重新校验;本地路径不能当作上游附件值。每次草稿操作自动清理超过 90 天未更新的普通草稿。正式提交成功或即时完成后删除;明确失败时保留;outcome_uncertain 时冻结且禁止自动重试,先实时核验本人发起记录。",
"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,它会实时校验本人已发起单据归属,但不会再次执行写操作。",
].join("\n");
@@ -56,7 +58,7 @@ export function createApprovalMcpServer(
invoiceDedup?: InvoiceDeduplicationService,
): 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 },
);
const unified = new UnifiedApprovalService(config, service, xft, xftChallenges, officialOaReturns, invoiceDedup);
@@ -170,7 +172,7 @@ export function createApprovalMcpServer(
server.registerTool("approval_get_submission_schema", {
title: "获取发起审批表单结构",
description: "使用模板列表返回的templateId实时读取结构化字段、必填状态和填写约束。Agent必须持续询问直至必填字段齐全,并主动提醒可选字段;不会返回超大的上游原始Schema。",
description: "使用模板列表返回的templateId实时读取结构化字段、必填状态、可读选项、填写约束和minimalExample。minimalExample只说明values形状,尖括号必须替换;选择项只向用户展示label/enumLabels;client_only字段不得索取内部值。Agent必须持续询问直至必填字段齐全,并主动提醒可选字段中能安全填写的业务项;不会返回超大的上游原始Schema。",
inputSchema: {
templateId: z.string().min(20).max(4096).describe("必须原样使用 approval_list_submission_templates 返回的短期签名引用"),
},
@@ -182,10 +184,10 @@ export function createApprovalMcpServer(
server.registerTool("approval_get_submission_options", {
title: "查询发起审批可用选项",
description: "使用薪福通templateId实时查询当前登录用户本人的承担部门,或按名称搜索城市编码。只返回可填写的code/label,不返回成员档案、员工号或原始上游数据。",
description: "使用薪福通templateId实时查询当前登录用户本人的承担部门,或按名称搜索城市和费用类型。所有结果只向用户展示label;用户选择后,values对应字段必须填同一项的code,不能填label。code仅留在工具调用内部,禁止向用户索取或展示。只返回可填写的code/label,不返回成员档案、员工号或原始上游数据。",
inputSchema: {
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(""),
limit: z.number().int().min(1).max(100).default(50),
},
@@ -197,7 +199,7 @@ export function createApprovalMcpServer(
const submissionInputSchema = {
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,由服务器实时解析并验证当前登录用户所属部门"),
targetSelectActioners: z.array(z.object({
actionerKey: z.string().min(1).max(500),
@@ -208,7 +210,7 @@ export function createApprovalMcpServer(
server.registerTool("approval_prepare_submission", {
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,
annotations: { readOnlyHint: true, destructiveHint: false },
}, async ({ templateId, values, deptId, targetSelectActioners, allowDuplicateInvoices }) => {
+329 -26
View File
@@ -18,6 +18,17 @@ const DINGTALK_SUBMISSION_TEMPLATES = {
"捷康_费用报销": ["差旅费用报销_捷康", "业务招待费用报销_捷康", "业务招待申请_捷康"],
} as const;
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> {
return {
status: "api_supported_with_constraints",
@@ -365,7 +376,8 @@ export class UnifiedApprovalService {
if (source !== "dingtalk") {
if (!this.xft) sourceStatus.xft = { status: "disabled" };
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({
kind: "submission_template", userId: user.userId, source: "xft", xftTemplate: template,
exp: expiresInMinutes(30),
@@ -375,7 +387,7 @@ export class UnifiedApprovalService {
kind: template.kind,
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) }; }));
}
await Promise.all(tasks);
@@ -389,11 +401,17 @@ export class UnifiedApprovalService {
const template = this.decodeSubmissionTemplate(user, templateId);
if (template.source === "xft") {
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 {
templateId,
source: "xft",
...(await this.xft.getSubmissionSchema(user, template.xftTemplate)),
...schema,
submissionCapability: capability,
};
}
@@ -435,6 +453,7 @@ export class UnifiedApprovalService {
if (template.source !== "xft" || !this.xft || !template.xftTemplate) {
throw new ApprovalSafetyError("实时提单选项查询当前仅用于薪福通模板");
}
assertXftSubmissionTemplateAllowed(template.xftTemplate);
return {
templateId,
source: "xft" as const,
@@ -454,6 +473,7 @@ export class UnifiedApprovalService {
let fingerprint: string;
if (template.source === "xft") {
if (!this.xft || !template.xftTemplate) throw new ApprovalSafetyError("薪福通提单源未启用");
assertXftSubmissionTemplateAllowed(template.xftTemplate);
const schema = await this.xft.getSubmissionSchema(user, template.xftTemplate);
validateXftSubmissionValues(template.xftTemplate.kind, input.values, schema.fields);
const identityValidation = await this.xft.validateSubmissionIdentity(user);
@@ -462,7 +482,7 @@ export class UnifiedApprovalService {
applicant: "当前登录用户(由服务器映射,不能由 Agent 指定)",
identityValidation,
submissionStatus: "APPRV_PEND",
formSummary: summarizeValues(input.values),
formSummary: await readableXftSubmissionSummary(this.xft, user, schema.template, schema.fields, schema.inputContract, input.values),
processForecast: { supported: false, message: "薪福通正式接口不提供无副作用的流程预测;提交时由上游完成最终业务校验" },
};
fingerprint = hashJson({ template: schema.template, fields: schema.fields });
@@ -548,6 +568,7 @@ export class UnifiedApprovalService {
try {
if (confirmation.source === "xft") {
if (!this.xft || !confirmation.xftTemplate) throw new ApprovalSafetyError("薪福通提单源未启用");
assertXftSubmissionTemplateAllowed(confirmation.xftTemplate);
const schema = await this.xft.getSubmissionSchema(user, confirmation.xftTemplate);
if (hashJson({ template: schema.template, fields: schema.fields }) !== confirmation.templateFingerprint) {
throw new ApprovalSafetyError("薪福通表单在确认后发生变化,请重新准备并确认");
@@ -2022,25 +2043,34 @@ function validateXftSubmissionValues(
common_reimburse: ["reimAmount", "reimAmountCurrencyCode", "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 missingCustom = missingRequiredXftCustomFields(customFields, values.customFieldValues);
if (missingBusiness.length || missingCustom.length) {
const businessLabels: Record<string, string> = {
businessTrip: "差旅行程", applyExpenses: "事前申请费用", entertainmentApplyApiCreateDTO: "招待信息",
useCarInfo: "用车信息", reimAmount: "报销总金额", reimAmountCurrencyCode: "报销币种", expenseList: "费用明细",
};
const parts = [
...(missingBusiness.length ? [`业务字段:${missingBusiness.join("、")}`] : []),
...(missingCustom.length ? [`自定义字段:${missingCustom.map((field) => `${field.name}(${field.fieldNumber})`).join("、")}`] : []),
...(missingBusiness.length ? [`业务字段:${missingBusiness.map((key) => businessLabels[key] ?? key).join("、")}`] : []),
...(missingCustom.length ? [`自定义字段:${missingCustom.map((field) => field.name).join("、")}`] : []),
];
throw new ApprovalSafetyError(`薪福通提单缺少必填${parts.join(";")}`);
}
const hasBillCompany = !isBlankSubmissionValue(values.billCompanyCode);
const hasBillDepartment = !isBlankSubmissionValue(values.billDepartmentCode);
if (hasBillCompany !== hasBillDepartment) {
throw new ApprovalSafetyError("薪福通单据公司 billCompanyCode 和单据部门 billDepartmentCode 必须同时填写或同时省略");
if (hasBillCompany || hasBillDepartment) {
throw new ApprovalSafetyError("单据公司和部门由薪福通按当前登录用户填写,不应向用户索取或由 Agent 指定");
}
validateRequiredXftCustomFields(customFields, values.customFieldValues);
validateXftCustomFieldValues(customFields, values.customFieldValues);
if (kind === "common_reimburse" || kind === "travel_reimburse") {
requirePositiveDecimal(values.reimAmount, "报销总金额 reimAmount");
if (values.reimAmountCurrencyCode !== "CNY") throw new ApprovalSafetyError("报销币种 reimAmountCurrencyCode 目前只支持 CNY");
if (values.reimAmountCurrencyCode !== "CNY") throw new ApprovalSafetyError("报销币种目前只支持人民币");
validateXftExpenses(values.expenseList);
}
if (kind === "travel_apply" || kind === "travel_reimburse") validateXftBusinessTrip(values.businessTrip);
@@ -2048,11 +2078,45 @@ function validateXftSubmissionValues(
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 {
if (!Array.isArray(value) || !value.length) throw new ApprovalSafetyError("事前申请费用 applyExpenses 至少需要 1 条");
value.forEach((item, index) => {
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`);
if (!isBlankSubmissionValue(item.participantNum)
&& (!Number.isSafeInteger(Number(item.participantNum)) || Number(item.participantNum) <= 0)) {
@@ -2080,7 +2144,7 @@ function validateRequiredXftCustomFields(
): void {
const missing = missingRequiredXftCustomFields(fields, value);
if (missing.length) {
throw new ApprovalSafetyError(`薪福通提单缺少必填自定义字段:${missing.map((field) => `${field.name}(${field.fieldNumber})`).join("、")}`);
throw new ApprovalSafetyError(`薪福通提单缺少必填自定义字段:${missing.map((field) => field.name).join("、")}`);
}
}
@@ -2106,17 +2170,20 @@ function validateXftCustomFieldValues(fields: XftSubmissionField[], value: unkno
if (!field) throw new ApprovalSafetyError(`薪福通自定义字段编码“${fieldNumber}”不在当前实时表单中`);
if (seen.has(fieldNumber)) throw new ApprovalSafetyError(`薪福通自定义字段“${field.name}(${fieldNumber})”重复`);
seen.add(fieldNumber);
if (field.selectionCapability?.status === "client_only") {
throw new ApprovalSafetyError(`“${field.name}”当前无法通过 MCP 按业务名称安全填写,请在薪福通客户端完成`);
}
if (typeof raw.fieldValue !== "string") throw new ApprovalSafetyError(`薪福通自定义字段“${field.name}”的 fieldValue 必须是字符串;数组和表格需传 JSON 字符串`);
const type = field.type.toUpperCase();
if (type.includes("TABLE")) validateXftTableField(field, 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 日期时间格式`);
}
if (type.includes("PERSON") || type.includes("EMP") || type.includes("DEPARTMENT") || type.includes("ORG")) {
if (type.includes("DEPARTMENT") || type.includes("ORG")) {
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())) {
throw new ApprovalSafetyError(`薪福通自定义字段“${field.name}”必须是非空编码组成的 JSON 数组字符串`);
throw new ApprovalSafetyError(`薪福通字段“${field.name}”的部门选择格式无效,请重新查询并选择部门名称`);
}
}
});
@@ -2134,6 +2201,10 @@ function validateXftTableField(field: XftSubmissionField, value: string): void {
if (!isSubmissionRecord(column) || isBlankSubmissionValue(column.titleCode) || isBlankSubmissionValue(column.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 +2213,10 @@ function validateXftExpenses(value: unknown): void {
if (!Array.isArray(value) || value.length === 0) throw new ApprovalSafetyError("费用明细 expenseList 至少需要一笔");
value.forEach((item, index) => {
if (!isSubmissionRecord(item)) throw new ApprovalSafetyError(`第 ${index + 1} 笔费用必须是对象`);
for (const key of ["expTypeCode", "currencyCode", "originalCurrencyReimAmt"] as const) {
if (isBlankSubmissionValue(item[key])) throw new ApprovalSafetyError(`第 ${index + 1} 笔费用缺少 ${key}`);
}
if (item.currencyCode !== "CNY") throw new ApprovalSafetyError(`第 ${index + 1} 笔费用币种目前只支持 CNY`);
if (isBlankSubmissionValue(item.expTypeCode)) throw new ApprovalSafetyError(`第 ${index + 1} 笔费用缺少费用类型,请先查询并选择可用名称`);
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} 笔费用币种目前只支持人民币`);
requirePositiveDecimal(item.originalCurrencyReimAmt, `第 ${index + 1} 笔费用金额 originalCurrencyReimAmt`);
if (item.wrTim !== undefined) requireDateString(item.wrTim, `第 ${index + 1} 笔消费日期 wrTim`, /^\d{4}-\d{2}-\d{2}$/);
});
@@ -2157,27 +2228,27 @@ function validateXftBusinessTrip(value: unknown): void {
}
value.businessTripDetails.forEach((item, index) => {
if (!isSubmissionRecord(item)) throw new ApprovalSafetyError(`第 ${index + 1} 段行程必须是对象`);
for (const key of ["departCityCode", "destinationCityCode", "endTime"] as const) {
if (isBlankSubmissionValue(item[key])) throw new ApprovalSafetyError(`第 ${index + 1} 段行程缺少 ${key}`);
}
if (isBlankSubmissionValue(item.departCityCode)) throw new ApprovalSafetyError(`第 ${index + 1} 段行程缺少出发城市`);
if (isBlankSubmissionValue(item.destinationCityCode)) throw new ApprovalSafetyError(`第 ${index + 1} 段行程缺少目的城市`);
if (isBlankSubmissionValue(item.endTime)) throw new ApprovalSafetyError(`第 ${index + 1} 段行程缺少结束时间`);
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(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 (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) {
const modes = String(item.travelMode).split(",").map((mode) => mode.trim()).filter(Boolean);
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 (!Array.isArray(item.estimateExpenses)) throw new ApprovalSafetyError(`第 ${index + 1} 段行程 estimateExpenses 必须是数组`);
item.estimateExpenses.forEach((expense, expenseIndex) => {
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`);
});
@@ -2359,6 +2430,238 @@ function summarizeValue(value: unknown): unknown {
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 {
if (typeof value === "string") return 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.17";
+566 -116
View File
@@ -80,6 +80,9 @@ const XFT_MANAGED_SUBMISSION_FIELDS = new Set([
const XFT_STANDARD_FORM_FIELD_NUMBERS = new Set(["billCompanyCode", "billDepartmentCode"]);
const CITY_OPTIONS_TIMEOUT_MS = 45_000;
const CITY_OPTIONS_CACHE_MS = 24 * 60 * 60 * 1000;
const EXPENSE_TYPE_SOURCE_PAGE_SIZE = 100;
const EXPENSE_TYPE_SOURCE_MAX_PAGES = 5;
const EXPENSE_TYPE_HINT = /费用\s*(?:类型|类别|项目)|(?:类型|类别|项目)\s*费用|费目|expense[\s_-]*type/i;
/** Official XFT OpenAPI adapter. It never accepts an empId from an Agent. */
export class XftOpenApiClient implements XftApprovalProvider {
@@ -247,13 +250,27 @@ export class XftOpenApiClient implements XftApprovalProvider {
items: XftApprovalBatchItem[],
) {
const identity = await this.resolveIdentity(user);
const results: XftApprovalBatchResultItem[] = [];
for (const item of items) {
const results: Array<XftApprovalBatchResultItem | undefined> = Array.from({ length: items.length }, () => undefined);
const candidates: Array<{ index: number; item: XftApprovalBatchItem }> = [];
for (const [index, item] of items.entries()) {
if (action === "return" && item.targetNodeId !== "OFFICIAL_DEFAULT") {
results[index] = {
billId: item.billId,
status: "failed",
success: false,
failureConfirmed: true,
error: "薪福通正式接口仅支持按当前流程规则退回,请重新获取可执行动作",
};
} else candidates.push({ index, item });
}
const accepted: Array<{ index: number; item: XftApprovalBatchItem }> = [];
for (const { index, item } of candidates) {
if (results[index]) continue;
let writeStarted = false;
try {
if (action === "return" && item.targetNodeId !== "OFFICIAL_DEFAULT") {
throw new ApprovalSafetyError("薪福通正式接口仅支持按当前流程规则退回,请重新获取可执行动作");
}
// Keep ownership validation immediately adjacent to every write. Only
// the slow post-write transition polling is shared by the batch.
await this.requireCurrentTodo(identity, item.billId);
writeStarted = true;
await this.request(ACTION_ENDPOINTS[action], {
@@ -264,47 +281,67 @@ export class XftOpenApiClient implements XftApprovalProvider {
}],
empId: identity.enterpriseUserId,
}, "POST", identity);
const stillPending = await this.waitForTodoTransition(identity, item.billId);
if (stillPending) {
results.push({
accepted.push({ index, item });
} catch (error) {
results[index] = writeStarted && isUncertainXftWriteError(error) ? {
billId: item.billId,
status: "outcome_uncertain",
success: false,
outcomeUncertain: true,
error: `${errorMessage(error)};写请求可能已经到达薪福通,禁止自动重试`,
} : {
billId: item.billId,
status: "failed",
success: false,
failureConfirmed: true,
error: errorMessage(error),
};
}
}
if (accepted.length > 0) {
try {
const stillPending = await this.waitForTodoTransitions(
identity,
new Set(accepted.map(({ item }) => item.billId)),
);
for (const { index, item } of accepted) {
results[index] = stillPending.has(item.billId) ? {
billId: item.billId,
status: "outcome_uncertain",
success: false,
outcomeUncertain: true,
error: "薪福通已接收操作,但单据仍在该用户实时待办中;禁止自动重试,请稍后重新查询待办",
});
continue;
} : { billId: item.billId, status: "succeeded", success: true };
}
results.push({ billId: item.billId, status: "succeeded", success: true });
} catch (error) {
if (writeStarted && isUncertainXftWriteError(error)) {
results.push({
billId: item.billId,
status: "outcome_uncertain",
success: false,
outcomeUncertain: true,
error: `${errorMessage(error)};写请求可能已经到达薪福通,禁止自动重试`,
});
} else {
results.push({
billId: item.billId,
status: "failed",
success: false,
failureConfirmed: true,
error: errorMessage(error),
});
}
for (const { index, item } of accepted) results[index] = {
billId: item.billId,
status: "outcome_uncertain",
success: false,
outcomeUncertain: true,
error: `薪福通已接收操作,但实时状态复核失败:${errorMessage(error)};禁止自动重试`,
};
}
}
const completed = results.map((result, index) => result ?? ({
billId: items[index]!.billId,
status: "failed" as const,
success: false as const,
failureConfirmed: true as const,
error: "薪福通未返回该单据的处理结果",
}));
return {
action,
total: results.length,
succeeded: results.filter((item) => item.success).length,
failed: results.filter((item) => !item.success).length,
results,
total: completed.length,
succeeded: completed.filter((item) => item.success).length,
failed: completed.filter((item) => !item.success).length,
results: completed,
diagnostics: {
source: "xft_official_openapi",
realtimeOwnershipValidation: true,
batchedPostWriteVerification: true,
transactional: false,
persistedBusinessData: false,
},
@@ -326,7 +363,7 @@ export class XftOpenApiClient implements XftApprovalProvider {
for (const item of list) {
if (stringAt(item, ["eventStatus"]) !== "1") continue;
const template = normalizeSubmissionTemplate(item);
if (!template || !filterQuery([template], search).length) continue;
if (!template || template.kind === "use_car_apply" || !filterQuery([template], search).length) continue;
output.push(template);
if (output.length >= limit) break;
}
@@ -369,13 +406,13 @@ export class XftOpenApiClient implements XftApprovalProvider {
limit = 50,
) {
const identity = await this.resolveIdentity(user);
await this.requireSubmissionTemplate(identity, template);
const current = await this.requireSubmissionTemplate(identity, template);
let options: XftSubmissionOption[];
let cacheHit = false;
if (kind === "department") {
const member = await this.requireCurrentMember(identity);
options = extractDepartmentOptions(member);
} else {
} else if (kind === "city") {
if (this.cityOptionsCache && Date.now() - this.cityOptionsCache.fetchedAt < CITY_OPTIONS_CACHE_MS) {
options = this.cityOptionsCache.items;
cacheHit = true;
@@ -390,13 +427,21 @@ export class XftOpenApiClient implements XftApprovalProvider {
options = extractCityOptions(cities);
this.cityOptionsCache = { fetchedAt: Date.now(), items: options };
}
} else {
options = await this.loadExpenseTypeOptions(identity, current);
if (!options.length) {
throw new ApprovalSafetyError("暂时无法实时获取可选费用类型,请稍后重试或在薪福通客户端完成");
}
}
const filtered = query.trim()
? options.filter((item) => `${item.label}\n${item.code}`.toLowerCase().includes(query.trim().toLowerCase()))
: options;
const boundedLimit = Math.min(100, Math.max(1, limit));
return {
kind,
items: filtered.slice(0, Math.min(100, Math.max(1, limit))),
items: filtered.slice(0, boundedLimit),
total: filtered.length,
hasMore: filtered.length > boundedLimit,
diagnostics: {
source: "xft_official_openapi",
realtime: !cacheHit,
@@ -407,6 +452,84 @@ export class XftOpenApiClient implements XftApprovalProvider {
};
}
private async loadExpenseTypeOptions(
identity: XftIdentity,
template: XftSubmissionTemplate,
): Promise<XftSubmissionOption[]> {
let customFieldError: unknown;
try {
const rawFields = await this.request(
`/itrip/xft-api/v1/form/customFields/eventSeqId/${encodeURIComponent(template.eventSeqId)}`,
undefined,
"GET",
identity,
);
const embedded = extractExpenseTypeOptions(rawFields);
if (embedded.length) return embedded;
} catch (error) {
customFieldError = error;
// Some templates do not expose standard expense dictionaries in custom fields.
// Fall through to the enterprise multilevel data-source APIs.
}
const candidates: Array<{ id: string; raw: JsonObject }> = [];
let sourceCount = 0;
for (let page = 0; page < EXPENSE_TYPE_SOURCE_MAX_PAGES; page += 1) {
const start = page * EXPENSE_TYPE_SOURCE_PAGE_SIZE;
const response = await this.request(
`/itrip/xft-api/v1/dataSource/findMultilevelSourcePage?start=${start}&limit=${EXPENSE_TYPE_SOURCE_PAGE_SIZE}`,
{},
"POST",
identity,
);
const records = pagedRecords(response);
sourceCount += records.length;
for (const record of records) {
const name = stringAt(record, [
"selectSourceName", "sourceName", "dataSourceName", "multilevelSourceName", "displayName", "name", "title",
]);
const id = stringAt(record, ["seqId", "sourceSeqId", "dataSourceSeqId", "multilevelSourceSeqId"]);
if (id && name && EXPENSE_TYPE_HINT.test(name)) candidates.push({ id, raw: record });
}
if (records.length < EXPENSE_TYPE_SOURCE_PAGE_SIZE) break;
}
if (!candidates.length) {
if (customFieldError) throw customFieldError;
const sourceResult = sourceCount === 0
? "企业多级数据源列表为空"
: `企业多级数据源共返回 ${sourceCount} 项,但没有费用类型数据源`;
throw new ApprovalSafetyError(
`薪福通费用类型查询没有返回可用选项:提单实时字段未提供内嵌选项,且${sourceResult}。`
+ "薪福通正式 OpenAPI 未提供独立的费用类型目录查询,请在费用管理页面确认配置或使用薪福通客户端完成",
);
}
const output: XftSubmissionOption[] = [];
const detailErrors: unknown[] = [];
for (const candidate of candidates.slice(0, 10)) {
output.push(...extractExpenseTypeOptions(candidate.raw, true));
try {
const detail = await this.request(
`/itrip/xft-api/v1/dataSource/getMultiLevelDetail/${encodeURIComponent(candidate.id)}`,
undefined,
"GET",
identity,
);
output.push(...extractExpenseTypeOptions(detail, true));
} catch (error) {
detailErrors.push(error);
// Continue with other matching sources. Empty output fails closed below.
}
}
if (!output.length && detailErrors.length) throw detailErrors[0];
if (!output.length) {
throw new ApprovalSafetyError(
`薪福通费用类型查询没有返回可用选项:已找到 ${candidates.length} 个费用类型数据源,但详情中没有可选择的名称与编码`,
);
}
return uniqueOptions(output);
}
async submitApproval(
user: AuthenticatedDingTalkUser,
template: XftSubmissionTemplate,
@@ -897,12 +1020,24 @@ export class XftOpenApiClient implements XftApprovalProvider {
return item;
}
private async waitForTodoTransition(identity: XftIdentity, billId: string): Promise<CurrentTodo | undefined> {
private async findCurrentTodoIds(identity: XftIdentity, billIds: Set<string>): Promise<Set<string>> {
const found = new Set<string>();
if (billIds.size === 0) return found;
const pageSize = 1000;
for (let currentPage = 1; currentPage <= 100; currentPage += 1) {
const page = await this.loadTodoPage(identity, currentPage, pageSize);
for (const item of page.items) if (billIds.has(item.billId)) found.add(item.billId);
if (found.size === billIds.size || page.pageRecordCount < pageSize) return found;
}
throw new ApprovalSafetyError("薪福通待办超过安全分页上限,无法确认单据归属");
}
private async waitForTodoTransitions(identity: XftIdentity, billIds: Set<string>): Promise<Set<string>> {
const deadline = Date.now() + 20_000;
let current = await this.findCurrentTodo(identity, billId);
while (current && Date.now() < deadline) {
let current = await this.findCurrentTodoIds(identity, billIds);
while (current.size > 0 && Date.now() < deadline) {
await new Promise<void>((resolve) => setTimeout(resolve, 1_000));
current = await this.findCurrentTodo(identity, billId);
current = await this.findCurrentTodoIds(identity, billIds);
}
return current;
}
@@ -963,30 +1098,31 @@ export class XftOpenApiClient implements XftApprovalProvider {
signal: AbortSignal.timeout(timeoutMs),
});
} catch (error) {
throw new XftApiError(`请求薪福通正式接口失败:${errorMessage(error)}`, 0, "TRANSPORT_ERROR");
throw xftTransportError(error, pathname, "request", timeoutMs);
}
let text: string;
try { text = await response.text(); }
catch (error) {
throw new XftApiError(`读取薪福通响应失败:${errorMessage(error)}`, 0, "RESPONSE_STREAM_ERROR");
throw xftTransportError(error, pathname, "response", timeoutMs);
}
const operation = xftOperationLabel(pathname);
let parsed: unknown;
try {
const plaintext = this.api.encrypted && /^[0-9a-fA-F]+$/.test(text) && text.length % 2 === 0
? String(sm4.decrypt(text, this.api.authoritySecret.slice(0, 32)))
: text;
parsed = plaintext ? JSON.parse(plaintext) : {};
} catch { throw new XftApiError(`薪福通正式接口返回解密或 JSON 解析失败(HTTP ${response.status})`, response.status); }
parsed = this.decryptEnvelope(parsed);
if (!response.ok) throw new XftApiError(apiMessage(parsed) ?? `薪福通接口 HTTP ${response.status}`, response.status, apiCode(parsed));
} catch { throw new XftApiError(`薪福通${operation}返回解密或 JSON 解析失败(HTTP ${response.status})`, response.status); }
parsed = this.decryptEnvelope(parsed, operation);
if (!response.ok) throw xftResponseError(parsed, response.status, operation);
const code = apiCode(parsed);
if (!isXftSuccessCode(code)) {
throw new XftApiError(apiMessage(parsed) ?? `薪福通接口错误 ${code}`, response.status, code);
throw xftResponseError(parsed, response.status, operation);
}
return unwrapData(parsed);
}
private decryptEnvelope(value: unknown): unknown {
private decryptEnvelope(value: unknown, operation: string): unknown {
if (!this.api.encrypted || !isObject(value)) return value;
const encrypted = typeof value.secretMsg === "string" ? value.secretMsg
: isObject(value.data) && typeof value.data.secretMsg === "string" ? value.data.secretMsg
@@ -996,11 +1132,114 @@ export class XftOpenApiClient implements XftApprovalProvider {
const plaintext = sm4.decrypt(encrypted, this.api.authoritySecret.slice(0, 32));
return JSON.parse(String(plaintext));
} catch {
throw new XftApiError("薪福通响应解密失败,请核对应用加密配置和 AuthoritySecret", 502, "DECRYPT_FAILED");
throw new XftApiError(`薪福通${operation}响应解密失败,请核对应用加密配置和 AuthoritySecret`, 502, "DECRYPT_FAILED");
}
}
}
type XftTransportPhase = "request" | "response";
interface XftTransportDiagnostic {
operation: string;
phase: XftTransportPhase;
category: string;
causeName?: string;
causeCode?: string;
timeoutMs: number;
}
function xftTransportError(error: unknown, path: string, phase: XftTransportPhase, timeoutMs: number): XftApiError {
const operation = xftOperationLabel(path);
const diagnostic = xftTransportDiagnostic(error, operation, phase, timeoutMs);
// Keep logs useful without recording URLs, query parameters, identities, request bodies, or upstream payloads.
console.error("[dingtalk-approval-mcp] XFT transport failure", diagnostic);
const technical = [
diagnostic.causeCode ? `底层错误码 ${diagnostic.causeCode}` : undefined,
diagnostic.causeName && diagnostic.causeName !== "TypeError" ? `错误类型 ${diagnostic.causeName}` : undefined,
`超时上限 ${timeoutMs}ms`,
].filter(Boolean).join(";");
const prefix = phase === "request" ? "请求" : "读取响应";
const code = phase === "request" ? "TRANSPORT_ERROR" : "RESPONSE_STREAM_ERROR";
return new XftApiError(`薪福通${operation}${prefix}失败:${diagnostic.category}(${technical})`, 0, code);
}
function xftTransportDiagnostic(
error: unknown,
operation: string,
phase: XftTransportPhase,
timeoutMs: number,
): XftTransportDiagnostic {
const chain = errorDiagnosticChain(error);
const causeCode = chain.map((item) => safeDiagnosticCode(item.code)).find(Boolean);
const names = chain.map((item) => safeDiagnosticName(item.name)).filter((name): name is string => Boolean(name));
const causeName = names.find((name) => name !== "Error" && name !== "TypeError")
?? names.find((name) => name !== "Error");
const markers = `${causeCode ?? ""} ${causeName ?? ""}`.toUpperCase();
let category = phase === "response" ? "响应流在读取过程中中断" : "网络传输失败";
if (/TIMEOUT|ETIMEDOUT/.test(markers)) category = phase === "response" ? "读取响应超时" : "连接或等待响应超时";
else if (/ENOTFOUND|EAI_AGAIN|DNS/.test(markers)) category = "DNS 解析失败";
else if (/ECONNREFUSED/.test(markers)) category = "连接被拒绝";
else if (/ECONNRESET|UND_ERR_SOCKET|EPIPE/.test(markers)) category = "网络连接被重置或中断";
else if (/ENETUNREACH|EHOSTUNREACH/.test(markers)) category = "目标网络或主机不可达";
else if (/CERT|TLS|SSL|UNABLE_TO_VERIFY|SELF_SIGNED/.test(markers)) category = "TLS 或证书校验失败";
else if (/ABORT/.test(markers)) category = "请求被取消或超过等待时间";
return { operation, phase, category, ...(causeName ? { causeName } : {}), ...(causeCode ? { causeCode } : {}), timeoutMs };
}
function errorDiagnosticChain(error: unknown): Array<Record<string, unknown>> {
const output: Array<Record<string, unknown>> = [];
const pending: unknown[] = [error];
const seen = new Set<object>();
while (pending.length && output.length < 8) {
const current = pending.shift();
if ((!current || typeof current !== "object") && typeof current !== "function") continue;
if (seen.has(current as object)) continue;
seen.add(current as object);
const record = current as Record<string, unknown>;
output.push(record);
if (record.cause) pending.push(record.cause);
if (Array.isArray(record.errors)) pending.push(...record.errors);
}
return output;
}
function safeDiagnosticCode(value: unknown): string | undefined {
return typeof value === "string" && /^[A-Z][A-Z0-9_]{1,63}$/.test(value) ? value : undefined;
}
function safeDiagnosticName(value: unknown): string | undefined {
return typeof value === "string" && /^[A-Za-z][A-Za-z0-9]{0,63}(?:Error|Exception)$/.test(value) ? value : undefined;
}
function xftResponseError(value: unknown, status: number, operation: string): XftApiError {
const code = apiCode(value);
const visibleCode = safeXftApiCode(code);
const metadata = [`操作:${operation}`, `HTTP ${status}`, ...(visibleCode ? [`薪福通错误码 ${visibleCode}`] : [])].join(";");
return new XftApiError(`${apiMessage(value) ?? "薪福通接口返回业务错误"}(${metadata})`, status, code);
}
function safeXftApiCode(value: string | undefined): string | undefined {
return value && /^[A-Za-z0-9_.:-]{1,64}$/.test(value) ? value : undefined;
}
function xftOperationLabel(path: string): string {
if (path.includes("user-mapping")) return "用户身份映射";
if (path.includes("member/get/by-id")) return "当前用户成员档案";
if (path.includes("todo-task/page/by-user")) return "待办或已处理列表";
if (path.includes("todo-task/detail/by-ids")) return "待办任务详情";
if (path.endsWith("/bill-list")) return "单据摘要";
if (path.includes("/form/query-event-list")) return "提单模板列表";
if (path.includes("/form/customFields/eventSeqId/")) return "提单实时字段";
if (path.includes("/dataSource/findMultilevelSourcePage")) return "费用类型数据源列表";
if (path.includes("/dataSource/getMultiLevelDetail/")) return "费用类型数据源详情";
if (path.includes("/common/city/getAllCity")) return "城市选项";
if (path.includes("/approve/")) return "审批写操作";
if (path.includes("/create")) return "审批发起";
if (path.includes("/detail") || path.includes("/taskHistory/")) return "审批详情";
if (path.includes("/queryBillByCondition")) return "本人发起记录";
return "业务接口";
}
function isUncertainXftWriteError(error: unknown): boolean {
return !(error instanceof ApprovalSafetyError)
&& (!(error instanceof XftApiError)
@@ -1258,6 +1497,41 @@ function extractCityOptions(value: unknown): XftSubmissionOption[] {
return uniqueOptions(candidates);
}
function extractExpenseTypeOptions(value: unknown, trustedExpenseSource = false): XftSubmissionOption[] {
const candidates: XftSubmissionOption[] = [];
const visit = (current: unknown, path: string[], inheritedHint: boolean, depth: number): void => {
if (depth > 12) return;
if (Array.isArray(current)) {
for (const item of current) visit(item, path, inheritedHint, depth + 1);
return;
}
if (!isObject(current)) return;
const record = current;
const recordHint = EXPENSE_TYPE_HINT.test(stringAt(record, [
"fieldName", "name", "label", "title", "sourceName", "dataSourceName", "multilevelSourceName",
]) ?? "");
const pathHint = path.some((part) => EXPENSE_TYPE_HINT.test(part));
const inExpenseContext = trustedExpenseSource || inheritedHint || recordHint || pathHint;
if (inExpenseContext && record.selectable !== false && record.canSelect !== false && record.disabled !== true) {
const explicitCode = stringAt(record, [
"expenseNumber", "expenseTypeNumber", "expTypeCode", "expenseTypeCode", "expenseTypeNo", "expTypeNo",
]);
const code = explicitCode ?? (path.length > 0 ? stringAt(record, [
"optionCode", "optionNumber", "itemCode", "itemNumber", "nodeCode", "nodeNumber", "dataCode", "dataNumber",
"sourceOptionCode", "sourceOptionNumber", "code", "number", "value", "key", "seqId",
]) : undefined);
const label = stringAt(record, [
"expenseName", "expenseTypeName", "expTypeName", "optionName", "itemName", "nodeName", "dataName",
"sourceOptionName", "selectOptionName", "label", "text", "displayName", "name", "title",
]);
if (code && label) candidates.push({ code, label });
}
for (const [key, child] of Object.entries(record)) visit(child, [...path, key], inExpenseContext, depth + 1);
};
visit(value, [], trustedExpenseSource, 0);
return uniqueOptions(candidates);
}
function visitObjects(
value: unknown,
visitor: (record: JsonObject, path: string[]) => void,
@@ -1411,6 +1685,11 @@ function normalizeSubmissionField(value: JsonObject): XftSubmissionField[] {
const name = stringAt(value, ["name"]);
const type = stringAt(value, ["type"]);
if (!fieldNumber || !name || !type || value.isDisplay === false) return [];
const options = Array.isArray(value.options) ? normalizeSubmissionOptions(value.options) : [];
const normalizedType = type.toUpperCase();
const optionSource = isExpenseTypeField(fieldNumber, name) ? expenseTypeOptionSource()
: normalizedType.includes("DEPARTMENT") || normalizedType.includes("ORG") ? departmentOptionSource()
: /城市|city/i.test(`${fieldNumber} ${name}`) ? cityOptionSource() : undefined;
return [{
fieldNumber,
name,
@@ -1418,38 +1697,104 @@ function normalizeSubmissionField(value: JsonObject): XftSubmissionField[] {
required: value.required === true,
...(typeof value.description === "string" && value.description.trim() ? { description: value.description.trim() } : {}),
...(typeof value.multiple === "boolean" ? { multiple: value.multiple } : {}),
...(Array.isArray(value.options) ? { options: value.options.map((option) => sanitizeOption(option)).filter((option) => option !== undefined) } : {}),
...(options.length ? { options } : {}),
...(typeof value.moduleType === "string" && value.moduleType ? { moduleType: value.moduleType } : {}),
...submissionFieldShape(value, type),
...(optionSource ? { optionSource } : {}),
...submissionFieldShape(value, type, options, optionSource),
}];
}
function submissionFieldShape(value: JsonObject, type: string): Pick<XftSubmissionField, "valueFormat" | "itemFields"> {
function isExpenseTypeField(fieldNumber: string, name: string): boolean {
return EXPENSE_TYPE_HINT.test(name) || /^(?:expenseNumber|expenseTypeNumber|expTypeCode)$/i.test(fieldNumber);
}
function expenseTypeOptionSource() {
return { tool: "approval_get_submission_options" as const, kind: "expense_type" as const };
}
function cityOptionSource() {
return { tool: "approval_get_submission_options" as const, kind: "city" as const };
}
function departmentOptionSource() {
return { tool: "approval_get_submission_options" as const, kind: "department" as const };
}
const LABEL_ONLY_SELECTION = {
status: "supported" as const,
userInput: "label" as const,
submitValue: "code" as const,
exposeInternalValue: false as const,
};
function clientOnlySelection(reason: string) {
return { status: "client_only" as const, reason };
}
function submissionFieldShape(
value: JsonObject,
type: string,
options: XftSubmissionOption[],
optionSource?: ReturnType<typeof expenseTypeOptionSource> | ReturnType<typeof cityOptionSource> | ReturnType<typeof departmentOptionSource>,
): Pick<XftSubmissionField, "valueFormat" | "itemFields" | "selectionCapability"> {
const normalized = type.toUpperCase();
if (normalized.includes("TABLE")) {
const candidates = [value.columns, value.children, value.fieldList, value.tableColumns]
.find(Array.isArray) as unknown[] | undefined;
const itemFields = (candidates ?? []).filter(isObject).flatMap((column) => {
const titleCode = stringAt(column, ["titleCode", "fieldNumber", "fieldCode", "code"]);
const label = stringAt(column, ["name", "title", "label"]);
const columnType = stringAt(column, ["type", "fieldType"]);
if (!titleCode) return [];
return [{ titleCode, ...(label ? { label } : {}), ...(columnType ? { type: columnType } : {}), required: column.required === true }];
});
const itemFields = (candidates ?? []).filter(isObject).flatMap(normalizeSubmissionTableColumn);
return {
valueFormat: "JSON array: [{sequence:1,columnValues:[{titleCode,value}]}]",
...(itemFields.length ? { itemFields } : {}),
};
}
if (normalized.includes("ATTACH") || normalized.includes("ACCESSOR")) return { valueFormat: "附件编号字符串(须先通过薪福通附件接口取得)" };
if (normalized.includes("PERSON") || normalized.includes("EMP")) return { valueFormat: "薪福通花名册编号 JSON 数组" };
if (normalized.includes("DEPARTMENT") || normalized.includes("ORG")) return { valueFormat: "薪福通组织编码 JSON 数组" };
if (normalized.includes("ATTACH") || normalized.includes("ACCESSOR")) return {
valueFormat: "当前 MCP 无法安全上传附件,请在薪福通客户端补充",
selectionCapability: clientOnlySelection("当前 MCP 没有薪福通附件上传能力"),
};
if (normalized.includes("PERSON") || normalized.includes("EMP")) return {
valueFormat: "当前 MCP 无法按姓名安全选择人员,请在薪福通客户端补充",
selectionCapability: clientOnlySelection("正式接口需要内部花名册值,但当前没有按姓名查询人员的安全接口"),
};
if (normalized.includes("DEPARTMENT") || normalized.includes("ORG")) return {
valueFormat: "向用户展示部门名称,Agent 内部使用所选项",
selectionCapability: LABEL_ONLY_SELECTION,
};
if (normalized === "DATE") return { valueFormat: "yyyy-MM-dd HH:mm" };
if (normalized.includes("SELECT")) return { valueFormat: "使用 options 中的编码" };
if (normalized.includes("SELECT")) {
if (options.length || optionSource) return {
valueFormat: "向用户展示选项名称,Agent 内部使用所选项",
selectionCapability: LABEL_ONLY_SELECTION,
};
return {
valueFormat: "当前模板未返回可读选项,请在薪福通客户端补充",
selectionCapability: clientOnlySelection("模板没有返回可验证的名称与内部值映射"),
};
}
if (normalized.includes("INPUT")) return { valueFormat: "文本字符串" };
return {};
}
function normalizeSubmissionTableColumn(column: JsonObject): Array<Record<string, unknown>> {
const titleCode = stringAt(column, ["titleCode", "fieldNumber", "fieldCode", "code"]);
const label = stringAt(column, ["name", "title", "label"]);
const type = stringAt(column, ["type", "fieldType"]);
if (!titleCode) return [];
const options = Array.isArray(column.options) ? normalizeSubmissionOptions(column.options) : [];
const normalizedType = (type ?? "").toUpperCase();
const optionSource = isExpenseTypeField(titleCode, label ?? "") ? expenseTypeOptionSource()
: normalizedType.includes("DEPARTMENT") || normalizedType.includes("ORG") ? departmentOptionSource()
: /城市|city/i.test(`${titleCode} ${label ?? ""}`) ? cityOptionSource() : undefined;
return [{
titleCode,
...(label ? { label } : {}),
...(type ? { type } : {}),
required: column.required === true,
...(options.length ? { options } : {}),
...(optionSource ? { optionSource } : {}),
...submissionFieldShape(column, type ?? "", options, optionSource),
}];
}
function deduplicateSubmissionFields(fields: XftSubmissionField[]): XftSubmissionField[] {
const seen = new Set<string>();
return fields.filter((field) => {
@@ -1462,13 +1807,16 @@ function deduplicateSubmissionFields(fields: XftSubmissionField[]): XftSubmissio
});
}
function sanitizeOption(value: unknown): unknown {
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
function sanitizeOption(value: unknown): XftSubmissionOption | undefined {
if (!isObject(value)) return undefined;
const label = stringAt(value, ["label", "name", "value", "optionName"]);
const code = stringAt(value, ["code", "number", "optionCode", "key"]);
if (!label && !code) return undefined;
return { ...(label ? { label } : {}), ...(code ? { code } : {}) };
const label = stringAt(value, ["label", "name", "optionName", "displayName", "value"]);
const code = stringAt(value, ["code", "number", "optionCode", "key", "value"]);
if (!label || !code) return undefined;
return { label, code };
}
function normalizeSubmissionOptions(values: unknown[]): XftSubmissionOption[] {
return uniqueOptions(values.map(sanitizeOption).filter((item): item is XftSubmissionOption => Boolean(item)));
}
function submissionInputContract(kind: XftSubmissionKind): Record<string, unknown> {
@@ -1485,18 +1833,66 @@ function submissionInputContract(kind: XftSubmissionKind): Record<string, unknow
values: {
...core[kind],
standardFields: submissionStandardFields(kind),
minimalExample: submissionMinimalExample(kind),
},
customFieldValues: "使用 fields[].fieldNumber 与 fieldValue 组装;具体值格式以 fields[].type/options 为准",
serverManagedFields: [...XFT_MANAGED_SUBMISSION_FIELDS],
submissionStatus: "APPRV_PEND",
notes: [
"申请人身份、表单编码、权限校验和审批状态由网关填写,Agent 不得传入",
"minimalExample 只说明 values 的 JSON 形状;尖括号占位符必须替换为用户业务回答或实时选项对应值,绝不能原样提交",
"表单配置中的 billCompanyCode/billDepartmentCode 属于标准抬头字段,不放入 customFieldValues;两者均省略时由薪福通按当前申请人默认公司和部门填写",
"薪福通不提供无副作用的流程预测接口,准备阶段只做本地结构校验,最终业务校验在提交时完成",
],
};
}
function submissionMinimalExample(kind: XftSubmissionKind): Record<string, unknown> {
const city = "<由城市选项中用户所选名称对应的code替换>";
const expense = "<由费用类型选项中用户所选名称对应的code替换>";
const currency = "<人民币对应的内部枚举值>";
if (kind === "travel_apply") return {
businessTrip: { businessTripDetails: [{
departCityCode: city, destinationCityCode: city,
beginTime: "<yyyy-MM-dd HH:mm>", endTime: "<yyyy-MM-dd HH:mm>",
}] },
};
if (kind === "expense_apply") return {
applyExpenses: [{ expenseNumber: expense, amount: "<正数金额,最多两位小数>" }],
};
if (kind === "entertainment_apply") return {
entertainmentApplyApiCreateDTO: {
entertainmentCityCode: city, entertainmentPersonCount: "<正整数>",
entertainmentStartTime: "<yyyy-MM-dd>", entertainmentEndTime: "<yyyy-MM-dd>",
},
};
if (kind === "common_apply") return {
customFieldValues: [{
fieldNumber: "<从实时fields中复制当前字段的fieldNumber,仅工具内部使用>",
fieldValue: "<按该field的type/options序列化用户业务回答>",
}],
};
if (kind === "common_reimburse") return {
reimAmount: "<报销总金额>", reimAmountCurrencyCode: currency,
expenseList: [{
expTypeCode: expense, currencyCode: currency,
originalCurrencyReimAmt: "<本笔费用金额>",
}],
};
if (kind === "travel_reimburse") return {
reimAmount: "<报销总金额>", reimAmountCurrencyCode: currency,
expenseList: [{
expTypeCode: expense, currencyCode: currency,
originalCurrencyReimAmt: "<本笔费用金额>",
}],
businessTrip: { businessTripDetails: [{
departCityCode: city, destinationCityCode: city,
beginTime: "<yyyy-MM-dd HH:mm>", endTime: "<yyyy-MM-dd HH:mm>",
}] },
};
return { clientOnly: "用车信息对象当前必须在薪福通客户端填写" };
}
function submissionStandardFields(kind: XftSubmissionKind): Array<Record<string, unknown>> {
if (kind === "travel_reimburse") return travelReimbursementFields();
if (kind === "common_reimburse") return commonReimbursementFields();
@@ -1519,13 +1915,67 @@ function submissionInputContractSummary(kind: XftSubmissionKind): Record<string,
return values[kind];
}
function cityField(key: string, label: string, required: boolean): Record<string, unknown> {
return {
key, label, required, type: "string",
description: "向用户展示城市名称,Agent 内部使用所选项",
optionSource: cityOptionSource(),
selectionCapability: LABEL_ONLY_SELECTION,
};
}
function expenseTypeField(key: string, required: boolean): Record<string, unknown> {
return {
key, label: "费用类型", required, type: "string",
description: "向用户展示费用名称,Agent 内部使用所选项",
optionSource: expenseTypeOptionSource(),
selectionCapability: LABEL_ONLY_SELECTION,
};
}
function readableEnumField(
key: string,
label: string,
required: boolean,
labels: Record<string, string>,
extra: Record<string, unknown> = {},
): Record<string, unknown> {
return {
key, label, required, type: "string", enum: Object.keys(labels), enumLabels: labels,
description: "只向用户展示中文选项名称,Agent 内部使用对应值",
selectionCapability: LABEL_ONLY_SELECTION,
...extra,
};
}
function clientOnlyField(
key: string,
label: string,
required: boolean,
reason: string,
extra: Record<string, unknown> = {},
): Record<string, unknown> {
return {
key, label, required, ...extra,
selectionCapability: clientOnlySelection(reason),
description: `${reason};不要向用户索取内部编码或 ID,如需填写请在薪福通客户端完成`,
};
}
const TRAVEL_MODE_LABELS = {
Train: "火车", Bus: "汽车", Ship: "轮船", Hotel: "住宿", RideHailing: "网约车", AirPlane: "飞机", Other: "其他",
} as const;
const ROUND_TRIP_LABELS = { N: "单程", Y: "往返" } as const;
const HALF_DAY_LABELS = { AM: "上午", PM: "下午" } as const;
const CURRENCY_LABELS = { CNY: "人民币" } as const;
function expenseApplicationFields(): Array<Record<string, unknown>> {
return [{
key: "applyExpenses", label: "事前申请费用列表", required: true, type: "array", minItems: 1, itemFields: [
{ key: "expenseNumber", label: "费用类型编码", required: true, type: "string", description: "企业费用管理中定义的费用类型编码" },
expenseTypeField("expenseNumber", true),
{ key: "amount", label: "申请金额", required: true, type: "decimal", precision: 2 },
{ key: "occurrenceDate", label: "费用发生时间", required: false, type: "datetime", description: "ISO 8601 时间" },
{ key: "payCityCode", label: "消费城市编码", required: false, type: "string" },
cityField("payCityCode", "消费城市", false),
{ key: "participantNum", label: "参与人数", required: false, type: "integer" },
{ key: "merchantName", label: "商户名称", required: false, type: "string" },
{ key: "remark", label: "备注", required: false, type: "string" },
@@ -1537,7 +1987,7 @@ function expenseApplicationFields(): Array<Record<string, unknown>> {
function entertainmentApplicationFields(): Array<Record<string, unknown>> {
return [{
key: "entertainmentApplyApiCreateDTO", label: "招待信息", required: true, type: "object", fields: [
{ key: "entertainmentCityCode", label: "招待城市编码", required: true, type: "string", description: "薪福通城市 cityCode" },
cityField("entertainmentCityCode", "招待城市", true),
{ key: "entertainmentPersonCount", label: "招待人数", required: true, type: "integer", minimum: 1 },
{ key: "entertainmentStartTime", label: "招待开始日期", required: true, type: "date", format: "yyyy-MM-dd" },
{ key: "entertainmentEndTime", label: "招待结束日期", required: true, type: "date", format: "yyyy-MM-dd" },
@@ -1550,44 +2000,44 @@ function travelApplicationFields(): Array<Record<string, unknown>> {
key: "businessTrip", label: "差旅行程", required: true, type: "object", fields: [{
key: "businessTripDetails", label: "行程明细", required: true, type: "array", minItems: 1,
itemFields: [
{ key: "departCityCode", label: "出发城市编码", required: true, type: "string" },
{ key: "destinationCityCode", label: "目的城市编码", required: true, type: "string" },
cityField("departCityCode", "出发城市", true),
cityField("destinationCityCode", "目的城市", true),
{ key: "beginTime", label: "开始时间", required: true, type: "datetime", format: "yyyy-MM-dd HH:mm" },
{ key: "endTime", label: "结束时间", required: true, type: "datetime", format: "yyyy-MM-dd HH:mm" },
{ key: "travelMode", label: "出行方式", required: false, type: "comma_separated_enum", enum: ["Train", "Bus", "Ship", "Hotel", "RideHailing", "AirPlane", "Other"] },
{ key: "roundTrip", label: "是否往返", required: false, type: "string", enum: ["N", "Y"] },
{ key: "beginTimePrecision", label: "开始时间半天精度", required: false, type: "string", enum: ["AM", "PM"] },
{ key: "endTimePrecision", label: "结束时间半天精度", required: false, type: "string", enum: ["AM", "PM"] },
{ key: "customArchiveOptionNumbers", label: "行程自定义档案选项", required: false, type: "array" },
readableEnumField("travelMode", "出行方式", false, TRAVEL_MODE_LABELS, { type: "comma_separated_enum", multiple: true }),
readableEnumField("roundTrip", "是否往返", false, ROUND_TRIP_LABELS),
readableEnumField("beginTimePrecision", "开始时间范围", false, HALF_DAY_LABELS),
readableEnumField("endTimePrecision", "结束时间范围", false, HALF_DAY_LABELS),
clientOnlyField("customArchiveOptionNumbers", "行程自定义档案", false, "当前 MCP 没有按名称查询企业自定义档案的可靠能力", { type: "array" }),
{ key: "estimateExpenses", label: "预计费用", required: false, type: "array", itemFields: [
{ key: "expenseTypeNumber", label: "费用类型编码", required: true, type: "string" },
expenseTypeField("expenseTypeNumber", true),
{ key: "applyAmount", label: "申请金额", required: true, type: "decimal" },
{ key: "remark", label: "备注", required: false, type: "string" },
{ key: "spreadInfo", label: "分摊信息", required: false, type: "object" },
clientOnlyField("spreadInfo", "分摊信息", false, "当前 MCP 无法把企业分摊名称安全映射为正式接口值", { type: "object" }),
] },
],
}] },
{ key: "relatedBillId", label: "关联单据 ID", required: false, type: "string" },
{ key: "peerEmpNumbers", label: "同行人员工号", required: false, type: "array" },
{ key: "billCompanyCode", label: "单据公司编码", required: false, type: "string" },
{ key: "billDepartmentCode", label: "单据部门编码", required: false, type: "string" },
clientOnlyField("relatedBillId", "关联申请单", false, "当前 MCP 没有按业务名称安全选择前置单据的能力", { type: "string" }),
clientOnlyField("peerEmpNumbers", "同行人员", false, "当前 MCP 没有按姓名查询薪福通人员的安全接口", { type: "array" }),
{ key: "billCompanyCode", label: "单据公司", required: false, type: "string", serverManaged: true, userVisible: false, description: "省略后由薪福通按当前登录用户填写" },
{ key: "billDepartmentCode", label: "单据部门", required: false, type: "string", serverManaged: true, userVisible: false, description: "省略后由薪福通按当前登录用户填写" },
];
}
function reimbursementHeaderFields(): Array<Record<string, unknown>> {
return [
{ key: "reimAmount", label: "报销总金额", required: true, type: "decimal", precision: 2 },
{ key: "reimAmountCurrencyCode", label: "报销币种", required: true, type: "string", enum: ["CNY"], description: "正式接口目前仅支持人民币" },
{ key: "outRelId", label: "外部关联 ID", required: false, type: "string", maxLength: 100 },
{ key: "billDepartmentCode", label: "单据部门编码", required: false, type: "string", maxLength: 100 },
{ key: "billCompanyCode", label: "单据公司编码", required: false, type: "string", maxLength: 50 },
{ key: "customFieldValues", label: "单据自定义字段", required: false, type: "array", itemFields: customFieldValueFields() },
{ key: "billCollectionInfo", label: "收款信息", required: false, type: "object", description: "需要付款时填写;包含收款人、银行、账号、金额和币种" },
{ key: "billLendPayRelList", label: "核销借款信息", required: false, type: "array", itemFields: [
readableEnumField("reimAmountCurrencyCode", "报销币种", true, CURRENCY_LABELS, { default: "CNY" }),
{ key: "outRelId", label: "外部关联标识", required: false, type: "string", maxLength: 100, userVisible: false, description: "仅供系统集成使用,不应询问普通用户" },
{ key: "billDepartmentCode", label: "单据部门", required: false, type: "string", maxLength: 100, serverManaged: true, userVisible: false, description: "省略后由薪福通按当前登录用户填写" },
{ key: "billCompanyCode", label: "单据公司", required: false, type: "string", maxLength: 50, serverManaged: true, userVisible: false, description: "省略后由薪福通按当前登录用户填写" },
{ key: "customFieldValues", label: "单据自定义字段", required: false, type: "array", userVisible: false, description: "由 Agent 根据实时 fields 组装,不向用户展示字段编码", itemFields: customFieldValueFields() },
clientOnlyField("billCollectionInfo", "收款信息", false, "当前 MCP 无法完整验证收款账户的正式接口引用", { type: "object" }),
clientOnlyField("billLendPayRelList", "核销借款", false, "当前 MCP 没有按可读单号安全选择本人借款单的能力", { type: "array", itemFields: [
{ key: "lendBillId", label: "借款单号", required: true, type: "string" },
{ key: "currencyCode", label: "核销币种", required: true, type: "string", enum: ["CNY"] },
readableEnumField("currencyCode", "核销币种", true, CURRENCY_LABELS, { default: "CNY" }),
{ key: "originalPayAmount", label: "核销金额", required: true, type: "decimal", precision: 2 },
] },
] }),
];
}
@@ -1599,21 +2049,21 @@ function travelReimbursementFields(): Array<Record<string, unknown>> {
return [
...reimbursementHeaderFields(),
expenseListField(true),
{ key: "peerEmpNumbers", label: "同行人员工号", required: false, type: "array", maxItems: 50 },
clientOnlyField("peerEmpNumbers", "同行人员", false, "当前 MCP 没有按姓名查询薪福通人员的安全接口", { type: "array", maxItems: 50 }),
{
key: "businessTrip", label: "差旅行程", required: true, type: "object", fields: [{
key: "businessTripDetails", label: "行程明细", required: true, type: "array", minItems: 1,
itemFields: [
{ key: "departCityCode", label: "出发城市编码", required: true, type: "string", description: "薪福通城市 cityCode" },
{ key: "destinationCityCode", label: "目的城市编码", required: true, type: "string", description: "薪福通城市 cityCode" },
cityField("departCityCode", "出发城市", true),
cityField("destinationCityCode", "目的城市", true),
{ key: "beginTime", aliases: ["startTime"], label: "出发时间", required: true, type: "datetime", format: "yyyy-MM-dd HH:mm" },
{ key: "endTime", label: "结束时间", required: true, type: "datetime", format: "yyyy-MM-dd HH:mm" },
{ key: "travelMode", label: "出行方式", required: false, type: "comma_separated_enum", enum: ["Train", "Bus", "Ship", "Hotel", "RideHailing", "AirPlane", "Other"] },
{ key: "roundTrip", label: "是否往返", required: false, type: "string", enum: ["N", "Y"], default: "N" },
{ key: "beginTimePrecision", label: "开始时间半天精度", required: false, type: "string", enum: ["AM", "PM"], description: "模板时间精度为半天时必填" },
{ key: "endTimePrecision", label: "结束时间半天精度", required: false, type: "string", enum: ["AM", "PM"], description: "模板时间精度为半天时必填" },
readableEnumField("travelMode", "出行方式", false, TRAVEL_MODE_LABELS, { type: "comma_separated_enum", multiple: true }),
readableEnumField("roundTrip", "是否往返", false, ROUND_TRIP_LABELS, { default: "N" }),
readableEnumField("beginTimePrecision", "开始时间范围", false, HALF_DAY_LABELS, { condition: "模板时间精度为半天时必填" }),
readableEnumField("endTimePrecision", "结束时间范围", false, HALF_DAY_LABELS, { condition: "模板时间精度为半天时必填" }),
{ key: "tripReason", label: "出差事由", required: false, type: "string", maxLength: 200, description: "模板将行程事由设为必填时必须填写" },
{ key: "customArchiveOptionNumbers", label: "行程自定义档案选项", required: false, type: "array" },
clientOnlyField("customArchiveOptionNumbers", "行程自定义档案", false, "当前 MCP 没有按名称查询企业自定义档案的可靠能力", { type: "array" }),
],
}],
},
@@ -1622,25 +2072,25 @@ function travelReimbursementFields(): Array<Record<string, unknown>> {
function expenseListField(travel: boolean): Record<string, unknown> {
const itemFields: Array<Record<string, unknown>> = [
{ key: "expTypeCode", label: "费用类型编码", required: true, type: "string", maxLength: 100, description: "企业费用管理中定义的费用类型编码" },
{ key: "currencyCode", label: "费用币种", required: true, type: "string", enum: ["CNY"] },
{ ...expenseTypeField("expTypeCode", true), maxLength: 100 },
readableEnumField("currencyCode", "费用币种", true, CURRENCY_LABELS, { default: "CNY" }),
{ key: "originalCurrencyReimAmt", label: "费用金额", required: true, type: "decimal", precision: 2 },
{ key: "wrTim", label: "消费日期", required: false, type: "date", format: "yyyy-MM-dd" },
{ key: "remark", label: "费用备注", required: false, type: "string", maxLength: 500 },
{ key: "merchantName", label: "商户名称", required: false, type: "string", maxLength: 200 },
{ key: "participants", label: "费用参与人员工号", required: false, type: "array", maxItems: 50 },
clientOnlyField("participants", "费用参与人员", false, "当前 MCP 没有按姓名查询薪福通人员的安全接口", { type: "array", maxItems: 50 }),
{ key: "partiNbr", label: "参与人数", required: false, type: "integer" },
{ key: "taxRate", label: "税率", required: false, type: "decimal", precision: 2 },
{ key: "taxFreeAmount", label: "不含税金额", required: false, type: "decimal", precision: 2 },
{ key: "departureCode", label: "费用出发城市编码", required: false, type: "string" },
{ key: "destinationCode", label: "费用目的城市编码", required: false, type: "string" },
cityField("departureCode", "费用出发城市", false),
cityField("destinationCode", "费用目的城市", false),
{ key: "bgnTim", label: "交通出发日期", required: false, type: "date", format: "yyyy-MM-dd" },
{ key: "endTim", label: "交通到达日期", required: false, type: "date", format: "yyyy-MM-dd" },
{ key: "checkInDate", label: "酒店入住日期", required: false, type: "date", format: "yyyy-MM-dd" },
{ key: "checkOutDate", label: "酒店离店日期", required: false, type: "date", format: "yyyy-MM-dd" },
{ key: "payCityCode", label: "消费城市编码", required: false, type: "string" },
{ key: "hotelStar", label: "酒店星级", required: false, type: "string", enum: ["1", "2", "3", "4"], enumLabels: { "1": "五星", "2": "四星", "3": "三星", "4": "其他" } },
{ key: "aircraftCabin", label: "飞机舱位", required: false, type: "string", enum: ["1", "2", "3", "4"] },
cityField("payCityCode", "消费城市", false),
readableEnumField("hotelStar", "酒店星级", false, { "1": "五星", "2": "四星", "3": "三星", "4": "其他" }),
clientOnlyField("aircraftCabin", "飞机舱位", false, "正式接口文档未提供可验证的舱位名称映射", { type: "string" }),
{ key: "airlineNo", label: "航班号", required: false, type: "string" },
{ key: "airlineCom", label: "航空公司", required: false, type: "string" },
{ key: "flightETicketNo", label: "电子客票号", required: false, type: "string" },
@@ -1648,20 +2098,20 @@ function expenseListField(travel: boolean): Record<string, unknown> {
{ key: "civilAviationFund", label: "民航发展基金", required: false, type: "decimal", precision: 2 },
{ key: "discount", label: "机票折扣", required: false, type: "decimal", precision: 2 },
{ key: "trainNo", label: "列车号", required: false, type: "string" },
{ key: "trainCabin", label: "火车席别", required: false, type: "string", description: "使用官方席别编码 1-15" },
clientOnlyField("trainCabin", "火车席别", false, "正式接口文档未提供可验证的席别名称映射", { type: "string" }),
{ key: "trainETicketNo", label: "火车票票号", required: false, type: "string" },
{ key: "shipCabin", label: "轮船舱位", required: false, type: "string", enum: ["1", "2", "3"] },
{ key: "customFieldValues", label: "费用自定义字段", required: false, type: "array", itemFields: customFieldValueFields() },
{ key: "spreadInfo", label: "费用分摊", required: false, type: "object", description: "按企业分摊方式填写" },
clientOnlyField("shipCabin", "轮船舱位", false, "正式接口文档未提供可验证的舱位名称映射", { type: "string" }),
{ key: "customFieldValues", label: "费用自定义字段", required: false, type: "array", userVisible: false, description: "由 Agent 根据实时 fields 组装,不向用户展示字段编码", itemFields: customFieldValueFields() },
clientOnlyField("spreadInfo", "费用分摊", false, "当前 MCP 无法把企业分摊名称安全映射为正式接口值", { type: "object" }),
];
if (travel) itemFields.push({ key: "dstntIndex", label: "归属行程序号", required: false, type: "string", description: "从 0 开始;不填则不归组到行程" });
if (travel) itemFields.push({ key: "dstntIndex", label: "归属行程", required: false, type: "string", userVisible: false, internallyDerived: true, description: "由 Agent 根据用户选择的行程位置内部换算,不向用户展示序号" });
return { key: "expenseList", label: "费用明细", required: true, type: "array", minItems: 1, itemFields };
}
function customFieldValueFields(): Array<Record<string, unknown>> {
return [
{ key: "fieldNumber", label: "自定义字段编码", required: true, type: "string" },
{ key: "fieldValue", label: "自定义字段值", required: true, type: "string", description: "复杂值按字段类型序列化为 JSON 字符串" },
{ key: "fieldNumber", label: "字段引用", required: true, type: "string", userVisible: false, description: "由 Agent 从实时 Schema 内部复制,不向用户展示或索取" },
{ key: "fieldValue", label: "字段值", required: true, type: "string", userVisible: false, description: "由 Agent 根据字段类型内部序列化" },
];
}
+22 -2
View File
@@ -77,19 +77,37 @@ export interface XftSubmissionField {
required: boolean;
description?: string;
multiple?: boolean;
options?: unknown[];
options?: XftSubmissionOption[];
enumLabels?: Record<string, string>;
moduleType?: string;
valueFormat?: string;
itemFields?: Array<Record<string, unknown>>;
optionSource?: {
tool: "approval_get_submission_options";
kind: XftSubmissionOptionKind;
};
selectionCapability?: XftSubmissionSelectionCapability;
}
export type XftSubmissionOptionKind = "department" | "city";
export type XftSubmissionOptionKind = "department" | "city" | "expense_type";
export interface XftSubmissionOption {
code: string;
label: string;
}
export type XftSubmissionSelectionCapability =
| {
status: "supported";
userInput: "label";
submitValue: "code";
exposeInternalValue: false;
}
| {
status: "client_only";
reason: string;
};
export interface XftApprovalProvider {
authStatus(user: AuthenticatedDingTalkUser): Promise<{
authenticated: true;
@@ -159,6 +177,8 @@ export interface XftApprovalProvider {
): Promise<{
kind: XftSubmissionOptionKind;
items: XftSubmissionOption[];
total?: number;
hasMore?: boolean;
diagnostics: Record<string, unknown>;
}>;
validateSubmissionIdentity(user: AuthenticatedDingTalkUser): Promise<{
+50 -1
View File
@@ -6,10 +6,49 @@ import test from "node:test";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import {
APPROVAL_ONBOARDING_EXAMPLES, RemoteBridge, bridgeAuthToolResult, createBridgeMcpServer,
APPROVAL_ONBOARDING_EXAMPLES, RemoteBridge, bridgeAuthToolResult, bridgeWriteOutcomeUncertainResult, createBridgeMcpServer,
syncBundledSkillForLegacyUpdate,
} 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("legacy updater post-install syncs the complete bundled Skill", () => {
const root = mkdtempSync(join(tmpdir(), "ding-bridge-skill-sync-"));
const bundled = join(root, "package", "skill", "dingtalk-approval");
@@ -19,12 +58,14 @@ test("legacy updater post-install syncs the complete bundled Skill", () => {
mkdirSync(target, { recursive: true });
writeFileSync(join(bundled, "SKILL.md"), "new skill");
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");
syncBundledSkillForLegacyUpdate(statePath, bundled);
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", "local-drafts.md"), "utf8"), "draft rules");
assert.deepEqual(readdirSync(join(root, "client", "skill")), ["dingtalk-approval"]);
});
@@ -245,6 +286,9 @@ test("MCP approval_login returns both portable text and a standard resource link
const instructions = client.getInstructions() ?? "";
assert.match(instructions, /登录链接过期.*approval_login.*绝不.*重新安装/);
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/);
const history = tools.tools.find((tool) => tool.name === "approval_list_history")!;
assert.equal((history.inputSchema as any).properties.startDate.default, "");
@@ -252,6 +296,11 @@ test("MCP approval_login returns both portable text and a standard resource link
const batch = tools.tools.find((tool) => tool.name === "approval_process_batch")!;
assert.equal((batch.inputSchema as any).properties.items.maxItems, 20);
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.*尖括号必须替换/);
} finally {
await client.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.skillFile), 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"));
assert.equal(state.version, "1.1.0-rc.5");
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 });
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", "local-drafts.md"), "# Local drafts\\n");
`);
if (process.platform === "win32") {
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, /登录链接过期.*绝不能重新安装.*approval_login.*loginCommand/s);
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.doesNotMatch(compatibilityInstaller, /TimeoutSec 300|--max-time 300|--timeout=300|至少 10 分钟超时/);
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 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\/local-drafts\.md` completely/);
assert.doesNotMatch(skill, /XFT recall is experimental/);
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/);
@@ -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, /onboarding\.firstRun=true.*Do not execute an example automatically/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, /local attachment path.*never a valid upstream attachment field value.*native client/s);
assert.match(createApproval, /Local file paths.*not valid attachment field values.*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");
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, /validateNodeRuntime/);
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, /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");
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);
+15 -1
View File
@@ -46,6 +46,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() ?? "", /近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();
assert.deepEqual(tools.tools.map((tool) => tool.name), [
@@ -77,9 +87,13 @@ test("MCP initialization teaches the workflow while tools/list stays compact", a
assert.match(descriptions.approval_process_batch ?? "", /Schema容量.*不得转述给用户.*任一无效.*零写入/s);
assert.match(descriptions.approval_prepare_submission ?? "", /明确确认|确认/);
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 ?? "", /查重.*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;
assert.equal(prepareSchema.properties.allowDuplicateInvoices.default, false);
assert.match(descriptions.approval_list_submission_templates ?? "", /出差申请.*外出.*差旅费用报销.*业务招待费用报销/);
+231 -2
View File
@@ -5,7 +5,7 @@ import { deflateRawSync } from "node:zlib";
import type { AppConfig } from "../src/config.js";
import { UnifiedApprovalService } from "../src/unified-approval.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 {
return {
@@ -1108,6 +1108,175 @@ test("Xft application templates support API submission with live post-write stat
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 () => {
const template: XftSubmissionTemplate = {
eventSeqId: "1", eventNumber: "XFT-E", eventName: "招待费用申请单",
@@ -1131,10 +1300,70 @@ test("Xft preparation reports standard and live custom required fields together"
const templateId = String((await service.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
await assert.rejects(
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 () => {
const ding = {
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+)?"/);
}
});
+449 -9
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>;
}
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 () => {
const calls: Array<{ path: string; body: Record<string, unknown>; headers: Headers; url: URL }> = [];
const fakeFetch: typeof fetch = async (input, init) => {
@@ -248,10 +273,37 @@ test("Xft OpenAPI rejects non-exact, missing-identity, and ambiguous user mappin
});
test("Xft OpenAPI surfaces XFT framework business errors", async () => {
const client = new XftOpenApiClient(config(), async () => encrypted({
SYCOMRETZ: [{ ERRCOD: "SYOPA16", ERRMSG: "应用状态错误" }],
}));
await assert.rejects(client.authStatus({ userId: "ding-u1" }), /应用状态错误/);
const client = new XftOpenApiClient(config(), async () => new Response(
String(sm4.encrypt(JSON.stringify({
SYCOMRETZ: [{ ERRCOD: "SYOPA16", ERRMSG: "应用状态错误" }],
}), privateKey.slice(0, 32))),
{ 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 () => {
@@ -304,6 +356,59 @@ test("Xft OpenAPI derives empId server-side and validates ownership before and a
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 () => {
const createBodies: Record<string, unknown>[] = [];
const fakeFetch: typeof fetch = async (input, init) => {
@@ -343,10 +448,18 @@ test("Xft submission discovers live templates and derives protected create field
assert.equal(schema.fields[0]?.fieldNumber, "C1");
assert.equal(schema.fields.length, 1);
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");
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",
]);
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" });
assert.equal(identityCheck.validated, true);
assert.doesNotMatch(JSON.stringify(identityCheck), /E001|EMP-1/);
@@ -360,13 +473,16 @@ test("Xft submission discovers live templates and derives protected create field
assert.equal(createBodies[0]?.billApplyAuthCheckType, "Y");
});
test("Xft submission options expose only the current user's departments and searchable city codes", async () => {
test("Xft submission options expose only departments, cities, and the matching expense data source", async () => {
const templateRecord = {
eventSeqId: 1, eventNumber: "TRAVEL-1", eventName: "出差费用申请单", eventStatus: "1", eventType: "TRAVEL_APPLY",
};
let cityRequests = 0;
let expenseSourceRequests = 0;
let unrelatedSourceRequests = 0;
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.endsWith("form/query-event-list")) return encrypted({ code: "0", data: { list: [templateRecord] } });
if (path.endsWith("member/get/by-id")) {
@@ -386,6 +502,35 @@ test("Xft submission options expose only the current user's departments and sear
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}`);
});
const template = (await client.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!;
@@ -400,6 +545,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);
assert.deepEqual(cachedCities.items, [{ code: "441900", label: "东莞市" }]);
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 () => {
@@ -564,7 +1004,8 @@ test("Xft submission routes every supported template kind to its documented crea
throw new Error(`unexpected ${path}`);
});
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");
for (const template of templates) await client.submitApproval({ userId: "ding-u1" }, template, {});
assert.deepEqual(createPaths.sort(), [
@@ -573,7 +1014,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/travel/create",
"/itrip/xft-api/v1/bills/apply/use-car/create",
"/itrip/xft-api/v1/reimburse/bill/create",
"/itrip/xft-api/v1/reimburse/bill/travel/create",
]);