Compare commits
3
Commits
v1.1.0-rc.18
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d796ae16c5 | ||
|
|
10523f34ec | ||
|
|
b7313ab74d |
@@ -21,8 +21,8 @@ Agent → 远程 Streamable HTTP MCP / OAuth 统一网关
|
||||
- `approval_list_submission_templates`:实时列出当前用户的提单候选模板。
|
||||
- `approval_get_submission_schema`:返回结构化可填写字段,不传输巨大原始 Schema。
|
||||
- `approval_get_submission_options`:实时查询当前用户可用的薪福通部门、城市和费用类型;所有选项面向用户只展示业务名称,内部映射正式接口值。
|
||||
- `approval_prepare_submission`:校验内容,自动查重本人近 30 天跨钉钉/薪福通的发票,并在安全时生成完整预览及短期确认引用;不创建单据。
|
||||
- `approval_submit`:用户确认预览后,使用相同内容正式发起审批。
|
||||
- `approval_prepare_submission`:校验内容和本机附件元数据,自动查重本人近 30 天跨钉钉/薪福通的发票,并在安全时生成完整预览及短期确认引用;不创建单据,也不上传附件。
|
||||
- `approval_submit`:用户确认预览后,签名本地 Bridge 才上传已确认的薪福通附件,并使用相同内容正式发起审批。
|
||||
- `approval_revoke_submission`:使用提单成功后返回的 `revokeId` 撤销本人刚通过 MCP 发起的钉钉或薪福通审批。
|
||||
- `approval_get_submission_status`:只读核验本人已发起薪福通单据的实时状态。
|
||||
|
||||
@@ -46,19 +46,19 @@ Agent 不传 `userId`、员工号或单据所有者。网关从 OAuth Token 确
|
||||
- 薪福通:可安全构造的申请和报销模板通过正式创建 API 发起,并在创建后实时核验状态。结果可能进入待审批,也可能因企业流程中没有其他有效审批人等规则即时完成;后者返回 `completed`,不会错误地当成接口失败,也不会签发不可用的撤回引用。用车申请不属于 MCP 发起范围,不会出现在候选模板中。
|
||||
- 钉钉:当前 6 个候选模板均返回 `client_only`,必须在钉钉客户端发起。其中 5 个包含企业 OpenAPI 无法可靠构造的 `DDBizSuite` 考勤或费控套件;`业务招待申请_北极大`虽是普通明细表,但真实创建时新版企业接口返回系统异常、旧版接口拒绝组件格式,因此同样安全降级。
|
||||
|
||||
发起审批固定为“列模板 → 取 Schema → 自动查重/预览 → 用户确认 → 正式提交”。准备阶段从新表单提取发票代码与号码、数电票唯一编号、票据号码、附件哈希;没有唯一编号时,金额、日期与商户组合只作为疑似提示。网关实时刷新当前用户本人近 30 天的钉钉和薪福通已发起审批:原单待审批、已通过或状态未知时默认阻止,原单拒绝、撤回、作废或弱匹配时警告。任一来源查询不完整也不会当成“没有重复”。只有用户看过匹配单号、状态和覆盖情况并明确要求继续后,Agent 才能以 `allowDuplicateInvoices=true` 重新准备。
|
||||
发起审批固定为“列模板 → 取 Schema → 自动查重/预览 → 用户确认 → 正式提交”。准备阶段从新表单提取发票代码与号码、数电票唯一编号、票据号码、附件哈希;没有唯一编号时,金额、日期与商户组合只作为疑似提示。本机附件在准备阶段只读取并复核普通文件、大小、类型和 SHA-256,网关只收到不含路径的元数据;用户最终确认后才通过签名 Bridge 上传。网关实时刷新当前用户本人近 30 天的钉钉和薪福通已发起审批:原单待审批、已通过或状态未知时默认阻止,原单拒绝、撤回、作废或弱匹配时警告。任一来源查询不完整也不会当成“没有重复”。只有用户看过匹配单号、状态和覆盖情况并明确要求继续后,Agent 才能以 `allowDuplicateInvoices=true` 重新准备。
|
||||
|
||||
用户不需要理解薪福通的任何内部选择值。实时 Schema 会把部门、城市、费用类型、出行方式、往返、时间范围、币种和酒店星级等字段标记为名称优先的选择项;Agent 只展示“深圳市”“交通费”“飞机”“往返”“人民币”等业务名称,选定后仅在内部写入正式接口值。动态下拉框也会标准化成 `label/code`,表格子字段同样递归处理。准备阶段的最终确认预览会再次按实时选项反向解析,只返回业务字段名和业务名称,不回显城市码、费用编码、字段号或表格列号;任何值无法可靠反向解析时,不生成 `confirmationId`。无法取得可靠名称映射时安全停止或允许跳过可选项,不退化为向用户索取工号、城市码、组织码、档案编号、数字舱位值或其他内部 ID。
|
||||
|
||||
薪福通正式 OpenAPI 当前没有独立的费用类型目录查询。费用类型会先从模板实时字段读取,再检查企业自定义多级数据源;两处都没有可用选项时,MCP 返回具体的实时检查结果并引导在费用管理页面确认或使用薪福通客户端,不会把空目录误报为网络 `fetch` 故障。
|
||||
|
||||
人员、企业自定义档案、关联前置单据、分摊及文档未给出可靠名称映射的舱位字段目前标记为 `client_only`。必填时不生成提交预览并引导薪福通客户端;可选时不会作为普通问题打扰用户。单据公司、部门、字段引用和行程序号等服务器管理或内部派生值也不会作为用户输入项展示。
|
||||
人员、企业自定义档案、关联前置单据、分摊及文档未给出可靠名称映射的舱位字段目前标记为 `client_only`。薪福通附件字段则由本机 Bridge 支持:PDF、OFD、JPG、PNG、XML 或 ZIP,单文件及单次提单附件总量均不超过 50MB,最多 10 个。必填附件可以由草稿引用满足;本地路径、文件内容和薪福通附件编号都不会出现在预览或对话中。单据公司、部门、字段引用和行程序号等服务器管理或内部派生值也不会作为用户输入项展示。
|
||||
|
||||
服务端临时保留不可逆的用户/发票 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 要求上游附件编号,本机路径不能代替,需保留草稿并在原生客户端完成。
|
||||
用户可以让 Agent 把尚未填完的提单保存为本机草稿。它不是钉钉或薪福通的官方草稿:只写入运行 Agent 的当前 OS 用户私有数据目录,不上传网关,也不跨设备同步。草稿保存用户填写值、可选项决定,以及附件所属表单字段、显示名、本地路径、大小、修改时间和 SHA-256;不保存 Token、身份 ID、短期模板/确认/撤回引用、薪福通附件编号或完整上游 Schema,也不复制或预上传文件。恢复时重新实时列模板和 Schema,并重新检查附件普通文件属性、类型、大小与 SHA-256。准备预览只发送不含路径的附件元数据;用户最终确认后,签名 Bridge 才把文件实时转发给薪福通,并把短时、不透明、用户与本次确认绑定的上传引用交给网关创建单据。网关和 Nginx 都不落盘附件。超过 90 天未更新的普通草稿在下次草稿操作时清理;提交结果不确定的草稿会冻结,核验清楚前禁止重试。宿主没有签名本地 Bridge 或安全文件能力时不提供附件上传或持久草稿,也不会改存服务器。
|
||||
|
||||
> 发布候选版限制:薪福通 `approve/recall` 撤回接口已经接入并具备本人归属校验与写后复核,但当前企业的真实待审批单仍返回 `XFTSYS9999`,尚无撤回成功样本。因此薪福通撤回属于实验能力;失败或结果不确定时绝不自动重试。查询、处理和发起审批不受此限制。
|
||||
|
||||
|
||||
@@ -82,6 +82,29 @@ server {
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
}
|
||||
|
||||
location = /xft/submission-attachments {
|
||||
client_max_body_size 50m;
|
||||
proxy_pass http://127.0.0.1:3010;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
# Attachment bytes must be forwarded in real time and never spooled
|
||||
# into Nginx temporary files on the gateway.
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
|
||||
add_header Strict-Transport-Security "max-age=31536000" always;
|
||||
add_header Cache-Control "no-store" always;
|
||||
add_header Pragma "no-cache" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3010;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -34,7 +34,9 @@ Agent 面向统一审批语义,不直接理解钉钉 `processInstanceId/taskId
|
||||
|
||||
草稿不是网关工具或上游单据状态,而是 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 天未更新会在下次草稿操作时清理。
|
||||
草稿仅保存用户填写值、来源和模板显示名、可选项决定,以及附件所属表单字段、本地路径、大小、修改时间和 SHA-256。它不保存 Token、当前用户身份、短期 `templateId`/`confirmationId`/`revokeId`、审批动作引用、薪福通附件编号或完整上游 Schema,也不复制或预上传附件。恢复时按来源和模板名重新实时列模板并获取 Schema;模板不唯一、字段变化、附件丢失或哈希变化都必须回到用户确认。普通草稿超过 90 天未更新会在下次草稿操作时清理。
|
||||
|
||||
附件准备阶段由签名本地 Bridge 打开用户明确选择的普通文件,拒绝符号链接、读取中变化的文件、伪造扩展名、零字节以及超限内容;支持 PDF、OFD、JPG、PNG、XML 和 ZIP,最多 10 个,单文件及总量均不超过 50MB。Bridge 重新计算 SHA-256,只把字段名称、文件名、大小和哈希发送给 `approval_prepare_submission`,因此预览无上传副作用且不含本地路径。`confirmationId` 绑定这份附件清单;用户最终确认后 Bridge 才把原始字节通过 OAuth 保护的专用 HTTPS 路由发送给网关。Nginx 关闭请求缓冲,网关只在内存中实时转发到薪福通自定义附件上传接口,不写数据库或磁盘。上传返回的薪福通 `fileId` 被封装为用户、本次确认和附件元数据绑定的 15 分钟加密引用;创建单据时才转换成实时 `ACCESSORIES` 字段的 JSON 附件编号数组,Agent 和用户都看不到上游编号。
|
||||
|
||||
正式提交仍完整经过网关的身份、模板、字段、发票查重、预览和一次性确认校验。本机文件被修改不能扩大权限。提交成功或即时完成后删除草稿;确定失败时保留并回到编辑;`outcome_uncertain` 时冻结草稿、丢弃短期引用并禁止自动重试,直到本人发起历史或原生客户端证明结果。
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
- 兼容安装结果的 `loginCommand` 能在 MCP 未加载时刷新登录链接;MCP 已加载时 `approval_login` 完成同一操作。不得因链接过期而重装,首次成功响应只展示一次 onboarding 示例。
|
||||
- 只读验证待办、本人已处理历史、本人发起历史、历史默认最近 30 天及显式时间窗口、搜索、详情、费用结构化字段、动作、模板 Schema、部门/城市选项和越权拒绝;历史引用必须被动作查询和批处理拒绝。
|
||||
- 发票查重验证钉钉/XFT 跨来源精确匹配、弱匹配、用户隔离、状态更新、来源不可用、历史截断、详情失败、并发确认、35 天过期清理和网关重启恢复;数据库不得出现用户 ID、发票原号、审批正文或附件。重复风险和不完整覆盖未获用户确认时不得签发可提交 `confirmationId`。
|
||||
- 本机草稿验证 Windows/macOS/Linux 私有目录选择、POSIX `0700`/`0600`、原子保存、90 天普通草稿清理、模板和 Schema 实时恢复、附件 SHA-256 变化、提交成功删除、确定失败保留及 `outcome_uncertain` 冻结。确认网关数据库、日志、发行包和项目仓库都不出现草稿正文,草稿中不保存 Token、身份 ID、短期模板/确认/撤回引用或完整 Schema。
|
||||
- 本机草稿验证 Windows/macOS/Linux 私有目录选择、POSIX `0700`/`0600`、原子保存、90 天普通草稿清理、模板和 Schema 实时恢复、附件 SHA-256 变化、提交成功删除、确定失败保留及 `outcome_uncertain` 冻结。附件另验证符号链接/读取中变化/伪造类型/大小与数量上限在零上传前拒绝,prepare 不上传且不向网关发送路径,submit 只在最终确认后上传,multipart 签名不含 `x-alb-digest`,上游 `fileId` 只进入创建请求的实时附件字段。确认 Nginx 请求缓冲关闭,网关数据库、磁盘、日志、发行包和项目仓库都不出现附件字节、草稿正文或本地路径;草稿中不保存 Token、身份 ID、短期模板/确认/撤回引用、上游附件编号或完整 Schema。
|
||||
- 真实写操作只执行用户明确授权的样本;任何 `outcome_uncertain` 都停止,不自动重试。
|
||||
- Mock 契约确认网关和 bridge 的批量 Schema 均固定为 20;第 6 个截断引用及任一无效退回目标都会返回 `preflight_failed`、`writesStarted=false`,上游写调用数为 0。
|
||||
- 代表性 v4 引用长度满足钉钉 OA 不超过 128 字符、薪福通不超过 80 字符,并通过篡改、过期、跨用户、跨密钥、同密钥重启及 v2/v3 兼容测试。
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
| 钉钉 OA | 差旅费用报销_捷康 | 仅客户端 | 钉钉费控报销套件含行程、报销明细、发票、收款账户和企业主体;另有关联出差申请和项目字段 |
|
||||
| 钉钉 OA | 业务招待费用报销_捷康 | 仅客户端 | 钉钉费控报销套件含报销明细、发票、收款账户和付款主体 |
|
||||
|
||||
薪福通自定义字段在工具内部统一使用 `customFieldValues[].fieldNumber/fieldValue`,但用户不应看到或提供这些引用。日期字段使用 `yyyy-MM-dd HH:mm`;部门由可读名称选择后内部映射;下拉框与表格子字段使用实时名称映射。人员、附件及其他没有安全名称查询或上传能力的字段标记为 `client_only`,不会要求用户填写花名册编号或附件编号。`approval_get_submission_schema` 会返回实时 `valueFormat`、`selectionCapability` 和该模板的精简 `minimalExample`;后者只说明 `values` 形状,所有尖括号占位符都必须由用户业务回答或实时选项替换,不能原样提交。Agent 不得按字段名称猜编码或结构。
|
||||
薪福通自定义字段在工具内部统一使用 `customFieldValues[].fieldNumber/fieldValue`,但用户不应看到或提供这些引用。日期字段使用 `yyyy-MM-dd HH:mm`;部门由可读名称选择后内部映射;下拉框与表格子字段使用实时名称映射。人员及其他没有安全名称查询能力的字段标记为 `client_only`,不会要求用户填写花名册编号。附件字段返回 `attachmentCapability.status=supported`:Agent 只把本机草稿中的字段名称和文件引用传给签名 Bridge;prepare 阶段复核文件并只发送不含路径的元数据,最终确认后的 submit 才上传。薪福通附件编号由网关封装并直接写入 `ACCESSORIES` 字段,不能由 Agent 传入 `values.customFieldValues`。`approval_get_submission_schema` 会返回实时 `valueFormat`、`selectionCapability`、`attachmentCapability` 和该模板的精简 `minimalExample`;后者只说明 `values` 形状,所有尖括号占位符都必须由用户业务回答或实时选项替换,不能原样提交。Agent 不得按字段名称猜编码或结构。
|
||||
|
||||
薪福通模板要求承担部门、城市或费用类型时,使用同一 `templateId` 调用 `approval_get_submission_options`。`kind=department` 只会返回当前登录用户本人档案中的可选组织;`kind=city` 可用 `query` 按城市名称搜索;`kind=expense_type` 先读取模板内嵌选项,再按需读取企业多级费用类型数据源。多级数据源分页接口按正式契约把 `start/limit` 放在 query,并用列表的 `seqId` 读取详情中的 `optionSaveDtoList`;该接口只覆盖企业自定义多级数据源,薪福通正式 OpenAPI 当前没有独立的费用类型目录查询。模板没有内嵌选项且企业多级数据源为空或不含费用类型时,工具会明确返回这两项实时检查结果并引导在费用管理页面确认或使用客户端,不再包装成笼统的获取失败。费用申请、差旅预计费用及报销明细均标记对应选项源。固定枚举提供 `enumLabels`,例如出行方式、单程/往返、上午/下午、人民币和酒店星级;动态 `SELECT` 的 `options` 统一为 `{label, code}`,表格列也递归标准化。Agent 只向用户显示名称并在内部使用正式值,不得向用户索取内部编码。`approval_prepare_submission` 会对标准字段、自定义下拉和表格列做第二次反向解析,确认预览只保留业务名称;无法映射时不签发确认引用。城市全量接口较慢,网关给予独立的 45 秒超时并在进程内缓存字典 24 小时,不写入磁盘;费用类型保持实时且不落盘。响应不会暴露员工号、完整数据源或原始成员档案。
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "dingtalk-approval-mcp",
|
||||
"version": "1.1.0-rc.18",
|
||||
"version": "1.1.0-rc.20",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dingtalk-approval-mcp",
|
||||
"version": "1.1.0-rc.18",
|
||||
"version": "1.1.0-rc.20",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"express": "^5.2.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dingtalk-approval-mcp",
|
||||
"version": "1.1.0-rc.18",
|
||||
"version": "1.1.0-rc.20",
|
||||
"description": "A lightweight Agent bridge for a unified DingTalk OA and XFT approval gateway.",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -10,10 +10,10 @@ Create only XFT approvals and these DingTalk templates: `出差申请单_北极
|
||||
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 user-visible, API-supported optional business fields with short explanations and ask whether the user wants to add any. Do not present fields marked `userVisible=false`, `serverManaged`, `internallyDerived`, or `selectionCapability.status=client_only` as ordinary questions. Briefly mention client-only optional capabilities only when they are relevant to the user's request. The user may choose to skip all optional fields; record that choice instead of silently omitting them.
|
||||
5. Call `approval_prepare_submission` with `allowDuplicateInvoices=false`. It automatically checks invoice identifiers and expense signatures against the signed-in user's submitted DingTalk and XFT history for the most recent 30 days. If it reports another missing, invalid, unsupported, or selection-required field, return to the collection loop and ask until validation succeeds. Never work around validation.
|
||||
5. Call `approval_prepare_submission` with `allowDuplicateInvoices=false`. For an XFT field with `attachmentCapability.status=supported`, pass its user-selected local file in the Bridge-only `attachments` array, not in `values`; the Bridge validates the regular file and sends only field name, file name, size, and SHA-256 for the no-upload preview. It automatically checks invoice identifiers, attachment hashes, and expense signatures against the signed-in user's submitted DingTalk and XFT history for the most recent 30 days. If it reports another missing, invalid, unsupported, or selection-required field, return to the collection loop and ask until validation succeeds. Never work around validation.
|
||||
6. If preparation returns `duplicate_confirmation_required` or `duplicate_check_unavailable`, no submit-capable `confirmationId` was created. Show the duplicate check's masked invoice evidence, matching source/business number/status, and coverage warning. Ask whether the user still wants to submit. Never infer consent from the original creation request. Only after the user explicitly accepts that exact risk, call prepare again with the same content and `allowDuplicateInvoices=true`. A rejected, revoked, or void prior bill is still a warning; a pending, approved, or unknown prior bill is blocking by default. If another active preparation reserved the same invoice, wait for or finish that preparation rather than overriding it.
|
||||
7. Show the successful returned preview, including `duplicateCheck`, and obtain explicit final confirmation to create the approval. The preview is already converted back to business labels; repeat those labels exactly and never replace them with values retained from Schema construction. If the gateway cannot map any selection back to a current label, it will refuse to create a `confirmationId`; refresh the live options instead of exposing the unresolved value. Do not treat answers to field questions, the choice to skip optional fields, or the duplicate-risk acknowledgement as this final submission confirmation.
|
||||
8. After final confirmation, call `approval_submit` once with the same `templateId`, `values`, department, selections, `allowDuplicateInvoices`, and returned `confirmationId`. Never modify the content or retry automatically.
|
||||
8. After final confirmation, call `approval_submit` once with the same `templateId`, `values`, local `attachments`, department, selections, `allowDuplicateInvoices`, and returned `confirmationId`. The signed Bridge revalidates and uploads attachments only at this point; the gateway never receives local paths and does not persist file bytes. Never modify the content or retry automatically.
|
||||
|
||||
If the user explicitly asks to undo a DingTalk or XFT approval just created through this MCP, call `approval_revoke_submission` once with the returned `revokeId`. Never use revoke to reject another person's todo, never accept a raw user ID, employee ID, or bill ID, and never automatically retry `failed` or `outcome_uncertain`. XFT recall is experimental in this release candidate: the current enterprise has returned `XFTSYS9999` for a real pending bill and no successful XFT recall has been observed. State this limitation when relevant and never report a recall as successful unless the tool returns `revoked` after live reconciliation.
|
||||
|
||||
@@ -21,4 +21,4 @@ For an XFT submission or recall result that needs reconciliation, call `approval
|
||||
|
||||
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`.
|
||||
Local file paths and file bytes are not valid `values` or `customFieldValues`. Use the signed Bridge `attachments` argument only when the live XFT Schema reports `attachmentCapability.status=supported`; prepare validates without uploading, and submit uploads only after final confirmation. If the host lacks the signed Bridge or the field does not advertise attachment support, preserve any local draft and direct the user to complete that form in the native client. Never pass base64 content or an invented attachment number.
|
||||
|
||||
@@ -74,7 +74,9 @@ Delete only the selected local file. Confirm deletion in business terms. Deletin
|
||||
|
||||
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.
|
||||
For each live XFT field whose `attachmentCapability.status` is `supported`, pass the draft attachment to the local Bridge `attachments` argument using exactly `fieldName`, `localPath`, `displayName`, `size`, `modifiedAt`, and `sha256` from the validated draft. Keep attachment fields out of `values.customFieldValues`. The signed Bridge opens the ordinary file without following a final symlink, checks its type and size, recomputes SHA-256, and sends only field name, file name, size, and hash to the gateway during `approval_prepare_submission`; it does not upload then. Supported local files are PDF, OFD, JPG/JPEG, PNG, XML, and ZIP, with at most 10 files and 50MB total. Show the returned attachment preview using business field and display names only; never reveal a local path, full hash, upload reference, or XFT file ID.
|
||||
|
||||
After the user explicitly confirms the complete preview, call `approval_submit` with the same local `attachments` array. The signed Bridge revalidates every file before any upload, uploads only after that final confirmation, and replaces paths with short-lived opaque upload references before calling the gateway tool. A local path, base64 content, or invented upstream attachment number is never a valid `values` field. If `attachmentCapability` is absent/client-only, the host has no signed Bridge, or local validation fails, keep the draft and explain the concrete file issue or require native-client completion.
|
||||
|
||||
Never persist the returned `templateId`, `confirmationId`, or `revokeId`. After the user's final confirmation, call `approval_submit` once with the in-memory live values and confirmation:
|
||||
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
export const APPROVAL_BATCH_LIMIT = 20;
|
||||
export const XFT_ATTACHMENT_MAX_FILE_BYTES = 50 * 1024 * 1024;
|
||||
export const XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION = 10;
|
||||
export const XFT_ATTACHMENT_MAX_TOTAL_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
+209
-6
@@ -1,14 +1,23 @@
|
||||
#!/usr/bin/env node
|
||||
import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
chmodSync, closeSync, constants as fsConstants, cpSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync,
|
||||
readFileSync, renameSync, rmSync, writeFileSync,
|
||||
} from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { z } from "zod";
|
||||
import { APPROVAL_BATCH_LIMIT } from "./approval-contract.js";
|
||||
import {
|
||||
APPROVAL_BATCH_LIMIT,
|
||||
XFT_ATTACHMENT_MAX_FILE_BYTES,
|
||||
XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION,
|
||||
XFT_ATTACHMENT_MAX_TOTAL_BYTES,
|
||||
} from "./approval-contract.js";
|
||||
import { isDirectEntrypoint } from "./entrypoint.js";
|
||||
import { APPLICATION_VERSION } from "./version.js";
|
||||
|
||||
@@ -39,6 +48,23 @@ export const APPROVAL_ONBOARDING_EXAMPLES = [
|
||||
|
||||
const REMOTE_WRITE_TIMEOUT_MS = 45_000;
|
||||
|
||||
interface LocalSubmissionAttachment {
|
||||
fieldName: string;
|
||||
localPath: string;
|
||||
displayName?: string;
|
||||
size?: number;
|
||||
modifiedAt?: string;
|
||||
sha256?: string;
|
||||
}
|
||||
|
||||
interface InspectedLocalAttachment {
|
||||
fieldName: string;
|
||||
fileName: string;
|
||||
size: number;
|
||||
sha256: string;
|
||||
content: Buffer;
|
||||
}
|
||||
|
||||
class AuthRequired extends Error {
|
||||
constructor(readonly challenge: NonNullable<BridgeState["device"]>) { super("需要钉钉登录"); }
|
||||
}
|
||||
@@ -100,6 +126,32 @@ export class RemoteBridge {
|
||||
});
|
||||
}
|
||||
|
||||
async prepareSubmission(args: Record<string, unknown>): Promise<any> {
|
||||
const attachments = inspectLocalSubmissionAttachments(args.attachments);
|
||||
return this.call("approval_prepare_submission", {
|
||||
...args,
|
||||
attachments: attachments.map(localAttachmentMetadata),
|
||||
});
|
||||
}
|
||||
|
||||
async submitApproval(args: Record<string, unknown>): Promise<any> {
|
||||
const attachments = inspectLocalSubmissionAttachments(args.attachments);
|
||||
if (!attachments.length) return this.call("approval_submit", { ...args, attachments: [] });
|
||||
const confirmationId = typeof args.confirmationId === "string" ? args.confirmationId : "";
|
||||
if (!confirmationId) throw new Error("附件提交缺少最终确认,请重新预览并取得用户确认");
|
||||
try {
|
||||
const uploaded = [];
|
||||
for (const attachment of attachments) {
|
||||
const result = await this.uploadSubmissionAttachment(attachment, confirmationId);
|
||||
uploaded.push({ ...localAttachmentMetadata(attachment), attachmentRef: result.attachmentRef });
|
||||
}
|
||||
return this.call("approval_submit", { ...args, attachments: uploaded });
|
||||
} catch (error) {
|
||||
if (error instanceof AuthRequired) return this.authResult(error.challenge);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async withCallSlot<T>(work: () => Promise<T>): Promise<T> {
|
||||
if (this.activeCalls < 3) this.activeCalls += 1;
|
||||
else await new Promise<void>((resolve) => this.callWaiters.push(resolve));
|
||||
@@ -111,6 +163,53 @@ export class RemoteBridge {
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadSubmissionAttachment(
|
||||
attachment: InspectedLocalAttachment,
|
||||
confirmationId: string,
|
||||
): Promise<{ attachmentRef: string }> {
|
||||
await this.tryCompleteDeviceLogin();
|
||||
if (!this.usableAccessToken()) await this.refreshAccessToken();
|
||||
if (!this.usableAccessToken()) throw new AuthRequired(await this.startDeviceLogin());
|
||||
let response: Response;
|
||||
try {
|
||||
response = await this.fetchFn(new URL("/xft/submission-attachments", this.origin), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${this.state.tokens!.access_token}`,
|
||||
"content-type": "application/octet-stream",
|
||||
"content-length": String(attachment.size),
|
||||
"x-dingmcp-field-name": encodeURIComponent(attachment.fieldName),
|
||||
"x-dingmcp-file-name": encodeURIComponent(attachment.fileName),
|
||||
"x-dingmcp-file-sha256": attachment.sha256,
|
||||
"x-dingmcp-confirmation-id": confirmationId,
|
||||
},
|
||||
body: attachment.content.buffer.slice(
|
||||
attachment.content.byteOffset,
|
||||
attachment.content.byteOffset + attachment.content.byteLength,
|
||||
) as ArrayBuffer,
|
||||
signal: AbortSignal.timeout(Math.max(REMOTE_WRITE_TIMEOUT_MS, 120_000)),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`附件“${attachment.fileName}”上传到审批网关时连接失败;审批尚未创建,请勿自动重试`, { cause: error });
|
||||
}
|
||||
let value: Record<string, unknown>;
|
||||
try { value = await response.json() as Record<string, unknown>; }
|
||||
catch { throw new Error(`附件“${attachment.fileName}”上传失败:审批网关返回了无法解析的响应`); }
|
||||
if (!response.ok || typeof value.attachmentRef !== "string") {
|
||||
const detail = typeof value.error === "string" ? value.error : `HTTP ${response.status}`;
|
||||
throw new Error(`附件“${attachment.fileName}”上传失败:${detail}`);
|
||||
}
|
||||
if (
|
||||
value.fieldName !== attachment.fieldName
|
||||
|| value.fileName !== attachment.fileName
|
||||
|| value.size !== attachment.size
|
||||
|| value.sha256 !== attachment.sha256
|
||||
) {
|
||||
throw new Error(`附件“${attachment.fileName}”上传响应与本地文件不一致,审批尚未创建`);
|
||||
}
|
||||
return { attachmentRef: value.attachmentRef };
|
||||
}
|
||||
|
||||
private async remoteClient(): Promise<Client> {
|
||||
if (this.client && this.usableAccessToken()) return this.client;
|
||||
if (this.client) {
|
||||
@@ -420,6 +519,101 @@ function isExplicitOAuthInvalidation(status: number, value: Record<string, unkno
|
||||
&& ["invalid_grant", "invalid_client", "unauthorized_client", "access_denied", "expired_token"].includes(code);
|
||||
}
|
||||
|
||||
function inspectLocalSubmissionAttachments(value: unknown): InspectedLocalAttachment[] {
|
||||
if (value === undefined) return [];
|
||||
if (!Array.isArray(value)) throw new Error("attachments 必须是本机附件数组");
|
||||
if (value.length > XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION) {
|
||||
throw new Error(`一次薪福通提单最多选择 ${XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION} 个附件`);
|
||||
}
|
||||
const attachments = value.map((item) => inspectLocalSubmissionAttachment(item));
|
||||
const total = attachments.reduce((sum, attachment) => sum + attachment.size, 0);
|
||||
if (total > XFT_ATTACHMENT_MAX_TOTAL_BYTES) throw new Error("一次薪福通提单的附件总大小不能超过 50MB");
|
||||
return attachments;
|
||||
}
|
||||
|
||||
function inspectLocalSubmissionAttachment(value: unknown): InspectedLocalAttachment {
|
||||
const attachment = isObject(value) ? value as unknown as LocalSubmissionAttachment : undefined;
|
||||
if (!attachment || typeof attachment.fieldName !== "string" || !attachment.fieldName.trim()) {
|
||||
throw new Error("附件缺少实时表单中的字段名称");
|
||||
}
|
||||
if (typeof attachment.localPath !== "string" || !attachment.localPath.trim()) {
|
||||
throw new Error("附件缺少用户明确选择的本机文件");
|
||||
}
|
||||
if (!isAbsolute(attachment.localPath)) throw new Error("附件必须使用本机绝对路径,请重新选择文件");
|
||||
const resolvedPath = resolve(attachment.localPath);
|
||||
const fileName = basename(resolvedPath);
|
||||
if (attachment.displayName !== undefined && attachment.displayName !== fileName) {
|
||||
throw new Error(`附件“${attachment.displayName}”与当前选择的文件名不一致,请重新选择`);
|
||||
}
|
||||
if (!fileName || fileName.length > 240 || /[\r\n\0"]/u.test(fileName)) throw new Error("附件文件名无效,请重新命名后选择");
|
||||
let before;
|
||||
let fd: number | undefined;
|
||||
try {
|
||||
before = lstatSync(resolvedPath);
|
||||
if (!before.isFile() || before.isSymbolicLink()) throw new Error("not a regular file");
|
||||
fd = openSync(resolvedPath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
|
||||
const opened = fstatSync(fd);
|
||||
if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino) throw new Error("file changed while opening");
|
||||
if (opened.size <= 0 || opened.size > XFT_ATTACHMENT_MAX_FILE_BYTES) throw new Error("invalid file size");
|
||||
const content = readFileSync(fd);
|
||||
const after = fstatSync(fd);
|
||||
if (after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || content.length !== opened.size) {
|
||||
throw new Error("file changed while reading");
|
||||
}
|
||||
validateLocalInvoiceAttachment(fileName, content);
|
||||
const sha256 = createHash("sha256").update(content).digest("hex");
|
||||
if (attachment.size !== undefined && attachment.size !== content.length) {
|
||||
throw new Error("stored size changed");
|
||||
}
|
||||
if (attachment.sha256 !== undefined && attachment.sha256 !== sha256) {
|
||||
throw new Error("stored hash changed");
|
||||
}
|
||||
return {
|
||||
fieldName: attachment.fieldName.trim(),
|
||||
fileName,
|
||||
size: content.length,
|
||||
sha256,
|
||||
content,
|
||||
};
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : "file validation failed";
|
||||
if (reason === "invalid file type") throw new Error(`附件“${fileName}”不是支持的 PDF、OFD、JPG、PNG、XML 或 ZIP 文件`);
|
||||
if (reason === "invalid file size") throw new Error(`附件“${fileName}”必须大于 0 字节且单个不超过 50MB`);
|
||||
if (reason === "stored hash changed" || reason === "stored size changed" || reason === "file changed while reading") {
|
||||
throw new Error(`附件“${fileName}”自保存草稿或预览后发生变化,请重新选择并确认`);
|
||||
}
|
||||
throw new Error(`无法安全读取附件“${fileName}”;请选择未经过符号链接的普通文件`);
|
||||
} finally {
|
||||
if (fd !== undefined) closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
function validateLocalInvoiceAttachment(fileName: string, content: Buffer): void {
|
||||
const extension = extname(fileName).toLowerCase();
|
||||
const zip = content.length >= 4 && content[0] === 0x50 && content[1] === 0x4b
|
||||
&& [[0x03, 0x04], [0x05, 0x06], [0x07, 0x08]].some(([a, b]) => content[2] === a && content[3] === b);
|
||||
const valid = extension === ".pdf" ? content.subarray(0, 5).toString("ascii") === "%PDF-"
|
||||
: extension === ".png" ? content.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
||||
: extension === ".jpg" || extension === ".jpeg" ? content[0] === 0xff && content[1] === 0xd8 && content[2] === 0xff
|
||||
: extension === ".xml" ? content.subarray(0, 1024).toString("utf8").replace(/^\uFEFF?\s*/u, "").startsWith("<")
|
||||
: extension === ".ofd" || extension === ".zip" ? zip
|
||||
: false;
|
||||
if (!valid) throw new Error("invalid file type");
|
||||
}
|
||||
|
||||
function localAttachmentMetadata(attachment: InspectedLocalAttachment) {
|
||||
return {
|
||||
fieldName: attachment.fieldName,
|
||||
fileName: attachment.fileName,
|
||||
size: attachment.size,
|
||||
sha256: attachment.sha256,
|
||||
};
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeEndpoint(value: string): URL {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "https:" || url.pathname !== "/mcp") throw new Error("远程 MCP 必须是 HTTPS /mcp 地址");
|
||||
@@ -433,7 +627,7 @@ function parseEndpoint(argv: string[]): string {
|
||||
|
||||
export function createBridgeMcpServer(bridge: RemoteBridge): McpServer {
|
||||
const server = new McpServer({ name: "dingtalk-approval-stdio-bridge", version: APPLICATION_VERSION }, {
|
||||
instructions: "首次使用或登录链接过期时调用approval_login;每次调用会检查登录并在需要时生成有效的新链接,绝不因链接过期而重新安装。工具不会通过其他消息通道自动发送链接,必须把content.text中的登录URL原样写入当前面向用户的回复,不得只声称已生成或已推送。审批引用、调用容量和内部分批不得向用户解释;查看时只回答业务信息,用户明确授权后在授权范围内自动处理。薪福通待办不按单据类型过滤;availableActions=[]且manualAction.reason=unsupported_xft_task_type的项目仍要展示,但只能引导薪福通客户端查看处理,禁止尝试MCP写入。写前校验失败时零写入,任何不确定结果都不得自动重试。提单草稿仅可保存在 Agent 所在机器的当前用户私有目录,不得上传网关;恢复草稿后必须重新读取实时模板 Schema,短期提交引用不得写入草稿。薪福通所有选择型字段只向用户展示业务名称:optionSource 选项展示label,内嵌枚举展示enumLabels;用户选定label后,values中必须填同一项的code而不能填label,code只存在于工具调用内部。不得向用户索取或展示城市码、组织码、工号、档案编号、数字舱位值或其他内部ID;selectionCapability=client_only时停止构造该必填项或允许跳过该可选项。",
|
||||
instructions: "首次使用或登录链接过期时调用approval_login;每次调用会检查登录并在需要时生成有效的新链接,绝不因链接过期而重新安装。工具不会通过其他消息通道自动发送链接,必须把content.text中的登录URL原样写入当前面向用户的回复,不得只声称已生成或已推送。审批引用、调用容量和内部分批不得向用户解释;查看时只回答业务信息,用户明确授权后在授权范围内自动处理。薪福通待办不按单据类型过滤;availableActions=[]且manualAction.reason=unsupported_xft_task_type的项目仍要展示,但只能引导薪福通客户端查看处理,禁止尝试MCP写入。写前校验失败时零写入,任何不确定结果都不得自动重试。提单草稿仅可保存在 Agent 所在机器的当前用户私有目录,不得上传网关;恢复草稿后必须重新读取实时模板 Schema,短期提交引用不得写入草稿。草稿附件保存字段名称、本地路径、大小、修改时间和SHA-256;prepare只读取并复核本地文件、把不含路径的元数据用于预览,最终确认后的submit才上传,网关不保存文件。薪福通所有选择型字段只向用户展示业务名称:optionSource 选项展示label,内嵌枚举展示enumLabels;用户选定label后,values中必须填同一项的code而不能填label,code只存在于工具调用内部。不得向用户索取或展示城市码、组织码、工号、档案编号、数字舱位值或其他内部ID;selectionCapability=client_only时停止构造该必填项或允许跳过该可选项。",
|
||||
});
|
||||
server.registerTool("approval_login", {
|
||||
title: "登录审批服务", description: "检查登录;未登录或旧链接已过期时生成新的钉钉授权链接。同一身份同时用于钉钉OA和薪福通,无需重新安装。", inputSchema: {},
|
||||
@@ -503,16 +697,25 @@ export function createBridgeMcpServer(bridge: RemoteBridge): McpServer {
|
||||
actionerStaffIds: z.array(z.string().min(1).max(500)).min(1).max(50),
|
||||
})).max(20).default([]),
|
||||
allowDuplicateInvoices: z.boolean().default(false).describe("仅在网关已提示重复风险或查重不完整、且用户明确确认仍要提交后设为 true"),
|
||||
attachments: z.array(z.object({
|
||||
fieldName: z.string().min(1).max(200).describe("实时 Schema 中的附件字段业务名称"),
|
||||
localPath: z.string().min(1).max(4096).describe("用户明确选择的本机绝对文件路径;只由签名 Bridge 读取,不发送给网关"),
|
||||
displayName: z.string().min(1).max(240).optional(),
|
||||
size: z.number().int().min(1).max(XFT_ATTACHMENT_MAX_FILE_BYTES).optional(),
|
||||
modifiedAt: z.string().datetime().optional(),
|
||||
sha256: z.string().regex(/^[0-9a-f]{64}$/).optional(),
|
||||
})).max(XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION).default([])
|
||||
.describe("本机草稿附件引用;prepare复核文件且不上传,用户最终确认后的submit才上传"),
|
||||
};
|
||||
server.registerTool("approval_prepare_submission", {
|
||||
title: "校验并预览新审批", description: "无副作用校验全部字段并自动查重本人近30天钉钉/薪福通发票;选择项在values中填所选label对应的code,成功预览只显示业务名称;有重复风险或查重不完整时要求用户明确确认,不会直接生成提交引用。", inputSchema: submissionInputSchema,
|
||||
annotations: { readOnlyHint: true, destructiveHint: false },
|
||||
}, async (args) => bridge.call("approval_prepare_submission", args));
|
||||
}, async (args) => bridge.prepareSubmission(args));
|
||||
server.registerTool("approval_submit", {
|
||||
title: "正式发起审批", description: "仅在用户看过最终预览并明确确认后,使用完全相同内容和重复风险决定发起审批;禁止自动重试。", inputSchema: {
|
||||
confirmationId: z.string().min(20).max(4096), ...submissionInputSchema,
|
||||
}, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
|
||||
}, async (args) => bridge.call("approval_submit", args));
|
||||
}, async (args) => bridge.submitApproval(args));
|
||||
server.registerTool("approval_revoke_submission", {
|
||||
title: "撤销本人发起的审批", description: "使用提单成功后返回的 revokeId 撤销本人发起的审批;禁止自动重试。薪福通 recall 当前为实验能力,必须以写后状态复核为准。", inputSchema: {
|
||||
revokeId: z.string().min(20).max(4096), remark: z.string().max(1000).default(""),
|
||||
|
||||
+111
-9
@@ -9,9 +9,13 @@ import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middlew
|
||||
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
|
||||
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { z } from "zod";
|
||||
import { APPROVAL_BATCH_LIMIT } from "./approval-contract.js";
|
||||
import {
|
||||
APPROVAL_BATCH_LIMIT,
|
||||
XFT_ATTACHMENT_MAX_FILE_BYTES,
|
||||
XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION,
|
||||
} from "./approval-contract.js";
|
||||
import { ApprovalService } from "./approval-service.js";
|
||||
import { errorMessage } from "./errors.js";
|
||||
import { ApprovalSafetyError, XftApiError, errorMessage } from "./errors.js";
|
||||
import type { AppConfig } from "./config.js";
|
||||
import { DingTalkClient } from "./dingtalk.js";
|
||||
import type { XftApprovalProvider } from "./xft.js";
|
||||
@@ -22,6 +26,7 @@ import { DINGTALK_OAUTH_AUTHORIZE_ORIGIN, DingTalkMcpOAuthProvider } from "./oau
|
||||
import type { AuthenticatedDingTalkUser } from "./xft.js";
|
||||
import { InvoiceDeduplicationService, InvoiceDedupStore, invoiceDedupDatabasePath } from "./invoice-dedup.js";
|
||||
import { APPLICATION_VERSION } from "./version.js";
|
||||
import { SignedReferenceCodec } from "./signed-reference.js";
|
||||
|
||||
type TransportEntry =
|
||||
| { kind: "streamable"; transport: StreamableHTTPServerTransport; userId: string; createdAt: number; lastSeenAt: number }
|
||||
@@ -42,7 +47,7 @@ export const APPROVAL_MCP_INSTRUCTIONS = [
|
||||
"钉钉开放接口无法预检部分模板的签字等业务规则;要求手写签名时返回客户端处理链接。处理结果中 failed 表示操作被拒绝且未生效;除非用户看过原因后再次明确要求,否则不要重试。succeeded 表示节点提交成功;completed 才表示无需当前用户继续审批。uncertain/outcome_uncertain 表示已经提交但未确认,禁止自动重试,应稍后重新列出待办。followUpRequired 大于 0 时应重新列出待办并告知用户。",
|
||||
"出现 authChallenge 时,工具不会通过其他消息通道自动发送内容。必须把 directUrl 原样写入当前面向用户的回复;有二维码图片时直接作为当前回复附件,禁止转成 ASCII,也不得只声称已生成或已推送。",
|
||||
"发起审批范围仅包括薪福通已支持模板,以及 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 时冻结且禁止自动重试,先实时核验本人发起记录。",
|
||||
"用户明确要求起草、保存、继续、列出或删除提单草稿时,草稿只能由 Agent 保存在运行机器的当前 OS 用户私有数据目录:Windows %LOCALAPPDATA%\\DingTalkApproval\\drafts、macOS ~/Library/Application Support/DingTalkApproval/drafts、Linux ${XDG_DATA_HOME:-~/.local/share}/dingtalk-approval/drafts;POSIX 目录/文件权限为 0700/0600并用同目录临时文件原子替换。不得上传网关、写入项目仓库或声称已在钉钉/薪福通创建草稿。宿主没有本地文件能力时明确说明无法持久保存。每个 UUID.json 使用 schemaVersion=1、kind=dingtalk-approval-local-draft、draftId、title、source、templateName、status(editing或submit_uncertain)、values、optionalFieldDecision、attachments、createdAt、updatedAt;只保存用户填写值和附件本地路径/大小/mtime/SHA-256,不得保存 Token、身份 ID、短期 templateId/confirmationId/revokeId 或完整上游 Schema。恢复时必须重新列模板并实时读取 Schema,附件必须在正式准备前重新校验。签名本地 Bridge 会把路径转换为不含本地路径的附件元数据参与预览,并只在最终确认后上传;网关不保存文件。每次草稿操作自动清理超过 90 天未更新的普通草稿。正式提交成功或即时完成后删除;明确失败时保留;outcome_uncertain 时冻结且禁止自动重试,先实时核验本人发起记录。",
|
||||
"approval_prepare_submission 会自动检查当前用户近30天本人发起的钉钉与薪福通审批是否使用了相同发票。若返回 duplicate_confirmation_required 或 duplicate_check_unavailable,本次没有生成 confirmationId;必须展示匹配单号、状态和查重覆盖情况并询问用户。只有用户明确表示知晓重复或查重不完整仍要提交后,才可将 allowDuplicateInvoices=true 原样重新 prepare。不得自行代替用户确认。",
|
||||
"approval_submit 发起后会返回实时核验结果;仍在审批中的单据会带 revokeId,按企业规则即时完成的薪福通流程返回 completed 且不可撤回。只有用户明确要求撤销该本人审批时才调用 approval_revoke_submission;禁止用撤销代替拒绝别人的待办,禁止自动重试 completed、failed 或 outcome_uncertain。薪福通创建或撤回需要复核时使用只读 approval_get_submission_status,它会实时校验本人已发起单据归属,但不会再次执行写操作。",
|
||||
].join("\n");
|
||||
@@ -207,14 +212,32 @@ export function createApprovalMcpServer(
|
||||
})).max(20).default([]),
|
||||
allowDuplicateInvoices: z.boolean().default(false).describe("仅当 prepare 已展示重复/疑似重复或查重覆盖不完整,且用户明确确认仍要提交时设为 true;不得由 Agent 自行开启"),
|
||||
};
|
||||
const attachmentMetadataSchema = z.object({
|
||||
fieldName: z.string().min(1).max(200).describe("实时 Schema 中的附件字段业务名称"),
|
||||
fileName: z.string().min(1).max(240),
|
||||
size: z.number().int().min(1).max(XFT_ATTACHMENT_MAX_FILE_BYTES),
|
||||
sha256: z.string().regex(/^[0-9a-f]{64}$/),
|
||||
});
|
||||
const prepareSubmissionInputSchema = {
|
||||
...submissionInputSchema,
|
||||
attachments: z.array(attachmentMetadataSchema).max(XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION).default([])
|
||||
.describe("薪福通本机附件元数据;不含本地路径、文件内容或预上传引用,prepare 不上传"),
|
||||
};
|
||||
const submitSubmissionInputSchema = {
|
||||
...submissionInputSchema,
|
||||
attachments: z.array(attachmentMetadataSchema.extend({
|
||||
attachmentRef: z.string().min(20).max(4096).describe("仅最终提交时由签名本地 Bridge 上传后填写"),
|
||||
})).max(XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION).default([])
|
||||
.describe("薪福通附件元数据和绑定本次确认的短期上传引用;不含本地路径和文件内容"),
|
||||
};
|
||||
|
||||
server.registerTool("approval_prepare_submission", {
|
||||
title: "校验并预览新审批",
|
||||
description: "仅在用户已补齐所有必填字段并选择是否填写可选字段后调用。选择项在values中使用所选label对应的code,不能直接填label;code不得展示给用户。实时复核模板和字段构造能力,并自动查重本人近30天钉钉/薪福通发票。若返回缺失或无效字段,Agent必须继续询问;若为 client_only 则停止 API 构造。重复风险或查重不完整时不生成confirmationId,必须展示结果并询问用户;只有用户明确接受后才以allowDuplicateInvoices=true重试准备。成功预览会反向显示业务名称,仍需另行获得最终提交确认。",
|
||||
inputSchema: submissionInputSchema,
|
||||
inputSchema: prepareSubmissionInputSchema,
|
||||
annotations: { readOnlyHint: true, destructiveHint: false },
|
||||
}, async ({ templateId, values, deptId, targetSelectActioners, allowDuplicateInvoices }) => {
|
||||
const result = await unified.prepareSubmission(user, { templateId, values, deptId, targetSelectActioners, allowDuplicateInvoices });
|
||||
}, async ({ templateId, values, deptId, targetSelectActioners, allowDuplicateInvoices, attachments }) => {
|
||||
const result = await unified.prepareSubmission(user, { templateId, values, deptId, targetSelectActioners, allowDuplicateInvoices, attachments });
|
||||
const summary = result.confirmationId
|
||||
? "审批尚未提交;已生成待用户确认的完整预览。"
|
||||
: "审批尚未提交;发票查重需要用户决定,本次未生成提交确认。";
|
||||
@@ -226,12 +249,12 @@ export function createApprovalMcpServer(
|
||||
description: "仅在用户看过approval_prepare_submission最终预览并明确确认后调用。必须原样重传相同表单内容、allowDuplicateInvoices决定和一次性confirmationId;服务端再次校验当前登录身份、模板和发票预留。薪福通流程可能进入待审批,也可能按企业规则即时完成并返回completed;两者都禁止自动重试。",
|
||||
inputSchema: {
|
||||
confirmationId: z.string().min(20).max(4096).describe("approval_prepare_submission返回的一次性确认引用"),
|
||||
...submissionInputSchema,
|
||||
...submitSubmissionInputSchema,
|
||||
},
|
||||
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
|
||||
}, async ({ confirmationId, templateId, values, deptId, targetSelectActioners, allowDuplicateInvoices }) => {
|
||||
}, async ({ confirmationId, templateId, values, deptId, targetSelectActioners, allowDuplicateInvoices, attachments }) => {
|
||||
try {
|
||||
const result = await unified.submitApproval(user, confirmationId, { templateId, values, deptId, targetSelectActioners, allowDuplicateInvoices });
|
||||
const result = await unified.submitApproval(user, confirmationId, { templateId, values, deptId, targetSelectActioners, allowDuplicateInvoices, attachments });
|
||||
audit?.(user.userId, "submit_approval", confirmationId, "submitted");
|
||||
return compactResult(
|
||||
result.status === "completed" ? "审批已发起并按企业流程规则即时完成。"
|
||||
@@ -400,6 +423,52 @@ export async function startHttpServer(config: AppConfig): Promise<void> {
|
||||
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpUrl),
|
||||
}) : undefined;
|
||||
|
||||
const attachmentSigner = new SignedReferenceCodec(config.approvalRefSecret);
|
||||
const attachmentUploadHandler = async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!xft?.uploadSubmissionAttachment) throw new ApprovalSafetyError("服务器未启用薪福通附件上传");
|
||||
const user = requestUser(config, oauth, req.auth);
|
||||
const fieldName = encodedHeader(req, "x-dingmcp-field-name");
|
||||
const fileName = encodedHeader(req, "x-dingmcp-file-name");
|
||||
const sha256 = headerString(req.headers["x-dingmcp-file-sha256"]);
|
||||
const confirmationId = headerString(req.headers["x-dingmcp-confirmation-id"]);
|
||||
if (!fieldName || !fileName || !sha256 || !confirmationId || !Buffer.isBuffer(req.body)) {
|
||||
throw new ApprovalSafetyError("附件上传请求缺少字段名称、文件名、SHA-256、提单确认或二进制内容");
|
||||
}
|
||||
assertAttachmentConfirmation(attachmentSigner, user, confirmationId, {
|
||||
fieldName, fileName, size: req.body.length, sha256,
|
||||
});
|
||||
const uploaded = await xft.uploadSubmissionAttachment(user, {
|
||||
fieldName,
|
||||
fileName,
|
||||
size: req.body.length,
|
||||
sha256,
|
||||
content: req.body,
|
||||
confirmationId,
|
||||
});
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.json({
|
||||
fieldName: uploaded.fieldName,
|
||||
fileName: uploaded.fileName,
|
||||
size: uploaded.size,
|
||||
sha256: uploaded.sha256,
|
||||
attachmentRef: uploaded.attachmentRef,
|
||||
});
|
||||
} catch (error) {
|
||||
const status = error instanceof ApprovalSafetyError ? 400 : error instanceof XftApiError ? 502 : 500;
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.status(status).json({
|
||||
error: errorMessage(error),
|
||||
...(error instanceof XftApiError && error.code ? { code: error.code } : {}),
|
||||
});
|
||||
}
|
||||
};
|
||||
const attachmentBody = express.raw({ type: "application/octet-stream", limit: `${XFT_ATTACHMENT_MAX_FILE_BYTES}b` });
|
||||
app.post(
|
||||
"/xft/submission-attachments",
|
||||
...(authMiddleware ? [authMiddleware, attachmentBody, attachmentUploadHandler] : [attachmentBody, attachmentUploadHandler]),
|
||||
);
|
||||
|
||||
const transports = new Map<string, TransportEntry>();
|
||||
let pendingTransportSessions = 0;
|
||||
const pendingTransportSessionsByUser = new Map<string, number>();
|
||||
@@ -587,6 +656,39 @@ export function isLoopbackAddress(value: string | undefined): boolean {
|
||||
}
|
||||
|
||||
function headerString(value: string | string[] | undefined): string | undefined { return Array.isArray(value) ? value[0] : value; }
|
||||
function encodedHeader(req: Request, name: string): string | undefined {
|
||||
const value = headerString(req.headers[name]);
|
||||
if (!value || value.length > 1000) return undefined;
|
||||
try { return decodeURIComponent(value); } catch { return undefined; }
|
||||
}
|
||||
|
||||
export function assertAttachmentConfirmation(
|
||||
signer: SignedReferenceCodec,
|
||||
user: AuthenticatedDingTalkUser,
|
||||
confirmationId: string,
|
||||
attachment: { fieldName: string; fileName: string; size: number; sha256: string },
|
||||
): void {
|
||||
let confirmation: Record<string, unknown>;
|
||||
try { confirmation = signer.verify<Record<string, unknown>>(confirmationId); }
|
||||
catch { throw new ApprovalSafetyError("提单确认无效或已过期,请重新预览后再上传附件"); }
|
||||
if (
|
||||
confirmation.kind !== "submission_confirmation"
|
||||
|| confirmation.source !== "xft"
|
||||
|| confirmation.userId !== user.userId
|
||||
|| typeof confirmation.exp !== "number"
|
||||
|| confirmation.exp < Date.now()
|
||||
) {
|
||||
throw new ApprovalSafetyError("提单确认无效、已过期或不属于当前用户,请重新预览后再上传附件");
|
||||
}
|
||||
const manifest = Array.isArray(confirmation.attachmentManifest) ? confirmation.attachmentManifest : [];
|
||||
if (!manifest.some((item) => item && typeof item === "object" && !Array.isArray(item)
|
||||
&& (item as Record<string, unknown>).fieldName === attachment.fieldName
|
||||
&& (item as Record<string, unknown>).fileName === attachment.fileName
|
||||
&& (item as Record<string, unknown>).size === attachment.size
|
||||
&& (item as Record<string, unknown>).sha256 === attachment.sha256)) {
|
||||
throw new ApprovalSafetyError(`附件“${attachment.fileName}”不在用户刚刚确认的提单预览中,请重新预览`);
|
||||
}
|
||||
}
|
||||
export function compactResult(summary: string, value: unknown, images?: XftChallengeProvider) {
|
||||
const visibleValue = agentVisibleResult(value);
|
||||
const serialized = JSON.stringify(visibleValue);
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
||||
import { deflateRawSync, inflateRawSync } from "node:zlib";
|
||||
|
||||
export class SignedReferenceCodec {
|
||||
private readonly key: Buffer;
|
||||
|
||||
constructor(secret: string) {
|
||||
this.key = createHash("sha256").update(secret).digest();
|
||||
}
|
||||
|
||||
sign(value: object): string {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv("aes-256-gcm", this.key, iv);
|
||||
const compressed = deflateRawSync(Buffer.from(JSON.stringify(value), "utf8"), { level: 9 });
|
||||
const payload = Buffer.concat([cipher.update(compressed), cipher.final()]);
|
||||
return `v3.${iv.toString("base64url")}.${payload.toString("base64url")}.${cipher.getAuthTag().toString("base64url")}`;
|
||||
}
|
||||
|
||||
verify<T>(value: string): T {
|
||||
const parts = value.split(".");
|
||||
if (parts.length !== 4) throw new SignedReferenceFormatError();
|
||||
const [version, encodedIv, encodedPayload, encodedTag] = parts;
|
||||
if (!version || !["v2", "v3"].includes(version) || !encodedIv || !encodedPayload || !encodedTag) {
|
||||
throw new SignedReferenceFormatError();
|
||||
}
|
||||
let iv: Buffer;
|
||||
let payload: Buffer;
|
||||
let tag: Buffer;
|
||||
try {
|
||||
iv = decodeCanonicalBase64Url(encodedIv);
|
||||
payload = decodeCanonicalBase64Url(encodedPayload);
|
||||
tag = decodeCanonicalBase64Url(encodedTag);
|
||||
if (iv.length !== 12 || tag.length !== 16 || payload.length === 0) throw new Error("invalid encrypted reference lengths");
|
||||
} catch {
|
||||
throw new SignedReferenceFormatError();
|
||||
}
|
||||
try {
|
||||
const decipher = createDecipheriv("aes-256-gcm", this.key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
const decrypted = Buffer.concat([decipher.update(payload), decipher.final()]);
|
||||
const plaintext = version === "v3" ? inflateRawSync(decrypted, { maxOutputLength: 64 * 1024 }) : decrypted;
|
||||
return JSON.parse(plaintext.toString("utf8")) as T;
|
||||
} catch {
|
||||
throw new SignedReferenceIntegrityError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class SignedReferenceFormatError extends Error {
|
||||
constructor() { super("引用格式不完整,可能在传输中被截断,请重新获取最新引用"); }
|
||||
}
|
||||
|
||||
export class SignedReferenceIntegrityError extends Error {
|
||||
constructor() { super("引用无效或已损坏,请重新获取最新引用"); }
|
||||
}
|
||||
|
||||
function decodeCanonicalBase64Url(value: string): Buffer {
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(value)) throw new Error("invalid base64url");
|
||||
const decoded = Buffer.from(value, "base64url");
|
||||
if (decoded.toString("base64url") !== value) throw new Error("non-canonical base64url");
|
||||
return decoded;
|
||||
}
|
||||
+137
-61
@@ -1,14 +1,32 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "node:crypto";
|
||||
import { deflateRawSync, inflateRawSync } from "node:zlib";
|
||||
import { APPROVAL_BATCH_LIMIT } from "./approval-contract.js";
|
||||
import {
|
||||
APPROVAL_BATCH_LIMIT,
|
||||
XFT_ATTACHMENT_MAX_FILE_BYTES,
|
||||
XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION,
|
||||
XFT_ATTACHMENT_MAX_TOTAL_BYTES,
|
||||
} from "./approval-contract.js";
|
||||
import type { AppConfig } from "./config.js";
|
||||
import { ApprovalSafetyError, XftSessionExpiredError, errorMessage } from "./errors.js";
|
||||
import { ApprovalService, type ApprovalHistoryItem, type ProcessApprovalItem, type TodoItem } from "./approval-service.js";
|
||||
import type { ApprovalHistoryRange, ApprovalHistoryScope, AuthenticatedDingTalkUser, XftApprovalProvider, XftHistoryItem, XftSubmissionField, XftSubmissionKind, XftSubmissionOptionKind, XftSubmissionTemplate, XftTodoItem } from "./xft.js";
|
||||
import {
|
||||
type ApprovalHistoryRange,
|
||||
type ApprovalHistoryScope,
|
||||
type AuthenticatedDingTalkUser,
|
||||
type XftApprovalProvider,
|
||||
type XftHistoryItem,
|
||||
type XftResolvedSubmissionAttachment,
|
||||
type XftSubmissionAttachment,
|
||||
type XftSubmissionField,
|
||||
type XftSubmissionKind,
|
||||
type XftSubmissionOptionKind,
|
||||
type XftSubmissionTemplate,
|
||||
type XftTodoItem,
|
||||
} from "./xft.js";
|
||||
import { browseRawDetail, compactApprovalDetail, type ApprovalDetailOptions, type ApprovalDetailView } from "./approval-detail.js";
|
||||
import { sensitiveLabel } from "./approval-metadata.js";
|
||||
import { isRecord } from "./json.js";
|
||||
import type { InvoiceDeduplicationService } from "./invoice-dedup.js";
|
||||
import { SignedReferenceCodec } from "./signed-reference.js";
|
||||
|
||||
export type UnifiedSource = "dingtalk" | "xft";
|
||||
export type UnifiedAction = "approve" | "reject" | "return";
|
||||
@@ -56,6 +74,7 @@ interface SubmissionConfirmationReference {
|
||||
nonce: string;
|
||||
serviceEpoch: string;
|
||||
invoiceReservationId?: string;
|
||||
attachmentManifest?: Array<{ fieldName: string; fileName: string; size: number; sha256: string }>;
|
||||
exp: number;
|
||||
processCode?: string;
|
||||
xftTemplate?: XftSubmissionTemplate;
|
||||
@@ -155,6 +174,7 @@ export interface SubmissionInput {
|
||||
deptId?: number;
|
||||
targetSelectActioners?: Array<{ actionerKey: string; actionerStaffIds: string[] }>;
|
||||
allowDuplicateInvoices?: boolean;
|
||||
attachments?: XftSubmissionAttachment[];
|
||||
}
|
||||
|
||||
export interface OfficialOaReturnClient {
|
||||
@@ -486,7 +506,8 @@ export class UnifiedApprovalService {
|
||||
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 attachments = validateXftSubmissionAttachments(schema.fields, input.attachments ?? [], false);
|
||||
validateXftSubmissionValues(template.xftTemplate.kind, input.values, schema.fields, attachments);
|
||||
const identityValidation = await this.xft.validateSubmissionIdentity(user);
|
||||
preview = {
|
||||
source: "xft", template: schema.template,
|
||||
@@ -494,10 +515,12 @@ export class UnifiedApprovalService {
|
||||
identityValidation,
|
||||
submissionStatus: "APPRV_PEND",
|
||||
formSummary: await readableXftSubmissionSummary(this.xft, user, schema.template, schema.fields, schema.inputContract, input.values),
|
||||
...(attachments.length ? { attachments: attachmentPreview(attachments) } : {}),
|
||||
processForecast: { supported: false, message: "薪福通正式接口不提供无副作用的流程预测;提交时由上游完成最终业务校验" },
|
||||
};
|
||||
fingerprint = hashJson({ template: schema.template, fields: schema.fields });
|
||||
} else {
|
||||
if ((input.attachments ?? []).length) throw new ApprovalSafetyError("本地附件上传当前仅支持薪福通模板");
|
||||
if (!template.processCode) throw new ApprovalSafetyError("钉钉模板引用缺少 processCode");
|
||||
const current = await this.dingTalk.getSubmissionSchema(template.processCode);
|
||||
assertDingTalkSubmissionTemplateAllowed(current.template);
|
||||
@@ -523,9 +546,10 @@ export class UnifiedApprovalService {
|
||||
const expiresAt = Date.now() + 10 * 60_000;
|
||||
const invoiceEvidenceExpected = template.source === "xft"
|
||||
&& (template.xftTemplate?.kind === "common_reimburse" || template.xftTemplate?.kind === "travel_reimburse");
|
||||
const deduplicationValues = submissionValuesForInvoiceDeduplication(input);
|
||||
const deduplication = this.invoiceDedup
|
||||
? await this.invoiceDedup.prepare(
|
||||
user, input.values, input.allowDuplicateInvoices === true, expiresAt, invoiceEvidenceExpected,
|
||||
user, deduplicationValues, input.allowDuplicateInvoices === true, expiresAt, invoiceEvidenceExpected,
|
||||
)
|
||||
: undefined;
|
||||
if (deduplication?.duplicateCheck.requiresUserConfirmation) {
|
||||
@@ -545,6 +569,7 @@ export class UnifiedApprovalService {
|
||||
templateFingerprint: fingerprint, requestHash: hashJson(normalized), nonce,
|
||||
serviceEpoch: this.submissionServiceEpoch,
|
||||
...(deduplication?.reservationId ? { invoiceReservationId: deduplication.reservationId } : {}),
|
||||
...((input.attachments ?? []).length ? { attachmentManifest: submissionAttachmentManifest(input) } : {}),
|
||||
...(template.processCode ? { processCode: template.processCode } : {}),
|
||||
...(template.xftTemplate ? { xftTemplate: template.xftTemplate } : {}),
|
||||
exp: expiresAt,
|
||||
@@ -574,6 +599,7 @@ export class UnifiedApprovalService {
|
||||
if (hashJson(normalizedSubmissionInput(input)) !== confirmation.requestHash) {
|
||||
throw new ApprovalSafetyError("提单内容与用户确认时不一致,请重新准备并确认");
|
||||
}
|
||||
const deduplicationValues = submissionValuesForInvoiceDeduplication(input);
|
||||
if (confirmation.invoiceReservationId) this.invoiceDedup?.assertReservation(user, confirmation.invoiceReservationId);
|
||||
this.consumedSubmissionNonces.set(confirmation.nonce, confirmation.exp);
|
||||
try {
|
||||
@@ -584,14 +610,21 @@ export class UnifiedApprovalService {
|
||||
if (hashJson({ template: schema.template, fields: schema.fields }) !== confirmation.templateFingerprint) {
|
||||
throw new ApprovalSafetyError("薪福通表单在确认后发生变化,请重新准备并确认");
|
||||
}
|
||||
validateXftSubmissionValues(confirmation.xftTemplate.kind, input.values, schema.fields);
|
||||
const submitted = await this.xft.submitApproval(user, confirmation.xftTemplate, input.values);
|
||||
const attachments = validateXftSubmissionAttachments(schema.fields, input.attachments ?? [], true);
|
||||
validateXftSubmissionValues(confirmation.xftTemplate.kind, input.values, schema.fields, attachments);
|
||||
const submitted = await this.xft.submitApproval(
|
||||
user,
|
||||
confirmation.xftTemplate,
|
||||
input.values,
|
||||
attachments,
|
||||
confirmationId,
|
||||
);
|
||||
let liveStatus;
|
||||
try {
|
||||
liveStatus = await this.xft.getMySubmissionStatus(user, submitted.billId);
|
||||
} catch {
|
||||
const duplicateIndexUpdate = await this.invoiceDedup?.recordSubmission(
|
||||
user, confirmation.invoiceReservationId, input.values, "xft", submitted.billId, "outcome_uncertain",
|
||||
user, confirmation.invoiceReservationId, deduplicationValues, "xft", submitted.billId, "outcome_uncertain",
|
||||
);
|
||||
return {
|
||||
source: "xft" as const,
|
||||
@@ -604,7 +637,7 @@ export class UnifiedApprovalService {
|
||||
const completedImmediately = /^(?:2|APPRV)$/i.test(liveStatus.status);
|
||||
if (completedImmediately) {
|
||||
const duplicateIndexUpdate = await this.invoiceDedup?.recordSubmission(
|
||||
user, confirmation.invoiceReservationId, input.values, "xft", submitted.billId, "approved",
|
||||
user, confirmation.invoiceReservationId, deduplicationValues, "xft", submitted.billId, "approved",
|
||||
);
|
||||
return {
|
||||
source: "xft" as const,
|
||||
@@ -619,7 +652,7 @@ export class UnifiedApprovalService {
|
||||
}
|
||||
if (!liveStatus.revokeAllowed) {
|
||||
const duplicateIndexUpdate = await this.invoiceDedup?.recordSubmission(
|
||||
user, confirmation.invoiceReservationId, input.values, "xft", submitted.billId, liveStatus.status,
|
||||
user, confirmation.invoiceReservationId, deduplicationValues, "xft", submitted.billId, liveStatus.status,
|
||||
);
|
||||
return {
|
||||
source: "xft" as const,
|
||||
@@ -636,7 +669,7 @@ export class UnifiedApprovalService {
|
||||
exp: expiresInMinutes(7 * 24 * 60),
|
||||
} satisfies SubmissionRevokeReference);
|
||||
const duplicateIndexUpdate = await this.invoiceDedup?.recordSubmission(
|
||||
user, confirmation.invoiceReservationId, input.values, "xft", submitted.billId, liveStatus.status,
|
||||
user, confirmation.invoiceReservationId, deduplicationValues, "xft", submitted.billId, liveStatus.status,
|
||||
);
|
||||
return {
|
||||
source: "xft", ...submitted, revokeId,
|
||||
@@ -664,7 +697,7 @@ export class UnifiedApprovalService {
|
||||
exp: expiresInMinutes(7 * 24 * 60),
|
||||
} satisfies SubmissionRevokeReference);
|
||||
const duplicateIndexUpdate = await this.invoiceDedup?.recordSubmission(
|
||||
user, confirmation.invoiceReservationId, input.values, "dingtalk", processInstanceId, "pending",
|
||||
user, confirmation.invoiceReservationId, deduplicationValues, "dingtalk", processInstanceId, "pending",
|
||||
);
|
||||
return {
|
||||
source: "dingtalk", status: "submitted", processInstanceId, revokeId,
|
||||
@@ -1506,49 +1539,6 @@ export class UnifiedApprovalService {
|
||||
}
|
||||
}
|
||||
|
||||
class SignedReferenceCodec {
|
||||
private readonly key: Buffer;
|
||||
|
||||
constructor(secret: string) {
|
||||
this.key = createHash("sha256").update(secret).digest();
|
||||
}
|
||||
|
||||
sign(value: object): string {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv("aes-256-gcm", this.key, iv);
|
||||
const compressed = deflateRawSync(Buffer.from(JSON.stringify(value), "utf8"), { level: 9 });
|
||||
const payload = Buffer.concat([cipher.update(compressed), cipher.final()]);
|
||||
return `v3.${iv.toString("base64url")}.${payload.toString("base64url")}.${cipher.getAuthTag().toString("base64url")}`;
|
||||
}
|
||||
|
||||
verify<T>(value: string): T {
|
||||
const parts = value.split(".");
|
||||
if (parts.length !== 4) throw new ReferenceFormatError();
|
||||
const [version, encodedIv, encodedPayload, encodedTag] = parts;
|
||||
if (!version || !["v2", "v3"].includes(version) || !encodedIv || !encodedPayload || !encodedTag) throw new ReferenceFormatError();
|
||||
let iv: Buffer;
|
||||
let payload: Buffer;
|
||||
let tag: Buffer;
|
||||
try {
|
||||
iv = decodeCanonicalBase64Url(encodedIv);
|
||||
payload = decodeCanonicalBase64Url(encodedPayload);
|
||||
tag = decodeCanonicalBase64Url(encodedTag);
|
||||
if (iv.length !== 12 || tag.length !== 16 || payload.length === 0) throw new Error("invalid encrypted reference lengths");
|
||||
} catch {
|
||||
throw new ReferenceFormatError();
|
||||
}
|
||||
try {
|
||||
const decipher = createDecipheriv("aes-256-gcm", this.key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
const decrypted = Buffer.concat([decipher.update(payload), decipher.final()]);
|
||||
const plaintext = version === "v3" ? inflateRawSync(decrypted, { maxOutputLength: 64 * 1024 }) : decrypted;
|
||||
return JSON.parse(plaintext.toString("utf8")) as T;
|
||||
} catch {
|
||||
throw new ReferenceIntegrityError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const COMPACT_APPROVAL_AAD = Buffer.from("dingmcp:approval:v4", "utf8");
|
||||
const MAX_COMPACT_FIELD_BYTES = 8 * 1024;
|
||||
|
||||
@@ -2091,6 +2081,7 @@ function validateXftSubmissionValues(
|
||||
kind: XftSubmissionKind,
|
||||
values: Record<string, unknown>,
|
||||
customFields: XftSubmissionField[],
|
||||
attachments: XftResolvedSubmissionAttachment[] = [],
|
||||
): void {
|
||||
const required: Record<XftSubmissionKind, string[]> = {
|
||||
travel_apply: ["businessTrip"], expense_apply: ["applyExpenses"],
|
||||
@@ -2104,7 +2095,7 @@ function validateXftSubmissionValues(
|
||||
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);
|
||||
const missingCustom = missingRequiredXftCustomFields(customFields, values.customFieldValues, attachments);
|
||||
if (missingBusiness.length || missingCustom.length) {
|
||||
const businessLabels: Record<string, string> = {
|
||||
businessTrip: "差旅行程", applyExpenses: "事前申请费用", entertainmentApplyApiCreateDTO: "招待信息",
|
||||
@@ -2121,7 +2112,7 @@ function validateXftSubmissionValues(
|
||||
if (hasBillCompany || hasBillDepartment) {
|
||||
throw new ApprovalSafetyError("单据公司和部门由薪福通按当前登录用户填写,不应向用户索取或由 Agent 指定");
|
||||
}
|
||||
validateRequiredXftCustomFields(customFields, values.customFieldValues);
|
||||
validateRequiredXftCustomFields(customFields, values.customFieldValues, attachments);
|
||||
validateXftCustomFieldValues(customFields, values.customFieldValues);
|
||||
if (kind === "common_reimburse" || kind === "travel_reimburse") {
|
||||
requirePositiveDecimal(values.reimAmount, "报销总金额 reimAmount");
|
||||
@@ -2196,8 +2187,9 @@ function validateXftEntertainment(value: unknown): void {
|
||||
function validateRequiredXftCustomFields(
|
||||
fields: XftSubmissionField[],
|
||||
value: unknown,
|
||||
attachments: XftResolvedSubmissionAttachment[] = [],
|
||||
): void {
|
||||
const missing = missingRequiredXftCustomFields(fields, value);
|
||||
const missing = missingRequiredXftCustomFields(fields, value, attachments);
|
||||
if (missing.length) {
|
||||
throw new ApprovalSafetyError(`薪福通提单缺少必填自定义字段:${missing.map((field) => field.name).join("、")}`);
|
||||
}
|
||||
@@ -2206,11 +2198,15 @@ function validateRequiredXftCustomFields(
|
||||
function missingRequiredXftCustomFields(
|
||||
fields: XftSubmissionField[],
|
||||
value: unknown,
|
||||
attachments: XftResolvedSubmissionAttachment[] = [],
|
||||
): XftSubmissionField[] {
|
||||
const entries = Array.isArray(value) ? value.filter(isSubmissionRecord) : [];
|
||||
return fields.filter((field) => field.required).filter((field) => !entries.some((entry) =>
|
||||
String(entry.fieldNumber ?? "") === field.fieldNumber && !isBlankSubmissionValue(entry.fieldValue),
|
||||
));
|
||||
const attachmentFields = new Set(attachments.map((attachment) => attachment.fieldNumber));
|
||||
return fields.filter((field) => field.required).filter((field) => {
|
||||
if (isXftAttachmentField(field) && attachmentFields.has(field.fieldNumber)) return false;
|
||||
return !entries.some((entry) => String(entry.fieldNumber ?? "") === field.fieldNumber
|
||||
&& !isBlankSubmissionValue(entry.fieldValue));
|
||||
});
|
||||
}
|
||||
|
||||
function validateXftCustomFieldValues(fields: XftSubmissionField[], value: unknown): void {
|
||||
@@ -2228,6 +2224,9 @@ function validateXftCustomFieldValues(fields: XftSubmissionField[], value: unkno
|
||||
if (field.selectionCapability?.status === "client_only") {
|
||||
throw new ApprovalSafetyError(`“${field.name}”当前无法通过 MCP 按业务名称安全填写,请在薪福通客户端完成`);
|
||||
}
|
||||
if (isXftAttachmentField(field)) {
|
||||
throw new ApprovalSafetyError(`附件字段“${field.name}”必须通过 attachments 使用本机文件,不能直接提供附件编号`);
|
||||
}
|
||||
if (typeof raw.fieldValue !== "string") throw new ApprovalSafetyError(`薪福通自定义字段“${field.name}”的 fieldValue 必须是字符串;数组和表格需传 JSON 字符串`);
|
||||
const type = field.type.toUpperCase();
|
||||
if (type.includes("TABLE")) validateXftTableField(field, raw.fieldValue);
|
||||
@@ -2244,6 +2243,60 @@ function validateXftCustomFieldValues(fields: XftSubmissionField[], value: unkno
|
||||
});
|
||||
}
|
||||
|
||||
function validateXftSubmissionAttachments(
|
||||
fields: XftSubmissionField[],
|
||||
attachments: XftSubmissionAttachment[],
|
||||
requireUploadReference: boolean,
|
||||
): XftResolvedSubmissionAttachment[] {
|
||||
if (!Array.isArray(attachments)) throw new ApprovalSafetyError("attachments 必须是数组");
|
||||
if (attachments.length > XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION) {
|
||||
throw new ApprovalSafetyError(`一次薪福通提单最多上传 ${XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION} 个附件`);
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
let totalBytes = 0;
|
||||
return attachments.map((attachment) => {
|
||||
if (!attachment || typeof attachment !== "object") throw new ApprovalSafetyError("附件信息格式无效");
|
||||
if (!attachment.fieldName || attachment.fieldName.length > 200) throw new ApprovalSafetyError("附件缺少有效的表单字段名称");
|
||||
if (!attachment.fileName || attachment.fileName.length > 240 || /[\\/\r\n\0"]/u.test(attachment.fileName)) {
|
||||
throw new ApprovalSafetyError("附件文件名无效,请重新选择本机普通文件");
|
||||
}
|
||||
if (!Number.isSafeInteger(attachment.size) || attachment.size <= 0 || attachment.size > XFT_ATTACHMENT_MAX_FILE_BYTES) {
|
||||
throw new ApprovalSafetyError(`附件“${attachment.fileName}”必须大于 0 字节且单个不超过 50MB`);
|
||||
}
|
||||
totalBytes += attachment.size;
|
||||
if (!Number.isSafeInteger(totalBytes) || totalBytes > XFT_ATTACHMENT_MAX_TOTAL_BYTES) {
|
||||
throw new ApprovalSafetyError("一次薪福通提单的附件总大小不能超过 50MB");
|
||||
}
|
||||
if (!/^[0-9a-f]{64}$/.test(attachment.sha256)) throw new ApprovalSafetyError(`附件“${attachment.fileName}”的 SHA-256 格式无效`);
|
||||
const matches = fields.filter((field) => field.name === attachment.fieldName && isXftAttachmentField(field));
|
||||
if (matches.length !== 1) {
|
||||
throw new ApprovalSafetyError(matches.length === 0
|
||||
? `实时薪福通表单中没有名为“${attachment.fieldName}”的附件字段,请重新读取表单`
|
||||
: `实时薪福通表单中“${attachment.fieldName}”附件字段不唯一,请重新选择`);
|
||||
}
|
||||
if (requireUploadReference && !attachment.attachmentRef) {
|
||||
throw new ApprovalSafetyError(`附件“${attachment.fileName}”尚未由本机 Bridge 上传,请重新执行最终提交`);
|
||||
}
|
||||
if (!requireUploadReference && attachment.attachmentRef) {
|
||||
throw new ApprovalSafetyError("预览阶段不得预上传附件,请移除上传引用后重新准备");
|
||||
}
|
||||
const field = matches[0]!;
|
||||
const duplicateKey = `${field.fieldNumber}\0${attachment.sha256}`;
|
||||
if (seen.has(duplicateKey)) throw new ApprovalSafetyError(`附件“${attachment.fileName}”在同一字段中重复`);
|
||||
seen.add(duplicateKey);
|
||||
return {
|
||||
...attachment,
|
||||
fieldNumber: field.fieldNumber,
|
||||
...(requireUploadReference ? { attachmentRef: attachment.attachmentRef! } : { attachmentRef: "" }),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function isXftAttachmentField(field: XftSubmissionField): boolean {
|
||||
const type = field.type.toUpperCase();
|
||||
return type.includes("ATTACH") || type.includes("ACCESSOR");
|
||||
}
|
||||
|
||||
function validateXftTableField(field: XftSubmissionField, value: string): void {
|
||||
let rows: unknown;
|
||||
try { rows = JSON.parse(value); } catch { throw new ApprovalSafetyError(`薪福通表格字段“${field.name}”必须是 JSON 数组字符串`); }
|
||||
@@ -2470,9 +2523,32 @@ function normalizedSubmissionInput(input: SubmissionInput): Record<string, unkno
|
||||
deptId: input.deptId ?? -1,
|
||||
targetSelectActioners: input.targetSelectActioners ?? [],
|
||||
allowDuplicateInvoices: input.allowDuplicateInvoices === true,
|
||||
attachments: submissionAttachmentManifest(input),
|
||||
};
|
||||
}
|
||||
|
||||
function submissionAttachmentManifest(input: SubmissionInput) {
|
||||
return (input.attachments ?? []).map(({ fieldName, fileName, size, sha256 }) => ({ fieldName, fileName, size, sha256 }));
|
||||
}
|
||||
|
||||
function submissionValuesForInvoiceDeduplication(input: SubmissionInput): Record<string, unknown> {
|
||||
if (!(input.attachments ?? []).length) return input.values;
|
||||
return {
|
||||
...input.values,
|
||||
invoiceAttachments: input.attachments!.map((attachment) => ({ sha256: attachment.sha256 })),
|
||||
};
|
||||
}
|
||||
|
||||
function attachmentPreview(attachments: XftResolvedSubmissionAttachment[]): Array<Record<string, unknown>> {
|
||||
return attachments.map((attachment) => ({
|
||||
field: attachment.fieldName,
|
||||
fileName: attachment.fileName,
|
||||
size: attachment.size,
|
||||
sha256: `…${attachment.sha256.slice(-8)}`,
|
||||
uploadStatus: "将在最终确认后上传",
|
||||
}));
|
||||
}
|
||||
|
||||
function summarizeValues(values: Record<string, unknown>): Record<string, unknown> {
|
||||
return Object.fromEntries(Object.entries(values).slice(0, 200).map(([key, value]) => [key, summarizeValue(value)]));
|
||||
}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export const APPLICATION_VERSION = "1.1.0-rc.18";
|
||||
export const APPLICATION_VERSION = "1.1.0-rc.20";
|
||||
|
||||
+224
-4
@@ -1,4 +1,9 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import smCrypto from "sm-crypto";
|
||||
import {
|
||||
XFT_ATTACHMENT_MAX_FILE_BYTES,
|
||||
XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION,
|
||||
} from "./approval-contract.js";
|
||||
import type { AppConfig } from "./config.js";
|
||||
import { ApprovalSafetyError, XftApiError, errorMessage } from "./errors.js";
|
||||
import { isRecord as isObject, filterQuery } from "./json.js";
|
||||
@@ -13,12 +18,15 @@ import {
|
||||
type XftApprovalProvider,
|
||||
type XftHistoryItem,
|
||||
type XftSubmissionField,
|
||||
type XftResolvedSubmissionAttachment,
|
||||
type XftSubmissionAttachmentUpload,
|
||||
type XftSubmissionKind,
|
||||
type XftSubmissionOption,
|
||||
type XftSubmissionOptionKind,
|
||||
type XftSubmissionTemplate,
|
||||
type XftTodoItem,
|
||||
} from "./xft.js";
|
||||
import { SignedReferenceCodec } from "./signed-reference.js";
|
||||
|
||||
type FetchLike = typeof fetch;
|
||||
type JsonObject = Record<string, unknown>;
|
||||
@@ -30,6 +38,18 @@ interface XftIdentity {
|
||||
employeeNumber?: string;
|
||||
}
|
||||
|
||||
interface XftUploadedAttachmentReference {
|
||||
kind: "xft_uploaded_attachment";
|
||||
userId: string;
|
||||
fileId: string;
|
||||
fieldName: string;
|
||||
fileName: string;
|
||||
size: number;
|
||||
sha256: string;
|
||||
confirmationHash: string;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
interface CurrentTodo extends XftTodoItem {
|
||||
raw: JsonObject;
|
||||
}
|
||||
@@ -87,6 +107,7 @@ const EXPENSE_TYPE_HINT = /费用\s*(?:类型|类别|项目)|(?:类型|类别|
|
||||
/** Official XFT OpenAPI adapter. It never accepts an empId from an Agent. */
|
||||
export class XftOpenApiClient implements XftApprovalProvider {
|
||||
private readonly api: NonNullable<AppConfig["xftOpenApi"]>;
|
||||
private readonly attachmentSigner: SignedReferenceCodec;
|
||||
private cityOptionsCache?: { fetchedAt: number; items: XftSubmissionOption[] };
|
||||
|
||||
constructor(
|
||||
@@ -95,6 +116,7 @@ export class XftOpenApiClient implements XftApprovalProvider {
|
||||
) {
|
||||
if (!config.xftOpenApi) throw new Error("服务器未启用薪福通正式 API");
|
||||
this.api = config.xftOpenApi;
|
||||
this.attachmentSigner = new SignedReferenceCodec(config.approvalRefSecret);
|
||||
if (!/^(?:[0-9a-fA-F]{32}|[0-9a-fA-F]{64})$/.test(this.api.authoritySecret)) {
|
||||
throw new Error("薪福通 AuthoritySecret 必须是 32 或 64 位十六进制密钥");
|
||||
}
|
||||
@@ -533,17 +555,63 @@ export class XftOpenApiClient implements XftApprovalProvider {
|
||||
return uniqueOptions(output);
|
||||
}
|
||||
|
||||
async uploadSubmissionAttachment(
|
||||
user: AuthenticatedDingTalkUser,
|
||||
attachment: XftSubmissionAttachmentUpload,
|
||||
) {
|
||||
assertAttachmentUpload(attachment);
|
||||
const identity = await this.resolveIdentity(user);
|
||||
const employeeNumber = await this.requireEmployeeNumber(identity);
|
||||
const result = await this.requestFileUpload(
|
||||
`/itrip/xft-api/v1/accessory/custom-field/upload?uploaderNumber=${encodeURIComponent(employeeNumber)}`,
|
||||
attachment.fileName,
|
||||
attachment.content,
|
||||
identity,
|
||||
);
|
||||
const fileId = typeof result === "string" || typeof result === "number"
|
||||
? String(result)
|
||||
: isObject(result)
|
||||
? stringAt(result, ["fileId", "id", "body"])
|
||||
: undefined;
|
||||
if (!fileId || fileId.length > 500) {
|
||||
throw new XftApiError("薪福通附件上传已返回成功,但响应缺少附件编号", 502, "MISSING_FILE_ID");
|
||||
}
|
||||
const exp = Date.now() + 15 * 60_000;
|
||||
const attachmentRef = this.attachmentSigner.sign({
|
||||
kind: "xft_uploaded_attachment",
|
||||
userId: user.userId,
|
||||
fileId,
|
||||
fieldName: attachment.fieldName,
|
||||
fileName: attachment.fileName,
|
||||
size: attachment.size,
|
||||
sha256: attachment.sha256,
|
||||
confirmationHash: attachmentConfirmationHash(attachment.confirmationId),
|
||||
exp,
|
||||
} satisfies XftUploadedAttachmentReference);
|
||||
return {
|
||||
fieldName: attachment.fieldName,
|
||||
fileName: attachment.fileName,
|
||||
size: attachment.size,
|
||||
sha256: attachment.sha256,
|
||||
attachmentRef,
|
||||
expiresAt: new Date(exp).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async submitApproval(
|
||||
user: AuthenticatedDingTalkUser,
|
||||
template: XftSubmissionTemplate,
|
||||
values: Record<string, unknown>,
|
||||
attachments: XftResolvedSubmissionAttachment[] = [],
|
||||
confirmationId = "",
|
||||
) {
|
||||
const identity = await this.resolveIdentity(user);
|
||||
const current = await this.requireSubmissionTemplate(identity, template);
|
||||
assertSubmissionValues(values);
|
||||
const employeeNumber = await this.requireEmployeeNumber(identity);
|
||||
const attachmentFields = this.resolveUploadedAttachments(user, attachments, confirmationId);
|
||||
const payload = {
|
||||
...structuredClone(values),
|
||||
...submissionValuesWithAttachments(values, attachmentFields),
|
||||
eventNumber: current.eventNumber,
|
||||
empNumber: employeeNumber,
|
||||
billStatus: "APPRV_PEND",
|
||||
@@ -564,11 +632,41 @@ export class XftOpenApiClient implements XftApprovalProvider {
|
||||
applicantIdentity: "server_derived",
|
||||
billStatus: "APPRV_PEND",
|
||||
permissionCheck: "enabled",
|
||||
attachmentCount: attachments.length,
|
||||
attachmentsUploadedAfterConfirmation: attachments.length > 0,
|
||||
persistedBusinessData: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private resolveUploadedAttachments(
|
||||
user: AuthenticatedDingTalkUser,
|
||||
attachments: XftResolvedSubmissionAttachment[],
|
||||
confirmationId: string,
|
||||
): Array<XftResolvedSubmissionAttachment & { fileId: string }> {
|
||||
if (!attachments.length) return [];
|
||||
if (!confirmationId) throw new ApprovalSafetyError("附件缺少本次提单确认上下文,请重新预览并确认");
|
||||
const confirmationHash = attachmentConfirmationHash(confirmationId);
|
||||
return attachments.map((attachment) => {
|
||||
let reference: XftUploadedAttachmentReference;
|
||||
try { reference = this.attachmentSigner.verify<XftUploadedAttachmentReference>(attachment.attachmentRef); }
|
||||
catch { throw new ApprovalSafetyError(`附件“${attachment.fileName}”的上传引用无效,请重新选择文件并预览`); }
|
||||
if (
|
||||
reference.kind !== "xft_uploaded_attachment"
|
||||
|| reference.userId !== user.userId
|
||||
|| reference.exp < Date.now()
|
||||
|| reference.confirmationHash !== confirmationHash
|
||||
|| reference.fieldName !== attachment.fieldName
|
||||
|| reference.fileName !== attachment.fileName
|
||||
|| reference.size !== attachment.size
|
||||
|| reference.sha256 !== attachment.sha256
|
||||
) {
|
||||
throw new ApprovalSafetyError(`附件“${attachment.fileName}”与本次用户确认或本地文件不一致,请重新预览并确认`);
|
||||
}
|
||||
return { ...attachment, fileId: reference.fileId };
|
||||
});
|
||||
}
|
||||
|
||||
async recallSubmission(user: AuthenticatedDingTalkUser, billId: string, reason = "") {
|
||||
const currentStatus = await this.getMySubmissionStatus(user, billId);
|
||||
if (!currentStatus.revokeAllowed) {
|
||||
@@ -1130,6 +1228,63 @@ export class XftOpenApiClient implements XftApprovalProvider {
|
||||
} catch (error) {
|
||||
throw xftTransportError(error, pathname, "request", timeoutMs);
|
||||
}
|
||||
return this.parseResponse(response, pathname, timeoutMs);
|
||||
}
|
||||
|
||||
private async requestFileUpload(
|
||||
path: string,
|
||||
fileName: string,
|
||||
content: Buffer,
|
||||
identity: XftIdentity,
|
||||
timeoutMs = Math.max(this.config.requestTimeoutMs, 90_000),
|
||||
) {
|
||||
const requestTimeMs = Date.now();
|
||||
const timestamp = Math.floor(requestTimeMs / 1000).toString();
|
||||
const queryStart = path.indexOf("?");
|
||||
const pathname = queryStart === -1 ? path : path.slice(0, queryStart);
|
||||
const endpointQuery = new URLSearchParams(queryStart === -1 ? "" : path.slice(queryStart + 1));
|
||||
const query = new URLSearchParams();
|
||||
query.set("CSCAPPUID", this.api.appId);
|
||||
query.set("CSCPRJCOD", this.api.enterpriseId);
|
||||
query.set("CSCUSRNBR", identity.enterpriseUserId);
|
||||
query.set("CSCREQTIM", String(requestTimeMs));
|
||||
query.set("OPAUID", this.api.enterpriseId);
|
||||
for (const [key, value] of endpointQuery) query.append(key, value);
|
||||
const requestTarget = `${pathname}?${query.toString()}`;
|
||||
// The official SDK intentionally omits x-alb-digest for multipart file
|
||||
// uploads and signs only method/path plus timestamp. File-upload request
|
||||
// bodies are also explicitly excluded from SM4 encryption.
|
||||
const signingText = `POST ${requestTarget}\nx-alb-timestamp: ${timestamp}`;
|
||||
const signature = sm2.doSignature(signingText, this.api.authoritySecret, {
|
||||
hash: true,
|
||||
userId: "1234567812345678",
|
||||
});
|
||||
const multipart = multipartFileBody(fileName, content);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await this.fetchFn(`${this.api.apiBaseUrl}${requestTarget}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
appid: this.api.appId,
|
||||
"x-alb-timestamp": timestamp,
|
||||
"x-alb-verify": "sm3withsm2",
|
||||
apisign: signature,
|
||||
"content-type": `multipart/form-data; charset=UTF-8; boundary=${multipart.boundary}`,
|
||||
"content-length": String(multipart.body.length),
|
||||
},
|
||||
body: multipart.body.buffer.slice(
|
||||
multipart.body.byteOffset,
|
||||
multipart.body.byteOffset + multipart.body.byteLength,
|
||||
) as ArrayBuffer,
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
} catch (error) {
|
||||
throw xftTransportError(error, pathname, "request", timeoutMs);
|
||||
}
|
||||
return this.parseResponse(response, pathname, timeoutMs);
|
||||
}
|
||||
|
||||
private async parseResponse(response: Response, pathname: string, timeoutMs: number) {
|
||||
let text: string;
|
||||
try { text = await response.text(); }
|
||||
catch (error) {
|
||||
@@ -1800,7 +1955,7 @@ function submissionFieldShape(
|
||||
type: string,
|
||||
options: XftSubmissionOption[],
|
||||
optionSource?: ReturnType<typeof expenseTypeOptionSource> | ReturnType<typeof cityOptionSource> | ReturnType<typeof departmentOptionSource>,
|
||||
): Pick<XftSubmissionField, "valueFormat" | "itemFields" | "selectionCapability"> {
|
||||
): Pick<XftSubmissionField, "valueFormat" | "itemFields" | "selectionCapability" | "attachmentCapability"> {
|
||||
const normalized = type.toUpperCase();
|
||||
if (normalized.includes("TABLE")) {
|
||||
const candidates = [value.columns, value.children, value.fieldList, value.tableColumns]
|
||||
@@ -1812,8 +1967,14 @@ function submissionFieldShape(
|
||||
};
|
||||
}
|
||||
if (normalized.includes("ATTACH") || normalized.includes("ACCESSOR")) return {
|
||||
valueFormat: "当前 MCP 无法安全上传附件,请在薪福通客户端补充",
|
||||
selectionCapability: clientOnlySelection("当前 MCP 没有薪福通附件上传能力"),
|
||||
valueFormat: "通过 attachments 传入本机文件引用;本地草稿保存路径和 SHA-256,最终确认后由签名 Bridge 上传",
|
||||
attachmentCapability: {
|
||||
status: "supported",
|
||||
input: "local_file",
|
||||
uploadWhen: "after_final_confirmation",
|
||||
maxFileBytes: XFT_ATTACHMENT_MAX_FILE_BYTES,
|
||||
maxFilesPerSubmission: XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION,
|
||||
},
|
||||
};
|
||||
if (normalized.includes("PERSON") || normalized.includes("EMP")) return {
|
||||
valueFormat: "当前 MCP 无法按姓名安全选择人员,请在薪福通客户端补充",
|
||||
@@ -2188,3 +2349,62 @@ function assertSubmissionValues(values: Record<string, unknown>): void {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertAttachmentUpload(attachment: XftSubmissionAttachmentUpload): void {
|
||||
if (!Buffer.isBuffer(attachment.content)) throw new ApprovalSafetyError("附件内容必须由签名本地 Bridge 读取");
|
||||
if (!Number.isSafeInteger(attachment.size) || attachment.size <= 0 || attachment.size > XFT_ATTACHMENT_MAX_FILE_BYTES) {
|
||||
throw new ApprovalSafetyError("薪福通附件必须大于 0 字节且单个不超过 50MB");
|
||||
}
|
||||
if (attachment.content.length !== attachment.size) throw new ApprovalSafetyError("附件大小在上传前发生变化,请重新选择文件");
|
||||
if (!attachment.fieldName || attachment.fieldName.length > 200) throw new ApprovalSafetyError("附件缺少有效的表单字段名称");
|
||||
if (!/^[0-9a-f]{64}$/.test(attachment.sha256)) throw new ApprovalSafetyError("附件 SHA-256 格式无效");
|
||||
const actualHash = createHash("sha256").update(attachment.content).digest("hex");
|
||||
if (actualHash !== attachment.sha256) throw new ApprovalSafetyError("附件内容与预览时的 SHA-256 不一致,请重新预览");
|
||||
if (!attachment.fileName || attachment.fileName.length > 240 || /[\\/\r\n\0"]/u.test(attachment.fileName)) {
|
||||
throw new ApprovalSafetyError("附件文件名无效,请使用普通文件名后重试");
|
||||
}
|
||||
if (!attachment.confirmationId || attachment.confirmationId.length > 4096) {
|
||||
throw new ApprovalSafetyError("附件缺少有效的提单确认,请重新预览");
|
||||
}
|
||||
}
|
||||
|
||||
function attachmentConfirmationHash(confirmationId: string): string {
|
||||
return createHash("sha256").update(confirmationId, "utf8").digest("base64url");
|
||||
}
|
||||
|
||||
function multipartFileBody(fileName: string, content: Buffer): { boundary: string; body: Buffer } {
|
||||
const boundary = `----------------dingmcp${randomBytes(12).toString("hex")}`;
|
||||
const prefix = Buffer.from(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${fileName}"\r\n\r\n`,
|
||||
"utf8",
|
||||
);
|
||||
const suffix = Buffer.from(`\r\n--${boundary}--\r\n`, "utf8");
|
||||
return { boundary, body: Buffer.concat([prefix, content, suffix]) };
|
||||
}
|
||||
|
||||
function submissionValuesWithAttachments(
|
||||
values: Record<string, unknown>,
|
||||
attachments: Array<XftResolvedSubmissionAttachment & { fileId: string }>,
|
||||
): Record<string, unknown> {
|
||||
const output = structuredClone(values);
|
||||
if (!attachments.length) return output;
|
||||
const current = output.customFieldValues;
|
||||
if (current !== undefined && !Array.isArray(current)) {
|
||||
throw new ApprovalSafetyError("薪福通 customFieldValues 必须是数组");
|
||||
}
|
||||
const customFieldValues = Array.isArray(current) ? structuredClone(current) : [];
|
||||
const grouped = new Map<string, string[]>();
|
||||
for (const attachment of attachments) {
|
||||
const fileIds = grouped.get(attachment.fieldNumber) ?? [];
|
||||
fileIds.push(attachment.fileId);
|
||||
grouped.set(attachment.fieldNumber, fileIds);
|
||||
}
|
||||
for (const [fieldNumber, fileIds] of grouped) {
|
||||
if (customFieldValues.some((item) => isObject(item) && String(item.fieldNumber ?? "") === fieldNumber)) {
|
||||
throw new ApprovalSafetyError("附件字段必须由本地 Bridge 上传,不能同时在 values 中提供附件编号");
|
||||
}
|
||||
customFieldValues.push({ fieldNumber, fieldValue: JSON.stringify(fileIds) });
|
||||
}
|
||||
output.customFieldValues = customFieldValues;
|
||||
return output;
|
||||
}
|
||||
|
||||
+35
@@ -90,6 +90,13 @@ export interface XftSubmissionField {
|
||||
kind: XftSubmissionOptionKind;
|
||||
};
|
||||
selectionCapability?: XftSubmissionSelectionCapability;
|
||||
attachmentCapability?: {
|
||||
status: "supported";
|
||||
input: "local_file";
|
||||
uploadWhen: "after_final_confirmation";
|
||||
maxFileBytes: number;
|
||||
maxFilesPerSubmission: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type XftSubmissionOptionKind = "department" | "city" | "expense_type";
|
||||
@@ -99,6 +106,28 @@ export interface XftSubmissionOption {
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface XftSubmissionAttachment {
|
||||
fieldName: string;
|
||||
fileName: string;
|
||||
size: number;
|
||||
sha256: string;
|
||||
attachmentRef?: string;
|
||||
}
|
||||
|
||||
export interface XftResolvedSubmissionAttachment extends XftSubmissionAttachment {
|
||||
fieldNumber: string;
|
||||
attachmentRef: string;
|
||||
}
|
||||
|
||||
export interface XftSubmissionAttachmentUpload {
|
||||
fieldName: string;
|
||||
fileName: string;
|
||||
size: number;
|
||||
sha256: string;
|
||||
content: Buffer;
|
||||
confirmationId: string;
|
||||
}
|
||||
|
||||
export type XftSubmissionSelectionCapability =
|
||||
| {
|
||||
status: "supported";
|
||||
@@ -188,10 +217,16 @@ export interface XftApprovalProvider {
|
||||
validated: true;
|
||||
diagnostics: Record<string, unknown>;
|
||||
}>;
|
||||
uploadSubmissionAttachment?(
|
||||
user: AuthenticatedDingTalkUser,
|
||||
attachment: XftSubmissionAttachmentUpload,
|
||||
): Promise<XftSubmissionAttachment & { attachmentRef: string }>;
|
||||
submitApproval(
|
||||
user: AuthenticatedDingTalkUser,
|
||||
template: XftSubmissionTemplate,
|
||||
values: Record<string, unknown>,
|
||||
attachments?: XftResolvedSubmissionAttachment[],
|
||||
confirmationId?: string,
|
||||
): Promise<{ billId: string; status: "submitted"; diagnostics: Record<string, unknown> }>;
|
||||
recallSubmission(
|
||||
user: AuthenticatedDingTalkUser,
|
||||
|
||||
+89
-1
@@ -1,5 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
@@ -49,6 +50,87 @@ test("stdio bridge converts a remote batch timeout into a structured uncertain o
|
||||
assert.match(result.content[0].text, /重新列出实时待办核对/);
|
||||
});
|
||||
|
||||
test("stdio bridge keeps draft paths local, previews attachment metadata, and uploads only after final confirmation", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "ding-bridge-attachment-"));
|
||||
const statePath = join(root, "session.json");
|
||||
const filePath = join(root, "invoice.pdf");
|
||||
const content = Buffer.from("%PDF-1.7\nmock invoice", "utf8");
|
||||
const sha256 = createHash("sha256").update(content).digest("hex");
|
||||
writeFileSync(filePath, content);
|
||||
writeFileSync(statePath, JSON.stringify({
|
||||
client: { client_id: "bridge-client" },
|
||||
tokens: { access_token: "fresh-access", savedAt: Date.now(), expires_in: 900 },
|
||||
}));
|
||||
const uploads: Array<{ headers: Headers; body: Buffer }> = [];
|
||||
const bridge = new RemoteBridge("https://approval.example.test/mcp", statePath, async (input, init) => {
|
||||
assert.equal(new URL(String(input)).pathname, "/xft/submission-attachments");
|
||||
uploads.push({ headers: new Headers(init?.headers), body: Buffer.from(init?.body as ArrayBuffer) });
|
||||
return Response.json({
|
||||
fieldName: "发票附件", fileName: "invoice.pdf", size: content.length, sha256,
|
||||
attachmentRef: "opaque-upload-reference-12345",
|
||||
});
|
||||
});
|
||||
const calls: Array<{ name: string; arguments: Record<string, unknown> }> = [];
|
||||
(bridge as unknown as { client: { callTool(request: any): Promise<unknown>; close(): Promise<void> } }).client = {
|
||||
async callTool(request: any) {
|
||||
calls.push(request);
|
||||
return { content: [{ type: "text", text: "ok" }], structuredContent: { result: { status: "ok" } } };
|
||||
},
|
||||
async close() {},
|
||||
};
|
||||
const localAttachment = {
|
||||
fieldName: "发票附件", displayName: "invoice.pdf", localPath: filePath,
|
||||
size: content.length, modifiedAt: new Date().toISOString(), sha256,
|
||||
};
|
||||
await bridge.prepareSubmission({ templateId: "template-reference-12345", values: {}, attachments: [localAttachment] });
|
||||
assert.equal(uploads.length, 0, "preview must have no upstream upload side effect");
|
||||
const preparedArgs = calls[0]!.arguments;
|
||||
assert.deepEqual(preparedArgs.attachments, [{
|
||||
fieldName: "发票附件", fileName: "invoice.pdf", size: content.length, sha256,
|
||||
}]);
|
||||
assert.doesNotMatch(JSON.stringify(preparedArgs), new RegExp(root.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||||
|
||||
await bridge.submitApproval({
|
||||
confirmationId: "confirmation-reference-12345", templateId: "template-reference-12345",
|
||||
values: {}, attachments: [localAttachment],
|
||||
});
|
||||
assert.equal(uploads.length, 1);
|
||||
assert.deepEqual(uploads[0]!.body, content);
|
||||
assert.equal(uploads[0]!.headers.get("authorization"), "Bearer fresh-access");
|
||||
assert.equal(decodeURIComponent(uploads[0]!.headers.get("x-dingmcp-field-name")!), "发票附件");
|
||||
assert.equal(decodeURIComponent(uploads[0]!.headers.get("x-dingmcp-file-name")!), "invoice.pdf");
|
||||
assert.equal(uploads[0]!.headers.get("x-dingmcp-file-sha256"), sha256);
|
||||
const submittedArgs = calls[1]!.arguments;
|
||||
assert.deepEqual(submittedArgs.attachments, [{
|
||||
fieldName: "发票附件", fileName: "invoice.pdf", size: content.length, sha256,
|
||||
attachmentRef: "opaque-upload-reference-12345",
|
||||
}]);
|
||||
assert.doesNotMatch(JSON.stringify(submittedArgs), new RegExp(root.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||||
});
|
||||
|
||||
test("stdio bridge rejects symlinked or changed draft attachments before any upload", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "ding-bridge-attachment-link-"));
|
||||
const statePath = join(root, "session.json");
|
||||
const realPath = join(root, "invoice.pdf");
|
||||
const linkPath = join(root, "invoice-link.pdf");
|
||||
writeFileSync(realPath, "%PDF-1.7\nmock invoice");
|
||||
symlinkSync(realPath, linkPath);
|
||||
writeFileSync(statePath, JSON.stringify({
|
||||
client: { client_id: "bridge-client" },
|
||||
tokens: { access_token: "fresh-access", savedAt: Date.now(), expires_in: 900 },
|
||||
}));
|
||||
let networkCalls = 0;
|
||||
const bridge = new RemoteBridge("https://approval.example.test/mcp", statePath, async () => {
|
||||
networkCalls += 1;
|
||||
throw new Error("network must not be called");
|
||||
});
|
||||
await assert.rejects(bridge.prepareSubmission({
|
||||
templateId: "template-reference-12345", values: {},
|
||||
attachments: [{ fieldName: "发票附件", localPath: linkPath }],
|
||||
}), /无法安全读取附件.*符号链接/);
|
||||
assert.equal(networkCalls, 0);
|
||||
});
|
||||
|
||||
test("legacy updater post-install syncs the complete bundled Skill", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "ding-bridge-skill-sync-"));
|
||||
const bundled = join(root, "package", "skill", "dingtalk-approval");
|
||||
@@ -301,6 +383,12 @@ test("MCP approval_login returns both portable text and a standard resource link
|
||||
assert.match(options.description ?? "", /所有选项只向用户展示label.*values.*code.*不能填label.*工具内部/s);
|
||||
const submissionSchema = tools.tools.find((tool) => tool.name === "approval_get_submission_schema")!;
|
||||
assert.match(submissionSchema.description ?? "", /minimalExample.*尖括号必须替换/);
|
||||
for (const toolName of ["approval_prepare_submission", "approval_submit"]) {
|
||||
const submission = tools.tools.find((tool) => tool.name === toolName)!;
|
||||
const attachmentItem = (submission.inputSchema as any).properties.attachments.items;
|
||||
assert.ok(attachmentItem.properties.localPath, `${toolName} must accept Bridge-local paths`);
|
||||
assert.equal(attachmentItem.properties.attachmentRef, undefined);
|
||||
}
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
|
||||
@@ -103,8 +103,8 @@ test("customer distribution prefers remote MCP OAuth and keeps a generic compati
|
||||
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(localDrafts, /localPath.*signed Bridge.*does not upload.*final confirmation.*native-client completion/s);
|
||||
assert.match(createApproval, /Local file paths.*not valid `values` or `customFieldValues`.*signed Bridge `attachments`.*submit uploads only after final confirmation.*native client/s);
|
||||
assert.match(createApproval, /every XFT selection.*label-only.*optionSource.*returned `label` values.*`code` only inside/s);
|
||||
assert.match(createApproval, /Never show, explain, or ask.*city code.*organization code.*employee number.*numeric cabin value.*`expenseNumber`.*`expTypeCode`/s);
|
||||
assert.match(createApproval, /selectionCapability\.status=client_only.*required field stops.*optional field may be skipped/s);
|
||||
|
||||
@@ -5,7 +5,8 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||
import type { AppConfig } from "../src/config.js";
|
||||
import type { ApprovalService } from "../src/approval-service.js";
|
||||
import type { XftApprovalProvider } from "../src/xft.js";
|
||||
import { approvalBatchSummary, APPROVAL_MCP_INSTRUCTIONS, compactResult, createApprovalMcpServer, isLoopbackAddress, mcpAllowedHosts, oauthHtmlContentSecurityPolicy } from "../src/server.js";
|
||||
import { SignedReferenceCodec } from "../src/signed-reference.js";
|
||||
import { approvalBatchSummary, APPROVAL_MCP_INSTRUCTIONS, assertAttachmentConfirmation, compactResult, createApprovalMcpServer, isLoopbackAddress, mcpAllowedHosts, oauthHtmlContentSecurityPolicy } from "../src/server.js";
|
||||
|
||||
function config(): AppConfig {
|
||||
return {
|
||||
@@ -96,6 +97,17 @@ test("MCP initialization teaches the workflow while tools/list stays compact", a
|
||||
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);
|
||||
for (const toolName of ["approval_prepare_submission", "approval_submit"]) {
|
||||
const submission = tools.tools.find((tool) => tool.name === toolName)!;
|
||||
const attachmentItem = (submission.inputSchema as any).properties.attachments.items;
|
||||
assert.ok(attachmentItem.properties.fileName, `${toolName} must accept validated attachment metadata`);
|
||||
assert.ok(attachmentItem.properties.sha256);
|
||||
assert.equal(attachmentItem.properties.localPath, undefined, "the gateway must never accept local paths");
|
||||
}
|
||||
const prepareAttachmentItem = prepareSchema.properties.attachments.items;
|
||||
assert.equal(prepareAttachmentItem.properties.attachmentRef, undefined, "prepare must not accept pre-uploaded references");
|
||||
const submitSchema = tools.tools.find((tool) => tool.name === "approval_submit")?.inputSchema as any;
|
||||
assert.ok(submitSchema.properties.attachments.items.properties.attachmentRef);
|
||||
assert.match(descriptions.approval_list_submission_templates ?? "", /出差申请.*外出.*差旅费用报销.*业务招待费用报销/);
|
||||
assert.match(descriptions.approval_submit ?? "", /禁止自动重试/);
|
||||
assert.match(descriptions.approval_revoke_submission ?? "", /禁止自动重试.*薪福通.*实验能力/);
|
||||
@@ -139,6 +151,29 @@ test("OAuth consent CSP permits only the DingTalk login redirect after same-orig
|
||||
assert.doesNotMatch(policy, /form-action[^;]*\*/);
|
||||
});
|
||||
|
||||
test("attachment uploads are bound to the signed-in user and the exact confirmed preview", () => {
|
||||
const signer = new SignedReferenceCodec("fixed-reference-secret-for-tests");
|
||||
const attachment = {
|
||||
fieldName: "发票附件", fileName: "invoice.pdf", size: 551,
|
||||
sha256: "a".repeat(64),
|
||||
};
|
||||
const confirmationId = signer.sign({
|
||||
kind: "submission_confirmation",
|
||||
source: "xft",
|
||||
userId: "user-1",
|
||||
attachmentManifest: [attachment],
|
||||
exp: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
assert.doesNotThrow(() => assertAttachmentConfirmation(signer, { userId: "user-1" }, confirmationId, attachment));
|
||||
assert.throws(() => assertAttachmentConfirmation(
|
||||
signer, { userId: "user-1" }, confirmationId, { ...attachment, fileName: "unconfirmed.pdf" },
|
||||
), /不在用户刚刚确认的提单预览中/);
|
||||
assert.throws(() => assertAttachmentConfirmation(
|
||||
signer, { userId: "another-user" }, confirmationId, attachment,
|
||||
), /不属于当前用户/);
|
||||
});
|
||||
|
||||
test("remote gateway lists DingTalk OA and XFT through one authenticated MCP session", async () => {
|
||||
const dingTalk = {
|
||||
async listMyTodos() {
|
||||
|
||||
@@ -1079,6 +1079,68 @@ test("Xft travel reimbursement preparation validates nested and custom required
|
||||
}), /第 1 段行程缺少/);
|
||||
});
|
||||
|
||||
test("Xft local attachment metadata satisfies required fields, is preview-bound, and never exposes a path", async () => {
|
||||
const template = {
|
||||
eventSeqId: "1", eventNumber: "XFT-A", eventName: "费用报销单",
|
||||
eventType: "COMMON_REIM", kind: "common_reimburse" as const,
|
||||
};
|
||||
const submittedAttachments: unknown[] = [];
|
||||
const xft = {
|
||||
async listSubmissionTemplates() { return [template]; },
|
||||
async getSubmissionSchema() { return {
|
||||
template,
|
||||
fields: [{
|
||||
fieldNumber: "ACCESSORIES_1", name: "发票附件", type: "ACCESSORIES", required: true,
|
||||
attachmentCapability: {
|
||||
status: "supported", input: "local_file", uploadWhen: "after_final_confirmation",
|
||||
maxFileBytes: 50 * 1024 * 1024, maxFilesPerSubmission: 10,
|
||||
},
|
||||
}],
|
||||
inputContract: {}, diagnostics: {},
|
||||
}; },
|
||||
async getSubmissionOptions() { return { kind: "expense_type", items: [{ code: "MEAL", label: "业务招待费" }], diagnostics: {} }; },
|
||||
async validateSubmissionIdentity() { return { validated: true, diagnostics: {} }; },
|
||||
async submitApproval(_user: unknown, _template: unknown, _values: unknown, attachments: unknown[]) {
|
||||
submittedAttachments.push(...attachments);
|
||||
return { billId: "2026090100000001", status: "submitted", diagnostics: {} };
|
||||
},
|
||||
async getMySubmissionStatus() { return {
|
||||
billId: "2026090100000001", status: "1", statusName: "待审批", revoked: false, revokeAllowed: true,
|
||||
}; },
|
||||
};
|
||||
const service = new UnifiedApprovalService(config(), {} as ApprovalService, xft as never);
|
||||
const user = { userId: "user-1" };
|
||||
const templateId = String((await service.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
|
||||
const values = {
|
||||
reimAmount: "551.00", reimAmountCurrencyCode: "CNY",
|
||||
expenseList: [{ expTypeCode: "MEAL", currencyCode: "CNY", originalCurrencyReimAmt: "551.00" }],
|
||||
};
|
||||
await assert.rejects(service.prepareSubmission(user, { templateId, values }), /发票附件/);
|
||||
const metadata = {
|
||||
fieldName: "发票附件", fileName: "invoice.pdf", size: 21,
|
||||
sha256: "a".repeat(64),
|
||||
};
|
||||
const prepared = await service.prepareSubmission(user, { templateId, values, attachments: [metadata] });
|
||||
assert.equal(prepared.status, "confirmation_required");
|
||||
assert.match(JSON.stringify(prepared.preview), /invoice\.pdf/);
|
||||
assert.doesNotMatch(JSON.stringify(prepared.preview), /a{32}|localPath|attachmentRef/);
|
||||
|
||||
const submitted = await service.submitApproval(user, prepared.confirmationId!, {
|
||||
templateId,
|
||||
values,
|
||||
attachments: [{ ...metadata, attachmentRef: "opaque-upload-reference-12345" }],
|
||||
});
|
||||
assert.equal(submitted.status, "submitted");
|
||||
assert.deepEqual(submittedAttachments, [{
|
||||
...metadata, fieldNumber: "ACCESSORIES_1", attachmentRef: "opaque-upload-reference-12345",
|
||||
}]);
|
||||
await assert.rejects(service.prepareSubmission(user, {
|
||||
templateId,
|
||||
values: { ...values, customFieldValues: [{ fieldNumber: "ACCESSORIES_1", fieldValue: "invented-file-id" }] },
|
||||
attachments: [metadata],
|
||||
}), /必须通过 attachments.*不能直接提供附件编号/);
|
||||
});
|
||||
|
||||
test("DingTalk submission rejects required client-only generated fields", async () => {
|
||||
const ding = {
|
||||
async listSubmissionTemplates() { return [{ processCode: "PROC-1", name: "外出_北极大", dirName: "北极大_人事假勤" }]; },
|
||||
|
||||
@@ -487,6 +487,8 @@ test("Xft submission discovers live templates and derives protected create field
|
||||
fieldNumber: "billDepartmentCode", name: "承担部门", type: "ADV_DEPARTMENT", required: true, options: [],
|
||||
}, {
|
||||
fieldNumber: "C-DYNAMIC-DEPT", name: "承担部门", type: "ADV_DEPARTMENT", required: true, options: [],
|
||||
}, {
|
||||
fieldNumber: "ACCESSORIES_1", name: "发票附件", type: "ACCESSORIES", required: false, options: [],
|
||||
}] });
|
||||
if (path.endsWith("reimburse/bill/create")) {
|
||||
createBodies.push(body);
|
||||
@@ -499,7 +501,14 @@ test("Xft submission discovers live templates and derives protected create field
|
||||
assert.equal(templates[0]?.kind, "common_reimburse");
|
||||
const schema = await client.getSubmissionSchema({ userId: "ding-u1" }, templates[0]!);
|
||||
assert.equal(schema.fields[0]?.fieldNumber, "C1");
|
||||
assert.equal(schema.fields.length, 1);
|
||||
assert.equal(schema.fields.length, 2);
|
||||
assert.deepEqual(schema.fields.find((field) => field.fieldNumber === "ACCESSORIES_1")?.attachmentCapability, {
|
||||
status: "supported",
|
||||
input: "local_file",
|
||||
uploadWhen: "after_final_confirmation",
|
||||
maxFileBytes: 50 * 1024 * 1024,
|
||||
maxFilesPerSubmission: 10,
|
||||
});
|
||||
const standardFields = (schema.inputContract.values as Record<string, unknown>).standardFields as Array<Record<string, unknown>>;
|
||||
const minimalExample = (schema.inputContract.values as Record<string, unknown>).minimalExample as Record<string, unknown>;
|
||||
assert.deepEqual(Object.keys(minimalExample), ["reimAmount", "reimAmountCurrencyCode", "expenseList"]);
|
||||
@@ -526,6 +535,70 @@ test("Xft submission discovers live templates and derives protected create field
|
||||
assert.equal(createBodies[0]?.billApplyAuthCheckType, "Y");
|
||||
});
|
||||
|
||||
test("Xft attachment upload follows the official multipart signing mode and injects opaque file IDs only at create", async () => {
|
||||
const file = Buffer.from("%PDF-1.7\nmock invoice", "utf8");
|
||||
const sha256 = (await import("node:crypto")).createHash("sha256").update(file).digest("hex");
|
||||
let uploadHeaders: Headers | undefined;
|
||||
let uploadBody: Buffer | undefined;
|
||||
let createBody: Record<string, unknown> | undefined;
|
||||
const fakeFetch: typeof fetch = async (input, init) => {
|
||||
const url = new URL(String(input));
|
||||
const path = url.pathname;
|
||||
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{
|
||||
thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1",
|
||||
}] } });
|
||||
if (path.endsWith("member/get/by-id")) return encrypted({ returnCode: "SUC0000", body: {
|
||||
number: "E001", idRelation: { enterpriseUserId: "EMP-1" },
|
||||
} });
|
||||
if (path.endsWith("form/query-event-list")) return encrypted({ code: "0", data: { list: [{
|
||||
eventSeqId: "1", eventNumber: "FORM-1", eventName: "费用报销单", eventStatus: "1", eventType: "COMMON_REIM",
|
||||
}] } });
|
||||
if (path.endsWith("accessory/custom-field/upload")) {
|
||||
uploadHeaders = new Headers(init?.headers);
|
||||
uploadBody = Buffer.from(init?.body as ArrayBuffer);
|
||||
assert.equal(url.searchParams.get("uploaderNumber"), "E001");
|
||||
return encrypted({ code: "SUC0000", body: { fileId: "file-secret-1" } });
|
||||
}
|
||||
if (path.endsWith("reimburse/bill/create")) {
|
||||
createBody = decodedBody(init);
|
||||
return encrypted({ code: "SUC0000", body: 2026090100000001 });
|
||||
}
|
||||
throw new Error(`unexpected ${path}`);
|
||||
};
|
||||
const client = new XftOpenApiClient(config(), fakeFetch);
|
||||
const confirmationId = "confirmation-bound-to-user-preview";
|
||||
const uploaded = await client.uploadSubmissionAttachment({ userId: "ding-u1" }, {
|
||||
fieldName: "发票附件", fileName: "invoice.pdf", size: file.length, sha256, content: file, confirmationId,
|
||||
});
|
||||
assert.equal(uploadHeaders?.has("x-alb-digest"), false, "official SDK omits body digest for multipart uploads");
|
||||
assert.equal(uploadHeaders?.get("x-alb-verify"), "sm3withsm2");
|
||||
assert.equal(uploadHeaders?.get("apisign")?.length, 128);
|
||||
assert.match(uploadHeaders?.get("content-type") ?? "", /^multipart\/form-data;.*boundary=/);
|
||||
assert.match(uploadBody?.toString("utf8") ?? "", /name="file"; filename="invoice\.pdf"/);
|
||||
assert.ok(uploadBody?.includes(file));
|
||||
assert.doesNotMatch(uploaded.attachmentRef, /file-secret-1/);
|
||||
|
||||
const template = {
|
||||
eventSeqId: "1", eventNumber: "FORM-1", eventName: "费用报销单",
|
||||
eventType: "COMMON_REIM", kind: "common_reimburse" as const,
|
||||
};
|
||||
await client.submitApproval({ userId: "ding-u1" }, template, {
|
||||
reimAmount: "551.00", reimAmountCurrencyCode: "CNY", expenseList: [{ expTypeCode: "MEAL" }],
|
||||
}, [{
|
||||
fieldNumber: "ACCESSORIES_1", fieldName: "发票附件", fileName: "invoice.pdf",
|
||||
size: file.length, sha256, attachmentRef: uploaded.attachmentRef,
|
||||
}], confirmationId);
|
||||
assert.deepEqual(createBody?.customFieldValues, [{
|
||||
fieldNumber: "ACCESSORIES_1", fieldValue: JSON.stringify(["file-secret-1"]),
|
||||
}]);
|
||||
assert.doesNotMatch(JSON.stringify(createBody), /confirmation-bound|attachmentRef|sha256/);
|
||||
|
||||
await assert.rejects(client.submitApproval({ userId: "ding-u1" }, template, {}, [{
|
||||
fieldNumber: "ACCESSORIES_1", fieldName: "发票附件", fileName: "invoice.pdf",
|
||||
size: file.length, sha256, attachmentRef: uploaded.attachmentRef,
|
||||
}], "another-confirmation"), /与本次用户确认.*不一致/);
|
||||
});
|
||||
|
||||
test("Xft submission options expose only departments, cities, and the matching expense data source", async () => {
|
||||
const templateRecord = {
|
||||
eventSeqId: 1, eventNumber: "TRAVEL-1", eventName: "出差费用申请单", eventStatus: "1", eventType: "TRAVEL_APPLY",
|
||||
|
||||
Reference in New Issue
Block a user