Author SHA1 Message Date
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
gitea-admin 0a7ad6a3b6 fix: honor proxies in runtime source verification
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-19 02:09:59 +00:00
gitea-admin 2aa78aebd9 fix: route cross-device OAuth through device flow
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-19 02:05:07 +00:00
gitea-admin 7e2a01c913 fix: make Windows portable Node bootstrap reliable 2026-08-19 01:12:13 +00:00
gitea-admin 0d5e192e95 feat: prefer remote MCP OAuth installation 2026-08-18 18:24:47 +00:00
36 changed files with 2114 additions and 585 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
+17 -30
View File
@@ -3,11 +3,13 @@
一个面向 Agent 的统一审批 MCP。用户只登录一次钉钉,即可读取、处理和发起本人在钉钉 OA 与薪福通中的审批:
```text
Agent → 本地轻量 stdio 桥 → HTTPS/OAuth 统一网关
├─ 钉钉 OA OpenAPI
└─ 薪福通 OpenAPI
Agent → 远程 Streamable HTTP MCP / OAuth 统一网关
├─ 钉钉 OA OpenAPI
└─ 薪福通 OpenAPI
```
只有宿主不支持远程 MCP 或标准 OAuth 时,才使用本地 STDIO Bridge 兼容层。
## 工具
- `approval_login`:检查登录或返回唯一授权链接。
@@ -18,7 +20,7 @@ Agent → 本地轻量 stdio 桥 → HTTPS/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 发起的钉钉或薪福通审批。
@@ -39,15 +41,21 @@ 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。
人员、企业自定义档案、关联前置单据、分摊及文档未给出可靠名称映射的舱位字段目前标记为 `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`,尚无撤回成功样本。因此薪福通撤回属于实验能力;失败或结果不确定时绝不自动重试。查询、处理和发起审批不受此限制。
## 部署
@@ -75,32 +83,11 @@ Agent 不传 `userId`、员工号或单据所有者。网关从 OAuth Token 确
https://approval.justconn.top/downloads/dingmcp/install.md
```
用户把这个链接交给 Agent 后,Agent 先校验并运行当前平台的原生 PowerShell 或 POSIX 安装引导。Windows、Linux、macOS 均支持 x64 与 ARM64,系统无需预装 Node.js:引导会复用兼容的系统 Node,或者从阿里云、npmmirror、Node 官方的精确版本地址依次下载 Node 22.23.2,并用固定的 Node 官方 SHA-256 校验后安装为插件私有运行时。它不修改系统 PATH、不需要管理员权限,也不使用任何 `latest` 镜像别名。
同机交互的默认流程不下载任何客户端:Agent 按宿主官方方式把 `https://approval.justconn.top/mcp` 注册为远程 Streamable HTTP MCP,由宿主通过标准 OAuth 元数据、动态客户端注册和 PKCE 完成登录。若宿主注册 `127.0.0.1`/`localhost` 回调,授权页必须在运行 Agent 的同一设备打开。用户端不需要 Node.js、npm、DWS、企业凭证或本地常驻进程。用户本人确认授权后,Agent 用 `approval_list_todos(source=all, limit=1)` 只读验证钉钉 OA 与薪福通都可用。
随后安装器使用固定 Ed25519 公钥验证清单和发布物,把轻量客户端安装到稳定的用户数据目录,并统一返回 `bridgePath`、`skillPath`、`loginCommand`、`nodeRuntime` 和标准 `mcpConfig`;不再区分 Agent 品牌,不全局安装 npm 包,也不会猜测或直接修改 Agent 的私有配置。启动器固定使用安装时选定的 Node,用户以后升级系统 Node 或切换版本管理器不会改变 MCP 命令。安装器会立即在受保护的本地会话中检查登录并返回机器可读 `postInstall`:未登录时直接带回当前授权链接,网关暂时不可用也不会使安装回滚。Agent 随后按自身官方方式安装 Skill、注册并信任本地 stdio MCP;不能热加载连接器时再新建会话或重启。用户端不安装 DWS,不需要企业凭证。
服务端 MCP `instructions` 和工具描述包含完整审批工作流,因此远程模式不依赖本地 Skill 才能安全使用。宿主需要重新加载连接器时按其官方机制处理;OAuth 过期也由宿主标准刷新/重新授权流程解决,无需重装。
公开 `install.md` 是一份可直接交给 Agent 执行的逐步教程,为 Windows PowerShell 与 Linux/macOS 分别提供完整路径。它覆盖平台/架构检测、安装引导校验、国内下载回退、安装 JSON 验收、Skill/MCP 注册、连接器信任、首次登录、钉钉 OA 与薪福通双来源验证、更新、卸载和故障恢复。Windows 必须原样使用安装器返回的本地 `.cmd` stdio 配置;远程网关 URL 不能直接配置为 MCP,否则宿主可能显示 HTTP 302 或把本地 Bridge 误判为崩溃。
首次安装的 Agent 工具调用应预留至少 10 分钟。平台引导和安装器持续把不含凭证的阶段进度写入 stderr,stdout 只保留最终 JSON;Windows 即使宿主默认 shell 是 Git Bash,也必须转由 PowerShell bootstrap 执行,不能直接调用内部 Node 安装器。客户端用跨进程锁阻止并发安装,外部终止后再次运行会回收已经死亡的锁和残留 staging;离线 npm 展开超过 180 秒时会返回明确错误,不会无限静默等待。
Agent 必须在安装后立即展示 `postInstall.authChallenge.directUrl`,不得等到第一次审批查询,也不得声称链接已另行推送。用户授权后,Agent 通过 `approval_login` 确认登录并用 `source=all` 做一次双来源只读验证;首次成功认证响应还会在普通 `content.text` 和 `structuredContent.onboarding` 中返回一次性的简短使用示例,展示后不会重复,也不会自动执行任何写操作。
授权链接约十分钟有效。过期后不需要更新或重装:MCP 已加载时再次调用 `approval_login`;尚未加载时执行安装结果中的 `loginCommand`(等价于稳定客户端的 `login --json`)。两条入口复用同一本地 OAuth 会话,都会返回当前有效链接或自动生成新链接。
首次授权会先显示网关自己的 OAuth 确认页,列出请求客户端、返回域名、权限范围或设备授权码;用户确认后才跳转钉钉。客户端名称由客户端自行声明,因此应同时核对域名或授权码。短暂的网关 5xx/网络故障不会清除本地 refresh token,也不会强迫用户重新登录。
客户端后续可使用稳定 CLI:
```text
dingtalk-approval login --json
dingtalk-approval print-path bridge
dingtalk-approval print-config --format mcp-json
dingtalk-approval doctor --json
dingtalk-approval update --json
dingtalk-approval uninstall --yes --json
```
实际 `dingtalk-approval` 路径由首次安装结果的 `cliPath` 给出。`login` 只使用现有运行时与登录会话;`update` 会复用已固定的清单地址与公钥,并保留 OAuth 登录会话。更新完成后应重新连接该 MCP,Agent 不能单独重启连接器时再新建会话或重启。卸载默认删除客户端运行时、Skill、安装记录和 OAuth 会话;Agent 中名为 `dingtalk-approval` 的连接器通过其官方入口删除。
宿主不支持远程 Streamable HTTP MCP/标准 OAuth,或用户通过 SSH、无头终端、Messenger 等方式跨设备操作而无法完成宿主本机回调时,使用 `compat-install.md` 安装本地 Bridge。Bridge 的 device-code 登录链接可以传到手机或另一台电脑打开。兼容引导仍支持 Windows、Linux、macOS x64/ARM64 和无 Node 环境,并使用固定 Ed25519 清单与 SHA-256 校验。PowerShell 发行脚本带 UTF-8 BOM,Node 探测避免 Windows PowerShell 5.1 吞引号;Windows 下载优先使用 `curl.exe`,连接、低速或 90 秒总超时会明确失败并换固定来源,不再静默等待 300 秒。
## 数据与安全
@@ -108,7 +95,7 @@ dingtalk-approval uninstall --yes --json
- `approvalId` 是绑定用户、来源和有效期的紧凑不透明加密引用;不包含展示字段或用户 ID 明文。
- 每批最多 20 条;引用预检失败时首个上游写调用不会发生。
- 提单的发起人、薪福通员工号和审批状态只能由网关填写。
- 审批正文、费用明细、附件和薪福通 Schema 不落库。
- 网关不保存审批正文、费用明细、附件和薪福通 Schema;用户明确创建的本机草稿是唯一例外,且只位于 Agent 机器的当前用户私有目录。
- 发票查重库只保存当前用户和发票标识的 HMAC、来源、上游业务单号、状态及时间,最多保留 35 天;不保存发票原号、商户原文或附件。
- 企业 AppSecret 与薪福通 AuthoritySecret 只保存在服务器。
- `failed`、`succeeded` 与 `outcome_uncertain` 有不同重试语义,后者禁止自动重试。
+11
View File
@@ -29,6 +29,17 @@ server {
add_header Content-Security-Policy "default-src 'none'" always;
}
location = /downloads/dingmcp/compat-install.md {
alias /var/www/approval.justconn.top/downloads/dingmcp/compat-install.md;
default_type text/markdown;
charset utf-8;
charset_types text/markdown;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header Cache-Control "no-cache" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Content-Security-Policy "default-src 'none'" always;
}
location = /downloads/dingmcp/manifest.json {
alias /var/www/approval.justconn.top/downloads/dingmcp/manifest.json;
default_type application/json;
+1
View File
@@ -176,6 +176,7 @@ curl --fail --silent --show-error --retry 10 --retry-delay 1 --retry-all-errors
curl --fail --silent --show-error \
"${public_base_url}/.well-known/oauth-protected-resource/mcp" >/dev/null
curl --fail --silent --show-error "${public_download_base_url}/install.md" >/dev/null
curl --fail --silent --show-error "${public_download_base_url}/compat-install.md" >/dev/null
curl --fail --silent --show-error "${public_download_base_url}/manifest.json" >/dev/null
curl --fail --silent --show-error "${public_download_base_url}/manifest.sig" >/dev/null
curl --fail --silent --show-error "${public_download_base_url}/dingtalk-approval-installer.mjs" >/dev/null
@@ -0,0 +1,279 @@
# 钉钉审批兼容客户端安装任务
仅当当前 Agent 明确不支持远程 Streamable HTTP MCP、标准 OAuth,或用户无法在宿主本机 OAuth 回调所在设备打开授权页面时,才使用本兼容安装。即使宿主支持远程 MCP,只要它使用 `127.0.0.1`/`localhost` 回调,而用户通过 SSH、无头环境或 Messenger 在另一台设备操作,就应使用本地 Bridge 的可跨设备 device-code 登录;同机回调可以完成时才返回主安装页直接连接 `{{GATEWAY_URL}}`。
用户把本文件或本文件的链接交给你,即表示授权你为当前用户安装或更新“钉钉审批”兼容客户端、Skill 和名为 `dingtalk-approval` 的 MCP。该授权不包含同意、拒绝、退回、撤回或发起任何审批。
请从“步骤 1”开始逐项执行并验收,不要跳步,不要让用户手工填写企业凭证,也不要根据经验自行改写安装器返回的路径或 MCP 配置。Windows 是完整支持的平台,必须优先使用下面的 PowerShell 步骤。
## 固定发布入口
- 版本:`{{VERSION}}`
- 安装器:`{{INSTALLER_URL}}`
- 安装器 SHA-256:`{{INSTALLER_SHA256}}`
- 发布公钥:`{{PUBLIC_KEY_URL}}`
- 公钥 SHA-256:`{{PUBLIC_KEY_SHA256}}`
- 公钥编号:`{{RELEASE_KEY_ID}}`
- 签名清单:`{{MANIFEST_URL}}`
- 清单签名:`{{MANIFEST_SIGNATURE_URL}}`
- 备用发布源:
{{DOWNLOAD_MIRROR_BASE_URLS_MARKDOWN}}
- 统一网关:`{{GATEWAY_URL}}`
- Windows 安装引导:`{{BOOTSTRAP_WINDOWS_URL}}`
- Windows 安装引导备用地址:
{{BOOTSTRAP_WINDOWS_MIRROR_URLS_MARKDOWN}}
- Windows 安装引导 SHA-256:`{{BOOTSTRAP_WINDOWS_SHA256}}`
- Linux/macOS 安装引导:`{{BOOTSTRAP_POSIX_URL}}`
- Linux/macOS 安装引导备用地址:
{{BOOTSTRAP_POSIX_MIRROR_URLS_MARKDOWN}}
- Linux/macOS 安装引导 SHA-256:`{{BOOTSTRAP_POSIX_SHA256}}`
- 私有 Node.js:`{{NODE_VERSION}}`(最低兼容版本 `{{NODE_MINIMUM_VERSION}}`)
首次安装通过上述 HTTPS 地址和固定 SHA-256 建立信任;安装器随后必须使用固定公钥验证 Ed25519 清单签名和全部发布物哈希。URL、TLS、签名或哈希任一不匹配都必须停止。不得索取 AppKey、AppSecret、AuthoritySecret、CorpId、userId、员工号、Cookie 或 Token。
## 完成标准
只有以下条件全部成立,才能告诉用户“安装完成,可以使用”:
1. 对应平台安装引导的 SHA-256 正确,并已选用兼容的系统 Node 或经过官方哈希校验的私有 Node;
2. 安装器 JSON 返回 `ok: true`;
3. 已按当前 Agent 的官方方式安装返回的 `skillPath`;
4. 已原样使用返回的 `mcpConfig` 注册并信任本地 stdio MCP;
5. `approval_login` 确认已登录;
6. `approval_list_todos` 的双来源验证中,钉钉 OA 与薪福通都为 `ok`;
7. 未执行任何审批写操作。
## 步骤 1:确认操作系统与架构
支持以下当前用户环境:
- Windows 10/11:x64 或 ARM64;
- Linux:x64 或 ARM64;
- macOS:Intel x64 或 Apple Silicon ARM64。
系统无需预装 Node.js。安装引导会优先复用版本不低于 `{{NODE_MINIMUM_VERSION}}` 且 npm 可用的系统 Node;否则自动准备只属于本插件的 Node `{{NODE_VERSION}}`。不要让用户安装全局 Node,不要使用管理员权限、`sudo`、全局 npm 安装、DWS 或浏览器自动化。
## 步骤 2:下载、校验并执行平台安装引导
不要在浏览器中复制脚本内容,不要对 URL 解码、重新编码或手工拼接。必须先校验安装引导 SHA-256,再执行它。
为执行平台安装引导的 Agent 工具调用设置合理超时。引导的单个下载会在连接失败、长时间无数据或 90 秒总时限到达时明确失败并换用下一固定来源,不会静默等待 300 秒;安装器会把 `[dingtalk-approval]` 开头的阶段进度写入 stderr,把唯一的最终结果 JSON 写入 stdout。进度文本不是失败,也不能混入 JSON 解析。进程仍在运行时不得并发启动第二次安装。
安装器和后续更新优先使用系统 `curl` 下载签名清单与发布物;`curl` 会直接使用 `HTTPS_PROXY`、`HTTP_PROXY`、`ALL_PROXY` 和 `NO_PROXY`,Node `fetch` 仅在系统没有 `curl` 时兜底。登录和 Bridge 仍会按 Node 能力启用环境代理。不要设置全局 `NODE_OPTIONS=--use-env-proxy`,也不要手工预装 `node_modules`;这会让 npm 与 Node 的代理机制叠加,并破坏安装器的原子安装与故障恢复。
### Windows(PowerShell)
无论当前 Agent 默认使用 PowerShell、CMD 还是 Git Bash,Windows 都必须调用普通 PowerShell 执行下面的完整流程。不得从 Git Bash 直接运行内部的 `node dingtalk-approval-installer.mjs ...` 命令;内部安装器不是用户安装入口。
在普通 PowerShell 中逐行运行:
```powershell
$dingUtf8 = New-Object System.Text.UTF8Encoding($false)
[Console]::InputEncoding = $dingUtf8
[Console]::OutputEncoding = $dingUtf8
$OutputEncoding = $dingUtf8
$dingBootstrapDir = Join-Path ([System.IO.Path]::GetTempPath()) ("dingmcp-bootstrap-" + [guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Path $dingBootstrapDir -Force | Out-Null
$dingBootstrap = Join-Path $dingBootstrapDir "dingtalk-approval-bootstrap.ps1"
$dingBootstrapUrls = @({{BOOTSTRAP_WINDOWS_URLS_POWERSHELL}})
$dingBootstrapDownloaded = $false
$dingCurl = Get-Command curl.exe -ErrorAction SilentlyContinue
foreach ($dingBootstrapUrl in $dingBootstrapUrls) {
try {
if ($dingCurl) {
& $dingCurl.Source --fail --location --silent --show-error --connect-timeout 10 --max-time 90 --speed-time 20 --speed-limit 1024 --output $dingBootstrap $dingBootstrapUrl
if ($LASTEXITCODE -ne 0) { throw "curl.exe 退出码 $LASTEXITCODE" }
} else {
Invoke-WebRequest -UseBasicParsing -Uri $dingBootstrapUrl -OutFile $dingBootstrap -TimeoutSec 90
}
} catch {
continue
}
$dingBootstrapHash = (Get-FileHash -Algorithm SHA256 $dingBootstrap).Hash.ToLowerInvariant()
if ($dingBootstrapHash -ne "{{BOOTSTRAP_WINDOWS_SHA256}}") { throw "Windows 安装引导 SHA-256 不匹配,停止安装:$dingBootstrapUrl" }
$dingBootstrapDownloaded = $true
break
}
if (-not $dingBootstrapDownloaded) { throw "所有 Windows 安装引导下载源均不可用" }
$dingInstallJson = & powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $dingBootstrap
if ($LASTEXITCODE -ne 0) { throw "钉钉审批安装引导执行失败:$dingInstallJson" }
$dingInstall = $dingInstallJson | ConvertFrom-Json
if ($dingInstall.ok -ne $true) { throw "钉钉审批安装失败:$($dingInstall.error)" }
$dingInstall | ConvertTo-Json -Depth 20
```
完成后可删除 `$dingBootstrapDir`。任一下载或哈希检查失败都不能忽略。
### Linux / macOS(POSIX shell)
```bash
ding_bootstrap_dir="$(mktemp -d)"
ding_bootstrap="$ding_bootstrap_dir/dingtalk-approval-bootstrap.sh"
ding_bootstrap_downloaded=0
for ding_bootstrap_url in {{BOOTSTRAP_POSIX_URLS_POSIX}}; do
rm -f "$ding_bootstrap"
if command -v curl >/dev/null 2>&1; then
curl --fail --location --connect-timeout 10 --max-time 90 --speed-time 20 --speed-limit 1024 --output "$ding_bootstrap" "$ding_bootstrap_url" || continue
elif command -v wget >/dev/null 2>&1; then
wget --https-only --timeout=90 --output-document="$ding_bootstrap" "$ding_bootstrap_url" || continue
else
echo '缺少 curl 或 wget,无法下载安装引导' >&2
exit 1
fi
if command -v sha256sum >/dev/null 2>&1; then
ding_bootstrap_hash="$(sha256sum "$ding_bootstrap" | awk '{print $1}')"
else
ding_bootstrap_hash="$(shasum -a 256 "$ding_bootstrap" | awk '{print $1}')"
fi
if [ "$ding_bootstrap_hash" != '{{BOOTSTRAP_POSIX_SHA256}}' ]; then
echo "Linux/macOS 安装引导 SHA-256 不匹配,停止安装:$ding_bootstrap_url" >&2
exit 1
fi
ding_bootstrap_downloaded=1
break
done
if [ "$ding_bootstrap_downloaded" -ne 1 ]; then
echo '所有 Linux/macOS 安装引导下载源均不可用' >&2
exit 1
fi
ding_install_json="$(sh "$ding_bootstrap")"
printf '%s\n' "$ding_install_json"
```
完成后可删除 `$ding_bootstrap_dir`。如果 `sha256sum` 与 `shasum` 都不存在,必须停止,不能跳过校验。
## 步骤 3:验收安装结果
步骤 2 的平台引导是唯一通用安装入口。它会:
1. 检查系统 Node;缺失或版本不兼容时,根据当前操作系统和 CPU 架构选择私有 Node;
2. 按“阿里云精确版本 → npmmirror 精确版本 → Node 官方精确版本”的顺序下载 Node;项目安装引导和发布物则优先使用国内 Gitea 静态发布源,再回退到自建 HTTPS 下载源和其他已配置镜像。网络或 HTTP 下载失败才切换来源,任一已下载文件的签名或 SHA-256 不匹配都会立即终止;
3. 把私有 Node 放在插件自己的当前用户数据目录,不修改系统 PATH,不要求管理员权限;
4. 下载并校验首次安装器和发布公钥,再由安装器验证 Ed25519 清单签名与全部客户端发布物;下载源不是信任根,备用源不能绕过签名或哈希;
5. 返回一份 JSON 安装结果。
安装器会在稳定的当前用户数据目录安装客户端与 Skill,不会全局安装 npm 包,也不会修改 Agent 私有配置。保留完整 JSON 结果,后续只使用其中的真实字段,不要猜路径。
安装结果至少必须包含:
- `ok: true`、`status: "installed"` 和 `version`;
- `cliPath`、`bridgePath`、`skillPath`;
- `loginCommand`;
- `mcpConfig.mcpServers.dingtalk-approval`;
- `postInstall`。
- `nodeRuntime`,其中 `managed: true` 表示正在使用插件私有 Node。
Windows 的 `bridgePath` 和 `mcpConfig...command` 正常应以 `dingtalk-approval-bridge.cmd` 结尾。路径中可能包含空格;保留完整字符串,不要拆分,不要去掉 `.cmd`,不要改为 `node`、`npx` 或远程 URL。
## 步骤 4:安装 Skill 并注册 MCP
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 已正确转义的反斜杠。
5. 不要把 `{{GATEWAY_URL}}` 或 `https://approval.justconn.top/mcp` 作为 MCP URL、command 或参数;Bridge 会从已签名安装状态取得网关地址。
6. 不要添加 `--url`、企业凭证、用户 ID、额外环境变量或 shell 包装器。
正确配置的形态由安装器返回,语义如下;示例路径不能代替真实返回值:
```json
{
"mcpServers": {
"dingtalk-approval": {
"type": "stdio",
"command": "<安装器返回的本地 bridgePath;Windows 为 .cmd>",
"args": []
}
}
}
```
## 步骤 5:处理首次登录状态
安装器已经主动尝试准备登录。读取返回的 `postInstall` 及其 `status`,不得等到用户第一次查询审批:
- `authorization_required`:在完成本地 Skill/MCP 配置的同一轮回复中,把 `postInstall.authChallenge.directUrl` 作为完整、可点击的 URL 原样发给用户;不得只说“已生成”或“已推送”。同时说明链接有有效期,需由用户本人核对授权页面并确认。
- `authenticated`:已有登录可复用,不需要再次让用户授权,继续步骤 6 和步骤 7。
- `unavailable`:客户端安装仍然有效。先完成步骤 4;连接器加载后调用 `approval_login` 重试。不要删除会话、不要重装。
保存 `loginCommand.command` 和 `loginCommand.args`。如果首次链接过期且 MCP 尚未加载,按该对象原样执行命令;Windows PowerShell 的通用执行方式是:
```powershell
$dingLoginJson = & $dingInstall.loginCommand.command @($dingInstall.loginCommand.args)
$dingLogin = $dingLoginJson | ConvertFrom-Json
$dingLogin | ConvertTo-Json -Depth 20
```
把新结果中的 `authChallenge.directUrl` 原样展示。该命令只检查登录或生成新链接,不下载发布物、不修改 Agent 配置。MCP 已加载时则直接重新调用 `approval_login`。登录链接过期时绝不能重新安装。
## 步骤 6:信任并加载连接器
1. 如果当前 Agent 要求信任本地连接器,按其官方界面或命令确认名称为 `dingtalk-approval` 的连接器。
2. 如果不能热加载,完整退出并重新启动 Agent,或新建一个确定会重新读取 MCP 配置的会话;只刷新聊天内容通常不够。
3. 重启后确认能发现 `approval_login`、`approval_list_todos` 等钉钉审批工具。当前版本应发现 13 个工具。
4. 若连接器进程退出、显示 302 或无法启动,先执行步骤 9 的故障排查,不要把连接器改成远程 HTTP MCP。
## 步骤 7:确认登录并验证两个审批来源
用户完成网页授权后:
1. 调用 `approval_login`。工具不会通过其他消息通道自动推送链接;如果响应仍给出 URL,必须把 `content.text` 中的完整登录 URL 原样写入当前回复。
2. 登录成功后,按 `postInstall.verifyTool` 调用一次只读的 `approval_list_todos`,即 `source="all"`、空搜索、`limit=1`。
3. 检查结果中的 `sourceStatus`:`dingtalk.status` 和 `xft.status` 都必须是 `ok`。`disabled`、`auth_required`、`unavailable` 或缺少任一来源都不算安装验证完成。
4. 验证期间只读取最多一条待办,不调用任何写工具。
5. 成功响应包含 `onboarding.firstRun=true` 时,把其中的简短示例和安全提示向用户展示一次,不要自动执行示例。
## 步骤 8:向用户报告结果
最终只需清楚报告:已安装的版本、Skill 与 MCP 已加载、登录是否成功、钉钉 OA 与薪福通是否都通过只读验证。可以给出一次性使用示例。不要向用户倾倒安装 JSON、内部路径、Token、审批引用或诊断细节,除非用户正在排障。
安装阶段不得处理审批,也不得在登录和双来源验证完成前声称“已可使用”。
## 步骤 9:常见故障排查
按顺序排查,不要跳到重装:
1. **显示 302 或启动后立即退出**:检查连接器是否被错误配置成 `{{GATEWAY_URL}}` 或其他 HTTP URL。正确配置必须是 `type: "stdio"`,`command` 为本机 `dingtalk-approval-bridge.cmd`,`args: []`。重新复制安装结果中的 `mcpConfig`。
2. **路径不存在或 Node 升级后失效**:不要引用 Node/NVM 的版本目录。执行安装结果中的 `cliPath`,或运行 `dingtalk-approval print-config --format mcp-json` 重新取得稳定配置。
3. **Bridge 仍无法启动**:执行 `<cliPath> doctor --json`。只有所有 `checks` 都为 `ok: true` 才继续;把失败检查和错误原文报告给维护者,不要提供 Token 或会话文件。
4. **提示找不到 npm-cli.js 或 Node 运行时损坏**:重新执行步骤 2 中已经过哈希校验的平台引导,让它修复插件私有 Node;不要安装全局 npm。
5. **路径包含空格**:不要手写命令行字符串。把安装器返回的整个 `mcpConfig` 作为 JSON 对象交给 Agent;在 PowerShell 中执行 CLI 时用调用运算符 `&` 和独立参数。
6. **连接器一直未出现**:确认已信任本地连接器并完整重启 Agent。不要通过反复重装刷新连接器。
7. **授权链接过期**:MCP 已加载就再次调用 `approval_login`;否则执行保存的 `loginCommand`。不要重装。
8. **Linux/macOS 缺少下载或校验命令**:必须先具备 `curl` 或 `wget`,以及 `sha256sum` 或 `shasum`;不能绕过 SHA-256 校验。系统 `tar` 必须支持 gzip 归档。
9. **安装长时间无最终 JSON或被宿主终止**:查看 stderr 中最后一条 `[dingtalk-approval]` 阶段信息。单个下载超过 90 秒或连续 20 秒低速应自动失败,而不是继续等待;不要改用内部安装器,也不要并发重试。原进程确认已经退出后,可重新执行同一平台引导;它会识别已经死亡的安装进程、清理残留 staging 并安全续装。若明确提示另一个安装仍在运行,应等待该进程结束。
## 更新
更新不需要重新下载首次安装器。使用上次安装结果中的 `cliPath`:
Windows PowerShell:
```powershell
$dingUpdateJson = & $dingInstall.cliPath update --json
$dingUpdate = $dingUpdateJson | ConvertFrom-Json
$dingUpdate | ConvertTo-Json -Depth 20
```
Linux / macOS:
```text
<cliPath> update --json
```
更新会复用已固定的签名清单地址和公钥并保留 OAuth 会话。更新成功后,重新使用返回的 `mcpConfig` 核对同名连接器,并重新连接 MCP;必要时完整重启 Agent。然后重复步骤 7 的登录确认和双来源只读验证。
## 卸载
1. 按当前 Agent 的官方方式删除名称精确为 `dingtalk-approval` 的 MCP/连接器条目。
2. 执行安装结果中的 `cliPath uninstall --yes --json`;Windows PowerShell 可运行:
```powershell
& $dingInstall.cliPath uninstall --yes --json
```
3. 确认返回 `uninstalled` 或 `cleanup_scheduled`。后者表示当前 CLI 退出后完成自清理。
卸载默认清除客户端运行时、Skill、安装记录和 OAuth 会话。不要手工递归删除未知目录。
授权页面会展示请求客户端、回调域名、权限范围或设备授权码。必须由用户本人核对并确认,不得替用户自动提交 consent。网关短暂不可用时保留现有登录状态并稍后重试,不要删除会话文件或主动要求用户重新授权。
+26 -255
View File
@@ -1,267 +1,38 @@
# 钉钉审批客户端安装任务
# 钉钉审批 MCP 安装任务
用户把本文件或本文件的链接交给你,即表示授权你为当前用户安装或更新“钉钉审批”客户端、Skill 和名为 `dingtalk-approval` 的 MCP。该授权不包含同意、拒绝、退回、撤回或发起任何审批。
用户把本文件或链接交给你,即授权你为当前用户连接或更新名为 `dingtalk-approval` 的 MCP。该授权不包含任何审批写操作。
请从“步骤 1”开始逐项执行并验收,不要跳步,不要让用户手工填写企业凭证,也不要根据经验自行改写安装器返回的路径或 MCP 配置。Windows 是完整支持的平台,必须优先使用下面的 PowerShell 步骤。
版本:`{{VERSION}}`
## 固定发布入口
## 默认安装
- 版本:`{{VERSION}}`
- 安装器:`{{INSTALLER_URL}}`
- 安装器 SHA-256:`{{INSTALLER_SHA256}}`
- 发布公钥:`{{PUBLIC_KEY_URL}}`
- 公钥 SHA-256:`{{PUBLIC_KEY_SHA256}}`
- 公钥编号:`{{RELEASE_KEY_ID}}`
- 签名清单:`{{MANIFEST_URL}}`
- 清单签名:`{{MANIFEST_SIGNATURE_URL}}`
- 备用发布源:
{{DOWNLOAD_MIRROR_BASE_URLS_MARKDOWN}}
- 统一网关:`{{GATEWAY_URL}}`
- Windows 安装引导:`{{BOOTSTRAP_WINDOWS_URL}}`
- Windows 安装引导备用地址:
{{BOOTSTRAP_WINDOWS_MIRROR_URLS_MARKDOWN}}
- Windows 安装引导 SHA-256:`{{BOOTSTRAP_WINDOWS_SHA256}}`
- Linux/macOS 安装引导:`{{BOOTSTRAP_POSIX_URL}}`
- Linux/macOS 安装引导备用地址:
{{BOOTSTRAP_POSIX_MIRROR_URLS_MARKDOWN}}
- Linux/macOS 安装引导 SHA-256:`{{BOOTSTRAP_POSIX_SHA256}}`
- 私有 Node.js:`{{NODE_VERSION}}`(最低兼容版本 `{{NODE_MINIMUM_VERSION}}`)
1. 先同时检查两项条件:
- 当前 Agent 支持远程 **Streamable HTTP MCP** 和 MCP 标准 **OAuth 2.0**;
- 用户打开授权页面的设备能够完成宿主注册的 OAuth 回调。若回调地址是 `127.0.0.1` 或 `localhost`,用户必须在运行 Agent 的同一台设备上打开页面。通过 SSH、无头终端或 Messenger 操作远程 Agent,而用户会在手机或另一台电脑打开链接时,不满足此条件。
按实际能力和交互环境判断,不要按 Agent 品牌判断。若无法确定,只问用户一个问题:是否会在运行 Agent 的这台设备上打开授权页面。
2. 仅当两项都满足时,按宿主官方方式新增或更新连接器:
- 名称:`dingtalk-approval`
- URL:`{{GATEWAY_URL}}`
- 传输:Streamable HTTP
- 认证:OAuth(允许宿主使用服务端发现元数据和动态客户端注册)
3. 不要安装 Node.js、npm、本地 Bridge、DWS 或浏览器自动化;不要索取 AppKey、AppSecret、AuthoritySecret、CorpId、userId、员工号、Cookie、Token 或其他企业凭证。
4. 让宿主发起标准 OAuth 登录,并确保用户在能够完成该回调的设备上本人确认。不得代替用户点击授权,也不得声称链接已通过其他消息通道发送。若此时发现宿主使用本机回调而用户只能跨设备打开链接,应停止等待并改用下面的兼容安装,不要把不可完成的链接交给用户反复尝试。
5. 连接成功后调用一次只读 `approval_list_todos`,参数为 `source="all"`、空搜索、`limit=1`。只有响应中的钉钉 OA 与薪福通来源都为 `ok`,才报告安装完成。不得在安装验证中执行任何审批写操作。
6. 若宿主需要重启或重新加载才能识别新连接器,按其官方机制操作;不要因为等待加载而改装本地 Bridge。
首次安装通过上述 HTTPS 地址和固定 SHA-256 建立信任;安装器随后必须使用固定公钥验证 Ed25519 清单签名和全部发布物哈希。URL、TLS、签名或哈希任一不匹配都必须停止。不得索取 AppKey、AppSecret、AuthoritySecret、CorpId、userId、员工号、Cookie 或 Token。
服务端会通过 MCP `instructions` 和工具描述提供审批工作流。登录完成后的首次响应可能包含简短示例;向用户展示一次即可,不要自动执行示例。
## 完成标准
## 兼容安装
只有以下条件全部成立,才能告诉用户“安装完成,可以使用”:
只有在宿主明确不支持远程 Streamable HTTP MCP、不能完成 MCP 标准 OAuth,或用户无法在宿主本机回调所在设备打开授权页面时,才使用本地 STDIO Bridge:
1. 对应平台安装引导的 SHA-256 正确,并已选用兼容的系统 Node 或经过官方哈希校验的私有 Node;
2. 安装器 JSON 返回 `ok: true`;
3. 已按当前 Agent 的官方方式安装返回的 `skillPath`;
4. 已原样使用返回的 `mcpConfig` 注册并信任本地 stdio MCP;
5. `approval_login` 确认已登录;
6. `approval_list_todos` 的双来源验证中,钉钉 OA 与薪福通都为 `ok`;
7. 未执行任何审批写操作。
`{{COMPAT_INSTALL_URL}}`
## 步骤 1:确认操作系统与架构
备用地址:
{{COMPAT_INSTALL_MIRROR_URLS_MARKDOWN}}
支持以下当前用户环境:
本地 Bridge 使用可跨设备完成的 device-code 登录,授权页面可以通过当前对话发给用户并在手机或另一台电脑打开。不要在同机回调明确可用时先安装 Bridge 来“提高成功率”;若已经明确是 SSH、无头或跨设备交互,应直接进入兼容路径,不必先制造一次必然失败的远程 OAuth。
- Windows 10/11:x64 或 ARM64;
- Linux:x64 或 ARM64;
- macOS:Intel x64 或 Apple Silicon ARM64。
## 完成后
系统无需预装 Node.js。安装引导会优先复用版本不低于 `{{NODE_MINIMUM_VERSION}}` 且 npm 可用的系统 Node;否则自动准备只属于本插件的 Node `{{NODE_VERSION}}`。不要让用户安装全局 Node,不要使用管理员权限、`sudo`、全局 npm 安装、DWS 或浏览器自动化。
## 步骤 2:下载、校验并执行平台安装引导
不要在浏览器中复制脚本内容,不要对 URL 解码、重新编码或手工拼接。必须先校验安装引导 SHA-256,再执行它。
为执行平台安装引导的 Agent 工具调用设置至少 10 分钟超时。引导和安装器会把 `[dingtalk-approval]` 开头的阶段进度写入 stderr,把唯一的最终结果 JSON 写入 stdout;进度文本不是失败,也不能混入 JSON 解析。进程仍在运行时不得并发启动第二次安装。
安装器和后续更新优先使用系统 `curl` 下载签名清单与发布物;`curl` 会直接使用 `HTTPS_PROXY`、`HTTP_PROXY`、`ALL_PROXY` 和 `NO_PROXY`,Node `fetch` 仅在系统没有 `curl` 时兜底。登录和 Bridge 仍会按 Node 能力启用环境代理。不要设置全局 `NODE_OPTIONS=--use-env-proxy`,也不要手工预装 `node_modules`;这会让 npm 与 Node 的代理机制叠加,并破坏安装器的原子安装与故障恢复。
### Windows(PowerShell)
无论当前 Agent 默认使用 PowerShell、CMD 还是 Git Bash,Windows 都必须调用普通 PowerShell 执行下面的完整流程。不得从 Git Bash 直接运行内部的 `node dingtalk-approval-installer.mjs ...` 命令;内部安装器不是用户安装入口。
在普通 PowerShell 中逐行运行:
```powershell
$dingBootstrapDir = Join-Path ([System.IO.Path]::GetTempPath()) ("dingmcp-bootstrap-" + [guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Path $dingBootstrapDir -Force | Out-Null
$dingBootstrap = Join-Path $dingBootstrapDir "dingtalk-approval-bootstrap.ps1"
$dingBootstrapUrls = @({{BOOTSTRAP_WINDOWS_URLS_POWERSHELL}})
$dingBootstrapDownloaded = $false
foreach ($dingBootstrapUrl in $dingBootstrapUrls) {
try {
Invoke-WebRequest -UseBasicParsing -Uri $dingBootstrapUrl -OutFile $dingBootstrap -TimeoutSec 300
} catch {
continue
}
$dingBootstrapHash = (Get-FileHash -Algorithm SHA256 $dingBootstrap).Hash.ToLowerInvariant()
if ($dingBootstrapHash -ne "{{BOOTSTRAP_WINDOWS_SHA256}}") { throw "Windows 安装引导 SHA-256 不匹配,停止安装:$dingBootstrapUrl" }
$dingBootstrapDownloaded = $true
break
}
if (-not $dingBootstrapDownloaded) { throw "所有 Windows 安装引导下载源均不可用" }
$dingInstallJson = & powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $dingBootstrap
if ($LASTEXITCODE -ne 0) { throw "钉钉审批安装引导执行失败:$dingInstallJson" }
$dingInstall = $dingInstallJson | ConvertFrom-Json
if ($dingInstall.ok -ne $true) { throw "钉钉审批安装失败:$($dingInstall.error)" }
$dingInstall | ConvertTo-Json -Depth 20
```
完成后可删除 `$dingBootstrapDir`。任一下载或哈希检查失败都不能忽略。
### Linux / macOS(POSIX shell)
```bash
ding_bootstrap_dir="$(mktemp -d)"
ding_bootstrap="$ding_bootstrap_dir/dingtalk-approval-bootstrap.sh"
ding_bootstrap_downloaded=0
for ding_bootstrap_url in {{BOOTSTRAP_POSIX_URLS_POSIX}}; do
rm -f "$ding_bootstrap"
if command -v curl >/dev/null 2>&1; then
curl --fail --location --connect-timeout 10 --max-time 300 --output "$ding_bootstrap" "$ding_bootstrap_url" || continue
elif command -v wget >/dev/null 2>&1; then
wget --https-only --timeout=300 --output-document="$ding_bootstrap" "$ding_bootstrap_url" || continue
else
echo '缺少 curl 或 wget,无法下载安装引导' >&2
exit 1
fi
if command -v sha256sum >/dev/null 2>&1; then
ding_bootstrap_hash="$(sha256sum "$ding_bootstrap" | awk '{print $1}')"
else
ding_bootstrap_hash="$(shasum -a 256 "$ding_bootstrap" | awk '{print $1}')"
fi
if [ "$ding_bootstrap_hash" != '{{BOOTSTRAP_POSIX_SHA256}}' ]; then
echo "Linux/macOS 安装引导 SHA-256 不匹配,停止安装:$ding_bootstrap_url" >&2
exit 1
fi
ding_bootstrap_downloaded=1
break
done
if [ "$ding_bootstrap_downloaded" -ne 1 ]; then
echo '所有 Linux/macOS 安装引导下载源均不可用' >&2
exit 1
fi
ding_install_json="$(sh "$ding_bootstrap")"
printf '%s\n' "$ding_install_json"
```
完成后可删除 `$ding_bootstrap_dir`。如果 `sha256sum` 与 `shasum` 都不存在,必须停止,不能跳过校验。
## 步骤 3:验收安装结果
步骤 2 的平台引导是唯一通用安装入口。它会:
1. 检查系统 Node;缺失或版本不兼容时,根据当前操作系统和 CPU 架构选择私有 Node;
2. 按“阿里云精确版本 → npmmirror 精确版本 → Node 官方精确版本”的顺序下载 Node;项目安装引导和发布物则优先使用国内 Gitea 静态发布源,再回退到自建 HTTPS 下载源和其他已配置镜像。网络或 HTTP 下载失败才切换来源,任一已下载文件的签名或 SHA-256 不匹配都会立即终止;
3. 把私有 Node 放在插件自己的当前用户数据目录,不修改系统 PATH,不要求管理员权限;
4. 下载并校验首次安装器和发布公钥,再由安装器验证 Ed25519 清单签名与全部客户端发布物;下载源不是信任根,备用源不能绕过签名或哈希;
5. 返回一份 JSON 安装结果。
安装器会在稳定的当前用户数据目录安装客户端与 Skill,不会全局安装 npm 包,也不会修改 Agent 私有配置。保留完整 JSON 结果,后续只使用其中的真实字段,不要猜路径。
安装结果至少必须包含:
- `ok: true`、`status: "installed"` 和 `version`;
- `cliPath`、`bridgePath`、`skillPath`;
- `loginCommand`;
- `mcpConfig.mcpServers.dingtalk-approval`;
- `postInstall`。
- `nodeRuntime`,其中 `managed: true` 表示正在使用插件私有 Node。
Windows 的 `bridgePath` 和 `mcpConfig...command` 正常应以 `dingtalk-approval-bridge.cmd` 结尾。路径中可能包含空格;保留完整字符串,不要拆分,不要去掉 `.cmd`,不要改为 `node`、`npx` 或远程 URL。
## 步骤 4:安装 Skill 并注册 MCP
1. 按当前 Agent 的官方方式安装 `skillPath` 指向的整个目录,而不是只复制 `SKILL.md`。确认目录中同时存在 `SKILL.md` 和 `references/create-approval.md`。
2. 使用 `mcpConfig` 只新增或替换名称精确为 `dingtalk-approval` 的连接器。安装器本身不会猜测或修改 Agent 的私有配置。
3. 如果 Agent 接受完整 MCP JSON,原样使用整个 `mcpConfig`;如果界面只接受单个服务器配置,原样使用 `mcpConfig.mcpServers.dingtalk-approval`。
4. 该连接器必须是本地 `stdio`,`command` 必须是返回的本地 `bridgePath`,`args` 必须为空数组。Windows 中必须保留 JSON 已正确转义的反斜杠。
5. 不要把 `{{GATEWAY_URL}}` 或 `https://approval.justconn.top/mcp` 作为 MCP URL、command 或参数;Bridge 会从已签名安装状态取得网关地址。
6. 不要添加 `--url`、企业凭证、用户 ID、额外环境变量或 shell 包装器。
正确配置的形态由安装器返回,语义如下;示例路径不能代替真实返回值:
```json
{
"mcpServers": {
"dingtalk-approval": {
"type": "stdio",
"command": "<安装器返回的本地 bridgePath;Windows 为 .cmd>",
"args": []
}
}
}
```
## 步骤 5:处理首次登录状态
安装器已经主动尝试准备登录。读取返回的 `postInstall` 及其 `status`,不得等到用户第一次查询审批:
- `authorization_required`:在完成本地 Skill/MCP 配置的同一轮回复中,把 `postInstall.authChallenge.directUrl` 作为完整、可点击的 URL 原样发给用户;不得只说“已生成”或“已推送”。同时说明链接有有效期,需由用户本人核对授权页面并确认。
- `authenticated`:已有登录可复用,不需要再次让用户授权,继续步骤 6 和步骤 7。
- `unavailable`:客户端安装仍然有效。先完成步骤 4;连接器加载后调用 `approval_login` 重试。不要删除会话、不要重装。
保存 `loginCommand.command` 和 `loginCommand.args`。如果首次链接过期且 MCP 尚未加载,按该对象原样执行命令;Windows PowerShell 的通用执行方式是:
```powershell
$dingLoginJson = & $dingInstall.loginCommand.command @($dingInstall.loginCommand.args)
$dingLogin = $dingLoginJson | ConvertFrom-Json
$dingLogin | ConvertTo-Json -Depth 20
```
把新结果中的 `authChallenge.directUrl` 原样展示。该命令只检查登录或生成新链接,不下载发布物、不修改 Agent 配置。MCP 已加载时则直接重新调用 `approval_login`。登录链接过期时绝不能重新安装。
## 步骤 6:信任并加载连接器
1. 如果当前 Agent 要求信任本地连接器,按其官方界面或命令确认名称为 `dingtalk-approval` 的连接器。
2. 如果不能热加载,完整退出并重新启动 Agent,或新建一个确定会重新读取 MCP 配置的会话;只刷新聊天内容通常不够。
3. 重启后确认能发现 `approval_login`、`approval_list_todos` 等钉钉审批工具。当前版本应发现 13 个工具。
4. 若连接器进程退出、显示 302 或无法启动,先执行步骤 9 的故障排查,不要把连接器改成远程 HTTP MCP。
## 步骤 7:确认登录并验证两个审批来源
用户完成网页授权后:
1. 调用 `approval_login`。工具不会通过其他消息通道自动推送链接;如果响应仍给出 URL,必须把 `content.text` 中的完整登录 URL 原样写入当前回复。
2. 登录成功后,按 `postInstall.verifyTool` 调用一次只读的 `approval_list_todos`,即 `source="all"`、空搜索、`limit=1`。
3. 检查结果中的 `sourceStatus`:`dingtalk.status` 和 `xft.status` 都必须是 `ok`。`disabled`、`auth_required`、`unavailable` 或缺少任一来源都不算安装验证完成。
4. 验证期间只读取最多一条待办,不调用任何写工具。
5. 成功响应包含 `onboarding.firstRun=true` 时,把其中的简短示例和安全提示向用户展示一次,不要自动执行示例。
## 步骤 8:向用户报告结果
最终只需清楚报告:已安装的版本、Skill 与 MCP 已加载、登录是否成功、钉钉 OA 与薪福通是否都通过只读验证。可以给出一次性使用示例。不要向用户倾倒安装 JSON、内部路径、Token、审批引用或诊断细节,除非用户正在排障。
安装阶段不得处理审批,也不得在登录和双来源验证完成前声称“已可使用”。
## 步骤 9:常见故障排查
按顺序排查,不要跳到重装:
1. **显示 302 或启动后立即退出**:检查连接器是否被错误配置成 `{{GATEWAY_URL}}` 或其他 HTTP URL。正确配置必须是 `type: "stdio"`,`command` 为本机 `dingtalk-approval-bridge.cmd`,`args: []`。重新复制安装结果中的 `mcpConfig`。
2. **路径不存在或 Node 升级后失效**:不要引用 Node/NVM 的版本目录。执行安装结果中的 `cliPath`,或运行 `dingtalk-approval print-config --format mcp-json` 重新取得稳定配置。
3. **Bridge 仍无法启动**:执行 `<cliPath> doctor --json`。只有所有 `checks` 都为 `ok: true` 才继续;把失败检查和错误原文报告给维护者,不要提供 Token 或会话文件。
4. **提示找不到 npm-cli.js 或 Node 运行时损坏**:重新执行步骤 2 中已经过哈希校验的平台引导,让它修复插件私有 Node;不要安装全局 npm。
5. **路径包含空格**:不要手写命令行字符串。把安装器返回的整个 `mcpConfig` 作为 JSON 对象交给 Agent;在 PowerShell 中执行 CLI 时用调用运算符 `&` 和独立参数。
6. **连接器一直未出现**:确认已信任本地连接器并完整重启 Agent。不要通过反复重装刷新连接器。
7. **授权链接过期**:MCP 已加载就再次调用 `approval_login`;否则执行保存的 `loginCommand`。不要重装。
8. **Linux/macOS 缺少下载或校验命令**:必须先具备 `curl` 或 `wget`,以及 `sha256sum` 或 `shasum`;不能绕过 SHA-256 校验。系统 `tar` 必须支持 gzip 归档。
9. **安装长时间无最终 JSON或被宿主终止**:先确认工具调用超时不少于 10 分钟,并查看 stderr 中最后一条 `[dingtalk-approval]` 阶段信息。不要改用内部安装器,也不要并发重试。原进程确认已经退出后,可重新执行同一平台引导;它会识别已经死亡的安装进程、清理残留 staging 并安全续装。若明确提示另一个安装仍在运行,应等待该进程结束。
## 更新
更新不需要重新下载首次安装器。使用上次安装结果中的 `cliPath`:
Windows PowerShell:
```powershell
$dingUpdateJson = & $dingInstall.cliPath update --json
$dingUpdate = $dingUpdateJson | ConvertFrom-Json
$dingUpdate | ConvertTo-Json -Depth 20
```
Linux / macOS:
```text
<cliPath> update --json
```
更新会复用已固定的签名清单地址和公钥并保留 OAuth 会话。更新成功后,重新使用返回的 `mcpConfig` 核对同名连接器,并重新连接 MCP;必要时完整重启 Agent。然后重复步骤 7 的登录确认和双来源只读验证。
## 卸载
1. 按当前 Agent 的官方方式删除名称精确为 `dingtalk-approval` 的 MCP/连接器条目。
2. 执行安装结果中的 `cliPath uninstall --yes --json`;Windows PowerShell 可运行:
```powershell
& $dingInstall.cliPath uninstall --yes --json
```
3. 确认返回 `uninstalled` 或 `cleanup_scheduled`。后者表示当前 CLI 退出后完成自清理。
卸载默认清除客户端运行时、Skill、安装记录和 OAuth 会话。不要手工递归删除未知目录。
授权页面会展示请求客户端、回调域名、权限范围或设备授权码。必须由用户本人核对并确认,不得替用户自动提交 consent。网关短暂不可用时保留现有登录状态并稍后重试,不要删除会话文件或主动要求用户重新授权。
只向用户说明连接器已就绪、登录状态,以及钉钉 OA/薪福通是否通过只读验证。不要展示内部 Token、用户 ID、审批引用或连接器诊断数据。
+2 -2
View File
@@ -4,9 +4,9 @@
- 测试包:`{{BUNDLE_URL}}`
- SHA-256:`{{BUNDLE_SHA256}}`
- 模式:轻量本地桥 + 统一审批网关;一次钉钉登录同时覆盖钉钉 OA 与薪福通,必须完整验证两个审批源
- 模式:同机回调可达时使用远程 Streamable HTTP MCP + 宿主原生 OAuth;跨设备且宿主使用 loopback 回调时使用本地 Bridge + device-code。一次钉钉登录同时覆盖钉钉 OA 与薪福通,必须完整验证两个审批源
安装必须从包内的 `install.md` 进入,通过原生安装器完成 Ed25519 清单和发布物校验;不要改用旧版全局 npm 安装步骤。
安装必须从包内的 `install.md` 进入。同机回调可达时不得安装 Node/npm 或本地 Bridge;只有宿主明确不支持远程 MCP/标准 OAuth,或用户无法在宿主 loopback 回调所在设备打开授权页时,才按其中链接的 `compat-install.md` 走签名兼容安装,并在报告中说明选择原因。
只通过安装后的 `dingtalk-approval` MCP 读取和处理审批业务,不使用上游接口旁路。验证一次登录、钉钉 OA 与薪福通的待办、详情和动作能力;任何来源显示 `disabled` 都应视为发行配置错误并停止测试。真实写操作仅限用户明确确认的一次同意;可从任一来源选择,但不得因其中一方不可用而跳过。不要真实拒绝或退回。若结果为 `outcome_uncertain`,禁止自动重试。
+3 -2
View File
@@ -29,7 +29,8 @@ ding_hash_file() {
ding_fetch() {
if command -v curl >/dev/null 2>&1; then
if ding_fetch_http="$(curl --fail --location --silent --show-error --connect-timeout 10 --max-time 300 \
if ding_fetch_http="$(curl --fail --location --silent --show-error --connect-timeout 10 --max-time 90 \
--speed-time 20 --speed-limit 1024 \
--write-out '%{http_code}' --output "$2" "$1")"; then
return 0
else
@@ -47,7 +48,7 @@ ding_fetch() {
return 1
elif command -v wget >/dev/null 2>&1; then
ding_wget_log="${2}.wget.log"
if wget --https-only --timeout=300 --server-response --output-document="$2" "$1" 2>"$ding_wget_log"; then
if wget --https-only --timeout=90 --server-response --output-document="$2" "$1" 2>"$ding_wget_log"; then
rm -f "$ding_wget_log"
return 0
else
+21 -4
View File
@@ -1,5 +1,9 @@
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
$dingUtf8 = New-Object System.Text.UTF8Encoding($false)
[Console]::InputEncoding = $dingUtf8
[Console]::OutputEncoding = $dingUtf8
$OutputEncoding = $dingUtf8
if (Get-Variable -Name PSNativeCommandUseErrorActionPreference -ErrorAction SilentlyContinue) {
$PSNativeCommandUseErrorActionPreference = $false
}
@@ -23,17 +27,30 @@ function Get-DingSha256([string] $dingPath) {
function Save-DingDownload([string[]] $dingUrls, [string] $dingTarget, [string] $dingExpectedSha256) {
$dingLastError = $null
$dingCurl = Get-Command curl.exe -ErrorAction SilentlyContinue
foreach ($dingUrl in $dingUrls) {
Write-DingProgress "正在下载并校验:$dingUrl"
try {
if (Test-Path -LiteralPath $dingTarget) { Remove-Item -LiteralPath $dingTarget -Force }
Invoke-WebRequest -UseBasicParsing -Uri $dingUrl -OutFile $dingTarget -TimeoutSec 300
if ($dingCurl) {
$dingCurlHttp = & $dingCurl.Source --fail --location --silent --show-error --connect-timeout 10 --max-time 90 --speed-time 20 --speed-limit 1024 --write-out "%{http_code}" --output $dingTarget $dingUrl
$dingCurlExit = $LASTEXITCODE
if ($dingCurlExit -ne 0) {
if ($dingCurlHttp -match '^[1-5][0-9][0-9]$' -and $dingCurlHttp -ne '000') { throw "HTTP $dingCurlHttp" }
throw "curl.exe 退出码 $dingCurlExit"
}
} else {
Invoke-WebRequest -UseBasicParsing -Uri $dingUrl -OutFile $dingTarget -TimeoutSec 90
}
} catch {
$dingLastError = $_.Exception.Message
$dingStatusCode = $null
if ($_.Exception.Response -and $_.Exception.Response.StatusCode) {
try { $dingStatusCode = [int]$_.Exception.Response.StatusCode } catch { $dingStatusCode = $null }
}
if ($null -eq $dingStatusCode -and $dingLastError -match 'HTTP ([0-9][0-9][0-9])') {
$dingStatusCode = [int]$Matches[1]
}
if ($null -eq $dingStatusCode -or $dingStatusCode -eq 404) { continue }
throw "下载失败且不允许换源(HTTP $dingStatusCode):$dingUrl;$dingLastError"
}
@@ -49,7 +66,7 @@ function Save-DingDownload([string[]] $dingUrls, [string] $dingTarget, [string]
function Test-DingNode([string] $dingNodePath, [bool] $dingRequireAdjacentNpm) {
if (-not $dingNodePath -or -not (Test-Path -LiteralPath $dingNodePath -PathType Leaf)) { return $false }
try {
& $dingNodePath -e 'const [a,b]=process.versions.node.split(".").map(Number);process.exit(a>22||(a===22&&b>=13)?0:1)'
& $dingNodePath -e "const [a,b]=process.versions.node.split('.').map(Number);process.exit(a>22||(a===22&&b>=13)?0:1)"
if ($LASTEXITCODE -ne 0) { return $false }
if ($dingRequireAdjacentNpm) {
$dingNpmCli = Join-Path (Split-Path -Parent $dingNodePath) "node_modules\npm\bin\npm-cli.js"
@@ -79,7 +96,7 @@ if (-not (Test-DingNode $dingNodePath $false)) {
Join-Path $env:LOCALAPPDATA "DingTalkApproval"
}
$dingRuntimeParent = Join-Path $dingClientRoot "node"
$dingRuntimeName = [IO.Path]::GetFileNameWithoutExtension([IO.Path]::GetFileNameWithoutExtension($dingNodeFile))
$dingRuntimeName = [IO.Path]::GetFileNameWithoutExtension($dingNodeFile)
$dingRuntimeDir = Join-Path $dingRuntimeParent $dingRuntimeName
$dingNodePath = Join-Path $dingRuntimeDir "node.exe"
if (-not (Test-DingNode $dingNodePath $true)) {
@@ -126,7 +143,7 @@ try {
$dingProxyConfigured = $env:HTTPS_PROXY -or $env:HTTP_PROXY -or $env:ALL_PROXY
$dingProxyAlreadyEnabled = ($env:NODE_OPTIONS -match '(^|\s)--use-env-proxy($|\s)') -or ($env:NODE_USE_ENV_PROXY -match '^(1|true)$')
if ($dingProxyConfigured -and -not $dingProxyAlreadyEnabled) {
& $dingNodePath -e 'process.exit(process.allowedNodeEnvironmentFlags.has("--use-env-proxy") ? 0 : 1)'
& $dingNodePath -e "process.exit(process.allowedNodeEnvironmentFlags.has('--use-env-proxy') ? 0 : 1)"
if ($LASTEXITCODE -eq 0) { $dingNodeProxyArgs = @("--use-env-proxy") }
}
$dingInstallOutput = & $dingNodePath @dingNodeProxyArgs @dingInstallerArgs
+24 -16
View File
@@ -3,31 +3,39 @@
## 数据路径
```text
用户 ─ Messenger ─ Agent ─ stdio 轻量桥 ─ HTTPS/OAuth ─ 统一审批网关
├─ 钉钉 OA
└─ 薪福通
用户 ─ Messenger ─ Agent
├─ 可选本机私有提单草稿
└─ 远程 Streamable HTTP MCP/OAuth ─ 统一审批网关
├─ 钉钉 OA
└─ 薪福通
```
本地桥只完成 MCP stdio 与远程 Streamable HTTP 的转换,保存用户自己的短期 access/refresh token,不包含企业密钥、不读取审批上游,也不监听端口。
支持远程 MCP 和标准 OAuth,且用户能完成宿主所注册回调的场景直接连接网关,不安装客户端运行时。若宿主使用 `127.0.0.1`/`localhost` 回调,授权页必须在 Agent 所在设备打开。宿主缺少远程传输/OAuth 能力,或用户通过 SSH、无头终端、Messenger 等方式跨设备交互而无法完成本机回调时,使用本地 Bridge 把 stdio 转换为远程 Streamable HTTP,并通过可跨设备的 device-code 登录。Bridge 只保存用户自己的短期 access/refresh token,不包含企业密钥、不读取审批上游,也不监听端口。
## 客户端安装边界
终端不再使用全局 npm prefix。无第三方依赖的首次安装器通过自有 HTTPS 到达用户机器,随后使用固定 Ed25519 公钥验证 schema v3 清单签名,并校验每个 artifact 的 SHA-256。客户端运行依赖被内置在签名 artifact 中,终端使用 npm 离线模式展开,不在安装时引入清单以外的代码。首次安装以 HTTPS 和安装说明中固定的安装器/公钥哈希建立信任;本地固定公钥保护后续更新链路。发布私钥不进入 Git、网关进程或公开目录。
同机回调可达时,默认安装仅在宿主中登记远程 HTTPS MCP URL。OAuth 客户端注册、PKCE、Token 保存和刷新均由宿主的标准 MCP 实现负责,终端不需要 Node/npm、Bridge 或项目安装器。安装 Agent 必须按实际回调拓扑选择路径:能够显示 URL 不等于能够完成回调;回调是 loopback 且用户会在另一台设备打开时直接选择兼容 Bridge。网关的 MCP `instructions` 与工具描述提供业务工作流,终端修改本地提示不能绕过服务器的身份和写前校验。
运行时位于稳定的用户数据目录,启动器与 Node/NVM 具体版本解耦。Windows、Linux、macOS 的平台引导会复用兼容系统 Node;系统没有 Node 时,从固定精确版本的国内镜像下载并用 Node 官方 SHA-256 校验 x64/ARM64 制品,再放入插件自己的用户目录。项目发布物把国内 Gitea Release 作为首选静态源,并在签名清单中固定自建 HTTPS 备用源,首次安装与更新可在网络失败时自动回退;下载源不构成信任根,签名或哈希不符时立即终止。安装和更新优先调用系统 `curl`,因此直接复用宿主已验证的代理配置;Node `fetch` 只在没有 `curl` 时兜底。安装不修改 PATH,也不需要管理员权限。启动器固定使用安装时选定的 Node,更新和卸载沿用同一运行时;离线 npm 子进程不会继承 Node 专用代理开关。
兼容发行物只为不支持远程 MCP/OAuth 的宿主保留。它不使用全局 npm prefix:首次安装器以固定 Ed25519 公钥验证 schema v3 清单和 artifact SHA-256,并把内置依赖展开到稳定的用户目录。Windows/Linux/macOS 引导可复用系统 Node 或安装经官方哈希校验的固定私有 Node,不改 PATH、不要求管理员权限。发布物优先使用国内 Gitea 和 Node 固定版本源,自建 HTTPS/官方源作为受同一签名或哈希保护的备用。
安装器只有一条通用路径,输出稳定 `bridgePath`、通用 `skillPath`、标准 `mcpConfig`、`nodeRuntime` 和机器可读 `postInstall`,不识别 Agent 品牌,也不写任何 Agent 的私有配置。stdout 只承载最终 JSON,stderr 输出可安全展示的阶段进度;默认写入使用同步文件描述符,减少宿主结束进程时诊断文本尚未刷新。离线 npm 展开有 180 秒硬超时和明确错误,三个独立发布物并行下载。跨进程目录锁阻止两个 Agent 同时安装;锁记录 PID,外部终止后下一次安装可回收死亡锁及 `.staging-*` 残留。
`postInstall` 通过已安装的 Bridge 和正式网关主动检查登录;它只返回认证状态、短期授权链接与下一步工具提示,不返回 Token 或 device secret。网关不可用只会把状态标为 `unavailable`,不会回滚已验证的客户端安装。
连接器信任和重启属于 Agent 自身的安全边界,安装器不会绕过。卸载默认删除运行时、Skill、安装记录和 OAuth 会话;Agent 拥有的连接器条目由其官方入口移除。旧 npm 入口只在名称和目标包都精确匹配时清理。
兼容 Windows 引导以 UTF-8 BOM 发布,确保 Windows PowerShell 5.1 正确解析中文,并显式把控制台和原生程序管道固定为 UTF-8,避免 Node 返回的 JSON/诊断被按系统代码页解码。传给 `node -e` 的脚本使用 PowerShell 不会吞掉的引号组合。Windows ZIP 目录名只去掉一次 `.zip`,避免把 Node 语义版本中的 `.2-win-x64` 误当作第二层扩展名。下载优先调用 `curl.exe` 复用代理,并设置 10 秒连接、20 秒低速和 90 秒总时限;`Invoke-WebRequest` 只在没有 curl 时兜底。安装器仍输出稳定 `bridgePath`、`skillPath`、`mcpConfig` 和 `postInstall`,保留原子安装、跨进程锁、明确诊断、更新与卸载能力。
## Agent 领域接口
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` 使用服务器派生的本人员工号查询单据,再由网关统一按时间窗口过滤。读取历史详情时再次验证当前用户确实处理过该任务或确实是发起人;审批正文、费用明细和附件仍只实时读取、不落库。
@@ -44,16 +52,16 @@ Agent 面向统一审批语义,不直接理解钉钉 `processInstanceId/taskId
## 一次登录
安装器在安装完成后立即调用 Bridge 的登录准备模式,不等待首次业务请求。未登录时,`postInstall.authChallenge.directUrl` 直接携带一个钉钉授权链接;用户授权后,Agent 调用 `approval_login` 确认状态。安装结果同时返回轻量 `loginCommand`:连接器尚未加载且链接过期时,它直接复用已安装 Bridge 和本地会话生成新链接,不下载发布物、不改 Agent 配置;连接器已加载时则再次调用 `approval_login`。授权成功后,网关从钉钉身份得到当前企业 `userId`:
远程模式由宿主按 MCP 标准读取网关的 Protected Resource / Authorization Server Metadata,通过动态客户端注册、授权码 + PKCE 和 Token 刷新完成登录。宿主负责呈现授权入口;用户本人完成 consent 和钉钉登录后,网关从已验证 Token 得到当前企业 `userId`:
- 钉钉 OA 直接按该 `userId` 查询本人审批任务;
- 薪福通先把该 `userId` 映射为企业成员,再从本人企业成员档案读取花名册员工号。
两条数据路径共用同一个网关 Token,因此不会出现本地 DWS 与网关分别登录的情况。
Bridge 的受保护会话中只记录 `pending/delivered` 一次性 onboarding 状态,不保存任何审批正文。登录挑战不会消费该状态;首次认证成功响应,或 Agent 跳过登录工具后的首个成功业务响应,会在普通 `content.text` 和 `structuredContent.onboarding` 中同时返回简短用法示例并标记已展示。错误响应、登录过期和后续升级都不会重复展示或自动执行示例。
首次认证后的首个成功业务响应会在普通 `content.text` 和 `structuredContent.onboarding` 中同时返回简短用法示例;错误响应不会自动执行示例。兼容 Bridge 另外在本地受保护会话中记录 `pending/delivered` 状态,避免重复展示,但不保存审批正文。
远程 `https://approval.justconn.top/mcp` 已按 MCP 授权规范提供 HTTP 401 challenge、OAuth Protected Resource Metadata、Authorization Server Metadata、动态客户端注册、PKCE、Token 刷新和撤销;原生支持远程 MCP OAuth 的宿主可以直接接管浏览器授权。面向只支持 stdio 或不能承接 OAuth 回调的宿主,轻量桥使用 device-code 兼容流程。由于部分宿主(例如只展示 `content.text` 的客户端)会丢弃 `structuredContent`,桥会同时在普通文本、标准 `resource_link` 和 `authChallenge.directUrl` 中返回同一个短期登录链接。桥不会调用不存在的消息通道自动推送链接;Agent 必须把 URL 原样写入当前回复,不得只声称已生成或已推送,也不得读取本地会话文件提取它。
远程 `https://approval.justconn.top/mcp` 已按 MCP 授权规范提供 HTTP 401 challenge、OAuth Protected Resource Metadata、Authorization Server Metadata、动态客户端注册、PKCE、Token 刷新和撤销。面向只支持 stdio 或不能承接 OAuth 回调的宿主,轻量桥使用 device-code 兼容流程;桥会在普通文本、标准 `resource_link` 和 `authChallenge.directUrl` 中返回同一个短期登录链接,不会调用不存在的消息通道自动推送。
开放动态客户端注册不等于静默授权。授权码和设备码流程都会先进入网关 consent 页,展示客户端自报名称、回调 origin、权限范围和设备授权码;只有用户显式确认后才生成钉钉登录跳转,回调也会再次检查 consent 状态。OAuth 客户端总量限制为 10,000,180 天未使用且没有活跃授权工件的客户端会被清理;过期登录请求、授权码和 Token 每小时清理。钉钉用户 token 刷新按用户 single-flight,避免并发消费同一个 refresh token;撤销 refresh token 会级联撤销同客户端、同用户的授权。
@@ -86,7 +94,7 @@ Bridge 的受保护会话中只记录 `pending/delivered` 一次性 onboarding
撤回接口若返回 `XFTSYS*` 服务异常或网络级异常,网关会立即只读复核本人单据状态:已撤回则返回 `revoked`,仍为待审批则返回带 `postWriteVerified` 的 `failed`,无法明确归类才返回 `outcome_uncertain`。任何分支都不会自动重试写操作。
截至 `1.1.0-rc.9`,薪福通撤回仍是实验能力:当前企业真实调用 `approve/recall` 会返回 `XFTSYS9999`,写后复核确认单据仍为待审批,尚无成功样本。该限制不影响薪福通查询、审批处理或发起审批;正式版不得把“接口已接通”表述为“撤回已验证成功”。
截至 `1.1.0-rc.15`,薪福通撤回仍是实验能力:当前企业真实调用 `approve/recall` 会返回 `XFTSYS9999`,写后复核确认单据仍为待审批,尚无成功样本。该限制不影响薪福通查询、审批处理或发起审批;正式版不得把“接口已接通”表述为“撤回已验证成功”。
薪福通创建或撤回需要复核时,`approval_get_submission_status` 先用服务器派生的本人员工号和单据详情双重确认目标属于当前用户的已发起单据,再返回状态名称和精简流程历史。只有实时状态仍为 `1(待审批)` 才签发新的短期、用户绑定 `revokeId`;已通过、草稿、拒绝或已撤回单据都不会获得撤回引用。该工具不返回审批正文,也不触发写操作。
@@ -98,4 +106,4 @@ Bridge 的受保护会话中只记录 `pending/delivered` 一次性 onboarding
钉钉企业 OpenAPI 与 DWS 的个人接口不是同一能力面,但两者都支持无副作用的流程预测。网关在准备提单时调用企业接口 `POST /v1.0/workflow/processes/forecast`,发起人身份仍由服务器从已验证 OAuth 用户派生;只有预测成功并补齐全部自选处理人后才签发提交确认。预测请求不会创建审批实例,也不会持久化表单正文。包含 `DDBizSuite` 的考勤/费控模板不会把套件子字段伪装成普通字段提交。`业务招待申请_北极大`虽由普通字段和明细表组成,但真实测试中新版企业创建接口返回系统异常、旧版企业接口拒绝组件格式。当前 6 个白名单钉钉模板因此全部标记为 `client_only`,必须在客户端发起。
薪福通 Schema 会组合正式创建接口字段与企业实时自定义字段。准备阶段递归校验报销金额、费用明细、表格自定义字段、企业必填字段、当前用户成员映射与员工号。人员字段使用花名册编号数组,部门字段使用组织编码数组,表格字段使用带 `sequence`、`columnValues` 和 `titleCode` 的 JSON 行结构。薪福通没有无副作用的流程预测接口,因此所有开放的创建接口都在写后实时核验结果;申请类流程若按企业配置即时完成,会明确返回 `completed`,而不是误报异常。
薪福通 Schema 会组合正式创建接口字段与企业实时自定义字段。准备阶段递归校验报销金额、费用明细、表格自定义字段、企业必填字段和当前用户身份映射。内部仍按正式接口需要使用组织值、选项值及表格 `titleCode`,但这些只存在于 Agent 与网关的工具请求中;用户只选择名称。人员等没有安全名称查询能力的字段不会接受调用方直接塞入内部值。薪福通没有无副作用的流程预测接口,因此所有开放的创建接口都在写后实时核验结果;申请类流程若按企业配置即时完成,会明确返回 `completed`,而不是误报异常。
+10 -22
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.9` 中薪福通撤回仍标记为实验能力:权限和调用契约已接通,但当前企业真实请求返回 `XFTSYS9999`,尚未取得成功样本。网关会写后复核并禁止自动重试。面向试用用户时必须保留这一说明;若业务要求撤回必须稳定可用,应等待薪福通定位后再升级为正式能力。
`1.1.0-rc.15` 中薪福通撤回仍标记为实验能力:权限和调用契约已接通,但当前企业真实请求返回 `XFTSYS9999`,尚未取得成功样本。网关会写后复核并禁止自动重试。面向试用用户时必须保留这一说明;若业务要求撤回必须稳定可用,应等待薪福通定位后再升级为正式能力。
## 首次凭证配置
@@ -80,7 +81,7 @@ cd /path/to/dingMCP
脚本自动测试、构建、用 Ed25519 私钥签署 schema v3 清单、重启服务、原子发布静态文件、安装 Nginx 配置并验证公网健康检查、OAuth 元数据和安装链接。首次签证书时,临时 HTTP 配置只开放 ACME challenge,其余请求返回 403;不会在明文 HTTP 上代理 OAuth、Token 或 MCP。证书通过 Certbot webroot 模式签发,正式配置启用 HTTPS 跳转与不带 `includeSubDomains` 的 HSTS。已有证书会自动复用;签名私钥缺失、权限不是 `0600` 或与固定公钥不匹配时发行会失败。
发行物先复制到独立 staging 目录,再通过符号链接原子切换;客户端不会看到新清单配旧 artifact 的中间状态。服务器保留当前版本和最近两个旧版本用于排障,较老版本自动清理。`install.md`、清单、签名、安装器、Skill、校验文件和测试包均以 `no-cache` 提供;内容寻址的客户端 tgz 才使用 immutable 缓存。签名清单的 `createdAt` 超过 180 天时客户端拒绝安装,避免旧签名版本冻结攻击。
发行物先复制到独立 staging 目录,再通过符号链接原子切换;客户端不会看到新清单配旧 artifact 的中间状态。服务器保留当前版本和最近两个旧版本用于排障,较老版本自动清理。`install.md`、`compat-install.md`、清单、签名、安装器、Skill、校验文件和测试包均以 `no-cache` 提供;内容寻址的客户端 tgz 才使用 immutable 缓存。签名清单的 `createdAt` 超过 180 天时兼容客户端拒绝安装,避免旧签名版本冻结攻击。
卸载:
@@ -97,6 +98,7 @@ cd /path/to/dingMCP
```text
release/
├── install.md
├── compat-install.md
├── manifest.json
├── manifest.sig
├── SHA256SUMS
@@ -116,27 +118,13 @@ release/
https://approval.justconn.top/downloads/dingmcp/install.md
```
Agent 先校验当前平台引导脚本的固定 SHA-256,再执行该引导。平台引导选择并校验 Node 运行时,同时校验首次安装器和发布公钥;安装器随后验证 `manifest.sig` 和全部 artifact 哈希,将内置运行依赖的客户端以 npm 离线模式安装到稳定用户目录并返回标准 MCP 配置。终端安装过程不会从 npm registry 下载额外代码。Bridge 的稳定绝对路径不会包含 NVM 的具体 Node 版本,配置也不再携带 `--url`;网关地址由受签名保护的安装状态提供。
公开 `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` 才算验收通过。
正式部署会先把同一批不可变安装文件上传到公开 Gitea Release,并把它写成每个 artifact 的首选下载地址;`approval.justconn.top` 静态目录作为签名清单固定的备用地址。黑盒测试包及其动态提示词只发布到自建静态目录,不参与客户端安装镜像。平台引导、首次安装和后续更新都会在网络失败、超时或 404 时自动回退;任何来源一旦返回内容,Ed25519 签名或 SHA-256 不匹配都会立即终止。同一版本重跑会复用 Release Tag 的提交时间,测试包也使用归一化 tar 元数据,保证部署可安全重入。两个域名仍可能共享基础设施,如需机器级容灾,可再向 `release:build` 追加一个或多个 `--download-mirror-base-url` 指向独立的国内 OSS/COS。
宿主缺少远程 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 吞掉传给原生程序的双引号。
公开 `install.md` 必须保留从平台检查到双来源验收的完整编号步骤,并分别提供 Windows PowerShell 与 Linux/macOS 的独立路径。三平台都必须支持系统没有 Node 的情况:发布流程固定 Node 精确版本及 Windows/Linux/macOS x64/ARM64 的官方 SHA-256;平台引导依次使用阿里云、npmmirror 和 Node 官方精确版本地址,网络失败才换源,哈希不符立即终止。禁止使用 `latest` 镜像别名、修改系统 PATH 或要求管理员权限。
兼容 Windows 下载优先使用 `curl.exe`,从系统代理变量继承代理,并设置 10 秒连接超时、20 秒低速判定和 90 秒单源总时限;没有 curl 才以 `Invoke-WebRequest -TimeoutSec 90` 兜底。Node 固定版本制品仍按阿里云、npmmirror、官方源顺序尝试并强制校验官方 SHA-256。项目 artifact 由 Ed25519 清单和 SHA-256 保护;来源失败可换源,签名或哈希不符必须立即停止。
Windows 安装说明至少要覆盖 `Invoke-WebRequest` 下载、`Get-FileHash` 校验、JSON 解析、`.cmd` stdio 配置、路径空格、连接器信任与完整重启、独立登录续期、`doctor --json`、更新和卸载。Linux/macOS 必须同时支持 `curl`/`wget` 和 `sha256sum`/`shasum`,并使用系统 gzip tar 解压私有 Node。教程必须明确禁止把网关 HTTPS URL 直接注册为 MCP。
平台引导下载 Node 时使用 300 秒超时,Node 制品不经过本项目的小型网关,避免占用网关带宽。引导只接受固定官方哈希,且把私有 Node 与 npm 保存在客户端专用目录。进入 Node 安装器后,每个项目发布物仍使用 30 秒超时和 25 MB 流式硬上限,不会先把无界响应完整缓冲到内存;互不依赖的客户端包、Skill 和公钥并行下载。
平台引导与安装器把阶段进度写入 stderr,stdout 只保留最终 JSON。Agent 执行首次安装时必须给予至少 10 分钟工具超时;Windows 不论默认 shell 是什么都必须使用 PowerShell bootstrap,禁止直接调用内部 Node 安装器。安装器优先由当前 Node 直接启动相邻的 `npm-cli.js`,不会依赖易受 PATH、shell 或 Node 安全加固影响的隐式 npm shim;离线展开限时 180 秒。跨进程安装锁禁止并发写入,同一 PID 存活时要求等待,进程已死亡时自动回收锁并清理残留 staging。
代理环境不需要 Agent 修改全局 `NODE_OPTIONS`。首次安装和后续更新优先使用系统 `curl` 获取清单与发布物,直接复用 `HTTPS_PROXY`、`HTTP_PROXY`、`ALL_PROXY` 与 `NO_PROXY`;只有系统没有 `curl` 时才使用 Node `fetch`。平台引导和稳定启动器只对登录及 Bridge 等联网 Node 子进程按能力添加 `--use-env-proxy`;npm 子进程会移除该 Node 专用开关和 `NODE_USE_ENV_PROXY`,但保留标准代理变量,由 npm 使用自己的代理实现。禁止用手工预装 `node_modules` 绕过这一流程。
安装器不会识别 Agent 品牌或直接修改其配置。所有 Agent 都使用返回的 `skillPath` 和标准 `mcpConfig`,按自身官方方式安装 Skill、注册并信任名为 `dingtalk-approval` 的本地 stdio MCP;不能热加载连接器时再新建会话或重启。
安装器在返回前通过已安装 Bridge 主动准备登录,并在 JSON 的 `postInstall` 中返回 `authenticated`、`authorization_required` 或 `unavailable`,同时返回独立的 `loginCommand`。Agent 必须立即显示其中的短期 `directUrl`,不得等待首次审批请求;网页授权后调用 `approval_login`,再按 `verifyTool` 用 `source=all` 做双来源只读验证。链接过期时,MCP 已加载就重新调用 `approval_login`,尚未加载就执行 `loginCommand`,禁止为刷新链接重跑安装。首次认证成功会返回一次性 onboarding 示例,普通文本宿主也可见。登录准备失败不回滚安装,且整个流程不提交 consent、不读取审批、不执行写操作。
安装后可用稳定 CLI:`login --json` 只检查登录或生成新授权链接;`print-path bridge` 默认只输出路径;`print-config --format mcp-json` 输出可直接消费的标准 MCP JSON;`doctor --json` 同时检查 Node、npm、运行时、Skill、公钥和网关配置。后续更新直接执行安装结果中 `cliPath` 对应的 `update --json`,它会复用已固定的签名清单地址和本地公钥,并保留 OAuth 会话。更新完成后应重新连接该 MCP;Agent 不能单独重启连接器时,再新建会话或重启。
客户端卸载使用安装结果返回的 `cliPath uninstall --yes --json`,默认删除 OAuth 会话,并安全清理由旧版手工删除留下的四个 npm 入口;只会删除名称精确匹配且目标属于 `dingtalk-approval-mcp` 的链接或 Windows shim。
正式部署会把同一批不可变兼容安装文件上传到公开 Gitea Release,并把自建 HTTPS 作为固定备用。远程默认路径不依赖这些客户端文件,只依赖网关健康、OAuth 元数据和 `/mcp`。同一版本重跑复用 Release Tag 的提交时间和归一化测试包,保持可重入。
## 运维检查
@@ -148,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)。
+9 -9
View File
@@ -3,21 +3,21 @@
## 每个候选版必须完成
- 当前提交位于 `main`,工作区除明确忽略的本地缓存外干净;版本号、插件版本和 Git 标签一致。
- `npm ci`、`npm test`、`npm audit --omit=dev --audit-level=high` 全部通过。
- `npm ci`、`npm test`、`npm audit --omit=dev --audit-level=high` 全部通过。发布机存在代理环境变量时,运行时来源校验应优先使用系统 `curl` 继承代理,并在短超时内给出明确失败;不要依赖默认不读取代理的 Node `fetch`。
- `npm run runtime:verify` 确认 Node 官方精确版本源和至少一个国内源同时提供全部六个平台制品,且镜像 `SHASUMS256.txt` 与仓库固定哈希一致。
- `release:build`、`test-bundle:build` 和 `release:verify` 成功,`SHA256SUMS` 校验通过,发行物不包含 `.secrets`、`.runtime`、数据库、私钥或机器私有路径。
- 国内 Gitea 首选源与自建 HTTPS 备用目录的 `manifest.json`、`manifest.sig` 和全部安装附件逐文件哈希一致;黑盒测试包及动态提示词只要求自建目录可用。连续部署同一 Tag 必须复用既有 Release 和固定 `createdAt`,不得重复创建或因测试包时间戳产生哈希漂移。模拟首选源网络失败/404 后能从备用源完成首次安装和更新,模拟首选源返回错误哈希或签名时必须立即停止而不换源。
- Nginx bootstrap 只开放 ACME challenge;正式站点启用 HSTS,mutable 发行文件返回 `no-cache`,客户端 tgz 才返回 immutable。连续两次部署时公开目录始终对应一个完整签名清单,旧版本保留数符合策略。
- 从新生成的公开 `install.md` 在干净的用户级目录安装,不使用 `sudo`;安装结果主动返回 `postInstall`,可发现全部 13 个 MCP 工具。
- 在未安装 Node 的干净 Windows x64/ARM64、Linux x64/ARM64、macOS Intel/Apple Silicon 用户环境验证平台引导。确认只使用固定精确版本、国内镜像失败会依次回退、任一哈希不符立即终止、私有 Node 不修改 PATH 且更新后仍可启动。至少在实际可用的每类操作系统完成一次端到端安装;其余架构用对应 CI 或发行制品校验覆盖。
- 在干净 Windows 用户环境用公开教程的 PowerShell 路径完成引导 SHA-256 校验、安装 JSON 解析和 stdio 注册;实际 `command` 以 `.cmd` 结尾、`args` 为空,不含网关 URL,并通过 `doctor --json`。同时验证路径含空格、连接器信任和完整重启场景。
- 验证 stdout 只有最终 JSON、stderr 持续包含阶段进度;宿主工具超时设为至少 10 分钟。模拟离线 npm 超时、外部终止留下的死亡锁和 staging、并发第二次安装,分别确认明确失败、安全恢复和零并发写入。Windows 从 Git Bash 发起测试时仍须进入 PowerShell bootstrap,不得直接运行内部安装器。
- 在 Windows 代理沙箱中验证系统 `curl` 是安装和更新的首选下载器,并实际使用 `HTTPS_PROXY`;登录和 Bridge 继续可联网。父进程含 `NODE_OPTIONS="--trace-warnings --use-env-proxy"` 与 `NODE_USE_ENV_PROXY=1` 时,npm 子进程只保留无关 Node 选项并继续保留标准代理变量,离线展开不得卡死。
- 验证安装结果返回 `loginCommand`:未加载 MCP 时执行 `login --json` 能复用现有运行时生成或检查登录链接,不下载发布物;链接过期后生成新链接,已登录时不再返回挑战,未安装时明确失败。
- 首次安装不等待业务查询即产生一条可传递链接;挑战阶段 onboarding 仍为 pending,授权后的首次成功响应在普通文本中展示一次示例,此后不重复。
- 授权后钉钉 OA 与薪福通均为 `ok`,网关重启后登录状态仍可恢复;登录准备暂时失败不会回滚安装或删除原 OAuth 会话。
- 从新生成的公开 `install.md` 验证两条决策路径:同机浏览器可完成回调时注册远程 `https://approval.justconn.top/mcp`,确认不下载 Node/npm/Bridge、不要求本地命令,并能通过 Protected Resource / Authorization Server Metadata、DCR 和 PKCE 完成宿主原生 OAuth;SSH/无头/Messenger 跨设备打开授权页且宿主使用 loopback 回调时,不先尝试必然失败的远程登录,直接选择兼容 Bridge。
- 远程 OAuth 后可发现全部 12 个业务工具,`approval_list_todos(source=all, limit=1)` 中钉钉 OA 与薪福通均为 `ok`;网关重启后宿主能刷新或重新授权,用户身份仍由服务端 Token 决定。
- `install.md` 只有在远程能力缺失或本机 OAuth 回调对用户不可达时才链接 `compat-install.md`。用至少一个只支持 stdio 的宿主和一个跨设备 loopback 场景验证兼容安装可发现 `approval_login` 加 12 个业务工具,device-code 链接可在另一台设备打开,且不产生第二套用户身份。
- 在未安装 Node 的干净 Windows x64/ARM64、Linux x64/ARM64、macOS Intel/Apple Silicon 环境验证兼容引导。确认固定版本、国内镜像回退、哈希失败即停止、私有 Node 不改 PATH;至少每类操作系统一次端到端,其余架构由 CI/制品校验覆盖。
- 在 Windows PowerShell 5.1 验证生成的 `.ps1` 具有 UTF-8 BOM,中文可解析,Node 版本和代理能力探测不会因原生程序参数引号被吞而失败;确认 `.zip` 仅去除一次扩展名,解压目录与 `node-v<version>-win-<arch>` 完全一致。Windows 下载优先 `curl.exe` 并继承 `HTTPS_PROXY`;连接 10 秒、低速 20 秒或总计 90 秒触发明确失败换源,不得出现 300 秒静默等待。
- 兼容安装继续验证 stdout 只有最终 JSON、stderr 有阶段进度;模拟离线 npm 超时、外部终止、死亡锁、staging 和并发安装,确认明确失败、安全恢复和零并发写入。父进程含 Node 代理选项时,npm 子进程不得继承 `--use-env-proxy`。
- 兼容安装结果的 `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` 先读取模板内嵌选项,再按需读取企业多级费用类型数据源。费用申请、差旅预计费用及报销明细均标记对应选项源。固定枚举提供 `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.12",
"version": "1.1.0-rc.15",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dingtalk-approval-mcp",
"version": "1.1.0-rc.12",
"version": "1.1.0-rc.15",
"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.12",
"version": "1.1.0-rc.15",
"description": "A lightweight Agent bridge for a unified DingTalk OA and XFT approval gateway.",
"private": true,
"type": "module",
+8 -8
View File
@@ -109,7 +109,7 @@ const commonBootstrapReplacements = new Map([
]);
const bootstrapWindowsName = "dingtalk-approval-bootstrap.ps1";
const bootstrapPosixName = "dingtalk-approval-bootstrap.sh";
renderTemplate("distribution/bootstrap-windows.template.ps1", join(out, bootstrapWindowsName), commonBootstrapReplacements);
renderTemplate("distribution/bootstrap-windows.template.ps1", join(out, bootstrapWindowsName), commonBootstrapReplacements, { utf8Bom: true });
renderTemplate("distribution/bootstrap-posix.template.sh", join(out, bootstrapPosixName), commonBootstrapReplacements);
execFileSync("sh", ["-n", join(out, bootstrapPosixName)]);
const artifacts = {
@@ -139,7 +139,6 @@ writeFileSync(join(out, "manifest.sig"), `${JSON.stringify({
schemaVersion: 1, algorithm: "Ed25519", keyId, signature,
}, null, 2)}\n`, "utf8");
const template = readFileSync(resolve("distribution/agent-install.template.md"), "utf8");
const replacements = new Map([
["{{VERSION}}", version], ["{{MANIFEST_URL}}", `${downloadBaseUrl}/manifest.json`],
["{{MANIFEST_SIGNATURE_URL}}", `${downloadBaseUrl}/manifest.sig`], ["{{GATEWAY_URL}}", gatewayUrl],
@@ -153,13 +152,14 @@ const replacements = new Map([
["{{BOOTSTRAP_POSIX_URL}}", artifacts.bootstrapPosix.url], ["{{BOOTSTRAP_POSIX_SHA256}}", artifacts.bootstrapPosix.sha256],
["{{BOOTSTRAP_POSIX_URLS_POSIX}}", artifactUrls(artifacts.bootstrapPosix).join(" ")],
["{{BOOTSTRAP_POSIX_MIRROR_URLS_MARKDOWN}}", (artifacts.bootstrapPosix.mirrors ?? []).map((value) => ` - \`${value}\``).join("\n") || " - 未配置"],
["{{COMPAT_INSTALL_URL}}", `${downloadBaseUrl}/compat-install.md`],
["{{COMPAT_INSTALL_MIRROR_URLS_MARKDOWN}}", downloadMirrorBaseUrls.length
? downloadMirrorBaseUrls.map((value) => ` - \`${value}/compat-install.md\``).join("\n") : " - 未配置"],
["{{NODE_VERSION}}", nodeRuntime.version], ["{{NODE_MINIMUM_VERSION}}", nodeRuntime.minimumVersion],
["{{RELEASE_KEY_ID}}", keyId],
]);
let prompt = template;
for (const [from, to] of replacements) prompt = prompt.replaceAll(from, to);
if (/\{\{[^}]+\}\}/.test(prompt)) throw new Error("安装指令仍包含未替换占位符");
writeFileSync(join(out, "install.md"), prompt, "utf8");
renderTemplate("distribution/agent-install.template.md", join(out, "install.md"), replacements);
renderTemplate("distribution/agent-compat-install.template.md", join(out, "compat-install.md"), replacements);
const checksums = Object.values(artifacts).map((value) => `${value.sha256} ${basename(new URL(value.url).pathname)}`);
checksums.push(`${sha256(join(out, "manifest.json"))} manifest.json`, `${sha256(join(out, "manifest.sig"))} manifest.sig`);
writeFileSync(join(out, "SHA256SUMS"), `${checksums.join("\n")}\n`, "utf8");
@@ -190,11 +190,11 @@ function lockedVersion(name) {
return value;
}
function renderTemplate(source, target, replacements) {
function renderTemplate(source, target, replacements, options = {}) {
let body = readFileSync(resolve(source), "utf8");
for (const [from, to] of replacements) body = body.replaceAll(from, String(to));
if (/\{\{[^}]+\}\}/.test(body)) throw new Error(`${source} 仍包含未替换占位符`);
writeFileSync(target, body, "utf8");
writeFileSync(target, options.utf8Bom ? `\uFEFF${body}` : body, "utf8");
}
function validateNodeRuntime(value) {
+1 -1
View File
@@ -24,7 +24,7 @@ if (gateway.protocol !== "https:" || gateway.pathname !== "/mcp" || gateway.sear
throw new Error("manifest.gatewayUrl 必须是无 query/hash 的 HTTPS /mcp 地址");
}
const artifactNames = [...new Set(Object.values(manifest.artifacts ?? {}).map((item) => basename(String(item.url))))];
const members = ["SHA256SUMS", "install.md", "manifest.json", "manifest.sig", ...artifactNames];
const members = ["SHA256SUMS", "install.md", "compat-install.md", "manifest.json", "manifest.sig", ...artifactNames];
execFileSync("tar", [
"--sort=name", "--mtime=@0", "--owner=0", "--group=0", "--numeric-owner", "--format=ustar",
"-czf", bundle, "-C", releaseDir, ...members,
+32 -9
View File
@@ -1,8 +1,13 @@
#!/usr/bin/env node
import { execFile as execFileCallback, spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { promisify } from "node:util";
const catalog = JSON.parse(readFileSync(new URL("../distribution/node-runtime.json", import.meta.url), "utf8"));
const expected = new Map(Object.values(catalog.platforms).map((value) => [value.file, value.sha256]));
const execFile = promisify(execFileCallback);
const curlAvailable = spawnSync("curl", ["--version"], { stdio: "ignore" }).status === 0;
const nullDevice = process.platform === "win32" ? "NUL" : "/dev/null";
const results = await Promise.all(catalog.sourceBases.map(checkSource));
for (const result of results) {
console.log(`${result.ok ? "OK" : "FAIL"} ${result.host}: ${result.message}`);
@@ -18,18 +23,11 @@ async function checkSource(sourceBase) {
const timeout = setTimeout(() => controller.abort(), 20_000);
timeout.unref();
try {
const checksumResponse = await fetch(new URL("SHASUMS256.txt", base), { signal: controller.signal });
if (!checksumResponse.ok) return failure(base, `SHASUMS256.txt HTTP ${checksumResponse.status}`);
const checksums = parseChecksums(await checksumResponse.text());
const checksums = parseChecksums(await requestText(new URL("SHASUMS256.txt", base), controller.signal));
for (const [file, sha256] of expected) {
if (checksums.get(file) !== sha256) return failure(base, `${file} 的官方 SHA-256 不匹配或缺失`);
}
const heads = await Promise.all([...expected.keys()].map(async (file) => {
const response = await fetch(new URL(file, base), { method: "HEAD", signal: controller.signal });
return { file, status: response.status, ok: response.ok };
}));
const unavailable = heads.filter((item) => !item.ok);
if (unavailable.length) return failure(base, `${unavailable[0].file} HTTP ${unavailable[0].status}`);
await Promise.all([...expected.keys()].map((file) => assertAvailable(new URL(file, base), controller.signal)));
return { host: base.host, ok: true, message: `${expected.size} 个三平台制品可用且哈希一致` };
} catch (error) {
return failure(base, error instanceof Error ? error.message : String(error));
@@ -38,6 +36,31 @@ async function checkSource(sourceBase) {
}
}
async function requestText(url, signal) {
if (!curlAvailable) {
const response = await fetch(url, { signal });
if (!response.ok) throw new Error(`${url.pathname} HTTP ${response.status}`);
return response.text();
}
const { stdout } = await execFile("curl", [
"--fail", "--silent", "--show-error", "--location",
"--connect-timeout", "10", "--max-time", "20", url.toString(),
], { encoding: "utf8", maxBuffer: 2 * 1024 * 1024, signal });
return stdout;
}
async function assertAvailable(url, signal) {
if (!curlAvailable) {
const response = await fetch(url, { method: "HEAD", signal });
if (!response.ok) throw new Error(`${url.pathname} HTTP ${response.status}`);
return;
}
await execFile("curl", [
"--fail", "--silent", "--show-error", "--location", "--head",
"--connect-timeout", "10", "--max-time", "20", "--output", nullDevice, url.toString(),
], { encoding: "utf8", maxBuffer: 256 * 1024, signal });
}
function parseChecksums(body) {
const values = new Map();
for (const line of body.split(/\r?\n/)) {
+31 -9
View File
@@ -70,8 +70,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,25 +81,46 @@ 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");
if (!installer.startsWith("#!/usr/bin/env node\n") || /from ["']\.\//.test(installer)) throw new Error("首次安装器必须是无相对依赖的独立 Node 脚本");
const install = readFileSync(join(releaseDir, "install.md"), "utf8");
if (install.includes("{{") || !install.includes(`版本:\`${version}\``)
|| !install.includes("唯一通用安装入口") || !install.includes("postInstall")
|| !install.includes("onboarding.firstRun=true") || !install.includes("系统无需预装 Node.js")
|| !install.includes("至少 10 分钟超时") || !install.includes("Git Bash")
|| /--host|WorkBuddy|Codex|Claude/.test(install)) {
throw new Error("install.md 版本、占位符或通用安装引导不正确");
|| !install.includes(manifest.gatewayUrl) || !install.includes("Streamable HTTP") || !install.includes("OAuth")
|| !install.includes("compat-install.md") || !install.includes("不要安装 Node.js、npm、本地 Bridge")
|| /PowerShell|Git Bash|postInstall|loginCommand|npm install -g|--host|WorkBuddy|Codex|Claude/.test(install)) {
throw new Error("install.md 不是有效的远程 MCP 优先安装引导");
}
const windowsBootstrap = readFileSync(join(releaseDir, artifactName(manifest.artifacts.bootstrapWindows, "bootstrapWindows")), "utf8");
const compatibilityInstall = readFileSync(join(releaseDir, "compat-install.md"), "utf8");
if (compatibilityInstall.includes("{{") || !compatibilityInstall.includes(`版本:\`${version}\``)
|| !compatibilityInstall.includes("仅当当前 Agent 明确不支持远程 Streamable HTTP MCP、标准 OAuth,或用户无法在宿主本机 OAuth 回调所在设备打开授权页面")
|| !compatibilityInstall.includes("可跨设备 device-code 登录")
|| !compatibilityInstall.includes("postInstall") || !compatibilityInstall.includes("onboarding.firstRun=true")
|| !compatibilityInstall.includes("系统无需预装 Node.js") || !compatibilityInstall.includes("Git Bash")
|| /TimeoutSec 300|--max-time 300|--timeout=300/.test(compatibilityInstall)
|| /--host|WorkBuddy|Codex|Claude/.test(compatibilityInstall)) {
throw new Error("compat-install.md 版本、占位符或兼容安装引导不正确");
}
const windowsBootstrapBytes = readFileSync(join(releaseDir, artifactName(manifest.artifacts.bootstrapWindows, "bootstrapWindows")));
if (windowsBootstrapBytes[0] !== 0xef || windowsBootstrapBytes[1] !== 0xbb || windowsBootstrapBytes[2] !== 0xbf) {
throw new Error("Windows PowerShell 引导缺少 UTF-8 BOM");
}
const windowsBootstrap = windowsBootstrapBytes.toString("utf8").replace(/^\uFEFF/, "");
const posixBootstrapPath = join(releaseDir, artifactName(manifest.artifacts.bootstrapPosix, "bootstrapPosix"));
const posixBootstrap = readFileSync(posixBootstrapPath, "utf8");
if (windowsBootstrap.includes("{{") || posixBootstrap.includes("{{")
|| !windowsBootstrap.includes("Save-DingDownload") || !windowsBootstrap.includes("Expand-Archive")
|| !windowsBootstrap.includes("[Console]::OutputEncoding = $dingUtf8") || !windowsBootstrap.includes("$OutputEncoding = $dingUtf8")
|| !windowsBootstrap.includes("curl.exe") || !windowsBootstrap.includes("--max-time 90")
|| !windowsBootstrap.includes(`-e "const [a,b]=process.versions.node.split('.').map(Number)`)
|| !windowsBootstrap.includes("$dingRuntimeName = [IO.Path]::GetFileNameWithoutExtension($dingNodeFile)")
|| windowsBootstrap.includes("GetFileNameWithoutExtension([IO.Path]::GetFileNameWithoutExtension")
|| windowsBootstrap.includes("TimeoutSec 300")
|| !windowsBootstrap.includes("Write-DingProgress") || !posixBootstrap.includes("ding_progress")
|| !posixBootstrap.includes("--max-time 90") || /--max-time 300|--timeout=300/.test(posixBootstrap)
|| !posixBootstrap.startsWith("#!/bin/sh\nset -eu\n") || !posixBootstrap.includes("tar -xzf")
|| /latest-v|npm install -g/.test(`${windowsBootstrap}\n${posixBootstrap}`)) {
throw new Error("无 Node 安装引导无效或仍包含不安全的动态版本来源");
+7 -5
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
@@ -9,11 +9,11 @@ Use only the `dingtalk-approval` MCP tools. DingTalk OA and XFT use the same gat
## Login and first run
After installation or update, handle the installer's `postInstall` result immediately instead of waiting for the user's first approval request. If it contains `authChallenge.directUrl`, copy that complete clickable URL into the current user-facing reply. After the user authorizes, call `approval_login`, then make one read-only `approval_list_todos` call with `source=all` to verify both DingTalk OA and XFT. If login preparation was temporarily unavailable, keep the installation and call `approval_login` after the MCP is loaded. If the installation link expires before the MCP can load, execute the returned `loginCommand` and show its new `authChallenge.directUrl`; never rerun installation just to renew a link.
Use the host's native remote-MCP OAuth flow when the user can complete its registered callback. If the redirect URI uses `127.0.0.1` or `localhost`, the user must open the authorization page on the same device that runs the Agent. An SSH, headless, Messenger, phone, or other cross-device interaction cannot complete that loopback callback and must use the local STDIO Bridge device-code flow instead. If the interaction topology is unknown during installation, ask only whether the user will open the page on the Agent's device. When native OAuth is suitable, let the host open or present the authorization URL and have the user authorize it personally. Never claim that a link was pushed through another channel. After OAuth succeeds, make one read-only `approval_list_todos` call with `source=all` and `limit=1`; both DingTalk OA and XFT must be available before reporting setup complete.
When a successful response contains `onboarding.firstRun=true`, show its short examples and write-safety note to the user once. Do not execute an example automatically. The same onboarding may be returned directly in `postInstall` when an existing login is reused.
When a successful response contains `onboarding.firstRun=true`, show its short examples and write-safety note once. Do not execute an example automatically.
On first use, expired authorization, or an expired login link, call `approval_login` again. It checks the existing session and creates a fresh link when necessary; never reinstall for this purpose. The tool does not send or push the login link through another channel. Copy the clickable URL from `content.text` verbatim into the current user-facing reply; when available, `authChallenge.directUrl` and the `resource_link` contain the same URL. Never merely claim that a link was generated or pushed, never read bridge state files to recover it, and never render QR images as ASCII. One successful login covers both approval sources.
Hosts that cannot use remote Streamable HTTP MCP or standard OAuth, and cross-device users who cannot reach a host loopback callback, should use the optional local STDIO Bridge. In that compatibility mode, handle the installer's `postInstall` immediately. If it contains `authChallenge.directUrl`, copy the complete clickable URL into the current reply; the user may open this device-code page on another device. If the link expires, call `approval_login`; if the connector is not loaded yet, run the returned `loginCommand`. Never reinstall merely to renew a link, read Bridge state files, or render QR images as ASCII. One successful gateway login covers both sources.
## Review and process
@@ -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.9" });
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.9" }, {
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.9" },
{ 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.15";
+434 -108
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,64 @@ export class XftOpenApiClient implements XftApprovalProvider {
};
}
private async loadExpenseTypeOptions(
identity: XftIdentity,
template: XftSubmissionTemplate,
): Promise<XftSubmissionOption[]> {
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 {
// 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 }> = [];
for (let page = 0; page < EXPENSE_TYPE_SOURCE_MAX_PAGES; page += 1) {
let response: unknown;
try {
response = await this.request(
"/itrip/xft-api/v1/dataSource/findMultilevelSourcePage",
{ start: page * EXPENSE_TYPE_SOURCE_PAGE_SIZE, limit: EXPENSE_TYPE_SOURCE_PAGE_SIZE },
"POST",
identity,
);
} catch {
return [];
}
const records = pagedRecords(response);
for (const record of records) {
const name = stringAt(record, ["sourceName", "dataSourceName", "multilevelSourceName", "displayName", "name", "title"]);
const id = stringAt(record, ["sourceSeqId", "dataSourceSeqId", "multilevelSourceSeqId", "seqId", "id"]);
if (id && name && EXPENSE_TYPE_HINT.test(name)) candidates.push({ id, raw: record });
}
if (records.length < EXPENSE_TYPE_SOURCE_PAGE_SIZE) break;
}
const output: XftSubmissionOption[] = [];
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 {
// Continue with other matching sources. Empty output fails closed below.
}
}
return uniqueOptions(output);
}
async submitApproval(
user: AuthenticatedDingTalkUser,
template: XftSubmissionTemplate,
@@ -897,12 +1000,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;
}
@@ -1258,6 +1373,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",
]) : undefined);
const label = stringAt(record, [
"expenseName", "expenseTypeName", "expTypeName", "optionName", "itemName", "nodeName", "dataName",
"sourceOptionName", "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 +1561,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 +1573,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 +1683,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 +1709,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 +1791,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 +1863,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 +1876,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 +1925,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 +1948,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 +1974,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");
+79 -36
View File
@@ -3,41 +3,44 @@ import { spawnSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import test from "node:test";
test("customer distribution is a generic single-login gateway bridge", () => {
test("customer distribution prefers remote MCP OAuth and keeps a generic compatibility bridge", () => {
const packageMetadata = JSON.parse(readFileSync("package.json", "utf8")) as { version: string };
assert.match(packageMetadata.version, /^\d+\.\d+\.\d+-rc\.\d+$/);
assert.equal(existsSync("plugins/dingtalk-approval"), false);
const installer = readFileSync("distribution/agent-install.template.md", "utf8");
assert.match(installer, /Ed25519/);
assert.match(installer, /唯一通用安装入口/);
assert.match(installer, /使用 `mcpConfig`/);
assert.match(installer, /安装器本身不会猜测或修改 Agent 的私有配置/);
assert.match(installer, /远程.*Streamable HTTP MCP/s);
assert.match(installer, /URL:`\{\{GATEWAY_URL\}\}`/);
assert.match(installer, /认证:OAuth/);
assert.match(installer, /动态客户端注册/);
assert.match(installer, /127\.0\.0\.1.*localhost.*同一台设备/s);
assert.match(installer, /SSH.*无头终端.*Messenger.*手机或另一台电脑/s);
assert.match(installer, /无法确定.*只问用户一个问题/s);
assert.match(installer, /不要安装 Node\.js、npm、本地 Bridge/);
assert.match(installer, /只有.*不支持远程 Streamable HTTP MCP.*标准 OAuth.*无法.*本机回调.*兼容/s);
assert.match(installer, /device-code.*手机或另一台电脑/s);
assert.match(installer, /跨设备交互.*直接进入兼容路径.*不必先制造.*失败/s);
assert.match(installer, /COMPAT_INSTALL_URL/);
assert.match(installer, /approval_list_todos.*source="all".*limit=1/s);
assert.match(installer, /钉钉 OA 与薪福通来源都为 `ok`/);
assert.doesNotMatch(installer, /PowerShell|Git Bash|stdio|postInstall|loginCommand|npm install -g/);
assert.doesNotMatch(installer, /--host|WorkBuddy|Codex|Claude/);
assert.match(installer, /不会通过其他消息通道自动推送链接.*完整登录 URL.*当前回复/s);
assert.match(installer, /读取返回的 `postInstall`.*不得等到用户第一次查询审批/s);
assert.match(installer, /登录链接过期.*绝不能重新安装.*approval_login.*loginCommand/s);
assert.match(installer, /onboarding\.firstRun=true.*简短示例.*不要自动执行/s);
assert.match(installer, /Windows(PowerShell)/);
assert.match(installer, /Invoke-WebRequest[\s\S]*Get-FileHash[\s\S]*ConvertFrom-Json/);
assert.match(installer, /至少 10 分钟超时[\s\S]*stderr[\s\S]*stdout/);
assert.match(installer, /Git Bash[\s\S]*不得.*内部.*installer\.mjs/s);
assert.match(installer, /安装长时间无最终 JSON[\s\S]*残留 staging/s);
assert.match(installer, /系统无需预装 Node\.js/);
assert.match(installer, /阿里云精确版本.*npmmirror 精确版本.*Node 官方精确版本/s);
assert.match(installer, /备用发布源[\s\S]*优先使用国内 Gitea 静态发布源/s);
assert.match(installer, /优先使用系统 `curl`[\s\S]*Node `fetch` 仅在系统没有 `curl` 时兜底/s);
assert.match(installer, /不要设置全局 `NODE_OPTIONS=--use-env-proxy`[\s\S]*不要手工预装 `node_modules`/s);
assert.match(installer, /BOOTSTRAP_WINDOWS_URL|Windows 安装引导/);
assert.match(installer, /Linux\/macOS 安装引导/);
assert.match(installer, /dingtalk-approval-bridge\.cmd/);
assert.match(installer, /显示 302 或启动后立即退出[\s\S]*type: \"stdio\"[\s\S]*doctor --json/);
assert.match(installer, /sourceStatus[\s\S]*dingtalk\.status[\s\S]*xft\.status[\s\S]*都必须是 `ok`/);
assert.match(installer, /更新[\s\S]*update --json[\s\S]*卸载[\s\S]*uninstall --yes --json/);
assert.match(installer, /不能热加载[\s\S]*完整退出并重新启动 Agent/);
assert.doesNotMatch(installer, /npm install -g|npx\s/);
assert.doesNotMatch(installer, /通过当前 Messenger/);
assert.doesNotMatch(installer, /DWS_CLI_PATH|dingtalk-workspace-cli|APP_MODE=local/);
const compatibilityInstaller = readFileSync("distribution/agent-compat-install.template.md", "utf8");
assert.match(compatibilityInstaller, /仅当.*不支持远程 Streamable HTTP MCP.*标准 OAuth.*无法.*本机 OAuth 回调/s);
assert.match(compatibilityInstaller, /127\.0\.0\.1.*localhost.*SSH.*无头环境.*Messenger.*device-code/s);
assert.match(compatibilityInstaller, /Ed25519/);
assert.match(compatibilityInstaller, /使用 `mcpConfig`/);
assert.match(compatibilityInstaller, /Windows(PowerShell)/);
assert.match(compatibilityInstaller, /curl\.exe[\s\S]*Invoke-WebRequest[\s\S]*Get-FileHash[\s\S]*ConvertFrom-Json/);
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/);
assert.doesNotMatch(compatibilityInstaller, /DWS_CLI_PATH|dingtalk-workspace-cli|APP_MODE=local/);
const runtime = JSON.parse(readFileSync("distribution/node-runtime.json", "utf8"));
assert.equal(runtime.version, "22.23.2");
@@ -48,29 +51,64 @@ test("customer distribution is a generic single-login gateway bridge", () => {
assert.ok(runtime.sourceBases.every((value: string) => value.includes(`/v${runtime.version}/`) && !value.includes("latest")));
assert.ok(Object.values(runtime.platforms).every((value) => /^[a-f0-9]{64}$/.test((value as { sha256: string }).sha256)));
const runtimeVerifier = readFileSync("scripts/verify-node-runtime-sources.mjs", "utf8");
assert.match(runtimeVerifier, /spawnSync\("curl"[\s\S]*curlAvailable/);
assert.match(runtimeVerifier, /--connect-timeout", "10"[\s\S]*--max-time", "20"/);
assert.match(runtimeVerifier, /if \(!curlAvailable\)[\s\S]*fetch\(/);
const windowsBootstrap = readFileSync("distribution/bootstrap-windows.template.ps1", "utf8");
const posixBootstrap = readFileSync("distribution/bootstrap-posix.template.sh", "utf8");
assert.match(windowsBootstrap, /OSArchitecture[\s\S]*Save-DingDownload[\s\S]*Expand-Archive/);
assert.match(windowsBootstrap, /SHA-256 不匹配,停止安装/);
assert.match(windowsBootstrap, /Write-DingProgress[\s\S]*\[dingtalk-approval\]/);
assert.match(windowsBootstrap, /UTF8Encoding\(\$false\)[\s\S]*Console\]::InputEncoding[\s\S]*Console\]::OutputEncoding[\s\S]*\$OutputEncoding/);
assert.match(windowsBootstrap, /dingInstallerUrls[\s\S]*dingManifestMirrorUrls[\s\S]*use-env-proxy/s);
assert.match(windowsBootstrap, /curl\.exe[\s\S]*--connect-timeout 10[\s\S]*--max-time 90/);
assert.match(windowsBootstrap, /-e "const \[a,b\]=process\.versions\.node\.split\('\.'\)/);
assert.match(windowsBootstrap, /-e "process\.exit\(process\.allowedNodeEnvironmentFlags\.has\('--use-env-proxy'\)/);
assert.match(windowsBootstrap, /dingRuntimeName = \[IO\.Path\]::GetFileNameWithoutExtension\(\$dingNodeFile\)/);
assert.doesNotMatch(windowsBootstrap, /GetFileNameWithoutExtension\(\[IO\.Path\]::GetFileNameWithoutExtension/);
assert.doesNotMatch(windowsBootstrap, /TimeoutSec 300|-e '.*split\("\."\)/);
assert.match(posixBootstrap, /^#!\/bin\/sh\nset -eu/);
assert.match(posixBootstrap, /uname -s[\s\S]*uname -m[\s\S]*tar -xzf/);
assert.match(posixBootstrap, /SHA-256 不匹配,停止安装/);
assert.match(posixBootstrap, /ding_progress[\s\S]*\[dingtalk-approval\]/);
assert.match(posixBootstrap, /ding_installer_urls[\s\S]*ding_manifest_mirror_urls[\s\S]*use-env-proxy/s);
assert.match(posixBootstrap, /--connect-timeout 10[\s\S]*--max-time 90[\s\S]*--speed-time 20/);
assert.doesNotMatch(posixBootstrap, /--max-time 300|--timeout=300/);
assert.equal(spawnSync("sh", ["-n", "distribution/bootstrap-posix.template.sh"]).status, 0);
assert.doesNotMatch(`${windowsBootstrap}\n${posixBootstrap}`, /latest-v|npm install -g/);
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, /does not send or push.*current user-facing reply/s);
assert.match(skill, /handle the installer's `postInstall` result immediately.*waiting for the user's first approval request/s);
assert.match(skill, /native remote-MCP OAuth flow when the user can complete its registered callback/);
assert.match(skill, /127\.0\.0\.1.*localhost.*same device.*SSH.*headless.*Messenger.*cross-device.*STDIO Bridge device-code/s);
assert.match(skill, /both DingTalk OA and XFT must be available/);
assert.match(skill, /cannot use remote Streamable HTTP MCP or standard OAuth.*cross-device users.*loopback callback.*local STDIO Bridge/s);
assert.match(skill, /authChallenge\.directUrl.*current reply/s);
assert.match(skill, /onboarding\.firstRun=true.*Do not execute an example automatically/s);
assert.match(skill, /expired login link.*approval_login.*never reinstall/s);
assert.match(skill, /link expires.*approval_login.*Never reinstall/s);
assert.match(localDrafts, /%LOCALAPPDATA%\\DingTalkApproval\\drafts/);
assert.match(localDrafts, /Library\/Application Support\/DingTalkApproval\/drafts/);
assert.match(localDrafts, /\.local\/share.*dingtalk-approval\/drafts/s);
assert.match(localDrafts, /mode `0700`.*mode `0600`/s);
assert.match(localDrafts, /atomically rename/);
assert.match(localDrafts, /more than 90 days old/);
assert.match(localDrafts, /SHA-256/);
assert.match(localDrafts, /Never store[\s\S]*templateId[\s\S]*confirmationId[\s\S]*complete upstream schema/);
assert.match(localDrafts, /outcome_uncertain[\s\S]*freeze[\s\S]*Never prepare or submit it again automatically/);
assert.match(localDrafts, /never on the approval gateway/);
assert.match(localDrafts, /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);
@@ -79,6 +117,9 @@ test("customer distribution is a generic single-login gateway bridge", () => {
assert.match(builder, /dingtalk-approval-client/);
assert.match(builder, /bootstrap-windows\.template\.ps1/);
assert.match(builder, /bootstrap-posix\.template\.sh/);
assert.match(builder, /agent-compat-install\.template\.md/);
assert.match(builder, /compat-install\.md/);
assert.match(builder, /utf8Bom: true/);
assert.match(builder, /validateNodeRuntime/);
assert.match(builder, /cpSync\(resolve\("skills\/dingtalk-approval"\).*recursive: true/s);
assert.doesNotMatch(builder, /codexPlugin|codex-plugin|\.codex-plugin|openai\.yaml/);
@@ -95,10 +136,12 @@ test("customer distribution is a generic single-login gateway bridge", () => {
test("public install page explicitly declares UTF-8 for browser rendering", () => {
const nginx = readFileSync("deploy/approval.justconn.top.nginx.conf", "utf8");
const installLocation = nginx.match(/location = \/downloads\/dingmcp\/install\.md \{([\s\S]*?)\n \}/)?.[1] ?? "";
assert.match(installLocation, /default_type text\/markdown;/);
assert.match(installLocation, /charset utf-8;/);
assert.match(installLocation, /charset_types text\/markdown;/);
for (const name of ["install", "compat-install"]) {
const location = nginx.match(new RegExp(`location = /downloads/dingmcp/${name}\\.md \\{([\\s\\S]*?)\\n \\}`))?.[1] ?? "";
assert.match(location, /default_type text\/markdown;/);
assert.match(location, /charset utf-8;/);
assert.match(location, /charset_types text\/markdown;/);
}
});
test("Gitea release lookup tolerates tea field-name corruption and fails closed", () => {
+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+)?"/);
}
});
+283 -4
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) => {
@@ -304,6 +329,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 +421,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,11 +446,13 @@ 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;
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
@@ -386,6 +474,30 @@ 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.deepEqual(decodedBody(init), { start: 0, limit: 100 });
return encrypted({ code: "0", data: { records: [
{ sourceSeqId: "DS-EXP", sourceName: "企业费用类型" },
{ sourceSeqId: "DS-PROJECT", sourceName: "项目档案" },
] } });
}
if (path.endsWith("dataSource/getMultiLevelDetail/DS-EXP")) {
expenseSourceRequests += 1;
return encrypted({ code: "0", data: {
sourceSeqId: "DS-EXP",
sourceName: "企业费用类型",
levels: [{ nodes: [
{ optionNumber: "TRAFFIC", optionName: "交通费" },
{ optionNumber: "HOTEL", optionName: "住宿费" },
{ optionNumber: "HIDDEN", optionName: "停用费用", selectable: false },
] }],
} });
}
if (path.endsWith("dataSource/getMultiLevelDetail/DS-PROJECT")) {
unrelatedSourceRequests += 1;
return encrypted({ code: "0", data: { nodes: [{ optionNumber: "P1", optionName: "项目一" }] } });
}
throw new Error(`unexpected ${path}`);
});
const template = (await client.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!;
@@ -400,6 +512,173 @@ 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: "HOTEL", 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({ code: "0", data: { records: [
{ sourceSeqId: "DS-PROJECT", sourceName: "项目档案" },
] } });
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"),
/暂时无法实时获取可选费用类型/,
);
});
test("Xft recall derives empId from the authenticated user and validates the upstream success key", async () => {
@@ -564,7 +843,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 +853,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",
]);