feat: initial public release
CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s
CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
# 统一审批网关:同时负责钉钉 OA 与薪福通;终端用户不接触这些值
|
||||
APP_MODE=gateway
|
||||
DINGTALK_CREDENTIALS_FILE=/opt/dingMCP/.secrets/dingtalk.json
|
||||
DINGTALK_CORP_ID=dingxxxxxxxx
|
||||
|
||||
# 固定公网 HTTPS 地址;反向代理转发到本机 3010 端口
|
||||
MCP_PUBLIC_BASE_URL=https://approval.example.com
|
||||
MCP_REMOTE_AUTH_ENABLED=true
|
||||
MCP_BIND_HOST=127.0.0.1
|
||||
PORT=3010
|
||||
MCP_AUTH_DATABASE=/opt/dingMCP/.runtime/auth.sqlite
|
||||
MCP_SESSION_SECRET_FILE=/opt/dingMCP/.secrets/oauth-session.key
|
||||
MCP_APPROVAL_REF_SECRET_FILE=/opt/dingMCP/.secrets/approval-ref.key
|
||||
|
||||
# 薪福通正式 OpenAPI(网关必填;不支持 Cookie/Chromium 兼容模式)
|
||||
XFT_OPEN_API_ENABLED=true
|
||||
# XFT_OPEN_API_APP_ID=your-app-id
|
||||
# XFT_OPEN_API_AUTHORITY_SECRET_FILE=/opt/dingMCP/.secrets/xft-open-api.secret
|
||||
# XFT_OPEN_API_ENTERPRISE_ID=your-enterprise-id
|
||||
# XFT_OPEN_API_BASE_URL=https://api.cmbchina.com
|
||||
# XFT_OPEN_API_ENCRYPTED=true
|
||||
# XFT_OPEN_API_DINGTALK_APP_CODE=DDingTalk
|
||||
|
||||
# 调优
|
||||
# OFFICIAL_OA_LOOKBACK_DAYS=120
|
||||
# 审批写入批量上限固定为 20,不提供环境变量覆盖。
|
||||
# MCP_ACCESS_TOKEN_TTL_SECONDS=900
|
||||
# MCP_REFRESH_TOKEN_TTL_SECONDS=2592000
|
||||
@@ -0,0 +1,68 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
client-installer:
|
||||
name: Client installer (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.13
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
- run: node --test dist/test/client-installer.test.js
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [22, 24]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm test
|
||||
- run: npm audit --omit=dev --audit-level=high
|
||||
|
||||
release-smoke:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: rm distribution/release-signing-public.pem
|
||||
- run: npm run release:keygen
|
||||
- run: >-
|
||||
npm run release:build --
|
||||
--gateway-url https://approval.example.com/mcp
|
||||
--download-base-url https://approval.example.com/downloads/dingmcp
|
||||
--out release-ci
|
||||
- run: >-
|
||||
npm run test-bundle:build --
|
||||
--release-dir release-ci
|
||||
--bundle release-ci/dingmcp-test-bundle.tar.gz
|
||||
--bundle-url https://approval.example.com/downloads/dingmcp/dingmcp-test-bundle.tar.gz
|
||||
--prompt release-ci/agent-test-install.md
|
||||
- run: cd release-ci && sha256sum -c SHA256SUMS
|
||||
- run: npm run release:verify -- --release-dir release-ci
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
secret
|
||||
.secrets/
|
||||
.runtime/
|
||||
.state/
|
||||
.dws-data/
|
||||
tools/
|
||||
.npm-cache/
|
||||
.playwright-cli/
|
||||
.playwright-browsers/
|
||||
.cache/
|
||||
.codegraph/
|
||||
.agents/
|
||||
.codex/
|
||||
release/
|
||||
release-test/
|
||||
output/
|
||||
*.sqlite
|
||||
*.sqlite-shm
|
||||
*.sqlite-wal
|
||||
*.tgz
|
||||
*.tar.gz
|
||||
*.log
|
||||
@@ -0,0 +1,38 @@
|
||||
# Repository Working Rules
|
||||
|
||||
## Scope and architecture
|
||||
|
||||
- This repository implements one lightweight stdio bridge and one unified remote gateway for DingTalk OA and XFT approvals.
|
||||
- The supported customer architecture uses one gateway OAuth login for both approval sources. Do not reintroduce a local DWS dependency or a second user login without explicit product approval.
|
||||
- The gateway must derive identity from its verified OAuth token. Never accept caller-supplied `userId`, employee ID, owner ID, or equivalent identity selectors.
|
||||
- Approval bodies, expense details, attachments, and upstream schemas must remain real-time and must not be persisted.
|
||||
|
||||
## Git workflow
|
||||
|
||||
- Use Git for every source, documentation, test, deployment, and distribution change.
|
||||
- Keep `main` releasable. Develop non-trivial changes on short-lived branches named `feat/<topic>`, `fix/<topic>`, `docs/<topic>`, or `chore/<topic>`.
|
||||
- Prefer small, atomic commits using Conventional Commit subjects such as `feat:`, `fix:`, `docs:`, `test:`, `refactor:`, and `chore:`.
|
||||
- Do not rewrite published history, force-push, or delete remote branches/tags unless the user explicitly requests it.
|
||||
- Before commit, run `npm test`, relevant distribution checks, and `git diff --check`. Update docs when architecture, installation, tools, authentication, or deployment changes.
|
||||
- Releases should be tagged only from a tested `main` commit. Do not commit generated `dist`, `release`, test bundles, package archives, or npm caches.
|
||||
|
||||
## Secrets and privacy
|
||||
|
||||
- Never commit `.secrets`, `.runtime`, `.env`, the root `secret` file, OAuth databases, tokens, cookies, credentials, approval data, or machine-specific absolute paths.
|
||||
- Before the first push and every release, inspect the staged file list and scan it for likely secrets.
|
||||
- Logs, tests, fixtures, and support references must not expose access tokens, AppSecret, AuthoritySecret, private approval IDs, internal user IDs, or raw upstream payloads.
|
||||
|
||||
## Testing and safety
|
||||
|
||||
- All approval reads and writes in product tests must go through MCP tools; do not bypass MCP to claim end-to-end coverage.
|
||||
- Automated tests must use mocks unless the user explicitly authorizes a real approval write. Never run real reject or return actions implicitly.
|
||||
- Preserve the distinction between `failed`, `succeeded`, and `outcome_uncertain`; never automatically retry an uncertain write.
|
||||
- Keep opaque approval references user-bound, source-bound, expiring, and revalidated against live ownership before every write.
|
||||
|
||||
## Deployment and distribution
|
||||
|
||||
- Use `./deploy/install.sh` for gateway installation and updates and `./deploy/uninstall.sh` for gateway removal. These are server-operator commands; keep both idempotent and preserve server secrets by default.
|
||||
- Customer installation and updates must use the signed native client installer. Do not reintroduce global npm installation, Node-version-specific MCP paths, prose-only installation, or direct mutation of host-private configuration.
|
||||
- Release manifests must be Ed25519 signed. Never commit, publish, or expose the release private key; release verification must fail closed on missing/invalid signatures or artifact hashes.
|
||||
- Customer installation must remain user-level, lightweight, and free of enterprise credentials. The distributed MCP command is `dingtalk-approval-bridge` pointing at the HTTPS gateway.
|
||||
- A complete test release must expose both DingTalk OA and XFT. Treat either source being disabled as a release error.
|
||||
@@ -0,0 +1,106 @@
|
||||
# 钉钉审批 MCP
|
||||
|
||||
一个面向 Agent 的统一审批 MCP。用户只登录一次钉钉,即可读取、处理和发起本人在钉钉 OA 与薪福通中的审批:
|
||||
|
||||
```text
|
||||
Agent → 本地轻量 stdio 桥 → HTTPS/OAuth 统一网关
|
||||
├─ 钉钉 OA OpenAPI
|
||||
└─ 薪福通 OpenAPI
|
||||
```
|
||||
|
||||
## 工具
|
||||
|
||||
- `approval_login`:检查登录或返回唯一授权链接。
|
||||
- `approval_list_todos`:实时列出本人待办,签发短期 `approvalId`,并为可安全整组处理的结果签发 `selectionId`。
|
||||
- `approval_list_history`:实时列出本人已经处理过或本人发起的审批;默认最近 30 天,可用 `startDate/endDate` 指定最长 120 天的时间窗口,并签发只能读取详情的短期 `approvalId`。
|
||||
- `approval_get_detail`:返回摘要、结构化费用明细或受限 raw 分页。
|
||||
- `approval_get_actions`:检查同意、拒绝与退回能力。
|
||||
- `approval_process_batch`:一次最多处理 20 条;整批引用预检通过后才逐项执行,并在写后复核。
|
||||
- `approval_list_submission_templates`:实时列出当前用户的提单候选模板。
|
||||
- `approval_get_submission_schema`:返回结构化可填写字段,不传输巨大原始 Schema。
|
||||
- `approval_get_submission_options`:查询当前用户可用的薪福通部门和城市编码。
|
||||
- `approval_prepare_submission`:校验内容,自动查重本人近 30 天跨钉钉/薪福通的发票,并在安全时生成完整预览及短期确认引用;不创建单据。
|
||||
- `approval_submit`:用户确认预览后,使用相同内容正式发起审批。
|
||||
- `approval_revoke_submission`:使用提单成功后返回的 `revokeId` 撤销本人刚通过 MCP 发起的钉钉或薪福通审批。
|
||||
- `approval_get_submission_status`:只读核验本人已发起薪福通单据的实时状态。
|
||||
|
||||
待办、历史和处理结果使用统一、精简的领域字段,不向 Agent 暴露钉钉任务 ID、薪福通员工号或上游原始响应。历史查询分为 `handled`(本人已经处理过)和 `submitted`(本人发起),结果没有 `selectionId` 和可执行动作。常见 `approvalId` 使用紧凑的 v4 无状态加密格式:薪福通通常约 60 字符、钉钉 OA 通常约 100 字符,并绑定当前用户和 30 分钟有效期;历史引用还绑定查询语义,只能读取详情。旧版未过期的 v2/v3 引用仍可读取。
|
||||
|
||||
一次写调用最多处理 20 条。整组同意/拒绝可直接使用 `selectionId`;超过 20 条时,Agent 可从同一次原始列表中按 `approvalId` 分组,不需要 `batchPlan`,也不能重新拉取后把新待办混入原授权。服务器在首个写入前校验整批 `approvalId` 和 `targetId`;任一引用无效时返回 `preflight_failed`、`writesStarted=false`,整批零写入且不自动补偿或重试。普通文本与结构化结果保持同一份精简数据,以兼容不渲染 `structuredContent` 的宿主。
|
||||
|
||||
Agent 不传 `userId`、员工号或单据所有者。网关从 OAuth Token 确定用户,再分别映射钉钉 OA 与薪福通身份。钉钉提单中的申请人和部门也由网关通过当前登录用户的通讯录档案实时填写;即使用户修改本地桥,也不能冒用他人身份或部门。
|
||||
|
||||
审批读取和处理范围不变;发起入口会列出当前企业的 4 个薪福通模板,以及以下 6 个钉钉模板:
|
||||
|
||||
- `北极大_人事假勤`:`出差申请单_北极大`、`外出_北极大`、`业务招待申请_北极大`
|
||||
- `捷康_费用报销`:`差旅费用报销_捷康`、`业务招待费用报销_捷康`、`业务招待申请_捷康`
|
||||
|
||||
范围限制由网关按分类和模板名双重执行,不依赖本地 Skill。同分类中未列出的模板也不能发起。
|
||||
|
||||
模板出现在列表中不等于所有控件都能由开放 API 构造。`approval_get_submission_schema` 会返回 `submissionCapability`,准备阶段也会再次安全校验:
|
||||
|
||||
- 薪福通:白名单中的申请和报销模板均通过正式创建 API 发起,并在创建后实时核验状态。结果可能进入待审批,也可能因企业流程中没有其他有效审批人等规则即时完成;后者返回 `completed`,不会错误地当成接口失败,也不会签发不可用的撤回引用。
|
||||
- 钉钉:当前 6 个候选模板均返回 `client_only`,必须在钉钉客户端发起。其中 5 个包含企业 OpenAPI 无法可靠构造的 `DDBizSuite` 考勤或费控套件;`业务招待申请_北极大`虽是普通明细表,但真实创建时新版企业接口返回系统异常、旧版接口拒绝组件格式,因此同样安全降级。
|
||||
|
||||
发起审批固定为“列模板 → 取 Schema → 自动查重/预览 → 用户确认 → 正式提交”。准备阶段从新表单提取发票代码与号码、数电票唯一编号、票据号码、附件哈希;没有唯一编号时,金额、日期与商户组合只作为疑似提示。网关实时刷新当前用户本人近 30 天的钉钉和薪福通已发起审批:原单待审批、已通过或状态未知时默认阻止,原单拒绝、撤回、作废或弱匹配时警告。任一来源查询不完整也不会当成“没有重复”。只有用户看过匹配单号、状态和覆盖情况并明确要求继续后,Agent 才能以 `allowDuplicateInvoices=true` 重新准备。
|
||||
|
||||
服务端临时保留不可逆的用户/发票 HMAC 指纹。首次使用时只为最近 30 天尚未索引的审批读取详情;以后准备提单会刷新精简历史状态,并只为新增审批补建指纹,不会重复逐张下载已索引发票。指纹和最小审批索引最多保留 35 天,过期后自动级联删除;10 分钟提单预留到期即删除。
|
||||
|
||||
安全准备后,确认引用绑定登录用户、模板版本、完整表单内容、重复风险决定和发票预留,10 分钟有效且只能使用一次;相同发票在另一个有效确认中时不会再签发确认,避免并发重复创建。正文和发票原号不落库。钉钉企业 OpenAPI 支持通过 `POST /v1.0/workflow/processes/forecast` 进行无副作用的流程预测;网关只在预测成功并补齐所有自选处理人后才生成提交确认。薪福通在正式提交时完成最终业务校验,并由网关写后读取 `billSts`:状态 `1(待审批)` 返回 `submitted` 和 `revokeId`,状态 `2(审批通过)` 返回 `completed` 且不提供撤回引用。
|
||||
|
||||
> 发布候选版限制:薪福通 `approve/recall` 撤回接口已经接入并具备本人归属校验与写后复核,但当前企业的真实待审批单仍返回 `XFTSYS9999`,尚无撤回成功样本。因此薪福通撤回属于实验能力;失败或结果不确定时绝不自动重试。查询、处理和发起审批不受此限制。
|
||||
|
||||
## 部署
|
||||
|
||||
服务器首次配置企业凭证和 Ed25519 发布签名私钥后,安装和以后每次更新统一执行:
|
||||
|
||||
```bash
|
||||
./deploy/install.sh
|
||||
```
|
||||
|
||||
卸载网关:
|
||||
|
||||
```bash
|
||||
./deploy/uninstall.sh
|
||||
```
|
||||
|
||||
该卸载命令只处理服务器网关,并默认保留 `.secrets`、`.runtime`、OAuth 数据库和 HTTPS 证书。详细步骤见 [部署文档](docs/deployment.md),安全边界见 [架构文档](docs/architecture.md)。
|
||||
|
||||
10 个提单模板的逐项字段与 API 能力结论见 [提单模板能力](docs/submission-templates.md)。
|
||||
发布候选版的放行条件见 [发布检查清单](docs/release-checklist.md)。
|
||||
|
||||
部署完成后,把下面的唯一链接交给用户或其 Agent:
|
||||
|
||||
```text
|
||||
https://approval.justconn.top/downloads/dingmcp/install.md
|
||||
```
|
||||
|
||||
用户把这个链接交给 Agent 后,Agent 只需校验并运行一个原生 Node 安装器。安装器使用固定 Ed25519 公钥验证清单和发布物,把轻量客户端安装到稳定的用户数据目录,并统一返回 `bridgePath`、`skillPath` 和标准 `mcpConfig`;不再区分 Agent 品牌,不全局安装 npm 包,也不会猜测或直接修改 Agent 的私有配置。安装器会立即在受保护的本地会话中检查登录并返回机器可读 `postInstall`:未登录时直接带回当前授权链接,网关暂时不可用也不会使安装回滚。Agent 随后按自身官方方式安装 Skill、注册并信任本地 stdio MCP;不能热加载连接器时再新建会话或重启。用户端不安装 DWS,不需要企业凭证。
|
||||
|
||||
Agent 必须在安装后立即展示 `postInstall.authChallenge.directUrl`,不得等到第一次审批查询,也不得声称链接已另行推送。用户授权后,Agent 通过 `approval_login` 确认登录并用 `source=all` 做一次双来源只读验证;首次成功认证响应还会在普通 `content.text` 和 `structuredContent.onboarding` 中返回一次性的简短使用示例,展示后不会重复,也不会自动执行任何写操作。
|
||||
|
||||
首次授权会先显示网关自己的 OAuth 确认页,列出请求客户端、返回域名、权限范围或设备授权码;用户确认后才跳转钉钉。客户端名称由客户端自行声明,因此应同时核对域名或授权码。短暂的网关 5xx/网络故障不会清除本地 refresh token,也不会强迫用户重新登录。
|
||||
|
||||
客户端后续可使用稳定 CLI:
|
||||
|
||||
```text
|
||||
dingtalk-approval print-path bridge
|
||||
dingtalk-approval print-config --format mcp-json
|
||||
dingtalk-approval doctor --json
|
||||
dingtalk-approval update --json
|
||||
dingtalk-approval uninstall --yes --json
|
||||
```
|
||||
|
||||
实际 `dingtalk-approval` 路径由首次安装结果的 `cliPath` 给出。`update` 会复用已固定的清单地址与公钥,并保留 OAuth 登录会话;更新完成后应重新连接该 MCP,Agent 不能单独重启连接器时再新建会话或重启。卸载默认删除客户端运行时、Skill、安装记录和 OAuth 会话;Agent 中名为 `dingtalk-approval` 的连接器通过其官方入口删除。
|
||||
|
||||
## 数据与安全
|
||||
|
||||
- 所有写操作重新校验审批仍属于当前登录用户。
|
||||
- `approvalId` 是绑定用户、来源和有效期的紧凑不透明加密引用;不包含展示字段或用户 ID 明文。
|
||||
- 每批最多 20 条;引用预检失败时首个上游写调用不会发生。
|
||||
- 提单的发起人、薪福通员工号和审批状态只能由网关填写。
|
||||
- 审批正文、费用明细、附件和薪福通 Schema 不落库。
|
||||
- 发票查重库只保存当前用户和发票标识的 HMAC、来源、上游业务单号、状态及时间,最多保留 35 天;不保存发票原号、商户原文或附件。
|
||||
- 企业 AppSecret 与薪福通 AuthoritySecret 只保存在服务器。
|
||||
- `failed`、`succeeded` 与 `outcome_uncertain` 有不同重试语义,后者禁止自动重试。
|
||||
- OAuth 客户端、Token 和 MCP 会话都有数量及生命周期上限;过期数据会周期清理。
|
||||
@@ -0,0 +1,15 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name approval.justconn.top;
|
||||
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root /var/www/approval.justconn.top/acme;
|
||||
default_type text/plain;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 403;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name approval.justconn.top;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name approval.justconn.top;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/approval.justconn.top/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/approval.justconn.top/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
client_max_body_size 1m;
|
||||
add_header Strict-Transport-Security "max-age=31536000" always;
|
||||
|
||||
location = /downloads/dingmcp/install.md {
|
||||
alias /var/www/approval.justconn.top/downloads/dingmcp/install.md;
|
||||
default_type text/markdown;
|
||||
charset utf-8;
|
||||
charset_types text/markdown;
|
||||
add_header Strict-Transport-Security "max-age=31536000" always;
|
||||
add_header Cache-Control "no-cache" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Content-Security-Policy "default-src 'none'" always;
|
||||
}
|
||||
|
||||
location = /downloads/dingmcp/manifest.json {
|
||||
alias /var/www/approval.justconn.top/downloads/dingmcp/manifest.json;
|
||||
default_type application/json;
|
||||
add_header Strict-Transport-Security "max-age=31536000" always;
|
||||
add_header Cache-Control "no-cache" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Content-Security-Policy "default-src 'none'" always;
|
||||
}
|
||||
|
||||
location = /downloads/dingmcp/manifest.sig {
|
||||
root /var/www/approval.justconn.top;
|
||||
default_type application/json;
|
||||
add_header Strict-Transport-Security "max-age=31536000" always;
|
||||
add_header Cache-Control "no-cache" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
}
|
||||
|
||||
location = /downloads/dingmcp/agent-test-install.md {
|
||||
root /var/www/approval.justconn.top;
|
||||
default_type text/markdown;
|
||||
charset utf-8;
|
||||
charset_types text/markdown;
|
||||
add_header Strict-Transport-Security "max-age=31536000" always;
|
||||
add_header Cache-Control "no-cache" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
}
|
||||
|
||||
location ~ ^/downloads/dingmcp/(?:dingtalk-approval-installer\.mjs|dingtalk-approval-SKILL\.md|release-signing-public\.pem|SHA256SUMS|dingmcp-test-bundle\.tar\.gz)$ {
|
||||
root /var/www/approval.justconn.top;
|
||||
add_header Strict-Transport-Security "max-age=31536000" always;
|
||||
add_header Cache-Control "no-cache" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
}
|
||||
|
||||
location /downloads/dingmcp/ {
|
||||
alias /var/www/approval.justconn.top/downloads/dingmcp/;
|
||||
autoindex off;
|
||||
add_header Strict-Transport-Security "max-age=31536000" always;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
}
|
||||
|
||||
location / {
|
||||
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;
|
||||
proxy_set_header Mcp-Session-Id $http_mcp_session_id;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
# MCP sessions have a hard 24-hour lifetime in the gateway. Keep the
|
||||
# reverse proxy from expiring a healthy, otherwise idle stream first.
|
||||
proxy_read_timeout 25h;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Executable
+117
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
domain="approval.justconn.top"
|
||||
public_base_url="https://${domain}"
|
||||
gateway_url="${public_base_url}/mcp"
|
||||
download_base_url="${public_base_url}/downloads/dingmcp"
|
||||
release_dir="${project_root}/release"
|
||||
web_base="/var/www/${domain}"
|
||||
web_root="${web_base}/downloads/dingmcp"
|
||||
web_releases="${web_base}/releases"
|
||||
acme_root="${web_base}/acme"
|
||||
nginx_site="/etc/nginx/conf.d/${domain}.conf"
|
||||
certificate="/etc/letsencrypt/live/${domain}/fullchain.pem"
|
||||
npm_cache="${project_root}/.npm-cache"
|
||||
release_private_key="${project_root}/.secrets/release-signing-private.pem"
|
||||
release_public_key="${project_root}/distribution/release-signing-public.pem"
|
||||
|
||||
for command_name in node npm curl systemctl sudo nginx certbot stat sha256sum tar find sort tail cut date; do
|
||||
command -v "${command_name}" >/dev/null 2>&1 || {
|
||||
echo "缺少部署命令:${command_name}" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
if [[ ! -f "${project_root}/.runtime/dingtalk-mcp.env" ]]; then
|
||||
echo "缺少 .runtime/dingtalk-mcp.env;首次部署前需先配置网关企业凭证。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "${release_private_key}" || ! -f "${release_public_key}" ]]; then
|
||||
echo "缺少 Ed25519 发布密钥;首次部署请先执行 npm run release:keygen 并安全备份私钥。" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$(stat -c '%a' "${release_private_key}")" != "600" ]]; then
|
||||
echo "发布私钥权限必须为 600:${release_private_key}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[1/7] 测试并构建"
|
||||
cd "${project_root}"
|
||||
npm --cache "${npm_cache}" test
|
||||
|
||||
echo "[2/7] 生成包含钉钉 OA 与薪福通的发行包"
|
||||
npm --cache "${npm_cache}" run release:build -- \
|
||||
--gateway-url "${gateway_url}" \
|
||||
--download-base-url "${download_base_url}" \
|
||||
--out release
|
||||
npm run test-bundle:build -- \
|
||||
--release-dir release \
|
||||
--bundle release/dingmcp-test-bundle.tar.gz \
|
||||
--bundle-url "${download_base_url}/dingmcp-test-bundle.tar.gz" \
|
||||
--prompt release/agent-test-install.md
|
||||
(cd "${release_dir}" && sha256sum -c SHA256SUMS)
|
||||
npm run release:verify -- --release-dir release
|
||||
|
||||
echo "[3/7] 安装并重启用户级网关服务"
|
||||
node dist/src/admin.js service start --project-root "${project_root}"
|
||||
|
||||
echo "[4/7] 原子发布安装文件"
|
||||
release_version="$(node -p "require('./package.json').version")"
|
||||
release_digest="$(sha256sum "${release_dir}/manifest.json" | cut -c1-12)"
|
||||
release_name="${release_version}-${release_digest}"
|
||||
release_target="${web_releases}/${release_name}"
|
||||
release_staging="${web_releases}/.staging-${release_name}-$$"
|
||||
sudo install -d -m 0755 "${web_base}/downloads" "${web_releases}" "${acme_root}/.well-known/acme-challenge"
|
||||
sudo rm -rf "${release_staging}"
|
||||
sudo install -d -m 0755 "${release_staging}"
|
||||
sudo cp -a "${release_dir}/." "${release_staging}/"
|
||||
if ! sudo test -e "${release_target}"; then
|
||||
sudo mv "${release_staging}" "${release_target}"
|
||||
else
|
||||
sudo rm -rf "${release_staging}"
|
||||
fi
|
||||
if sudo test -d "${web_root}" && ! sudo test -L "${web_root}"; then
|
||||
sudo mv "${web_root}" "${web_releases}/legacy-$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
fi
|
||||
sudo ln -sfn "${release_target}" "${web_root}.next"
|
||||
sudo mv -Tf "${web_root}.next" "${web_root}"
|
||||
|
||||
mapfile -t obsolete_releases < <(find "${web_releases}" -mindepth 1 -maxdepth 1 -type d -name '[0-9]*' ! -name "${release_name}" -printf '%T@ %f\n' | sort -nr | tail -n +3 | cut -d' ' -f2-)
|
||||
for obsolete in "${obsolete_releases[@]}"; do
|
||||
[[ "${obsolete}" =~ ^[A-Za-z0-9._-]+$ ]] || continue
|
||||
sudo rm -rf "${web_releases:?}/${obsolete}"
|
||||
done
|
||||
|
||||
if ! sudo test -f "${certificate}"; then
|
||||
echo "[5/7] 首次签发 HTTPS 证书"
|
||||
sudo install -m 0644 "${project_root}/deploy/${domain}.bootstrap.nginx.conf" "${nginx_site}"
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
sudo certbot certonly --webroot -w "${acme_root}" -d "${domain}"
|
||||
else
|
||||
echo "[5/7] 复用现有 HTTPS 证书"
|
||||
fi
|
||||
|
||||
echo "[6/7] 安装正式 Nginx 配置"
|
||||
sudo install -m 0644 "${project_root}/deploy/${domain}.nginx.conf" "${nginx_site}"
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
|
||||
echo "[7/7] 验证公网服务"
|
||||
curl --fail --silent --show-error --retry 10 --retry-delay 1 --retry-all-errors \
|
||||
"${public_base_url}/healthz" >/dev/null
|
||||
curl --fail --silent --show-error \
|
||||
"${public_base_url}/.well-known/oauth-protected-resource/mcp" >/dev/null
|
||||
curl --fail --silent --show-error "${download_base_url}/install.md" >/dev/null
|
||||
curl --fail --silent --show-error "${download_base_url}/manifest.json" >/dev/null
|
||||
curl --fail --silent --show-error "${download_base_url}/manifest.sig" >/dev/null
|
||||
curl --fail --silent --show-error "${download_base_url}/dingtalk-approval-installer.mjs" >/dev/null
|
||||
curl --fail --silent --show-error "${download_base_url}/release-signing-public.pem" >/dev/null
|
||||
|
||||
echo "部署完成。"
|
||||
echo "MCP:${gateway_url}"
|
||||
echo "用户安装链接:${download_base_url}/install.md"
|
||||
echo "黑盒测试提示词:${download_base_url}/agent-test-install.md"
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
domain="approval.justconn.top"
|
||||
service_name="dingtalk-approval-mcp.service"
|
||||
unit_file="${XDG_CONFIG_HOME:-${HOME}/.config}/systemd/user/${service_name}"
|
||||
nginx_site="/etc/nginx/conf.d/${domain}.conf"
|
||||
web_root="/var/www/${domain}"
|
||||
|
||||
if [[ "${nginx_site}" != "/etc/nginx/conf.d/approval.justconn.top.conf" || "${web_root}" != "/var/www/approval.justconn.top" ]]; then
|
||||
echo "拒绝卸载:部署目标校验失败。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${1:-}" != "--yes" ]]; then
|
||||
read -r -p "卸载 ${domain} 网关和公开文件?凭证、OAuth 数据及证书会保留。[y/N] " answer
|
||||
[[ "${answer}" == "y" || "${answer}" == "Y" ]] || exit 0
|
||||
fi
|
||||
|
||||
systemctl --user disable --now "${service_name}" >/dev/null 2>&1 || true
|
||||
rm -f "${unit_file}"
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user reset-failed "${service_name}" >/dev/null 2>&1 || true
|
||||
|
||||
sudo rm -f "${nginx_site}"
|
||||
sudo rm -rf "${web_root}"
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
|
||||
echo "卸载完成。"
|
||||
echo "已保留:${project_root}/.secrets、${project_root}/.runtime 和 ${domain} 的证书。"
|
||||
@@ -0,0 +1,36 @@
|
||||
# 钉钉审批客户端安装任务
|
||||
|
||||
用户提供本文件或链接,即表示授权你为当前用户安装或更新“钉钉审批”客户端、Skill 和名为 `dingtalk-approval` 的 MCP;不代表授权执行任何审批写操作。
|
||||
|
||||
## 固定发布入口
|
||||
|
||||
- 版本:`{{VERSION}}`
|
||||
- 安装器:`{{INSTALLER_URL}}`
|
||||
- 安装器 SHA-256:`{{INSTALLER_SHA256}}`
|
||||
- 发布公钥:`{{PUBLIC_KEY_URL}}`
|
||||
- 公钥 SHA-256:`{{PUBLIC_KEY_SHA256}}`
|
||||
- 公钥编号:`{{RELEASE_KEY_ID}}`
|
||||
- 签名清单:`{{MANIFEST_URL}}`
|
||||
- 清单签名:`{{MANIFEST_SIGNATURE_URL}}`
|
||||
- 统一网关:`{{GATEWAY_URL}}`
|
||||
- Node.js:`>=22.13.0`
|
||||
|
||||
首次安装通过上述 HTTPS 地址和固定 SHA-256 建立信任;安装器随后必须使用固定公钥验证 Ed25519 清单签名和全部发布物哈希。任一 URL、证书、签名或哈希不匹配时立即停止。不得索取 AppKey、AppSecret、AuthoritySecret、CorpId、userId、员工号、Cookie 或 Token。
|
||||
|
||||
## 执行
|
||||
|
||||
1. 在仅当前用户可访问的临时目录下载安装器和发布公钥,分别核对上述 SHA-256。
|
||||
2. 执行以下唯一通用安装入口,并读取其 JSON 结果:
|
||||
|
||||
```text
|
||||
node <已校验的安装器路径> install --manifest-url {{MANIFEST_URL}} --public-key <已校验的公钥路径> --json
|
||||
```
|
||||
|
||||
3. 安装器会立即尝试准备登录。读取返回的 `postInstall`,不得等到用户第一次查询审批:若状态为 `authorization_required`,必须把 `authChallenge.directUrl` 原样写入当前回复;若为 `authenticated`,直接继续验证;若为 `unavailable`,保留安装并在 MCP 加载后调用 `approval_login` 重试。不得声称链接已通过其他渠道推送。
|
||||
4. 按当前 Agent 的官方方式安装返回的 `skillPath`,并使用 `mcpConfig` 只新增或替换名为 `dingtalk-approval` 的本地 stdio MCP。安装器本身不会猜测或修改 Agent 的私有配置。
|
||||
5. 如果当前 Agent 要求信任本地连接器,请完成其官方信任流程。连接器不能热加载时,新建会话或完整重启 Agent,并恢复本节尚未完成的登录步骤。
|
||||
6. 用户完成网页授权后调用 `approval_login`。工具不会通过其他消息通道自动推送链接,必须把 `content.text` 中的完整登录 URL 原样写入当前回复。然后按 `postInstall.verifyTool` 只读调用 `approval_list_todos`,确认钉钉 OA 与薪福通均可读取。成功响应包含 `onboarding.firstRun=true` 时,把其中的简短示例和安全提示展示一次,但不要自动执行示例。安装阶段不得处理审批,也不得在登录和双来源验证完成前声称“已可使用”。
|
||||
|
||||
更新时重复执行同一安装入口;安装器会保留 OAuth 登录会话,稳定 Bridge 路径不会随 Node/NVM 版本改变。更新成功后重新连接名为 `dingtalk-approval` 的 MCP;Agent 不能单独重启连接器时,新建会话或重启 Agent。卸载时先从 Agent 删除同名条目,再执行安装结果中的 `cliPath uninstall --yes --json`。卸载默认清除运行时、Skill、安装记录和 OAuth 会话。
|
||||
|
||||
授权页面会先展示请求客户端、回调域名、权限范围或设备授权码。应让用户本人核对并确认,不得替用户自动提交 consent。网关短暂不可用时保留现有登录状态并稍后重试,不要删除会话文件或主动要求用户重新授权。
|
||||
@@ -0,0 +1,13 @@
|
||||
# 钉钉审批 MCP 黑盒测试任务
|
||||
|
||||
请以普通用户首次安装的视角完成整条链路:下载安装并配置 Agent、完成首次登录、体验待办列表/搜索/详情/费用信息/可执行动作,经用户确认后真实同意一张审批并复核结果,最后清理本轮测试产生的文件和配置并提交测试报告。
|
||||
|
||||
- 测试包:`{{BUNDLE_URL}}`
|
||||
- SHA-256:`{{BUNDLE_SHA256}}`
|
||||
- 模式:轻量本地桥 + 统一审批网关;一次钉钉登录同时覆盖钉钉 OA 与薪福通,必须完整验证两个审批源
|
||||
|
||||
安装必须从包内的 `install.md` 进入,通过原生安装器完成 Ed25519 清单和发布物校验;不要改用旧版全局 npm 安装步骤。
|
||||
|
||||
只通过安装后的 `dingtalk-approval` MCP 读取和处理审批业务,不使用上游接口旁路。验证一次登录、钉钉 OA 与薪福通的待办、详情和动作能力;任何来源显示 `disabled` 都应视为发行配置错误并停止测试。真实写操作仅限用户明确确认的一次同意;可从任一来源选择,但不得因其中一方不可用而跳过。不要真实拒绝或退回。若结果为 `outcome_uncertain`,禁止自动重试。
|
||||
|
||||
报告请说明覆盖范围、正常体验、按严重度排列的问题与复现方式、真实审批及写后复核、未验证项和清理结果;不要包含 Token、Cookie、approvalId、内部用户 ID 或私人绝对路径。
|
||||
@@ -0,0 +1,3 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEAQujxanGlrWpR3YL4kwuF98Hl6PZgvrr1yUK4DlQd9GU=
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -0,0 +1,97 @@
|
||||
# 架构
|
||||
|
||||
## 数据路径
|
||||
|
||||
```text
|
||||
用户 ─ Messenger ─ Agent ─ stdio 轻量桥 ─ HTTPS/OAuth ─ 统一审批网关
|
||||
├─ 钉钉 OA
|
||||
└─ 薪福通
|
||||
```
|
||||
|
||||
本地桥只完成 MCP stdio 与远程 Streamable HTTP 的转换,保存用户自己的短期 access/refresh token,不包含企业密钥、不读取审批上游,也不监听端口。
|
||||
|
||||
## 客户端安装边界
|
||||
|
||||
终端不再使用全局 npm prefix。无第三方依赖的首次安装器通过自有 HTTPS 到达用户机器,随后使用固定 Ed25519 公钥验证 schema v3 清单签名,并校验每个 artifact 的 SHA-256。客户端运行依赖被内置在签名 artifact 中,终端使用 npm 离线模式展开,不在安装时引入清单以外的代码。首次安装以 HTTPS 和安装说明中固定的安装器/公钥哈希建立信任;本地固定公钥保护后续更新链路。发布私钥不进入 Git、网关进程或公开目录。
|
||||
|
||||
运行时位于稳定的用户数据目录,启动器与 Node/NVM 具体版本解耦。安装器只有一条通用路径,输出稳定 `bridgePath`、通用 `skillPath`、标准 `mcpConfig` 和机器可读 `postInstall`,不识别 Agent 品牌,也不写任何 Agent 的私有配置。`postInstall` 通过已安装的 Bridge 和正式网关主动检查登录;它只返回认证状态、短期授权链接与下一步工具提示,不返回 Token 或 device secret。网关不可用只会把状态标为 `unavailable`,不会回滚已验证的客户端安装。
|
||||
|
||||
连接器信任和重启属于 Agent 自身的安全边界,安装器不会绕过。卸载默认删除运行时、Skill、安装记录和 OAuth 会话;Agent 拥有的连接器条目由其官方入口移除。旧 npm 入口只在名称和目标包都精确匹配时清理。
|
||||
|
||||
## Agent 领域接口
|
||||
|
||||
Agent 面向统一审批语义,不直接理解钉钉 `processInstanceId/taskId`、薪福通员工号、上游响应或完整 Schema。网关在内部完成来源路由、字段抽取、动作调用和写后复核,只返回审批判断所需的精简字段与统一状态。
|
||||
|
||||
所有工具以普通 `content.text` 中的精简 JSON 作为跨宿主可见结果,同时保留相同的精简 `structuredContent` 供支持结构化结果的宿主使用;两处都不携带诊断字段或上游原始响应。`raw` 仍是受过滤、分页和硬限额保护的按需诊断视图,不是默认数据通道。
|
||||
|
||||
单张或部分操作使用 v4 紧凑无状态 `approvalId`。载荷只包含来源、上游标识、用途和 30 分钟有效期,不包含标题、申请人、金额、详情或用户 ID;网关以当前 OAuth 用户派生专属 AES-GCM 密钥,因此同一服务器密钥下其他用户也无法解开。常见薪福通引用约 60 字符,钉钉 OA 引用约 100 字符;长度头可以把宿主截断与普通篡改区分开。待办引用允许进入实时写前校验;`approval_list_history` 签发的 `handled/submitted` 引用只能读取详情,动作查询和批处理会在首次上游写入前拒绝。网关继续接受尚未过期的 v2/v3 待办引用,升级或进程重启不会让同一密钥签发的引用失效。
|
||||
|
||||
历史列表不与待办混合,也不签发 `selectionId`。未指定时间时默认查询最近 30 天;`startDate/endDate` 接受按 `Asia/Shanghai` 解释的 `YYYY-MM-DD` 或带时区的 ISO 8601 时间,结束日期包含全天,单次跨度最多 120 天。钉钉 `handled` 从本人已完成工作记录取得,`submitted` 在该时间窗口内按当前用户可见模板查询本人发起实例;薪福通 `handled` 从本人的已处理任务取得,`submitted` 使用服务器派生的本人员工号查询单据,再由网关统一按时间窗口过滤。读取历史详情时再次验证当前用户确实处理过该任务或确实是发起人;审批正文、费用明细和附件仍只实时读取、不落库。
|
||||
|
||||
发起费用审批时,`approval_prepare_submission` 在网关内部完成跨来源发票查重。它从待提交表单和本人近 30 天 `submitted` 历史详情中有界提取发票代码/号码、数电票唯一编号、票据号码和附件哈希;金额、日期、商户三项组合仅作为弱匹配。首次索引只读取近 30 天尚未索引的本人历史详情,后续准备仍实时刷新两端精简历史列表和状态,但只为新增业务单补读详情,不会重复逐张下载已索引发票。任一来源异常、历史被上游截断或详情读取失败都会把覆盖标记为不完整,不会静默返回“无重复”。
|
||||
|
||||
发票指纹以服务器密钥进行 HMAC,另以用户 HMAC 分区。SQLite 只保存用户 HMAC、发票 HMAC、匹配类型、来源、上游业务单号、规范化状态和时间;不保存发票原号、审批正文、费用详情、商户、附件或上游 Schema。指纹、最小审批索引和非活跃扫描水位最多保留 35 天,网关启动及每次同步时自动清理,删除审批索引会级联删除全部关联指纹。活跃确认还为每个发票 HMAC 建立 10 分钟唯一预留,到期自动删除,因此两个 Agent 会话不能同时为同一用户、同一发票取得可提交确认。历史重复或覆盖不完整只能在用户明确确认后继续,该决定和预留均绑定到一次性 `confirmationId`。
|
||||
|
||||
每次 `approval_process_batch` 最多接收 20 条。整组同意或拒绝可使用列表返回的 `selectionId`:它绑定当前用户、查询条件、返回数量和审批集合指纹,执行前网关会重新实时查询;列表有任何变化、任一来源异常或超过上限都会拒绝写入。超过 20 条时不签发 `batchPlan`,Agent 可在用户授权范围内从同一次原始列表中按 `approvalId` 分组,不能重新查询后混入新待办。退回因每张目标节点不同,仍必须逐张取得 `targetId`。
|
||||
|
||||
显式 `items` 写入采用两阶段安全边界:先解码并校验整批 `approvalId`,同时校验所有退回 `targetId` 的签名、有效期、用户、来源和任务匹配;任一引用失败即返回 `preflight_failed`、`writesStarted=false`,整批零写入。全部引用通过后才逐张做实时本人待办校验和上游写入。开始写入后的业务结果仍是非事务逐项结果,不会自动补偿或重试;引用只在批次开始时检查一次,避免一个已通过预检的批次在执行中途因引用到期而产生人为部分失败。
|
||||
|
||||
同意和拒绝不要求 Agent 对每张待办重复调用动作查询。列表已经标明候选动作,网关会在写操作前逐张验证本人待办;`allowed=true, guaranteed=false` 表示任务状态预检通过,但钉钉签字等最终业务规则仍可能在执行时拒绝。bridge 最多允许 3 个远程工具调用同时在途,避免宿主瞬间并发压垮上游。
|
||||
|
||||
## 一次登录
|
||||
|
||||
安装器在安装完成后立即调用 Bridge 的登录准备模式,不等待首次业务请求。未登录时,`postInstall.authChallenge.directUrl` 直接携带一个钉钉授权链接;用户授权后,Agent 调用 `approval_login` 确认状态。授权成功后,网关从钉钉身份得到当前企业 `userId`:
|
||||
|
||||
- 钉钉 OA 直接按该 `userId` 查询本人审批任务;
|
||||
- 薪福通先把该 `userId` 映射为企业成员,再从本人企业成员档案读取花名册员工号。
|
||||
|
||||
两条数据路径共用同一个网关 Token,因此不会出现本地 DWS 与网关分别登录的情况。
|
||||
|
||||
Bridge 的受保护会话中只记录 `pending/delivered` 一次性 onboarding 状态,不保存任何审批正文。登录挑战不会消费该状态;首次认证成功响应,或 Agent 跳过登录工具后的首个成功业务响应,会在普通 `content.text` 和 `structuredContent.onboarding` 中同时返回简短用法示例并标记已展示。错误响应、登录过期和后续升级都不会重复展示或自动执行示例。
|
||||
|
||||
远程 `https://approval.justconn.top/mcp` 已按 MCP 授权规范提供 HTTP 401 challenge、OAuth Protected Resource Metadata、Authorization Server Metadata、动态客户端注册、PKCE、Token 刷新和撤销;原生支持远程 MCP OAuth 的宿主可以直接接管浏览器授权。面向只支持 stdio 或不能承接 OAuth 回调的宿主,轻量桥使用 device-code 兼容流程。由于部分宿主(例如只展示 `content.text` 的客户端)会丢弃 `structuredContent`,桥会同时在普通文本、标准 `resource_link` 和 `authChallenge.directUrl` 中返回同一个短期登录链接。桥不会调用不存在的消息通道自动推送链接;Agent 必须把 URL 原样写入当前回复,不得只声称已生成或已推送,也不得读取本地会话文件提取它。
|
||||
|
||||
开放动态客户端注册不等于静默授权。授权码和设备码流程都会先进入网关 consent 页,展示客户端自报名称、回调 origin、权限范围和设备授权码;只有用户显式确认后才生成钉钉登录跳转,回调也会再次检查 consent 状态。OAuth 客户端总量限制为 10,000,180 天未使用且没有活跃授权工件的客户端会被清理;过期登录请求、授权码和 Token 每小时清理。钉钉用户 token 刷新按用户 single-flight,避免并发消费同一个 refresh token;撤销 refresh token 会级联撤销同客户端、同用户的授权。
|
||||
|
||||
每个 MCP 会话绑定 OAuth 用户,未知或过期会话返回 404,跨用户会话返回 403。会话空闲上限 2 小时、绝对上限 24 小时,每用户最多 8 个、网关总计最多 1,000 个,周期清理后才接受新会话。Nginx 的流读取超时略长于会话绝对上限,不会先于网关任意切断健康会话。
|
||||
|
||||
## 网关职责
|
||||
|
||||
网关只承担:
|
||||
|
||||
- OAuth 登录、Token 刷新与客户端会话;
|
||||
- 钉钉 OA 待办、详情和审批 OpenAPI 调用;
|
||||
- 钉钉 OA 模板、字段能力检查与审批发起;
|
||||
- 薪福通用户映射、本人员工号查询、请求签名、待办、详情、处理与审批发起;
|
||||
- 每次写入前后的本人待办校验;
|
||||
- 签发绑定用户、来源和有效期的不透明引用;
|
||||
- 实时同步本人提单历史并维护不可逆的最小发票查重索引;
|
||||
- 记录不含审批正文的最小审计事件。
|
||||
|
||||
网关不运行浏览器,不保存审批正文、费用明细或附件。SQLite 仅保存 OAuth 客户端、哈希 Token、必要的钉钉刷新凭证、最小审计数据,以及上述不可逆发票/用户 HMAC 和最小业务状态元数据。
|
||||
|
||||
## 越权防护
|
||||
|
||||
工具没有 `userId` 或薪福通员工号参数。待办详情和写操作只接受待办列表签发的短期 `approvalId`,并在执行前重新查询上游实时待办;历史详情只接受历史列表签发的只读引用,并重新验证本人已处理或本人发起关系。修改本地 Skill 或桥只能改变请求内容,不能伪造服务器认证身份、把历史引用升级为可写引用或绕过归属校验。
|
||||
|
||||
发起审批使用绑定当前用户、模板指纹、完整表单哈希、发票重复决定和并发预留的短期一次性 `confirmationId`。正式提交会重新读取模板,并拒绝内容变化、过期确认、重复提交、失效的发票预留以及 Agent 传入的身份、员工号或审批状态。钉钉的申请人和部门控件由网关通过当前登录用户的通讯录档案填写,显式部门必须属于本人;薪福通成员映射、企业成员档案和员工号任一不一致或缺失时都会安全拒绝。
|
||||
|
||||
钉钉提单成功后,网关签发绑定当前用户和新实例的短期 `revokeId`。撤销前重新读取实例,只有发起人仍是当前登录用户且实例仍在运行中才调用官方 terminate 接口;写后再次读取状态。已接受但状态尚未变化时返回 `outcome_uncertain`,禁止自动重试。
|
||||
|
||||
薪福通创建接口返回单号后,网关实时读取本人单据状态。`billSts=1(待审批)` 返回 `submitted` 并签发 `revokeId`;`billSts=2(审批通过)` 表示流程已按企业配置即时完成,返回 `completed`,不签发撤回引用,也禁止重复提交。其他与请求不一致的终态返回 `upstream_state_mismatch`。撤回时网关重新完成钉钉用户到薪福通成员的唯一映射,从本人成员档案读取员工号,再调用 `itrip-gateway/v1/approve/recall`;Agent 不能提供或替换 `empId`、单据号。
|
||||
|
||||
撤回接口若返回 `XFTSYS*` 服务异常或网络级异常,网关会立即只读复核本人单据状态:已撤回则返回 `revoked`,仍为待审批则返回带 `postWriteVerified` 的 `failed`,无法明确归类才返回 `outcome_uncertain`。任何分支都不会自动重试写操作。
|
||||
|
||||
截至 `1.1.0-rc.8`,薪福通撤回仍是实验能力:当前企业真实调用 `approve/recall` 会返回 `XFTSYS9999`,写后复核确认单据仍为待审批,尚无成功样本。该限制不影响薪福通查询、审批处理或发起审批;正式版不得把“接口已接通”表述为“撤回已验证成功”。
|
||||
|
||||
薪福通创建或撤回需要复核时,`approval_get_submission_status` 先用服务器派生的本人员工号和单据详情双重确认目标属于当前用户的已发起单据,再返回状态名称和精简流程历史。只有实时状态仍为 `1(待审批)` 才签发新的短期、用户绑定 `revokeId`;已通过、草稿、拒绝或已撤回单据都不会获得撤回引用。该工具不返回审批正文,也不触发写操作。
|
||||
|
||||
网关只签发薪福通已支持模板,以及 6 个钉钉模板的提单引用:`出差申请单_北极大`、`外出_北极大`、`业务招待申请_北极大`、`差旅费用报销_捷康`、`业务招待费用报销_捷康`、`业务招待申请_捷康`。分类和模板名必须同时匹配。获取 Schema、准备和正式提交都会重新校验范围,因此修改本地 Skill、请求同分类的其他模板或复用旧引用都不能越过白名单。
|
||||
|
||||
## 能力边界
|
||||
|
||||
普通钉钉 OA 的读取、同意和拒绝通过官方 OpenAPI 完成。要求手写签名或其他客户端交互的模板不会伪造操作,而是返回钉钉客户端链接。退回能力仅在上游能返回可靠目标节点时开放,否则明确标记为不可用。
|
||||
|
||||
钉钉企业 OpenAPI 与 DWS 的个人接口不是同一能力面,但两者都支持无副作用的流程预测。网关在准备提单时调用企业接口 `POST /v1.0/workflow/processes/forecast`,发起人身份仍由服务器从已验证 OAuth 用户派生;只有预测成功并补齐全部自选处理人后才签发提交确认。预测请求不会创建审批实例,也不会持久化表单正文。包含 `DDBizSuite` 的考勤/费控模板不会把套件子字段伪装成普通字段提交。`业务招待申请_北极大`虽由普通字段和明细表组成,但真实测试中新版企业创建接口返回系统异常、旧版企业接口拒绝组件格式。当前 6 个白名单钉钉模板因此全部标记为 `client_only`,必须在客户端发起。
|
||||
|
||||
薪福通 Schema 会组合正式创建接口字段与企业实时自定义字段。准备阶段递归校验报销金额、费用明细、表格自定义字段、企业必填字段、当前用户成员映射与员工号。人员字段使用花名册编号数组,部门字段使用组织编码数组,表格字段使用带 `sequence`、`columnValues` 和 `titleCode` 的 JSON 行结构。薪福通没有无副作用的流程预测接口,因此所有开放的创建接口都在写后实时核验结果;申请类流程若按企业配置即时完成,会明确返回 `completed`,而不是误报异常。
|
||||
@@ -0,0 +1,150 @@
|
||||
# 部署与分发
|
||||
|
||||
## 服务器形态
|
||||
|
||||
统一网关是一个小型 Node.js 服务,监听 `127.0.0.1:3010`,由 Nginx 提供 HTTPS。它只调用钉钉与薪福通 API,不运行浏览器,通常 1 核 CPU、512 MB–1 GB 内存即可。
|
||||
|
||||
需要:
|
||||
|
||||
- Node.js 22.13+;
|
||||
- Nginx、Certbot 和固定域名;
|
||||
- 一个专用钉钉企业内部应用;
|
||||
- 薪福通正式 OpenAPI 应用。
|
||||
|
||||
钉钉应用 OAuth 回调:
|
||||
|
||||
```text
|
||||
https://approval.justconn.top/oauth/dingtalk/callback
|
||||
```
|
||||
|
||||
钉钉应用需要用户登录/身份、通讯录个人信息读取(用于实时取得当前用户所属部门)、OA 审批待办与已处理工作记录、本人发起实例 ID、实例详情、模板 Schema、审批处理和发起实例所需权限。
|
||||
|
||||
薪福通除已有的用户映射、本人待办、单据详情、审批历史和 pass/reject/back 权限外,发起审批还需要一次性开通:
|
||||
|
||||
- `xft-member/openapi/xft-member/member/get/by-id`(查询企业成员详情,用于由当前登录身份安全取得本人花名册员工号);
|
||||
- `itrip-gateway/v1/form/query-event-list`;
|
||||
- `itrip-gateway/v1/form/customFields/eventSeqId/{eventSeqId}`;
|
||||
- `itrip-gateway/v1/approve/recall`(单据撤回);
|
||||
- `itrip-gateway/v1/bills/apply/travel/create`;
|
||||
- `itrip-gateway/v1/bills/apply/expense/create`;
|
||||
- `itrip-gateway/v1/bills/apply/entertainment/create`;
|
||||
- `itrip-gateway/v1/bills/apply/common/create`;
|
||||
- `itrip-gateway/v1/bills/apply/use-car/create`;
|
||||
- `itrip-gateway/v1/reimburse/bill/create`;
|
||||
- `itrip-gateway/v1/reimburse/bill/travel/create`。
|
||||
|
||||
统一历史查询还会使用已开通的 `itrip-gateway/v1/bills/queryBillByCondition`(本人发起单据)和 `itrip-gateway/v1/bills/bill-list`(精简列表信息)。两者必须保留权限校验;`empId` 只能由网关从当前登录用户的成员档案派生。
|
||||
|
||||
创建接口必须保持权限校验开启。网关统一提交 `APPRV_PEND`,并在写后实时读取本人单据状态:进入待审批时提供撤回引用;按企业流程规则即时通过时返回 `completed`,不提供撤回引用且禁止重复提交。
|
||||
|
||||
`1.1.0-rc.8` 中薪福通撤回仍标记为实验能力:权限和调用契约已接通,但当前企业真实请求返回 `XFTSYS9999`,尚未取得成功样本。网关会写后复核并禁止自动重试。面向试用用户时必须保留这一说明;若业务要求撤回必须稳定可用,应等待薪福通定位后再升级为正式能力。
|
||||
|
||||
## 首次凭证配置
|
||||
|
||||
把钉钉 AppKey/AppSecret 保存为权限 `0600` 的 `.secrets/dingtalk.json`,把薪福通 AuthoritySecret 保存为权限 `0600` 的密钥文件。然后运行一次:
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
node dist/src/admin.js service configure \
|
||||
--public-base-url https://approval.justconn.top \
|
||||
--corp-id YOUR_CORP_ID \
|
||||
--credentials-file .secrets/dingtalk.json \
|
||||
--xft-open-api-app-id YOUR_XFT_APP_ID \
|
||||
--xft-open-api-secret-file .secrets/xft-open-api.secret \
|
||||
--xft-open-api-enterprise-id YOUR_XFT_ENTERPRISE_ID \
|
||||
--port 3010
|
||||
```
|
||||
|
||||
不要把这些企业凭证发给终端用户。
|
||||
|
||||
`MCP_SESSION_SECRET_FILE` 与 `MCP_APPROVAL_REF_SECRET_FILE` 由管理命令生成,内容至少 32 字节且权限必须为 `0600`(或更严格)。手工配置环境变量时同样强制至少 32 字节;缺失时网关拒绝启动,不再临时生成会导致重启后引用失效的随机密钥。`MCP_PUBLIC_BASE_URL` 必须是无内嵌凭证、无额外路径/query/hash 的 HTTPS origin,端口范围为 1–65535。
|
||||
|
||||
## 一键安装和更新
|
||||
|
||||
首次发布还需要生成一对 Ed25519 发布密钥:
|
||||
|
||||
```bash
|
||||
npm run release:keygen
|
||||
```
|
||||
|
||||
私钥保存在 `.secrets/release-signing-private.pem`(`0600`),必须离线备份且禁止提交、复制到 Web 目录或提供给网关进程;公钥位于 `distribution/release-signing-public.pem` 并随客户端发布。密钥已经存在时脚本会拒绝覆盖,轮换必须另行设计交叉签名迁移,不能直接替换。
|
||||
|
||||
首次配置完成后,每次安装、升级或 Bug 修复统一执行:
|
||||
|
||||
```bash
|
||||
cd /path/to/dingMCP
|
||||
./deploy/install.sh
|
||||
```
|
||||
|
||||
脚本自动测试、构建、用 Ed25519 私钥签署 schema v3 清单、重启服务、原子发布静态文件、安装 Nginx 配置并验证公网健康检查、OAuth 元数据和安装链接。首次签证书时,临时 HTTP 配置只开放 ACME challenge,其余请求返回 403;不会在明文 HTTP 上代理 OAuth、Token 或 MCP。证书通过 Certbot webroot 模式签发,正式配置启用 HTTPS 跳转与不带 `includeSubDomains` 的 HSTS。已有证书会自动复用;签名私钥缺失、权限不是 `0600` 或与固定公钥不匹配时发行会失败。
|
||||
|
||||
发行物先复制到独立 staging 目录,再通过符号链接原子切换;客户端不会看到新清单配旧 artifact 的中间状态。服务器保留当前版本和最近两个旧版本用于排障,较老版本自动清理。`install.md`、清单、签名、安装器、Skill、校验文件和测试包均以 `no-cache` 提供;内容寻址的客户端 tgz 才使用 immutable 缓存。签名清单的 `createdAt` 超过 180 天时客户端拒绝安装,避免旧签名版本冻结攻击。
|
||||
|
||||
卸载:
|
||||
|
||||
```bash
|
||||
./deploy/uninstall.sh
|
||||
```
|
||||
|
||||
这是服务器卸载命令,不是终端用户插件卸载。它默认保留凭证、OAuth 数据库和 Let's Encrypt 证书,重新安装无需重新录入企业密钥。自动化环境可使用 `./deploy/uninstall.sh --yes`。
|
||||
|
||||
## 用户分发
|
||||
|
||||
部署脚本会生成:
|
||||
|
||||
```text
|
||||
release/
|
||||
├── install.md
|
||||
├── manifest.json
|
||||
├── manifest.sig
|
||||
├── SHA256SUMS
|
||||
├── dingtalk-approval-installer.mjs
|
||||
├── release-signing-public.pem
|
||||
├── dingtalk-approval-client-*.tgz
|
||||
├── dingtalk-approval-SKILL.md
|
||||
├── dingmcp-test-bundle.tar.gz
|
||||
└── agent-test-install.md
|
||||
```
|
||||
|
||||
终端用户只需要把下面链接发给自己的 Agent:
|
||||
|
||||
```text
|
||||
https://approval.justconn.top/downloads/dingmcp/install.md
|
||||
```
|
||||
|
||||
Agent 只需校验安装器和公钥 SHA-256,再调用原生安装器。安装器验证 `manifest.sig` 和全部 artifact 哈希,将内置运行依赖的客户端以 npm 离线模式安装到稳定用户目录并返回标准 MCP 配置;终端安装过程不会从 npm registry 下载额外代码。Bridge 的稳定绝对路径不会包含 NVM 的具体 Node 版本,配置也不再携带 `--url`;网关地址由受签名保护的安装状态提供。
|
||||
|
||||
安装器对每个下载设置 30 秒超时,并在流式读取时执行 25 MB 硬上限,不会先把无界响应完整缓冲到内存。Windows 必须能定位由当前 Node 安全启动的 `npm-cli.js`;不会退回到受 Node 安全加固影响的 `npm.cmd` 隐式执行。
|
||||
|
||||
安装器不会识别 Agent 品牌或直接修改其配置。所有 Agent 都使用返回的 `skillPath` 和标准 `mcpConfig`,按自身官方方式安装 Skill、注册并信任名为 `dingtalk-approval` 的本地 stdio MCP;不能热加载连接器时再新建会话或重启。
|
||||
|
||||
安装器在返回前通过已安装 Bridge 主动准备登录,并在 JSON 的 `postInstall` 中返回 `authenticated`、`authorization_required` 或 `unavailable`。Agent 必须立即显示其中的短期 `directUrl`,不得等待首次审批请求;网页授权后调用 `approval_login`,再按 `verifyTool` 用 `source=all` 做双来源只读验证。首次认证成功会返回一次性 onboarding 示例,普通文本宿主也可见。登录准备失败不回滚安装,且整个流程不提交 consent、不读取审批、不执行写操作。
|
||||
|
||||
安装后可用稳定 CLI 自检或提取配置:`print-path bridge` 默认只输出路径,`print-config --format mcp-json` 输出可直接消费的标准 MCP JSON,`doctor --json` 同时检查 Node、npm、运行时、Skill、公钥和网关配置。后续更新直接执行安装结果中 `cliPath` 对应的 `update --json`,它会复用已固定的签名清单地址和本地公钥,并保留 OAuth 会话。更新完成后应重新连接该 MCP;Agent 不能单独重启连接器时,再新建会话或重启。
|
||||
|
||||
客户端卸载使用安装结果返回的 `cliPath uninstall --yes --json`,默认删除 OAuth 会话,并安全清理由旧版手工删除留下的四个 npm 入口;只会删除名称精确匹配且目标属于 `dingtalk-approval-mcp` 的链接或 Windows shim。
|
||||
|
||||
## 运维检查
|
||||
|
||||
```bash
|
||||
systemctl --user status dingtalk-approval-mcp.service
|
||||
curl --fail http://127.0.0.1:3010/healthz
|
||||
curl --fail https://approval.justconn.top/healthz
|
||||
curl --fail https://approval.justconn.top/downloads/dingmcp/manifest.json
|
||||
curl --fail https://approval.justconn.top/downloads/dingmcp/manifest.sig
|
||||
```
|
||||
|
||||
备份时必须把 `.runtime/auth.sqlite`、同路径派生的 `.runtime/auth.sqlite.invoice-dedup.sqlite` 与 `.secrets/oauth-session.key` 一并迁移。发票查重库只含不可逆 HMAC 和最小状态元数据;审批正文不落库,不需要备份。
|
||||
|
||||
每个候选版和正式版发布前执行 [发布检查清单](release-checklist.md)。
|
||||
|
||||
只读 E2E 脚本没有生产默认地址,也不会自动挑选第一张审批:
|
||||
|
||||
```bash
|
||||
DINGTALK_APPROVAL_MCP_URL=https://staging.example.com/mcp \
|
||||
node scripts/e2e-approval-mcp.mjs dingtalk
|
||||
```
|
||||
|
||||
读取指定单据时加 `--business-no EXACT`。只有已取得真实写入授权时,才可再加 `--execute-real-write`;脚本要求精确业务编号,并在写后重新列待办复核。任何 `outcome_uncertain` 均不得重试。
|
||||
@@ -0,0 +1,25 @@
|
||||
# 发布检查清单
|
||||
|
||||
## 每个候选版必须完成
|
||||
|
||||
- 当前提交位于 `main`,工作区除明确忽略的本地缓存外干净;版本号、插件版本和 Git 标签一致。
|
||||
- `npm ci`、`npm test`、`npm audit --omit=dev --audit-level=high` 全部通过。
|
||||
- `release:build`、`test-bundle:build` 和 `release:verify` 成功,`SHA256SUMS` 校验通过,发行物不包含 `.secrets`、`.runtime`、数据库、私钥或机器私有路径。
|
||||
- Nginx bootstrap 只开放 ACME challenge;正式站点启用 HSTS,mutable 发行文件返回 `no-cache`,客户端 tgz 才返回 immutable。连续两次部署时公开目录始终对应一个完整签名清单,旧版本保留数符合策略。
|
||||
- 从新生成的公开 `install.md` 在干净的用户级目录安装,不使用 `sudo`;安装结果主动返回 `postInstall`,可发现全部 13 个 MCP 工具。
|
||||
- 首次安装不等待业务查询即产生一条可传递链接;挑战阶段 onboarding 仍为 pending,授权后的首次成功响应在普通文本中展示一次示例,此后不重复。
|
||||
- 授权后钉钉 OA 与薪福通均为 `ok`,网关重启后登录状态仍可恢复;登录准备暂时失败不会回滚安装或删除原 OAuth 会话。
|
||||
- 只读验证待办、本人已处理历史、本人发起历史、历史默认最近 30 天及显式时间窗口、搜索、详情、费用结构化字段、动作、模板 Schema、部门/城市选项和越权拒绝;历史引用必须被动作查询和批处理拒绝。
|
||||
- 发票查重验证钉钉/XFT 跨来源精确匹配、弱匹配、用户隔离、状态更新、来源不可用、历史截断、详情失败、并发确认、35 天过期清理和网关重启恢复;数据库不得出现用户 ID、发票原号、审批正文或附件。重复风险和不完整覆盖未获用户确认时不得签发可提交 `confirmationId`。
|
||||
- 真实写操作只执行用户明确授权的样本;任何 `outcome_uncertain` 都停止,不自动重试。
|
||||
- Mock 契约确认网关和 bridge 的批量 Schema 均固定为 20;第 6 个截断引用及任一无效退回目标都会返回 `preflight_failed`、`writesStarted=false`,上游写调用数为 0。
|
||||
- 代表性 v4 引用长度满足钉钉 OA 不超过 128 字符、薪福通不超过 80 字符,并通过篡改、过期、跨用户、跨密钥、同密钥重启及 v2/v3 兼容测试。
|
||||
- 公网 `manifest.json` 的版本、生成时间和 SHA-256 与当前候选版一致。
|
||||
- E2E 脚本在未设置网关地址、未指定业务编号或未加真实写入开关时不会执行审批;只读运行不会自动选取待办。
|
||||
|
||||
## 正式版额外条件
|
||||
|
||||
- 钉钉和薪福通的同意至少各有一个近期真实成功样本,并完成写后复核。
|
||||
- 对外开放的拒绝、退回和发起模板具备对应的真实成功样本或明确的客户端/实验性降级说明。
|
||||
- 薪福通撤回取得真实成功样本;在此之前必须保留实验性标记,不得作为正式稳定能力承诺。
|
||||
- GitHub CI、外部健康检查、OAuth 数据库与密钥成对备份、升级回滚流程均已验证。
|
||||
@@ -0,0 +1,24 @@
|
||||
# 提单模板能力
|
||||
|
||||
以下结论来自 2026-08-12 通过本项目 MCP 实时读取的 10 个模板 Schema,以及对应正式接口文档。模板每次发起前仍会重新读取,本文不是可复用的静态 Schema。
|
||||
|
||||
| 来源 | 模板 | API 能力 | 关键字段或限制 |
|
||||
|---|---|---|---|
|
||||
| 薪福通 | 差旅报销单 | 支持,有约束 | 报销总额、币种、费用明细、差旅行程;费用和行程逐项递归校验,企业模板还要求承担部门 |
|
||||
| 薪福通 | 费用报销单 | 支持,有约束 | 报销总额、币种、费用明细;当前模板要求承担部门和费用说明 |
|
||||
| 薪福通 | 招待费用申请单 | 支持,有约束 | 企业模板类型为 `EXPENSE_APPLY`,使用费用申请创建接口的 `applyExpenses`(费用类型编码、金额等),而不是按模板名称误用招待申请专用 DTO;写后实时核验状态 |
|
||||
| 薪福通 | 出差费用申请单 | 支持,有约束 | 行程、城市和预计费用递归校验;实测仅有发起节点时流程即时完成并返回 `completed` |
|
||||
| 钉钉 OA | 业务招待申请_北极大 | 仅客户端 | 普通字段和明细表可读取,但真实测试中新版企业创建接口返回系统异常、旧版企业接口拒绝组件格式 |
|
||||
| 钉钉 OA | 出差申请单_北极大 | 仅客户端 | `DDBizSuite` 出差套件包含日程、天数、同行人及考勤联动,企业 OpenAPI 没有可靠构造协议 |
|
||||
| 钉钉 OA | 外出_北极大 | 仅客户端 | `DDBizSuite` 外出套件包含类型、起止时间、时长和考勤联动 |
|
||||
| 钉钉 OA | 业务招待申请_捷康 | 仅客户端 | 钉钉费控申请套件与普通 OA 明细并存,含企业主体、归属、成本中心等套件字段 |
|
||||
| 钉钉 OA | 差旅费用报销_捷康 | 仅客户端 | 钉钉费控报销套件含行程、报销明细、发票、收款账户和企业主体;另有关联出差申请和项目字段 |
|
||||
| 钉钉 OA | 业务招待费用报销_捷康 | 仅客户端 | 钉钉费控报销套件含报销明细、发票、收款账户和付款主体 |
|
||||
|
||||
薪福通自定义字段统一使用 `customFieldValues[].fieldNumber/fieldValue`。日期字段使用 `yyyy-MM-dd HH:mm`,人员字段值是花名册编号 JSON 数组,部门字段值是组织编码 JSON 数组,表格字段值是包含 `sequence`、`columnValues`、`titleCode` 的 JSON 数组字符串,附件字段值是先通过薪福通附件能力取得的附件编号。`approval_get_submission_schema` 会返回实时 `valueFormat`;Agent 不应按字段名称猜编码或结构。
|
||||
|
||||
薪福通模板要求承担部门或城市编码时,使用同一 `templateId` 调用 `approval_get_submission_options`。`kind=department` 只会返回当前登录用户本人档案中的可选组织;`kind=city` 可用 `query` 按城市名称搜索。城市全量接口较慢,网关给予独立的 45 秒超时并在进程内缓存字典 24 小时,不写入磁盘。响应仅包含可填写的 `code` 和 `label`,不会暴露员工号或原始成员档案。
|
||||
|
||||
薪福通的自定义字段接口可能同时列出 `billCompanyCode`、`billDepartmentCode` 等标准单据抬头字段,也可能用动态字段号返回名为“承担部门/单据部门”的 `ADV_DEPARTMENT` 控件,但创建接口禁止把它们放进 `customFieldValues`。MCP 会剔除这些服务器管理控件;公司和部门都省略时,薪福通根据当前登录申请人的档案默认填写。若通过标准抬头字段显式指定,则公司与部门必须成对填写。
|
||||
|
||||
任一来源模板的 `submissionCapability.status=client_only` 时,`approval_prepare_submission` 会再次拒绝,不能通过修改本地 Skill 或桥绕过。薪福通白名单模板使用正式创建接口,并在写后实时核验:`billSts=1` 为待审批,`billSts=2` 为企业流程规则导致的即时完成。当前列出的 6 个钉钉模板均为 `client_only`;对于以后可由 API 构造的钉钉模板,网关会先调用企业接口 `POST /v1.0/workflow/processes/forecast`,预测成功并补齐自选处理人后才生成提交确认。
|
||||
Generated
+1848
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "dingtalk-approval-mcp",
|
||||
"version": "1.1.0-rc.8",
|
||||
"description": "A lightweight Agent bridge for a unified DingTalk OA and XFT approval gateway.",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"dingtalk-approval-mcp": "dist/src/index.js",
|
||||
"dingtalk-mcp": "dist/src/index.js",
|
||||
"dingtalk-admin": "dist/src/admin.js",
|
||||
"dingtalk-approval-bridge": "dist/src/bridge.js"
|
||||
},
|
||||
"files": [
|
||||
"dist/src",
|
||||
"docs",
|
||||
"README.md",
|
||||
".env.example"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "node scripts/clean-dist.mjs && tsc -p tsconfig.json",
|
||||
"start": "node dist/src/index.js",
|
||||
"dev": "tsx src/index.ts",
|
||||
"import:dingcli": "node scripts/import-dingcli-credentials.mjs",
|
||||
"admin": "node dist/src/admin.js",
|
||||
"deploy": "bash deploy/install.sh",
|
||||
"deploy:uninstall": "bash deploy/uninstall.sh",
|
||||
"release:build": "node scripts/build-release.mjs",
|
||||
"release:verify": "node scripts/verify-release.mjs",
|
||||
"release:keygen": "node scripts/generate-release-key.mjs",
|
||||
"test-bundle:build": "node scripts/build-test-bundle.mjs",
|
||||
"test": "npm run build && node --test dist/test/*.test.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.13"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"express": "^5.2.1",
|
||||
"sm-crypto": "0.5.5",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^26.2.0",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash, createPublicKey, sign as signData } from "node:crypto";
|
||||
import { cpSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { basename, join, resolve, sep } from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { parseArgs } from "node:util";
|
||||
|
||||
const { values } = parseArgs({ options: {
|
||||
"gateway-url": { type: "string" },
|
||||
"download-base-url": { type: "string" },
|
||||
"signing-key": { type: "string", default: ".secrets/release-signing-private.pem" },
|
||||
out: { type: "string", default: "release" },
|
||||
} });
|
||||
const gatewayUrl = exactHttps(values["gateway-url"], "/mcp", "--gateway-url");
|
||||
const downloadBaseUrl = httpsBase(values["download-base-url"], "--download-base-url");
|
||||
const out = resolve(String(values.out));
|
||||
const projectRoot = resolve(".");
|
||||
const signingKeyPath = resolve(String(values["signing-key"]));
|
||||
const publicKeyPath = resolve("distribution/release-signing-public.pem");
|
||||
const packageMetadata = JSON.parse(readFileSync(resolve("package.json"), "utf8"));
|
||||
const packageLock = JSON.parse(readFileSync(resolve("package-lock.json"), "utf8"));
|
||||
const version = String(packageMetadata.version ?? "").trim();
|
||||
if (!version) throw new Error("package.json 缺少版本号");
|
||||
if (out === projectRoot || !out.startsWith(`${projectRoot}${sep}`)) throw new Error("--out 必须是项目目录下的专用子目录");
|
||||
assertPrivateKey(signingKeyPath);
|
||||
const privateKey = readFileSync(signingKeyPath);
|
||||
const publicKey = readFileSync(publicKeyPath);
|
||||
const derivedPublic = createPublicKey(privateKey).export({ type: "spki", format: "pem" });
|
||||
if (normalizePem(derivedPublic) !== normalizePem(publicKey)) throw new Error("发布私钥与仓库固定公钥不匹配");
|
||||
const keyId = releaseKeyId(publicKey);
|
||||
|
||||
const clientStaging = join(out, ".client-staging");
|
||||
rmSync(out, { recursive: true, force: true });
|
||||
mkdirSync(out, { recursive: true, mode: 0o755 });
|
||||
|
||||
execFileSync("npm", ["run", "build"], { stdio: "inherit", env: npmEnvironment() });
|
||||
|
||||
mkdirSync(join(clientStaging, "dist", "src"), { recursive: true });
|
||||
for (const name of ["bridge.js", "entrypoint.js", "client-installer.js", "approval-contract.js"]) {
|
||||
cpSync(resolve("dist", "src", name), join(clientStaging, "dist", "src", name));
|
||||
}
|
||||
const clientPackage = {
|
||||
name: "dingtalk-approval-client",
|
||||
version,
|
||||
description: "Lightweight user client for the protected DingTalk OA and XFT approval gateway.",
|
||||
type: "module",
|
||||
bin: {
|
||||
"dingtalk-approval": "dist/src/client-installer.js",
|
||||
"dingtalk-approval-bridge": "dist/src/bridge.js",
|
||||
},
|
||||
files: ["dist/src"],
|
||||
engines: { node: ">=22.13" },
|
||||
dependencies: {
|
||||
"@modelcontextprotocol/sdk": lockedVersion("@modelcontextprotocol/sdk"),
|
||||
zod: lockedVersion("zod"),
|
||||
},
|
||||
bundledDependencies: ["@modelcontextprotocol/sdk", "zod"],
|
||||
};
|
||||
writeFileSync(join(clientStaging, "package.json"), `${JSON.stringify(clientPackage, null, 2)}\n`, "utf8");
|
||||
execFileSync("npm", ["install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"], {
|
||||
cwd: clientStaging, stdio: "inherit", env: npmEnvironment(),
|
||||
});
|
||||
const packed = JSON.parse(execFileSync("npm", ["pack", "--json", "--pack-destination", out], {
|
||||
cwd: clientStaging, encoding: "utf8", env: npmEnvironment(),
|
||||
}));
|
||||
const clientName = packed?.[0]?.filename;
|
||||
if (typeof clientName !== "string") throw new Error("npm pack 没有返回客户端包文件名");
|
||||
|
||||
const installerName = "dingtalk-approval-installer.mjs";
|
||||
const publicKeyName = "release-signing-public.pem";
|
||||
const skillName = "dingtalk-approval-SKILL.md";
|
||||
cpSync(resolve("dist/src/client-installer.js"), join(out, installerName));
|
||||
cpSync(publicKeyPath, join(out, publicKeyName));
|
||||
cpSync(resolve("skills/dingtalk-approval/SKILL.md"), join(out, skillName));
|
||||
rmSync(clientStaging, { recursive: true, force: true });
|
||||
|
||||
const artifacts = {
|
||||
installer: artifact(installerName),
|
||||
publicKey: { ...artifact(publicKeyName), keyId },
|
||||
client: artifact(clientName),
|
||||
skill: artifact(skillName),
|
||||
};
|
||||
const manifest = {
|
||||
schemaVersion: 3,
|
||||
version,
|
||||
createdAt: new Date().toISOString(),
|
||||
gatewayUrl,
|
||||
signing: { algorithm: "Ed25519", keyId },
|
||||
requirements: { node: ">=22.13.0" },
|
||||
artifacts,
|
||||
};
|
||||
const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
|
||||
writeFileSync(join(out, "manifest.json"), manifestBytes);
|
||||
const signature = signData(null, manifestBytes, privateKey).toString("base64");
|
||||
writeFileSync(join(out, "manifest.sig"), `${JSON.stringify({
|
||||
schemaVersion: 1, algorithm: "Ed25519", keyId, signature,
|
||||
}, null, 2)}\n`, "utf8");
|
||||
|
||||
const template = readFileSync(resolve("distribution/agent-install.template.md"), "utf8");
|
||||
const replacements = new Map([
|
||||
["{{VERSION}}", version], ["{{MANIFEST_URL}}", `${downloadBaseUrl}/manifest.json`],
|
||||
["{{MANIFEST_SIGNATURE_URL}}", `${downloadBaseUrl}/manifest.sig`], ["{{GATEWAY_URL}}", gatewayUrl],
|
||||
["{{INSTALLER_URL}}", artifacts.installer.url], ["{{INSTALLER_SHA256}}", artifacts.installer.sha256],
|
||||
["{{PUBLIC_KEY_URL}}", artifacts.publicKey.url], ["{{PUBLIC_KEY_SHA256}}", artifacts.publicKey.sha256],
|
||||
["{{RELEASE_KEY_ID}}", keyId],
|
||||
]);
|
||||
let prompt = template;
|
||||
for (const [from, to] of replacements) prompt = prompt.replaceAll(from, to);
|
||||
if (/\{\{[^}]+\}\}/.test(prompt)) throw new Error("安装指令仍包含未替换占位符");
|
||||
writeFileSync(join(out, "install.md"), prompt, "utf8");
|
||||
const checksums = Object.values(artifacts).map((value) => `${value.sha256} ${basename(new URL(value.url).pathname)}`);
|
||||
checksums.push(`${sha256(join(out, "manifest.json"))} manifest.json`, `${sha256(join(out, "manifest.sig"))} manifest.sig`);
|
||||
writeFileSync(join(out, "SHA256SUMS"), `${checksums.join("\n")}\n`, "utf8");
|
||||
console.log(`发布目录:${out}`);
|
||||
console.log(`签名密钥:${keyId}`);
|
||||
console.log(`给用户的唯一链接:${downloadBaseUrl}/install.md`);
|
||||
|
||||
function artifact(name) {
|
||||
return { url: `${downloadBaseUrl}/${basename(name)}`, sha256: sha256(join(out, name)) };
|
||||
}
|
||||
|
||||
function sha256(path) { return createHash("sha256").update(readFileSync(path)).digest("hex"); }
|
||||
function releaseKeyId(value) {
|
||||
const der = createPublicKey(value).export({ type: "spki", format: "der" });
|
||||
return createHash("sha256").update(der).digest("hex");
|
||||
}
|
||||
function normalizePem(value) { return Buffer.from(value).toString("utf8").replace(/\r/g, "").trim(); }
|
||||
function npmEnvironment() { return { ...process.env, npm_config_cache: join(projectRoot, ".npm-cache") }; }
|
||||
function lockedVersion(name) {
|
||||
const value = packageLock.packages?.[`node_modules/${name}`]?.version;
|
||||
if (typeof value !== "string" || !/^\d+\.\d+\.\d+/.test(value)) throw new Error(`package-lock.json 缺少 ${name} 的固定版本`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertPrivateKey(path) {
|
||||
const stat = statSync(path);
|
||||
if (!stat.isFile()) throw new Error("发布签名私钥不是普通文件");
|
||||
if (process.platform !== "win32" && (stat.mode & 0o077) !== 0) throw new Error("发布签名私钥权限必须为 600");
|
||||
}
|
||||
|
||||
function exactHttps(value, pathname, flag) {
|
||||
const url = parseHttps(value, flag);
|
||||
if (url.pathname !== pathname || url.search || url.hash) throw new Error(`${flag} 必须是 HTTPS ${pathname} 地址`);
|
||||
return url.toString().replace(/\/$/, "");
|
||||
}
|
||||
function httpsBase(value, flag) {
|
||||
const url = parseHttps(value, flag);
|
||||
if (url.search || url.hash) throw new Error(`${flag} 不能包含 query 或 hash`);
|
||||
return url.toString().replace(/\/$/, "");
|
||||
}
|
||||
function parseHttps(value, flag) {
|
||||
if (!value) throw new Error(`缺少 ${flag}`);
|
||||
const url = new URL(String(value));
|
||||
if (url.protocol !== "https:" || url.username || url.password) throw new Error(`${flag} 必须使用无内嵌凭证的 HTTPS URL`);
|
||||
return url;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { basename, resolve } from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { parseArgs } from "node:util";
|
||||
|
||||
const { values } = parseArgs({ options: {
|
||||
"release-dir": { type: "string", default: "release-test" },
|
||||
bundle: { type: "string", default: "output/dingmcp-test-bundle.tar.gz" },
|
||||
"bundle-url": { type: "string" },
|
||||
prompt: { type: "string", default: "output/agent-test-install.md" },
|
||||
} });
|
||||
const releaseDir = resolve(String(values["release-dir"]));
|
||||
const bundle = resolve(String(values.bundle));
|
||||
const bundleUrl = exactHttps(values["bundle-url"]);
|
||||
const promptPath = resolve(String(values.prompt));
|
||||
const manifest = JSON.parse(readFileSync(`${releaseDir}/manifest.json`, "utf8"));
|
||||
if (typeof manifest.gatewayUrl !== "string" || !manifest.gatewayUrl.trim()) {
|
||||
throw new Error("完整黑盒测试包必须配置薪福通 gatewayUrl;请先部署网关,并用 release:build --gateway-url https://<域名>/mcp 重新生成发行目录");
|
||||
}
|
||||
const gateway = new URL(manifest.gatewayUrl);
|
||||
if (gateway.protocol !== "https:" || gateway.pathname !== "/mcp" || gateway.search || gateway.hash) {
|
||||
throw new Error("manifest.gatewayUrl 必须是无 query/hash 的 HTTPS /mcp 地址");
|
||||
}
|
||||
const artifactNames = [...new Set(Object.values(manifest.artifacts ?? {}).map((item) => basename(String(item.url))))];
|
||||
const members = ["SHA256SUMS", "install.md", "manifest.json", "manifest.sig", ...artifactNames];
|
||||
execFileSync("tar", ["-czf", bundle, "-C", releaseDir, ...members]);
|
||||
const digest = createHash("sha256").update(readFileSync(bundle)).digest("hex");
|
||||
let prompt = readFileSync(resolve("distribution/agent-test.template.md"), "utf8");
|
||||
prompt = prompt.replaceAll("{{BUNDLE_URL}}", bundleUrl).replaceAll("{{BUNDLE_SHA256}}", digest);
|
||||
writeFileSync(promptPath, prompt, "utf8");
|
||||
console.log(`测试包:${bundle}`);
|
||||
console.log(`SHA-256:${digest}`);
|
||||
console.log(`测试提示词:${promptPath}`);
|
||||
|
||||
function exactHttps(value) {
|
||||
if (!value) throw new Error("缺少 --bundle-url");
|
||||
const url = new URL(String(value));
|
||||
if (url.protocol !== "https:" || url.username || url.password || url.hash) throw new Error("--bundle-url 必须是无内嵌凭证的 HTTPS URL");
|
||||
return url.toString();
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { rmSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
// Build output only; source, runtime state and secrets are never touched.
|
||||
rmSync(resolve("dist"), { recursive: true, force: true });
|
||||
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env node
|
||||
import { parseArgs } from "node:util";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||
|
||||
const [mode, ...optionArgs] = process.argv.slice(2);
|
||||
if (!mode || !["dingtalk", "xft"].includes(mode)) throw new Error(usage());
|
||||
const { values } = parseArgs({ args: optionArgs, options: {
|
||||
"business-no": { type: "string" },
|
||||
"execute-real-write": { type: "boolean", default: false },
|
||||
}, strict: true });
|
||||
const endpoint = process.env.DINGTALK_APPROVAL_MCP_URL;
|
||||
if (!endpoint) throw new Error("缺少 DINGTALK_APPROVAL_MCP_URL;脚本不会默认连接任何生产网关");
|
||||
const endpointUrl = new URL(endpoint);
|
||||
if (endpointUrl.protocol !== "https:" || endpointUrl.pathname !== "/mcp") {
|
||||
throw new Error("DINGTALK_APPROVAL_MCP_URL 必须是 HTTPS /mcp 地址");
|
||||
}
|
||||
const businessNo = String(values["business-no"] ?? "").trim();
|
||||
const execute = values["execute-real-write"] === true;
|
||||
if (execute && !businessNo) throw new Error("真实写入必须同时提供 --business-no 精确指定审批");
|
||||
|
||||
const client = new Client({ name: "dingmcp-e2e", version: "1.0.0" });
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [new URL("../dist/src/bridge.js", import.meta.url).pathname, "--url", endpointUrl.toString()],
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
await client.connect(transport);
|
||||
try {
|
||||
await runWorkflow();
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
|
||||
async function runWorkflow() {
|
||||
const listed = result(await client.callTool({
|
||||
name: "approval_list_todos",
|
||||
arguments: { source: mode, query: "", limit: 200 },
|
||||
}));
|
||||
const candidates = Array.isArray(listed.items) ? listed.items : [];
|
||||
if (!businessNo) {
|
||||
console.log(JSON.stringify({
|
||||
mode, writeExecuted: false, total: candidates.length,
|
||||
items: candidates.map(summaryItem),
|
||||
next: "如需验证单张详情,重新运行并传 --business-no;除非明确获批,不要传 --execute-real-write。",
|
||||
}, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = candidates.find((item) => item?.businessNo === businessNo);
|
||||
if (!selected) throw new Error(`${mode} 实时待办中不存在 businessNo=${businessNo}`);
|
||||
const approvalId = selected.approvalId;
|
||||
const detail = result(await client.callTool({
|
||||
name: "approval_get_detail", arguments: { approvalId, view: mode === "xft" ? "expense" : "summary" },
|
||||
}));
|
||||
const actions = result(await client.callTool({ name: "approval_get_actions", arguments: { approvalId } }));
|
||||
const approve = Array.isArray(actions.actions) && actions.actions.find((action) => action?.action === "approve");
|
||||
if (!execute) {
|
||||
console.log(JSON.stringify({
|
||||
mode, writeExecuted: false, selected: summaryItem(selected),
|
||||
detail: summarizeDetail(detail), actions: actions.actions,
|
||||
}, null, 2));
|
||||
return;
|
||||
}
|
||||
if (!approve?.allowed) throw new Error(`${mode} 指定待办实时动作不允许 approve`);
|
||||
if (!Array.isArray(selected.availableActions) || !selected.availableActions.includes("approve") || selected.manualAction) {
|
||||
throw new Error(`${mode} 指定待办不能通过 MCP 自动同意`);
|
||||
}
|
||||
const processed = result(await client.callTool({
|
||||
name: "approval_process_batch",
|
||||
arguments: { action: "approve", remark: "同意", items: [{ approvalId }] },
|
||||
}));
|
||||
const verified = result(await client.callTool({
|
||||
name: "approval_list_todos", arguments: { source: mode, query: "", limit: 200 },
|
||||
}));
|
||||
const stillPresent = Array.isArray(verified.items) && verified.items.some((item) => item?.businessNo === businessNo);
|
||||
console.log(JSON.stringify({
|
||||
mode, writeExecuted: true, selected: summaryItem(selected), detail: summarizeDetail(detail),
|
||||
actions: actions.actions, processed,
|
||||
verification: { stillPresent, remaining: Array.isArray(verified.items) ? verified.items.length : undefined },
|
||||
}, null, 2));
|
||||
if (processed.succeeded !== 1 || stillPresent) process.exitCode = 2;
|
||||
}
|
||||
|
||||
function usage() {
|
||||
return "usage: DINGTALK_APPROVAL_MCP_URL=https://host/mcp e2e-approval-mcp.mjs dingtalk|xft [--business-no EXACT] [--execute-real-write]";
|
||||
}
|
||||
|
||||
function result(call) {
|
||||
const value = call?.structuredContent?.result;
|
||||
if (!value || typeof value !== "object") throw new Error("MCP 工具没有返回 structuredContent.result");
|
||||
return value;
|
||||
}
|
||||
|
||||
function summaryItem(item) {
|
||||
return {
|
||||
businessNo: item?.businessNo, title: item?.title, applicant: item?.applicant,
|
||||
amount: item?.amount, submittedAt: item?.submittedAt, availableActions: item?.availableActions,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeDetail(value) {
|
||||
const detail = value?.detail && typeof value.detail === "object" ? value.detail : {};
|
||||
return {
|
||||
source: value.source, sourceType: value.sourceType, view: value.view,
|
||||
applicant: detail.applicant, amount: detail.amount, submittedAt: detail.submittedAt,
|
||||
summary: detail.summary,
|
||||
fields: Array.isArray(detail.formFields) ? detail.formFields.slice(0, 12) : undefined,
|
||||
expenseItems: Array.isArray(detail.expenseItems) ? detail.expenseItems.slice(0, 5) : undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env node
|
||||
import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { createHash, createPublicKey, generateKeyPairSync } from "node:crypto";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { parseArgs } from "node:util";
|
||||
|
||||
const { values } = parseArgs({ options: {
|
||||
"private-key": { type: "string", default: ".secrets/release-signing-private.pem" },
|
||||
"public-key": { type: "string", default: "distribution/release-signing-public.pem" },
|
||||
} });
|
||||
const privatePath = resolve(String(values["private-key"]));
|
||||
const publicPath = resolve(String(values["public-key"]));
|
||||
if (existsSync(privatePath) || existsSync(publicPath)) throw new Error("发布签名密钥已存在;拒绝覆盖或静默轮换");
|
||||
const pair = generateKeyPairSync("ed25519");
|
||||
const privatePem = pair.privateKey.export({ type: "pkcs8", format: "pem" });
|
||||
const publicPem = pair.publicKey.export({ type: "spki", format: "pem" });
|
||||
mkdirSync(dirname(privatePath), { recursive: true, mode: 0o700 });
|
||||
mkdirSync(dirname(publicPath), { recursive: true, mode: 0o755 });
|
||||
writeFileSync(privatePath, privatePem, { mode: 0o600 });
|
||||
writeFileSync(publicPath, publicPem, { mode: 0o644 });
|
||||
if (process.platform !== "win32") chmodSync(privatePath, 0o600);
|
||||
const keyId = createHash("sha256").update(createPublicKey(publicPem).export({ type: "spki", format: "der" })).digest("hex");
|
||||
console.log(`发布签名密钥已生成;keyId=${keyId}`);
|
||||
console.log(`私钥(禁止提交或部署到下载目录):${privatePath}`);
|
||||
console.log(`公钥(应提交 Git):${publicPath}`);
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, openSync, closeSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
const host = process.argv[2];
|
||||
const remotePath = process.argv[3] ?? "/home/pi/.config/ding-approval-tui/config.yaml";
|
||||
const destination = resolve(process.env.DINGTALK_CREDENTIALS_FILE ?? ".secrets/dingtalk.json");
|
||||
|
||||
if (!host || host.startsWith("-") || !/^[a-zA-Z0-9_.@:-]+$/.test(host)) {
|
||||
throw new Error("用法:npm run import:dingcli -- 用户@主机 [远程配置路径]");
|
||||
}
|
||||
if (!remotePath.startsWith("/") || /[\r\n\0]/.test(remotePath)) {
|
||||
throw new Error("远程配置路径必须是绝对路径");
|
||||
}
|
||||
|
||||
const yaml = execFileSync("ssh", ["-o", "BatchMode=yes", host, "cat", remotePath], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "inherit"],
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
|
||||
const appKey = yamlValue(yaml, "app_key");
|
||||
const appSecret = yamlValue(yaml, "app_secret");
|
||||
if (!appKey || !appSecret) throw new Error("远端 dingCLI 配置缺少 app_key 或 app_secret");
|
||||
|
||||
mkdirSync(dirname(destination), { recursive: true, mode: 0o700 });
|
||||
const fd = openSync(destination, "wx", 0o600);
|
||||
try {
|
||||
writeFileSync(fd, `${JSON.stringify({ clientId: appKey, clientSecret: appSecret }, null, 2)}\n`);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
console.error(`钉钉凭证已安全导入 ${destination}(未复制其他配置)`);
|
||||
|
||||
function yamlValue(source, key) {
|
||||
const match = source.match(new RegExp(`^\\s*${key}:\\s*(.+?)\\s*$`, "m"));
|
||||
if (!match) return undefined;
|
||||
const value = match[1].trim();
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
return value.replace(/\s+#.*$/, "").trim();
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash, createPublicKey, verify as verifySignature } from "node:crypto";
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, join, resolve, sep } from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { parseArgs } from "node:util";
|
||||
|
||||
const { values } = parseArgs({ options: { "release-dir": { type: "string", default: "release" } } });
|
||||
const releaseDir = resolve(String(values["release-dir"]));
|
||||
const manifestBytes = readFileSync(join(releaseDir, "manifest.json"));
|
||||
const manifest = JSON.parse(manifestBytes.toString("utf8"));
|
||||
const signature = JSON.parse(readFileSync(join(releaseDir, "manifest.sig"), "utf8"));
|
||||
const publicKey = readFileSync(resolve("distribution/release-signing-public.pem"));
|
||||
const keyId = releaseKeyId(publicKey);
|
||||
const version = String(manifest.version ?? "");
|
||||
if (manifest.schemaVersion !== 3 || !/^\d+\.\d+\.\d+(?:-rc\.\d+)?$/.test(version)) throw new Error("manifest 缺少有效 schemaVersion 3 或版本号");
|
||||
if (manifest.signing?.algorithm !== "Ed25519" || manifest.signing?.keyId !== keyId) throw new Error("manifest 发布公钥编号不匹配");
|
||||
if (signature.schemaVersion !== 1 || signature.algorithm !== "Ed25519" || signature.keyId !== keyId) throw new Error("manifest.sig 元数据不匹配");
|
||||
if (!verifySignature(null, manifestBytes, publicKey, Buffer.from(String(signature.signature), "base64"))) throw new Error("manifest Ed25519 签名无效");
|
||||
|
||||
for (const [kind, artifact] of Object.entries(manifest.artifacts ?? {})) {
|
||||
const name = artifactName(artifact, kind);
|
||||
const expected = String(artifact.sha256 ?? "");
|
||||
const actual = sha256(join(releaseDir, name));
|
||||
if (!/^[a-f0-9]{64}$/.test(expected) || actual !== expected) throw new Error(`${kind} SHA-256 不匹配`);
|
||||
}
|
||||
if (manifest.artifacts.publicKey.keyId !== keyId) throw new Error("publicKey artifact keyId 不匹配");
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), "dingmcp-release-verify-"));
|
||||
try {
|
||||
const clientName = artifactName(manifest.artifacts.client, "client");
|
||||
const archivePath = join(releaseDir, clientName);
|
||||
const members = execFileSync("tar", ["-tzf", archivePath], { encoding: "utf8" }).split(/\r?\n/).filter(Boolean);
|
||||
const verboseMembers = execFileSync("tar", ["-tvzf", archivePath], { encoding: "utf8" }).split(/\r?\n/).filter(Boolean);
|
||||
if (verboseMembers.some((line) => line.startsWith("l") || line.startsWith("h"))) {
|
||||
throw new Error(`${clientName} 含符号链接或硬链接,拒绝解压`);
|
||||
}
|
||||
for (const member of members) {
|
||||
if (member.startsWith("/") || member.split("/").includes("..")) throw new Error(`${clientName} 含不安全归档路径:${member}`);
|
||||
if (/(?:^|\/)(?:\.secrets|\.runtime|auth\.sqlite|session\.json)(?:\/|$)/.test(member)) throw new Error(`${clientName} 含禁止发布文件:${member}`);
|
||||
}
|
||||
const clientRoot = join(root, "client");
|
||||
execFileSync("mkdir", ["-p", clientRoot]);
|
||||
execFileSync("tar", ["-xzf", archivePath, "-C", clientRoot]);
|
||||
scanTree(clientRoot);
|
||||
const packageMetadata = JSON.parse(readFileSync(join(root, "client", "package", "package.json"), "utf8"));
|
||||
if (packageMetadata.name !== "dingtalk-approval-client" || packageMetadata.version !== version) throw new Error("客户端 package.json 名称或版本不一致");
|
||||
if (JSON.stringify(Object.keys(packageMetadata.bin ?? {}).sort()) !== JSON.stringify(["dingtalk-approval", "dingtalk-approval-bridge"])) {
|
||||
throw new Error("客户端只能发布安装 CLI 和 Bridge 两个 bin");
|
||||
}
|
||||
if (JSON.stringify([...(packageMetadata.bundledDependencies ?? packageMetadata.bundleDependencies ?? [])].sort()) !== JSON.stringify(["@modelcontextprotocol/sdk", "zod"])) {
|
||||
throw new Error("客户端必须内置全部签名运行依赖");
|
||||
}
|
||||
for (const dependency of ["@modelcontextprotocol/sdk", "zod"]) {
|
||||
if (!existsFile(join(root, "client", "package", "node_modules", ...dependency.split("/"), "package.json"))) {
|
||||
throw new Error(`客户端缺少内置依赖:${dependency}`);
|
||||
}
|
||||
}
|
||||
for (const target of Object.values(packageMetadata.bin ?? {})) {
|
||||
const body = readFileSync(join(root, "client", "package", String(target)), "utf8");
|
||||
if (!body.startsWith("#!/usr/bin/env node\n")) throw new Error(`客户端 CLI 缺少 Node shebang:${target}`);
|
||||
}
|
||||
const offlineRoot = join(root, "offline-install");
|
||||
execFileSync("npm", [
|
||||
"install", "--prefix", offlineRoot, "--cache", join(root, "empty-cache"), "--offline",
|
||||
"--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund", join(releaseDir, artifactName(manifest.artifacts.client, "client")),
|
||||
], { stdio: "pipe", env: { ...process.env, npm_config_update_notifier: "false" } });
|
||||
if (!existsFile(join(offlineRoot, "node_modules", "dingtalk-approval-client", "dist", "src", "bridge.js"))
|
||||
|| !existsFile(join(offlineRoot, "node_modules", "dingtalk-approval-client", "dist", "src", "approval-contract.js"))) {
|
||||
throw new Error("签名客户端无法在空缓存中离线安装");
|
||||
}
|
||||
const installer = readFileSync(join(releaseDir, artifactName(manifest.artifacts.installer, "installer")), "utf8");
|
||||
if (!installer.startsWith("#!/usr/bin/env node\n") || /from ["']\.\//.test(installer)) throw new Error("首次安装器必须是无相对依赖的独立 Node 脚本");
|
||||
const install = readFileSync(join(releaseDir, "install.md"), "utf8");
|
||||
if (install.includes("{{") || !install.includes(`版本:\`${version}\``)
|
||||
|| !install.includes("唯一通用安装入口") || !install.includes("postInstall")
|
||||
|| !install.includes("onboarding.firstRun=true") || /--host|WorkBuddy|Codex|Claude/.test(install)) {
|
||||
throw new Error("install.md 版本、占位符或通用安装引导不正确");
|
||||
}
|
||||
const skill = readFileSync(join(releaseDir, artifactName(manifest.artifacts.skill, "skill")), "utf8");
|
||||
if (!skill.startsWith("---\nname: dingtalk-approval\n")) throw new Error("通用 Skill artifact 无效");
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
console.log(`发行包签名与客户端校验通过:${version}`);
|
||||
|
||||
function artifactName(value, kind) {
|
||||
const url = value?.url;
|
||||
if (typeof url !== "string") throw new Error(`manifest 缺少 ${kind} URL`);
|
||||
return basename(new URL(url).pathname);
|
||||
}
|
||||
function sha256(path) { return createHash("sha256").update(readFileSync(path)).digest("hex"); }
|
||||
function releaseKeyId(value) {
|
||||
const der = createPublicKey(value).export({ type: "spki", format: "der" });
|
||||
return createHash("sha256").update(der).digest("hex");
|
||||
}
|
||||
function existsFile(path) { try { return statSync(path).isFile(); } catch { return false; } }
|
||||
function scanTree(rootPath) {
|
||||
for (const path of walk(rootPath)) {
|
||||
if (statSync(path).size > 2_000_000) continue;
|
||||
const body = readFileSync(path, "utf8");
|
||||
if (/(?:^|\r?\n)-----BEGIN (?:RSA |EC |ENCRYPTED )?PRIVATE KEY-----\r?\n(?:[A-Za-z0-9+/=]{20,}\r?\n)+/.test(body)) {
|
||||
throw new Error(`发行物疑似包含私钥:${path}`);
|
||||
}
|
||||
if (!path.includes(`${sep}node_modules${sep}`)
|
||||
&& (/\/(?:home|Users)\/[a-zA-Z0-9._-]+\//.test(body) || /[A-Za-z]:\\Users\\[a-zA-Z0-9._-]+\\/.test(body))) {
|
||||
throw new Error(`发行物包含机器私有绝对路径:${path}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
function* walk(rootPath) {
|
||||
for (const entry of readdirSync(rootPath, { withFileTypes: true })) {
|
||||
const path = join(rootPath, entry.name);
|
||||
if (entry.isDirectory()) yield* walk(path);
|
||||
else if (entry.isFile()) yield path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFileSync } from "node:fs";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||
import { createApprovalMcpServer } from "../dist/src/server.js";
|
||||
import { XftOpenApiClient } from "../dist/src/xft-open-api.js";
|
||||
|
||||
const [appId, secretFile, enterpriseId, dingTalkUserId] = process.argv.slice(2);
|
||||
if (!appId || !secretFile || !enterpriseId || !dingTalkUserId) {
|
||||
throw new Error("usage: xft-mcp-readonly-matrix.mjs <appId> <secretFile> <enterpriseId> <dingTalkUserId>");
|
||||
}
|
||||
const lines = readFileSync(secretFile, "utf8").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
const config = {
|
||||
appMode: "gateway",
|
||||
clientId: "readonly-mcp", clientSecret: "readonly-mcp",
|
||||
apiBaseUrl: "https://api.dingtalk.com", oapiBaseUrl: "https://oapi.dingtalk.com",
|
||||
publicBaseUrl: "https://readonly.invalid", port: 3000, requestTimeoutMs: 15_000,
|
||||
officialLookbackDays: 120, officialScanConcurrency: 2, officialClientOnlyProcessCodes: [],
|
||||
batchLimit: 20, approvalRefSecret: "readonly-matrix-reference-secret",
|
||||
xftOpenApi: {
|
||||
appId, authoritySecret: lines.length >= 2 ? lines[1] : lines[0], enterpriseId,
|
||||
apiBaseUrl: "https://api.cmbchina.com", encrypted: true, dingTalkAppCode: "DDingTalk",
|
||||
},
|
||||
};
|
||||
const xft = new XftOpenApiClient(config);
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
const server = createApprovalMcpServer(
|
||||
config,
|
||||
{ async listMyTodos() { throw new Error("DingTalk source must not be called"); } },
|
||||
xft,
|
||||
undefined,
|
||||
undefined,
|
||||
{ userId: dingTalkUserId },
|
||||
);
|
||||
const client = new Client({ name: "xft-readonly-matrix", version: "1.0.0" });
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
try {
|
||||
const listed = result(await client.callTool({ name: "approval_list_todos", arguments: { source: "xft", query: "", limit: 100 } }));
|
||||
const todos = Array.isArray(listed.items) ? listed.items : [];
|
||||
const selected = distinctBy(todos, (item) => `${item.title ?? ""}|${item.amount?.value ?? ""}`).slice(0, 8);
|
||||
const flows = [];
|
||||
for (const todo of selected) {
|
||||
try {
|
||||
const summary = result(await client.callTool({ name: "approval_get_detail", arguments: { approvalId: todo.approvalId, view: "summary" } }));
|
||||
const expense = result(await client.callTool({ name: "approval_get_detail", arguments: { approvalId: todo.approvalId, view: "expense" } }));
|
||||
const actions = result(await client.callTool({ name: "approval_get_actions", arguments: { approvalId: todo.approvalId } }));
|
||||
flows.push({
|
||||
businessNo: todo.businessNo, title: todo.title, applicant: todo.applicant, amount: todo.amount, status: "ok",
|
||||
summaryFields: Array.isArray(summary.detail?.formFields) ? summary.detail.formFields.length : 0,
|
||||
expenseItems: Array.isArray(expense.detail?.expenseItems) ? expense.detail.expenseItems.length : 0,
|
||||
actions: Array.isArray(actions.actions) ? actions.actions.map((action) => ({ action: action.action, allowed: action.allowed })) : [],
|
||||
rawOmitted: summary.diagnostics?.rawOmitted === true,
|
||||
});
|
||||
} catch (error) {
|
||||
flows.push({ businessNo: todo.businessNo, title: todo.title, status: "failed", error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
console.log(JSON.stringify({ totalTodos: todos.length, testedFlows: flows.length, sourceStatus: listed.sourceStatus, flows }, null, 2));
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
|
||||
function result(call) {
|
||||
const value = call?.structuredContent?.result;
|
||||
if (!value || typeof value !== "object") throw new Error("MCP tool did not return structuredContent.result");
|
||||
return value;
|
||||
}
|
||||
|
||||
function distinctBy(items, key) {
|
||||
const seen = new Set();
|
||||
return items.filter((item) => { const value = key(item); if (seen.has(value)) return false; seen.add(value); return true; });
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFileSync } from "node:fs";
|
||||
import { XftOpenApiClient } from "../dist/src/xft-open-api.js";
|
||||
|
||||
const [appId, secretFile, enterpriseId, dingTalkUserId, historyBillId] = process.argv.slice(2);
|
||||
if (!appId || !secretFile || !enterpriseId || !dingTalkUserId) {
|
||||
console.error("usage: node scripts/xft-open-api-readonly.mjs <appId> <secretFile> <enterpriseId> <dingTalkUserId>");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const config = {
|
||||
clientId: "readonly-smoke-test",
|
||||
clientSecret: "readonly-smoke-test",
|
||||
apiBaseUrl: "https://api.dingtalk.com",
|
||||
oapiBaseUrl: "https://oapi.dingtalk.com",
|
||||
publicBaseUrl: "https://localhost",
|
||||
port: 3000,
|
||||
requestTimeoutMs: 15_000,
|
||||
officialLookbackDays: 120,
|
||||
officialScanConcurrency: 2,
|
||||
officialClientOnlyProcessCodes: [],
|
||||
batchLimit: 20,
|
||||
xftOpenApi: {
|
||||
appId,
|
||||
authoritySecret: (() => {
|
||||
const lines = readFileSync(secretFile, "utf8").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
return lines.length >= 2 ? lines[1] : lines[0];
|
||||
})(),
|
||||
enterpriseId,
|
||||
apiBaseUrl: "https://api.cmbchina.com",
|
||||
encrypted: true,
|
||||
dingTalkAppCode: "DDingTalk",
|
||||
},
|
||||
};
|
||||
|
||||
const client = new XftOpenApiClient(config);
|
||||
if (historyBillId && historyBillId !== "--auth-only") {
|
||||
try {
|
||||
const history = await client.request(
|
||||
`/itrip/xft-api/v1/bills/taskHistory/${encodeURIComponent(historyBillId)}`,
|
||||
undefined,
|
||||
"GET",
|
||||
);
|
||||
console.log(JSON.stringify({ historyProbe: "ok", responseType: Array.isArray(history) ? "array" : typeof history }));
|
||||
} catch (error) {
|
||||
console.log(JSON.stringify({ historyProbe: "failed", message: error instanceof Error ? error.message : String(error) }));
|
||||
}
|
||||
}
|
||||
const user = { userId: dingTalkUserId };
|
||||
const auth = await client.authStatus(user);
|
||||
if (historyBillId === "--auth-only") {
|
||||
console.log(JSON.stringify({ authenticated: auth.authenticated, identity: auth.identity }, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
const todos = await client.listMyTodos(user, 200);
|
||||
const detailCandidates = ["COMMON_REIM", "TRAVEL_REIM"]
|
||||
.map((eventType) => todos.items.find((item) => item.eventType === eventType))
|
||||
.filter(Boolean);
|
||||
const details = await Promise.all(detailCandidates.map((item) => client.getTodoDetail(user, item.billId)));
|
||||
console.log(JSON.stringify({
|
||||
authenticated: auth.authenticated,
|
||||
identity: auth.identity,
|
||||
todoCount: todos.totalCount,
|
||||
sample: todos.items.slice(0, 3).map(({ billId, title, applicantName, amount, eventType }) => ({ billId, title, applicantName, amount, eventType })),
|
||||
detailProbes: details.map((detail) => ({
|
||||
billId: detail.todo.billId,
|
||||
eventType: detail.todo.eventType,
|
||||
formType: Array.isArray(detail.form) ? "array" : typeof detail.form,
|
||||
formTopLevelKeys: detail.form && typeof detail.form === "object" && !Array.isArray(detail.form)
|
||||
? Object.keys(detail.form).slice(0, 30)
|
||||
: [],
|
||||
})),
|
||||
diagnostics: todos.diagnostics,
|
||||
}, null, 2));
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: dingtalk-approval
|
||||
description: Use the protected DingTalk Approval MCP to list, inspect, summarize, approve, reject, return, or create the signed-in user's DingTalk OA and XFT approvals, including approvals the user handled or submitted. Trigger for approval todos or history, expense review, reimbursement details, explicit approval actions, or requests to start and submit a new approval.
|
||||
---
|
||||
|
||||
# DingTalk Approval
|
||||
|
||||
Use only the `dingtalk-approval` MCP tools. DingTalk OA and XFT use the same gateway-authenticated DingTalk user. Never ask for AppKey, Secret, userId, employee ID, cookies, or raw access tokens.
|
||||
|
||||
## Login and first run
|
||||
|
||||
After installation or update, handle the installer's `postInstall` result immediately instead of waiting for the user's first approval request. If it contains `authChallenge.directUrl`, copy that complete clickable URL into the current user-facing reply. After the user authorizes, call `approval_login`, then make one read-only `approval_list_todos` call with `source=all` to verify both DingTalk OA and XFT. If login preparation was temporarily unavailable, keep the installation and call `approval_login` after the MCP is loaded.
|
||||
|
||||
When a successful response contains `onboarding.firstRun=true`, show its short examples and write-safety note to the user once. Do not execute an example automatically. The same onboarding may be returned directly in `postInstall` when an existing login is reused.
|
||||
|
||||
On first use or expired authorization, call `approval_login`. The tool does not send or push the login link through another channel. Copy the clickable URL from `content.text` verbatim into the current user-facing reply; when available, `authChallenge.directUrl` and the `resource_link` contain the same URL. Never merely claim that a link was generated or pushed, never read bridge state files to recover it, and never render QR images as ASCII. One successful login covers both approval sources.
|
||||
|
||||
## Review and process
|
||||
|
||||
1. Call `approval_list_todos` first. Use its `approvalId` values for one or some items. When the user explicitly asks to process the entire returned result set and `batchPolicy.selectionAvailable=true`, prefer its `selectionId`; it is bound to that exact live set and the gateway refuses the write if the set changes.
|
||||
2. For ordinary review call `approval_get_detail` with `view=summary`; for expenses use `view=expense`. Use filtered `raw` paging only when a required field is missing.
|
||||
3. Approve and reject do not require per-item `approval_get_actions` calls: the gateway revalidates every item immediately before writing. `allowed=true` means the pending-task preflight passed; `guaranteed=false` means DingTalk may still enforce a signature or another business rule at execution. Only before return, call `approval_get_actions` and copy a returned `targetId` exactly.
|
||||
4. Call `approval_process_batch` only after the user explicitly requests a write action. One call accepts at most 20 items. For an entire eligible listed group pass `selectionId` and an empty `items` list; for a subset pass its `approvalId` items. Never combine both. If one confirmed list contains more than 20 requested items, split only those original `approvalId` values into groups of at most 20; do not relist between groups or mix newly appeared todos into the authorization. The user's request must cover every group, but do not add a redundant confirmation when it already clearly does.
|
||||
5. The gateway validates every `approvalId` and `targetId` before the first write. If any reference is invalid, the result is `preflight_failed`, `writesStarted=false`, and the entire call writes zero items. Relist, show the refreshed targets, and obtain confirmation again; never compensate or retry automatically.
|
||||
6. Report every item result from the compact tool text. `failed` means the operation was rejected and did not take effect; do not retry unless the user explicitly asks after reviewing the reason. `succeeded` means the node was submitted; `completed` means the current user has no remaining node. `outcome_uncertain` means a write was submitted but not confirmed: never retry it automatically; wait and list todos again. If follow-up is required, list todos again. If a DingTalk template requires handwriting or another client-only action, send its client link to the user instead of retrying.
|
||||
|
||||
## Review history
|
||||
|
||||
Call `approval_list_history` with `scope=handled` for approvals the current user already processed, or `scope=submitted` for approvals the current user initiated. The default window is the most recent 30 days. Use optional `startDate` and `endDate` for a specific window of at most 120 days; `YYYY-MM-DD` is interpreted in `Asia/Shanghai`, the end date is inclusive, and timezone-aware ISO 8601 timestamps are also accepted. Use `source`, `query`, and `limit` to narrow the live result. Historical `approvalId` values are user-bound, short-lived, and read-only: use them only with `approval_get_detail`. Never pass them to `approval_get_actions` or `approval_process_batch`; the gateway rejects them before any write. Historical results intentionally have no `selectionId` or available actions.
|
||||
|
||||
## Create an approval
|
||||
|
||||
Create only XFT approvals and these DingTalk templates: `出差申请单_北极大`, `外出_北极大`, `业务招待申请_北极大`, `差旅费用报销_捷康`, `业务招待费用报销_捷康`, and `业务招待申请_捷康`. Other templates remain unsupported even when they are in the same DingTalk category; never attempt to construct or reuse a hidden template reference.
|
||||
|
||||
1. Call `approval_list_submission_templates` and use only its returned `templateId`.
|
||||
2. Call `approval_get_submission_schema`. Compare every required field in `fields` and `inputContract` with information the user has already supplied. Never guess a missing value.
|
||||
If `submissionCapability.status` is `client_only`, stop API construction and tell the user to start that template in its native client. All six current DingTalk candidates are client-only: five contain unsupported business suites, while live enterprise-API tests rejected the ordinary table form in `业务招待申请_北极大`. XFT whitelist templates use their formal create APIs and are verified against live status after submission.
|
||||
For an XFT department or city code, call `approval_get_submission_options` with the same `templateId`; use only a returned `code`. Department results are restricted to the current signed-in user's member record. Never invent or request an internal code when the tool can resolve it.
|
||||
For DingTalk, do not ask for or fill applicant and top-level department controls marked `serverManaged`; keep `deptId=-1` unless the user selected a department that the gateway can verify belongs to them. The gateway derives the default department from the authenticated user's live directory record.
|
||||
3. While any required field is missing or ambiguous, ask the user for the missing information and repeat the check after each answer. Prefer one concise question at a time, grouping fields only when they naturally belong together. Do not call `approval_prepare_submission` until all required fields are complete.
|
||||
4. Explicitly show the remaining optional fields with short explanations and ask whether the user wants to add any. The user may choose to skip all optional fields; record that choice instead of silently omitting them.
|
||||
5. Call `approval_prepare_submission` with `allowDuplicateInvoices=false`. It automatically checks invoice identifiers and expense signatures against the signed-in user's submitted DingTalk and XFT history for the most recent 30 days. If it reports another missing, invalid, unsupported, or selection-required field, return to the collection loop and ask until validation succeeds. Never work around validation.
|
||||
6. If preparation returns `duplicate_confirmation_required` or `duplicate_check_unavailable`, no submit-capable `confirmationId` was created. Show the duplicate check's masked invoice evidence, matching source/business number/status, and coverage warning. Ask whether the user still wants to submit. Never infer consent from the original creation request. Only after the user explicitly accepts that exact risk, call prepare again with the same content and `allowDuplicateInvoices=true`. A rejected, revoked, or void prior bill is still a warning; a pending, approved, or unknown prior bill is blocking by default. If another active preparation reserved the same invoice, wait for or finish that preparation rather than overriding it.
|
||||
7. Show the successful returned preview, including `duplicateCheck`, and obtain explicit final confirmation to create the approval. Do not treat answers to field questions, the choice to skip optional fields, or the duplicate-risk acknowledgement as this final submission confirmation.
|
||||
8. After final confirmation, call `approval_submit` once with the same `templateId`, `values`, department, selections, `allowDuplicateInvoices`, and returned `confirmationId`. Never modify the content or retry automatically.
|
||||
|
||||
If the user explicitly asks to undo a DingTalk or XFT approval just created through this MCP, call `approval_revoke_submission` once with the returned `revokeId`. Never use revoke to reject another person's todo, never accept a raw user ID, employee ID, or bill ID, and never automatically retry `failed` or `outcome_uncertain`. XFT recall is experimental in this release candidate: the current enterprise has returned `XFTSYS9999` for a real pending bill and no successful XFT recall has been observed. State this limitation when relevant and never report a recall as successful unless the tool returns `revoked` after live reconciliation.
|
||||
|
||||
For an XFT submission or recall result that needs reconciliation, call `approval_get_submission_status` with the `businessId` returned by `approval_submit`. It is read-only and revalidates that the bill belongs to the signed-in user's initiated bills. Report `statusName` and the compact flow history. `completed` means the workflow finished immediately under the enterprise's approval rules (for example, no other effective approver); do not retry it. A fresh short-lived `revokeId` is returned only while the live bill status is pending approval; it still requires the user's explicit authorization before one recall attempt. Never treat status lookup alone as permission to repeat a write, and never retry `completed`, `upstream_state_mismatch`, or `outcome_uncertain`.
|
||||
|
||||
Never ask for or pass an applicant user ID, employee number, owner, or approval status. The gateway derives them from the authenticated user. For XFT custom fields, use the live `fieldNumber` and its declared `valueFormat`; people use roster-number arrays, departments use organization-code arrays, and tables use `sequence`/`columnValues`/`titleCode`. If a required field type is unsupported or XFT reports a missing employee profile or permission, explain the requirement and stop.
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { parseArgs } from "node:util";
|
||||
import { isDirectEntrypoint } from "./entrypoint.js";
|
||||
|
||||
export const SERVICE_NAME = "dingtalk-approval-mcp.service";
|
||||
|
||||
interface Paths {
|
||||
root: string;
|
||||
env: string;
|
||||
unit: string;
|
||||
refSecret: string;
|
||||
sessionSecret: string;
|
||||
authDatabase: string;
|
||||
}
|
||||
type Systemctl = (args: string[], tolerateFailure?: boolean) => void;
|
||||
|
||||
export function servicePaths(projectRoot: string, env: NodeJS.ProcessEnv = process.env): Paths {
|
||||
const root = resolve(projectRoot);
|
||||
const configHome = resolve(env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config"));
|
||||
return {
|
||||
root,
|
||||
env: join(root, ".runtime", "dingtalk-mcp.env"),
|
||||
unit: join(configHome, "systemd", "user", SERVICE_NAME),
|
||||
refSecret: join(root, ".secrets", "approval-ref.key"),
|
||||
sessionSecret: join(root, ".secrets", "oauth-session.key"),
|
||||
authDatabase: join(root, ".runtime", "auth.sqlite"),
|
||||
};
|
||||
}
|
||||
|
||||
export function configureService(options: {
|
||||
projectRoot: string;
|
||||
publicBaseUrl: string;
|
||||
corpId: string;
|
||||
port: number;
|
||||
credentialsFile: string;
|
||||
xftOpenApiAppId?: string;
|
||||
xftOpenApiSecretFile?: string;
|
||||
xftOpenApiEnterpriseId?: string;
|
||||
xftOpenApiBaseUrl?: string;
|
||||
xftOpenApiEncrypted?: boolean;
|
||||
dingtalkClientOnlyProcessCodes?: string;
|
||||
}): Paths {
|
||||
const paths = servicePaths(options.projectRoot);
|
||||
const baseUrl = normalizePublicBaseUrl(options.publicBaseUrl);
|
||||
if (!options.corpId.trim()) throw new Error("必须配置钉钉 CorpId");
|
||||
const credentials = projectPath(paths.root, options.credentialsFile);
|
||||
assertPrivateFile(credentials, "钉钉凭证文件");
|
||||
ensureSecret(paths.refSecret);
|
||||
ensureSecret(paths.sessionSecret);
|
||||
mkdirSync(dirname(paths.authDatabase), { recursive: true, mode: 0o700 });
|
||||
chmodSync(dirname(paths.authDatabase), 0o700);
|
||||
|
||||
const values: Array<[string, string]> = [
|
||||
["APP_MODE", "gateway"],
|
||||
["DINGTALK_CREDENTIALS_FILE", credentials],
|
||||
["DINGTALK_CORP_ID", options.corpId.trim()],
|
||||
["MCP_PUBLIC_BASE_URL", baseUrl],
|
||||
["MCP_REMOTE_AUTH_ENABLED", "true"],
|
||||
["MCP_BIND_HOST", "127.0.0.1"],
|
||||
["PORT", String(validPort(options.port))],
|
||||
["MCP_AUTH_DATABASE", paths.authDatabase],
|
||||
["MCP_SESSION_SECRET_FILE", paths.sessionSecret],
|
||||
["MCP_APPROVAL_REF_SECRET_FILE", paths.refSecret],
|
||||
];
|
||||
if (options.dingtalkClientOnlyProcessCodes?.trim()) {
|
||||
values.push(["DINGTALK_CLIENT_ONLY_PROCESS_CODES", options.dingtalkClientOnlyProcessCodes.trim()]);
|
||||
}
|
||||
|
||||
if (options.xftOpenApiAppId || options.xftOpenApiSecretFile || options.xftOpenApiEnterpriseId) {
|
||||
if (!options.xftOpenApiAppId?.trim() || !options.xftOpenApiSecretFile?.trim() || !options.xftOpenApiEnterpriseId?.trim()) {
|
||||
throw new Error("薪福通正式 API 必须同时配置 AppId、AuthoritySecret 文件和企业号");
|
||||
}
|
||||
const secretFile = projectPath(paths.root, options.xftOpenApiSecretFile);
|
||||
assertPrivateFile(secretFile, "薪福通 AuthoritySecret 文件");
|
||||
values.push(
|
||||
["XFT_OPEN_API_ENABLED", "true"],
|
||||
["XFT_OPEN_API_APP_ID", options.xftOpenApiAppId.trim()],
|
||||
["XFT_OPEN_API_AUTHORITY_SECRET_FILE", secretFile],
|
||||
["XFT_OPEN_API_ENTERPRISE_ID", options.xftOpenApiEnterpriseId.trim()],
|
||||
["XFT_OPEN_API_ENCRYPTED", String(options.xftOpenApiEncrypted ?? true)],
|
||||
);
|
||||
if (options.xftOpenApiBaseUrl?.trim()) values.push(["XFT_OPEN_API_BASE_URL", options.xftOpenApiBaseUrl.trim()]);
|
||||
} else throw new Error("薪福通安全网关必须配置正式 OpenAPI AppId、AuthoritySecret 文件和企业号");
|
||||
|
||||
values.push(["YIDA_ENABLED", "false"]);
|
||||
|
||||
mkdirSync(dirname(paths.env), { recursive: true, mode: 0o700 });
|
||||
chmodSync(dirname(paths.env), 0o700);
|
||||
const temporary = `${paths.env}.tmp-${process.pid}`;
|
||||
writeFileSync(temporary, values.map(([key, value]) => `${key}=${JSON.stringify(value)}\n`).join(""), { mode: 0o600 });
|
||||
chmodSync(temporary, 0o600);
|
||||
renameSync(temporary, paths.env);
|
||||
chmodSync(paths.env, 0o600);
|
||||
return paths;
|
||||
}
|
||||
|
||||
export function renderUnit(paths: Paths, nodePath = process.execPath): string {
|
||||
return `[Unit]\nDescription=DingTalk approval remote MCP\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nWorkingDirectory=${unitPath(paths.root)}\nEnvironmentFile=${unitPath(paths.env)}\nExecStart=${quote(nodePath)} ${quote(join(paths.root, "dist", "src", "index.js"))}\nRestart=on-failure\nRestartSec=5\nNoNewPrivileges=true\nPrivateTmp=true\nProtectSystem=strict\nReadWritePaths=${unitPath(join(paths.root, ".runtime"))}\n\n[Install]\nWantedBy=default.target\n`;
|
||||
}
|
||||
|
||||
export function startService(projectRoot: string, systemctl: Systemctl = runSystemctl): string {
|
||||
const paths = servicePaths(projectRoot);
|
||||
if (!existsSync(paths.env)) throw new Error("缺少运行配置,请先执行 service configure");
|
||||
if (!existsSync(join(paths.root, "dist", "src", "index.js"))) throw new Error("缺少构建产物,请先执行 npm run build");
|
||||
mkdirSync(dirname(paths.unit), { recursive: true });
|
||||
writeFileSync(paths.unit, renderUnit(paths), "utf8");
|
||||
systemctl(["daemon-reload"]);
|
||||
systemctl(["enable", SERVICE_NAME]);
|
||||
systemctl(["restart", SERVICE_NAME]);
|
||||
return showConfig(projectRoot);
|
||||
}
|
||||
|
||||
export function uninstallService(projectRoot: string, systemctl: Systemctl = runSystemctl): void {
|
||||
const paths = servicePaths(projectRoot);
|
||||
systemctl(["disable", "--now", SERVICE_NAME], true);
|
||||
if (existsSync(paths.unit)) rmSync(paths.unit);
|
||||
if (existsSync(paths.env)) rmSync(paths.env);
|
||||
systemctl(["daemon-reload"], true);
|
||||
systemctl(["reset-failed", SERVICE_NAME], true);
|
||||
}
|
||||
|
||||
export function showConfig(projectRoot: string): string {
|
||||
const values = readEnv(servicePaths(projectRoot).env);
|
||||
const base = normalizePublicBaseUrl(values.MCP_PUBLIC_BASE_URL ?? "");
|
||||
return `MCP URL: ${base}/mcp\nOAuth issuer: ${base}\nHealth (local): http://127.0.0.1:${values.PORT ?? "3000"}/healthz\n`;
|
||||
}
|
||||
|
||||
function ensureSecret(path: string): void {
|
||||
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
||||
chmodSync(dirname(path), 0o700);
|
||||
if (!existsSync(path)) writeFileSync(path, `${randomBytes(32).toString("base64url")}\n`, { mode: 0o600 });
|
||||
chmodSync(path, 0o600);
|
||||
}
|
||||
|
||||
function readEnv(path: string): Record<string, string> {
|
||||
if (!existsSync(path)) throw new Error(`缺少运行配置:${path}`);
|
||||
return Object.fromEntries(readFileSync(path, "utf8").split(/\r?\n/).flatMap((line) => {
|
||||
const separator = line.indexOf("=");
|
||||
if (separator <= 0) return [];
|
||||
const key = line.slice(0, separator);
|
||||
const raw = line.slice(separator + 1);
|
||||
try { return [[key, JSON.parse(raw) as string]]; } catch { return [[key, raw]]; }
|
||||
}));
|
||||
}
|
||||
|
||||
function runSystemctl(args: string[], tolerateFailure = false): void {
|
||||
try { execFileSync("systemctl", ["--user", ...args], { stdio: tolerateFailure ? "ignore" : "inherit" }); }
|
||||
catch (error) { if (!tolerateFailure) throw error; }
|
||||
}
|
||||
|
||||
function assertPrivateFile(path: string, label: string): void {
|
||||
let stat;
|
||||
try { stat = statSync(path); } catch { throw new Error(`${label}不存在:${path}`); }
|
||||
if (!stat.isFile()) throw new Error(`${label}不是普通文件:${path}`);
|
||||
if (process.platform !== "win32" && (stat.mode & 0o077) !== 0) throw new Error(`${label}权限必须为 600`);
|
||||
}
|
||||
|
||||
function normalizePublicBaseUrl(value: string): string {
|
||||
const normalized = value.trim().replace(/\/$/, "");
|
||||
const parsed = new URL(normalized);
|
||||
if (parsed.protocol !== "https:") throw new Error("公网 MCP 地址必须使用 HTTPS");
|
||||
if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) {
|
||||
throw new Error("公网 MCP 地址只能包含 https://域名[:端口]");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
function validPort(value: number): number { if (!Number.isSafeInteger(value) || value < 1 || value > 65535) throw new Error("端口无效"); return value; }
|
||||
function projectPath(root: string, value: string): string { return resolve(root, value); }
|
||||
function unitPath(value: string): string { return value.replace(/\\/g, "\\\\").replace(/ /g, "\\x20").replace(/\t/g, "\\t"); }
|
||||
function quote(value: string): string { return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; }
|
||||
function usage(): string { return "dingtalk-admin service configure|start|restart|stop|status|show-config|uninstall [options]"; }
|
||||
|
||||
export function main(argv = process.argv.slice(2)): number {
|
||||
const [group, action, ...rest] = argv;
|
||||
if (group !== "service" || !action || argv.includes("--help")) { console.log(usage()); return argv.includes("--help") || argv.length === 0 ? 0 : 1; }
|
||||
const common = { "project-root": { type: "string" as const, default: process.cwd() } };
|
||||
if (action === "configure") {
|
||||
const { values } = parseArgs({ args: rest, strict: true, options: {
|
||||
...common,
|
||||
"public-base-url": { type: "string" }, "corp-id": { type: "string" }, port: { type: "string", default: "3000" },
|
||||
"credentials-file": { type: "string", default: ".secrets/dingtalk.json" },
|
||||
"xft-open-api-app-id": { type: "string" }, "xft-open-api-secret-file": { type: "string" },
|
||||
"xft-open-api-enterprise-id": { type: "string" }, "xft-open-api-base-url": { type: "string" },
|
||||
"xft-open-api-unencrypted": { type: "boolean" }, "dingtalk-client-only-process-codes": { type: "string" },
|
||||
}});
|
||||
if (!values["public-base-url"] || !values["corp-id"]) throw new Error("缺少 --public-base-url 或 --corp-id");
|
||||
const paths = configureService({
|
||||
projectRoot: String(values["project-root"]), publicBaseUrl: String(values["public-base-url"]),
|
||||
corpId: String(values["corp-id"]), port: Number(values.port), credentialsFile: String(values["credentials-file"]),
|
||||
...(values["xft-open-api-app-id"] ? { xftOpenApiAppId: String(values["xft-open-api-app-id"]) } : {}),
|
||||
...(values["xft-open-api-secret-file"] ? { xftOpenApiSecretFile: String(values["xft-open-api-secret-file"]) } : {}),
|
||||
...(values["xft-open-api-enterprise-id"] ? { xftOpenApiEnterpriseId: String(values["xft-open-api-enterprise-id"]) } : {}),
|
||||
...(values["xft-open-api-base-url"] ? { xftOpenApiBaseUrl: String(values["xft-open-api-base-url"]) } : {}),
|
||||
xftOpenApiEncrypted: !values["xft-open-api-unencrypted"],
|
||||
...(values["dingtalk-client-only-process-codes"] ? { dingtalkClientOnlyProcessCodes: String(values["dingtalk-client-only-process-codes"]) } : {}),
|
||||
});
|
||||
console.log(`运行配置已写入 ${paths.env}(0600)`); console.log(showConfig(paths.root)); return 0;
|
||||
}
|
||||
const { values } = parseArgs({ args: rest, strict: true, options: common });
|
||||
const root = String(values["project-root"]);
|
||||
if (action === "start") console.log(startService(root));
|
||||
else if (action === "restart") { runSystemctl(["restart", SERVICE_NAME]); console.log("MCP service restarted."); }
|
||||
else if (action === "stop") { runSystemctl(["stop", SERVICE_NAME]); console.log("MCP service stopped."); }
|
||||
else if (action === "status") runSystemctl(["status", SERVICE_NAME]);
|
||||
else if (action === "show-config") console.log(showConfig(root));
|
||||
else if (action === "uninstall") { uninstallService(root); console.log("MCP service uninstalled; secrets and OAuth database were preserved."); }
|
||||
else { console.error(usage()); return 1; }
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (isDirectEntrypoint(import.meta.url, process.argv[1])) {
|
||||
try { process.exitCode = main(); } catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const APPROVAL_BATCH_LIMIT = 20;
|
||||
@@ -0,0 +1,661 @@
|
||||
import { extractApprovalListMetadata, normalizeLabel, sensitiveLabel } from "./approval-metadata.js";
|
||||
import { isRecord } from "./json.js";
|
||||
|
||||
export type ApprovalDetailView = "summary" | "expense" | "raw";
|
||||
|
||||
export interface ApprovalDetailOptions {
|
||||
view?: ApprovalDetailView;
|
||||
rawPath?: string;
|
||||
rawCursor?: number;
|
||||
rawLimit?: number;
|
||||
rawQuery?: string;
|
||||
}
|
||||
|
||||
export interface ExpenseItem {
|
||||
occurredOn: string | null;
|
||||
expenseType: string | null;
|
||||
amount: number | null;
|
||||
currency: string;
|
||||
description: string | null;
|
||||
merchant: string | null;
|
||||
location: string | null;
|
||||
customer: string | null;
|
||||
contacts: string[];
|
||||
relatedTripId: string | null;
|
||||
invoiceDates: string[];
|
||||
projectId: string | null;
|
||||
crmRecordId: string | null;
|
||||
}
|
||||
|
||||
interface LabeledValue { label: string; value: unknown }
|
||||
|
||||
const MAX_FORM_FIELDS = 120;
|
||||
const MAX_EXPENSE_ITEMS = 200;
|
||||
const MAX_FIELD_TEXT = 1_000;
|
||||
const MAX_RAW_SCALAR_TEXT = 4_000;
|
||||
|
||||
export function compactApprovalDetail(raw: unknown, view: Exclude<ApprovalDetailView, "raw">) {
|
||||
const metadata = extractApprovalListMetadata(...detailMetadataSources(raw));
|
||||
const xftRows = collectXftExpenseRows(raw);
|
||||
const fields = uniqueLabeledValues([
|
||||
...collectOfficialXftFormFields(raw),
|
||||
...collectLabeledValues(raw),
|
||||
...xftRows.flat(),
|
||||
]);
|
||||
const expenseItems = extractExpenseItemsFrom(raw, xftRows);
|
||||
const summedAmount = sumExpenseAmounts(expenseItems);
|
||||
const detail: Record<string, unknown> = {
|
||||
applicant: {
|
||||
...(metadata.applicantName ? { name: metadata.applicantName } : {}),
|
||||
...(metadata.applicantUserId ? { userId: metadata.applicantUserId } : {}),
|
||||
...(metadata.applicantDepartment ? { department: metadata.applicantDepartment } : {}),
|
||||
},
|
||||
...(metadata.submittedAt ? { submittedAt: metadata.submittedAt } : {}),
|
||||
...(metadata.submittedAt ? localTimeFields(metadata.submittedAt) : {}),
|
||||
...(metadata.amount !== undefined
|
||||
? { amount: { value: metadata.amount, currency: metadata.currency ?? "CNY" }, amountSource: "form_total" }
|
||||
: summedAmount ? { amount: summedAmount.amount, amountSource: "expense_items_sum" } : {}),
|
||||
...(metadata.summary ? { summary: metadata.summary } : {}),
|
||||
expenseItems,
|
||||
};
|
||||
detail.formFields = fields.slice(0, MAX_FORM_FIELDS).map((field) => ({
|
||||
label: field.label,
|
||||
value: field.display,
|
||||
}));
|
||||
detail.formFieldsTruncated = fields.length > MAX_FORM_FIELDS;
|
||||
if (view === "expense") {
|
||||
detail.supportingFields = fields
|
||||
.filter((field) => /(发票|开票|付款主体|支付主体|收款主体|费用承担|成本中心|项目|客户|事由|日期|地址)/.test(normalizeLabel(field.label)))
|
||||
.slice(0, 50)
|
||||
.map((field) => ({ label: field.label, value: field.display }));
|
||||
}
|
||||
return {
|
||||
detail,
|
||||
diagnostics: {
|
||||
realtime: true,
|
||||
rawAvailable: true,
|
||||
rawOmitted: true,
|
||||
rawAccess: "使用 view=raw,并通过 rawPath、rawCursor、rawLimit 分页浏览;单次不会返回完整大对象。",
|
||||
expenseItemsExtracted: expenseItems.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function localTimeFields(value: string): Record<string, string> {
|
||||
const time = Date.parse(value);
|
||||
if (!Number.isFinite(time)) return {};
|
||||
return {
|
||||
submittedAtLocal: new Date(time + 8 * 3_600_000).toISOString().replace("Z", "+08:00"),
|
||||
submittedTimeZone: "Asia/Shanghai",
|
||||
};
|
||||
}
|
||||
|
||||
export function browseRawDetail(
|
||||
raw: unknown,
|
||||
rawPath = "",
|
||||
rawCursor = 0,
|
||||
rawLimit = 20,
|
||||
rawQuery = "",
|
||||
) {
|
||||
const resolved = resolveJsonPointer(raw, rawPath);
|
||||
const limit = Math.max(1, Math.min(50, Math.floor(rawLimit)));
|
||||
const cursor = Math.max(0, Math.floor(rawCursor));
|
||||
const container = parseJsonContainer(resolved.value);
|
||||
const value = container.value;
|
||||
if (rawQuery.trim()) return searchRawDetail(value, rawPath, rawQuery, cursor, limit, resolved.encodedAsJsonString || container.parsed);
|
||||
if (Array.isArray(value)) {
|
||||
const end = Math.min(value.length, cursor + limit);
|
||||
return rawPage(rawPath, resolved.encodedAsJsonString || container.parsed, cursor, end, value.length,
|
||||
value.slice(cursor, end).map((item, index) => rawEntry(String(cursor + index), item, pointerJoin(rawPath, String(cursor + index)))));
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
const entries = visibleObjectEntries(value);
|
||||
const end = Math.min(entries.length, cursor + limit);
|
||||
return rawPage(rawPath, resolved.encodedAsJsonString || container.parsed, cursor, end, entries.length,
|
||||
entries.slice(cursor, end).map(([key, item]) => rawEntry(key, item, pointerJoin(rawPath, key))));
|
||||
}
|
||||
return {
|
||||
path: rawPath,
|
||||
kind: scalarKind(value),
|
||||
...scalarPreview(value),
|
||||
diagnostics: { realtime: true, bounded: true, filteredForAgent: true, maxScalarCharacters: MAX_RAW_SCALAR_TEXT },
|
||||
};
|
||||
}
|
||||
|
||||
function sumExpenseAmounts(items: ExpenseItem[]): { amount: { value: number; currency: string } } | undefined {
|
||||
if (items.length === 0 || items.some((item) => item.amount === null)) return undefined;
|
||||
const currencies = [...new Set(items.map((item) => item.currency))];
|
||||
if (currencies.length !== 1) return undefined;
|
||||
const cents = items.reduce((total, item) => total + Math.round(item.amount! * 100), 0);
|
||||
return { amount: { value: cents / 100, currency: currencies[0]! } };
|
||||
}
|
||||
|
||||
function searchRawDetail(
|
||||
root: unknown,
|
||||
basePath: string,
|
||||
query: string,
|
||||
cursor: number,
|
||||
limit: number,
|
||||
encodedAsJsonString: boolean,
|
||||
) {
|
||||
const needle = query.trim().toLocaleLowerCase();
|
||||
const matches: Array<Record<string, unknown>> = [];
|
||||
const seen = new WeakSet<object>();
|
||||
let visited = 0;
|
||||
let truncated = false;
|
||||
const MAX_VISITED = 20_000;
|
||||
const MAX_MATCHES = 500;
|
||||
const walk = (input: unknown, path: string, depth: number): void => {
|
||||
if (depth > 16 || visited >= MAX_VISITED || matches.length >= MAX_MATCHES) { truncated = true; return; }
|
||||
const parsed = parseJsonContainer(input);
|
||||
const value = parsed.value;
|
||||
if (value && typeof value === "object") {
|
||||
if (seen.has(value)) return;
|
||||
seen.add(value);
|
||||
}
|
||||
visited += 1;
|
||||
if (Array.isArray(value)) {
|
||||
for (let index = 0; index < value.length; index += 1) walk(value[index], pointerJoin(path, String(index)), depth + 1);
|
||||
return;
|
||||
}
|
||||
if (!isRecord(value)) return;
|
||||
if (sensitiveLabel(fieldLabel(value) ?? "")) return;
|
||||
const label = fieldLabel(value);
|
||||
const fieldValue = fieldRawValue(value);
|
||||
if (label?.toLocaleLowerCase().includes(needle)) {
|
||||
matches.push({ path, key: path.split("/").at(-1) ?? "", label, ...(fieldValue !== undefined ? { valuePreview: compactDisplayValue(fieldValue) } : {}) });
|
||||
}
|
||||
for (const [key, child] of visibleObjectEntries(value)) {
|
||||
const childPath = pointerJoin(path, key);
|
||||
if (key.toLocaleLowerCase().includes(needle) && !label) {
|
||||
matches.push({ path: childPath, key, ...scalarPreviewForSearch(child) });
|
||||
} else if ((typeof child === "string" || typeof child === "number" || typeof child === "boolean")
|
||||
&& !label && String(child).toLocaleLowerCase().includes(needle) && !/^(?:value|content|displayValue|fieldValue|extValue)$/i.test(key)) {
|
||||
matches.push({ path: childPath, key, ...scalarPreviewForSearch(child) });
|
||||
}
|
||||
if (matches.length >= MAX_MATCHES) { truncated = true; break; }
|
||||
walk(child, childPath, depth + 1);
|
||||
}
|
||||
};
|
||||
walk(root, basePath, 0);
|
||||
const end = Math.min(matches.length, cursor + limit);
|
||||
return {
|
||||
path: basePath,
|
||||
kind: "search",
|
||||
query,
|
||||
encodedAsJsonString,
|
||||
cursor,
|
||||
returned: Math.max(0, end - cursor),
|
||||
total: matches.length,
|
||||
nextCursor: end < matches.length ? end : null,
|
||||
matches: matches.slice(cursor, end),
|
||||
diagnostics: { realtime: true, bounded: true, filteredForAgent: true, visitedNodes: visited, searchTruncated: truncated, maxVisitedNodes: MAX_VISITED, maxMatches: MAX_MATCHES },
|
||||
};
|
||||
}
|
||||
|
||||
function scalarPreviewForSearch(value: unknown): Record<string, unknown> {
|
||||
const displayed = compactDisplayValue(value);
|
||||
return displayed === undefined ? {} : { valuePreview: typeof displayed === "string" ? displayed.slice(0, 500) : displayed };
|
||||
}
|
||||
|
||||
export function extractExpenseItems(raw: unknown): ExpenseItem[] {
|
||||
return extractExpenseItemsFrom(raw, collectXftExpenseRows(raw));
|
||||
}
|
||||
|
||||
function extractExpenseItemsFrom(raw: unknown, xftRows: LabeledValue[][]): ExpenseItem[] {
|
||||
const candidates: LabeledValue[][] = [...xftRows];
|
||||
const seenObjects = new WeakSet<object>();
|
||||
let visited = 0;
|
||||
const walk = (value: unknown, parentLabel = "", depth = 0): void => {
|
||||
if (depth > 16 || visited > 75_000) return;
|
||||
const parsed = parseJsonContainer(value);
|
||||
value = parsed.value;
|
||||
if (!value || typeof value !== "object") return;
|
||||
if (seenObjects.has(value)) return;
|
||||
seenObjects.add(value);
|
||||
visited += 1;
|
||||
if (Array.isArray(value)) {
|
||||
const direct = labeledArray(value);
|
||||
if (direct.length >= 2 && looksLikeExpenseRow(direct)) candidates.push(direct);
|
||||
for (const item of value) walk(item, parentLabel, depth + 1);
|
||||
return;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const label = fieldLabel(record) ?? parentLabel;
|
||||
for (const key of ["rowValue", "rowValues", "fields", "fieldValues", "values"]) {
|
||||
const row = Array.isArray(record[key]) ? labeledArray(record[key] as unknown[]) : [];
|
||||
if (row.length >= 2 && looksLikeExpenseRow(row)) candidates.push(row);
|
||||
}
|
||||
const direct = directRecordFields(record);
|
||||
if (direct.length >= 2 && looksLikeExpenseRow(direct)) candidates.push(direct);
|
||||
for (const [key, child] of Object.entries(record)) {
|
||||
if (isHeavyBinaryField(key, label)) continue;
|
||||
walk(child, label || key, depth + 1);
|
||||
}
|
||||
};
|
||||
walk(raw);
|
||||
const normalized = candidates.map(normalizeExpenseItem).filter(hasExpenseIdentity);
|
||||
const unique = new Map<string, ExpenseItem>();
|
||||
for (const item of normalized) {
|
||||
const key = JSON.stringify(item);
|
||||
if (!unique.has(key)) unique.set(key, item);
|
||||
if (unique.size >= MAX_EXPENSE_ITEMS) break;
|
||||
}
|
||||
return [...unique.values()];
|
||||
}
|
||||
|
||||
function collectXftExpenseRows(raw: unknown): LabeledValue[][] {
|
||||
const rows: LabeledValue[][] = [];
|
||||
const seen = new WeakSet<object>();
|
||||
let visited = 0;
|
||||
const walk = (value: unknown, depth = 0): void => {
|
||||
if (!value || typeof value !== "object" || depth > 12 || visited++ > 40_000 || seen.has(value)) return;
|
||||
seen.add(value);
|
||||
if (Array.isArray(value)) {
|
||||
for (const child of value) walk(child, depth + 1);
|
||||
return;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const expenseList = [record.expenseList, record.expList, record.applyExpenseDtoList].find(Array.isArray);
|
||||
if (expenseList) {
|
||||
for (const entry of expenseList) {
|
||||
const row = xftExpenseRow(entry);
|
||||
if (row.length) rows.push(row);
|
||||
}
|
||||
}
|
||||
for (const [key, child] of Object.entries(record)) {
|
||||
if (/businessConfig$/i.test(key) || isHeavyBinaryField(key, "")) continue;
|
||||
walk(child, depth + 1);
|
||||
}
|
||||
};
|
||||
walk(raw);
|
||||
return rows;
|
||||
}
|
||||
|
||||
function xftExpenseRow(value: unknown): LabeledValue[] {
|
||||
if (!isRecord(value)) return [];
|
||||
const officialFields = officialXftExpenseFields(value);
|
||||
const values = isRecord(value.values) ? value.values : {};
|
||||
const config = isRecord(value.businessConfigDTO) ? value.businessConfigDTO : undefined;
|
||||
const expenseSchema = isRecord(config?.expenseSchema) ? config.expenseSchema : undefined;
|
||||
const schemaBody = Array.isArray(expenseSchema?.body) ? expenseSchema.body : [];
|
||||
const fields: LabeledValue[] = [];
|
||||
const schemaFields: LabeledValue[] = [];
|
||||
for (const schemaValue of schemaBody) {
|
||||
if (!isRecord(schemaValue)) continue;
|
||||
const key = typeof schemaValue.key === "string" ? schemaValue.key : undefined;
|
||||
const label = typeof schemaValue.name === "string" ? schemaValue.name : undefined;
|
||||
const holder = key ? values[key] : undefined;
|
||||
const fieldValue = isRecord(holder) && "value" in holder ? holder.value : holder;
|
||||
if (key && label && fieldValue !== undefined && !heavyValue(label, fieldValue) && !sensitiveLabel(label)) {
|
||||
schemaFields.push({ label, value: fieldValue });
|
||||
}
|
||||
}
|
||||
const businessData = isRecord(value.businessDataDTO) ? value.businessDataDTO : undefined;
|
||||
const body = isRecord(businessData?.expBodyInsDto) ? businessData.expBodyInsDto : undefined;
|
||||
if (body) {
|
||||
addField(fields, "费用类型", body.expNam);
|
||||
addField(fields, "费用发生日期", body.wrTim);
|
||||
addField(fields, "实际报销金额", body.reimAmt ?? body.expenseTotalAmount);
|
||||
addField(fields, "币种", body.currencyCode ?? (isRecord(body.expenseTotalAmountDTO) ? body.expenseTotalAmountDTO.currencyCode : undefined));
|
||||
addField(fields, "费用说明", body.remark);
|
||||
const expenseJson = isRecord(body.expJsonObject) ? body.expJsonObject : undefined;
|
||||
addBusinessField(fields, "商户", expenseJson?.merchantNam);
|
||||
addBusinessField(fields, "消费地点", expenseJson?.payCity ?? expenseJson?.destination);
|
||||
addBusinessField(fields, "费用发生日期", expenseJson?.bgnTim ?? expenseJson?.wrTim);
|
||||
const certificate = isRecord(body.certificate) ? body.certificate : undefined;
|
||||
const invoices = Array.isArray(certificate?.invoiceCloudCertificates) ? certificate.invoiceCloudCertificates : [];
|
||||
const invoiceDates = invoices.flatMap((invoice) => isRecord(invoice) && invoice.invoiceDate !== undefined ? [invoice.invoiceDate] : []);
|
||||
if (invoiceDates.length) fields.push({ label: "发票日期", value: invoiceDates });
|
||||
}
|
||||
return [...officialFields, ...schemaFields, ...fields];
|
||||
}
|
||||
|
||||
function officialXftExpenseFields(row: Record<string, unknown>): LabeledValue[] {
|
||||
if (!("expTypeName" in row || "expNam" in row || "reimAmount" in row || "reimAmt" in row
|
||||
|| "applyAmount" in row || "expAttribute" in row || "invoiceInfoList" in row)) return [];
|
||||
const fields: LabeledValue[] = [];
|
||||
const invoices = Array.isArray(row.invoiceInfoList) ? row.invoiceInfoList.filter(isRecord) : [];
|
||||
const firstInvoiceDate = invoices.find((invoice) => present(invoice.invoiceDate))?.invoiceDate;
|
||||
addField(fields, "费用发生日期", row.wrTim ?? row.occurrenceDate ?? row.bgnTim ?? row.checkInDate ?? firstInvoiceDate);
|
||||
addField(fields, "费用类型", row.expTypeName ?? row.expNam);
|
||||
addField(fields, "实际报销金额", row.reimAmount ?? row.originalCurrencyReimAmt ?? row.reimAmt ?? row.applyAmount);
|
||||
addField(fields, "币种", row.currencyCode);
|
||||
addField(fields, "费用说明", row.remark);
|
||||
const sellerName = invoices.find((invoice) => present(invoice.sellerName))?.sellerName;
|
||||
addBusinessField(fields, "商户", row.merchantName ?? row.merchantNam ?? sellerName);
|
||||
const route = invoices.map((invoice) => [invoice.trafficFrom, invoice.trafficTo].filter(present).join(" → ")).find(Boolean);
|
||||
addBusinessField(fields, "消费地点", row.payCity ?? row.payCityCode ?? row.destination ?? route);
|
||||
const invoiceDates = invoices.flatMap((invoice) => present(invoice.invoiceDate) ? [invoice.invoiceDate] : []);
|
||||
if (invoiceDates.length) fields.push({ label: "发票日期", value: invoiceDates });
|
||||
appendOfficialCustomFields(fields, row.expCustomFieldList);
|
||||
return fields;
|
||||
}
|
||||
|
||||
function collectOfficialXftFormFields(raw: unknown): LabeledValue[] {
|
||||
const root = isRecord(raw) ? raw : {};
|
||||
const form = isRecord(root.form) ? root.form : undefined;
|
||||
if (!form || !("eventType" in form || "expenseList" in form || "expTotalCnt" in form)) return [];
|
||||
const fields: LabeledValue[] = [];
|
||||
addField(fields, "单据类型", form.eventName ?? form.eventType);
|
||||
addField(fields, "单据编号", form.billId ?? root.billId ?? (isRecord(root.todo) ? root.todo.approvalId : undefined));
|
||||
addField(fields, "申请人", form.aplName ?? form.wrName);
|
||||
addField(fields, "部门", form.aplDepName ?? form.billDepName);
|
||||
addField(fields, "提交时间", form.applyTim);
|
||||
addField(fields, "报销总额", form.expTotalCnt);
|
||||
addField(fields, "事由", form.billRemark);
|
||||
appendOfficialCustomFields(fields, form.billCustomFieldList);
|
||||
return fields;
|
||||
}
|
||||
|
||||
function appendOfficialCustomFields(fields: LabeledValue[], value: unknown): void {
|
||||
if (!Array.isArray(value)) return;
|
||||
for (const item of value) {
|
||||
if (!isRecord(item)) continue;
|
||||
const label = typeof item.fieldName === "string" ? item.fieldName.trim() : "";
|
||||
const fieldValue = item.showValue ?? item.displayValue ?? item.value;
|
||||
if (!label || !present(fieldValue) || sensitiveLabel(label) || heavyValue(label, fieldValue)) continue;
|
||||
fields.push({ label, value: fieldValue });
|
||||
}
|
||||
}
|
||||
|
||||
function present(value: unknown): boolean {
|
||||
return value !== undefined && value !== null && value !== "";
|
||||
}
|
||||
|
||||
function uniqueLabeledValues(fields: LabeledValue[]): Array<LabeledValue & { display: string | number | boolean }> {
|
||||
const unique = new Map<string, LabeledValue & { display: string | number | boolean }>();
|
||||
for (const field of fields) {
|
||||
const label = normalizeLabel(field.label);
|
||||
if (!label || /^\[\s*\]$/.test(label)) continue;
|
||||
const display = compactDisplayValue(field.value);
|
||||
if (display === undefined) continue;
|
||||
const key = `${label}\u0000${String(display)}`;
|
||||
if (!unique.has(key)) unique.set(key, { ...field, display });
|
||||
}
|
||||
return [...unique.values()];
|
||||
}
|
||||
|
||||
function addField(fields: LabeledValue[], label: string, value: unknown): void {
|
||||
if (present(value)) fields.push({ label, value });
|
||||
}
|
||||
|
||||
function addBusinessField(fields: LabeledValue[], label: string, value: unknown): void {
|
||||
if (value === 0 || value === 1 || value === "0" || value === "1" || value === false || value === true) return;
|
||||
addField(fields, label, value);
|
||||
}
|
||||
|
||||
function detailMetadataSources(raw: unknown): unknown[] {
|
||||
const record = isRecord(raw) ? raw : {};
|
||||
const form = isRecord(record.form) ? record.form : undefined;
|
||||
const businessData = isRecord(form?.businessData) ? form.businessData : undefined;
|
||||
return [raw, record.todo, form, businessData?.expBillInsDto].filter((value) => value !== undefined);
|
||||
}
|
||||
|
||||
function collectLabeledValues(raw: unknown): LabeledValue[] {
|
||||
const output: LabeledValue[] = [];
|
||||
const seenObjects = new WeakSet<object>();
|
||||
let visited = 0;
|
||||
const walk = (value: unknown, depth = 0): void => {
|
||||
if (depth > 12 || visited > 30_000 || output.length >= MAX_FORM_FIELDS * 2) return;
|
||||
value = parseJsonContainer(value).value;
|
||||
if (!value || typeof value !== "object") return;
|
||||
if (seenObjects.has(value)) return;
|
||||
seenObjects.add(value);
|
||||
visited += 1;
|
||||
if (Array.isArray(value)) {
|
||||
for (const child of value) walk(child, depth + 1);
|
||||
return;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const label = fieldLabel(record);
|
||||
const fieldValue = fieldRawValue(record);
|
||||
if (label && fieldValue !== undefined && !sensitiveLabel(label) && !heavyValue(label, fieldValue)) {
|
||||
output.push({ label, value: fieldValue });
|
||||
}
|
||||
for (const [key, child] of Object.entries(record)) {
|
||||
if (/customFieldConfig$/i.test(key) || isHeavyBinaryField(key, label ?? "")) continue;
|
||||
walk(child, depth + 1);
|
||||
}
|
||||
};
|
||||
walk(raw);
|
||||
// Deduplication happens in uniqueLabeledValues at the single call site.
|
||||
return output;
|
||||
}
|
||||
|
||||
function normalizeExpenseItem(fields: LabeledValue[]): ExpenseItem {
|
||||
const values = new Map(fields.map((field) => [normalizeLabel(field.label), field.value]));
|
||||
const find = (pattern: RegExp): unknown => [...values].find(([label]) => pattern.test(label))?.[1];
|
||||
const occurredOn = dateOnly(find(/费用发生日期|实际发生日期|发生日期|消费日期|支出日期|交易日期/));
|
||||
const amount = numberValue(find(/实际报销金额|报销金额|费用金额|申请金额|含税金额|^金额$/));
|
||||
return {
|
||||
occurredOn: occurredOn ?? null,
|
||||
expenseType: textValue(find(/费用类型|费用项目|报销类型|支出类型|费用类别/)) ?? null,
|
||||
amount: amount ?? null,
|
||||
currency: currencyValue(find(/币种|货币/)) ?? "CNY",
|
||||
description: textValue(find(/费用说明|报销说明|费用事由|申请事由|用途|摘要|备注/)) ?? null,
|
||||
merchant: textValue(find(/商户|商家|供应商|销售方/)) ?? null,
|
||||
location: textValue(find(/消费地点|发生地点|费用地点|消费城市|所在城市/)) ?? null,
|
||||
customer: textValue(find(/接待客户|客户名称|拜访客户|^客户$/)) ?? null,
|
||||
contacts: textList(find(/接待对象|客户联系人|联系人|参与人员|同行人员/)),
|
||||
relatedTripId: textValue(find(/关联出差单|出差申请单|出差单号|关联出差/)) ?? null,
|
||||
invoiceDates: dateList(find(/发票日期|开票日期/)),
|
||||
projectId: textValue(find(/项目编号|项目号|项目ID|项目代码/)) ?? null,
|
||||
crmRecordId: textValue(find(/CRM记录ID|CRMID|CRM编号|CRM记录/)) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function hasExpenseIdentity(item: ExpenseItem): boolean {
|
||||
return item.occurredOn !== null || item.expenseType !== null || item.amount !== null;
|
||||
}
|
||||
|
||||
function looksLikeExpenseRow(fields: LabeledValue[]): boolean {
|
||||
const labels = fields.map((field) => normalizeLabel(field.label)).join("|");
|
||||
const score = [
|
||||
/费用发生日期|实际发生日期|发生日期|消费日期/.test(labels),
|
||||
/费用类型|费用项目|报销类型|费用类别/.test(labels),
|
||||
/实际报销金额|报销金额|费用金额/.test(labels),
|
||||
/费用说明|报销说明/.test(labels),
|
||||
].filter(Boolean).length;
|
||||
return score >= 2;
|
||||
}
|
||||
|
||||
function labeledArray(values: unknown[]): LabeledValue[] {
|
||||
return values.flatMap((value) => {
|
||||
if (!isRecord(value)) return [];
|
||||
const label = fieldLabel(value);
|
||||
const fieldValue = fieldRawValue(value);
|
||||
return label && fieldValue !== undefined ? [{ label, value: fieldValue }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function directRecordFields(record: Record<string, unknown>): LabeledValue[] {
|
||||
return Object.entries(record).flatMap(([label, value]) => {
|
||||
if (/^(id|key|uuid|componentType|bizAlias|rowValue|rowValues|fields|fieldValues|values)$/i.test(label)) return [];
|
||||
if (value === undefined || value === null || typeof value === "object") return [];
|
||||
return [{ label, value }];
|
||||
});
|
||||
}
|
||||
|
||||
function fieldLabel(record: Record<string, unknown>): string | undefined {
|
||||
for (const key of ["label", "name", "title", "fieldName", "customFieldName", "fieldLabel", "bizAlias"]) {
|
||||
if (typeof record[key] === "string" && record[key].trim()) return record[key].trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function fieldRawValue(record: Record<string, unknown>): unknown {
|
||||
for (const key of ["value", "content", "displayValue", "fieldValue", "customFieldValue", "extValue"]) {
|
||||
if (present(record[key])) return record[key];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function compactDisplayValue(value: unknown): string | number | boolean | undefined {
|
||||
const parsed = parseJsonContainer(value).value;
|
||||
if (typeof parsed === "number" || typeof parsed === "boolean") return parsed;
|
||||
if (typeof parsed === "string") return parsed.slice(0, MAX_FIELD_TEXT);
|
||||
if (Array.isArray(parsed)) {
|
||||
const scalars = parsed.flatMap((item) => {
|
||||
const displayed = compactDisplayValue(item);
|
||||
return displayed === undefined || typeof displayed === "object" ? [] : [String(displayed)];
|
||||
});
|
||||
return scalars.length ? scalars.join("、").slice(0, MAX_FIELD_TEXT) : undefined;
|
||||
}
|
||||
if (isRecord(parsed)) {
|
||||
for (const key of ["label", "name", "text", "displayValue", "value"]) {
|
||||
const displayed = compactDisplayValue(parsed[key]);
|
||||
if (displayed !== undefined) return displayed;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveJsonPointer(root: unknown, path: string): { value: unknown; encodedAsJsonString: boolean } {
|
||||
if (path && !path.startsWith("/")) throw new Error("rawPath 必须是 JSON Pointer(空字符串或以 / 开头)");
|
||||
let value = root;
|
||||
let encodedAsJsonString = false;
|
||||
const segments = path ? path.slice(1).split("/").map(pointerDecode) : [];
|
||||
for (const segment of segments) {
|
||||
const parsed = parseJsonContainer(value);
|
||||
value = parsed.value;
|
||||
encodedAsJsonString ||= parsed.parsed;
|
||||
if (Array.isArray(value)) {
|
||||
if (!/^\d+$/.test(segment) || Number(segment) >= value.length) throw new Error(`rawPath 不存在:${path}`);
|
||||
value = value[Number(segment)];
|
||||
} else if (isRecord(value) && shouldExposeRawKey(segment, value) && Object.prototype.hasOwnProperty.call(value, segment)) {
|
||||
value = value[segment];
|
||||
} else throw new Error(`rawPath 不存在:${path}`);
|
||||
}
|
||||
return { value, encodedAsJsonString };
|
||||
}
|
||||
|
||||
function rawPage(path: string, encodedAsJsonString: boolean, cursor: number, end: number, total: number, entries: unknown[]) {
|
||||
return {
|
||||
path,
|
||||
kind: "container",
|
||||
encodedAsJsonString,
|
||||
cursor,
|
||||
returned: entries.length,
|
||||
total,
|
||||
nextCursor: end < total ? end : null,
|
||||
entries,
|
||||
diagnostics: {
|
||||
realtime: true,
|
||||
bounded: true,
|
||||
filteredForAgent: true,
|
||||
policy: "仅暴露审批业务字段;配置/schema、二进制内容、内部诊断和敏感账户字段已过滤。",
|
||||
maxEntriesPerPage: 50,
|
||||
maxScalarCharacters: MAX_RAW_SCALAR_TEXT,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rawEntry(key: string, value: unknown, path: string) {
|
||||
const parsed = parseJsonContainer(value);
|
||||
if (Array.isArray(parsed.value)) return { key, path, kind: "array", size: parsed.value.length, encodedAsJsonString: parsed.parsed };
|
||||
if (isRecord(parsed.value)) return { key, path, kind: "object", size: Object.keys(parsed.value).length, encodedAsJsonString: parsed.parsed };
|
||||
return { key, path, kind: scalarKind(parsed.value), ...scalarPreview(parsed.value) };
|
||||
}
|
||||
|
||||
function visibleObjectEntries(record: Record<string, unknown>): Array<[string, unknown]> {
|
||||
if (sensitiveLabel(fieldLabel(record) ?? "")) return [];
|
||||
return Object.entries(record).filter(([key]) => shouldExposeRawKey(key, record));
|
||||
}
|
||||
|
||||
function shouldExposeRawKey(key: string, parent: Record<string, unknown>): boolean {
|
||||
if (/^(businessConfig|businessConfigDTO|submitDTO|diagnostics|identity|cookie|cookies|headers?|request|response|stack)$/i.test(key)) return false;
|
||||
if (/(?:^|_)(schema|componentSchema|formSchema|templateConfig|runtimeConfig)(?:$|_)/i.test(key)) return false;
|
||||
if (/fileContent|base64|thumbnail|imageData|binary|downloadToken/i.test(key)) return false;
|
||||
if (/^(?:originator|actioner|approver|executor)?(?:user|dept)(?:id|ids|userid|deptid)$/i.test(key)) return false;
|
||||
if (/^(?:originatorUserid|originatorDeptId|actionerUserId|approverUserId|executorUserId)$/i.test(key)) return false;
|
||||
if (sensitiveLabel(key)) return false;
|
||||
const label = fieldLabel(parent);
|
||||
if (label && sensitiveLabel(label) && /^(value|content|displayValue|fieldValue|extValue)$/i.test(key)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function scalarPreview(value: unknown): Record<string, unknown> {
|
||||
if (typeof value === "string") return {
|
||||
value: value.slice(0, MAX_RAW_SCALAR_TEXT),
|
||||
valueTruncated: value.length > MAX_RAW_SCALAR_TEXT,
|
||||
totalCharacters: value.length,
|
||||
};
|
||||
return { value };
|
||||
}
|
||||
|
||||
function scalarKind(value: unknown): string {
|
||||
if (value === null) return "null";
|
||||
return typeof value;
|
||||
}
|
||||
|
||||
function parseJsonContainer(value: unknown): { value: unknown; parsed: boolean } {
|
||||
if (typeof value !== "string") return { value, parsed: false };
|
||||
const trimmed = value.trim();
|
||||
if ((!trimmed.startsWith("{") && !trimmed.startsWith("[")) || trimmed.length > 2_000_000) return { value, parsed: false };
|
||||
try { return { value: JSON.parse(trimmed), parsed: true }; } catch { return { value, parsed: false }; }
|
||||
}
|
||||
|
||||
function pointerJoin(base: string, segment: string): string {
|
||||
return `${base}/${segment.replace(/~/g, "~0").replace(/\//g, "~1")}`;
|
||||
}
|
||||
function pointerDecode(segment: string): string { return segment.replace(/~1/g, "/").replace(/~0/g, "~"); }
|
||||
function isHeavyBinaryField(key: string, label: string): boolean { return /attachment|fileContent|base64|thumbnail|imageData|附件内容|影像/i.test(`${key} ${label}`); }
|
||||
function heavyValue(label: string, value: unknown): boolean { return /附件|影像|发票文件|电子发票/i.test(label) || (typeof value === "string" && value.length > 20_000); }
|
||||
|
||||
function textValue(value: unknown): string | undefined {
|
||||
const displayed = compactDisplayValue(value);
|
||||
if (displayed === undefined) return undefined;
|
||||
return String(displayed).trim() || undefined;
|
||||
}
|
||||
function numberValue(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
const parsedValue = parseJsonContainer(value).value;
|
||||
if (isRecord(parsedValue)) {
|
||||
for (const key of ["reimAmt", "amount", "originalCurrencyReimAmt", "expenseTotalAmount", "value"]) {
|
||||
const nested = numberValue(parsedValue[key]);
|
||||
if (nested !== undefined) return nested;
|
||||
}
|
||||
}
|
||||
const text = textValue(value)?.replace(/,/g, "");
|
||||
const matched = text?.match(/-?\d+(?:\.\d+)?/);
|
||||
const parsed = matched ? Number(matched[0]) : NaN;
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
function dateOnly(value: unknown): string | undefined {
|
||||
const text = textValue(value);
|
||||
if (!text) return undefined;
|
||||
const direct = text.match(/(20\d{2})[-/.年](\d{1,2})[-/.月](\d{1,2})/);
|
||||
if (direct) return `${direct[1]}-${direct[2]!.padStart(2, "0")}-${direct[3]!.padStart(2, "0")}`;
|
||||
if (/^\d{10,13}$/.test(text)) {
|
||||
const timestamp = Number(text) * (text.length === 10 ? 1000 : 1);
|
||||
const date = new Date(timestamp);
|
||||
if (!Number.isNaN(date.getTime())) return new Date(date.getTime() + 8 * 3_600_000).toISOString().slice(0, 10);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
function dateList(value: unknown): string[] {
|
||||
const parsed = parseJsonContainer(value).value;
|
||||
const values = Array.isArray(parsed) ? parsed : [parsed];
|
||||
return [...new Set(values.flatMap((item) => {
|
||||
const text = textValue(item) ?? "";
|
||||
const matches = [...text.matchAll(/(20\d{2})[-/.年](\d{1,2})[-/.月](\d{1,2})/g)];
|
||||
return matches.map((match) => `${match[1]}-${match[2]!.padStart(2, "0")}-${match[3]!.padStart(2, "0")}`);
|
||||
}))];
|
||||
}
|
||||
function textList(value: unknown): string[] {
|
||||
const text = textValue(value);
|
||||
return text ? text.split(/[、,,;;\n]/).map((item) => item.trim()).filter(Boolean).slice(0, 50) : [];
|
||||
}
|
||||
function currencyValue(value: unknown): string | undefined {
|
||||
const parsedValue = parseJsonContainer(value).value;
|
||||
if (isRecord(parsedValue)) return currencyValue(parsedValue.currencyCode ?? parsedValue.currency ?? parsedValue.cry);
|
||||
const text = textValue(value)?.toUpperCase();
|
||||
if (!text) return undefined;
|
||||
if (/USD|美元/.test(text)) return "USD";
|
||||
if (/EUR|欧元/.test(text)) return "EUR";
|
||||
if (/HKD|港币/.test(text)) return "HKD";
|
||||
if (/CNY|RMB|人民币/.test(text)) return "CNY";
|
||||
return text.slice(0, 10);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { objectValue } from "./json.js";
|
||||
|
||||
export interface ApprovalListMetadata {
|
||||
applicantName?: string;
|
||||
applicantUserId?: string;
|
||||
applicantDepartment?: string;
|
||||
submittedAt?: string;
|
||||
amount?: number;
|
||||
currency?: string;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
interface FormField { label: string; value: string }
|
||||
|
||||
export function extractApprovalListMetadata(...sources: unknown[]): ApprovalListMetadata {
|
||||
const records = sources.flatMap(knownRecords);
|
||||
const fields = records.flatMap(formFields);
|
||||
const applicantName = firstDirectString(records, [
|
||||
"originatorUserName", "originatorName", "applicantName", "creatorName", "createUserName", "submitterName",
|
||||
"aplName", "wrName",
|
||||
]) ?? formValue(fields, /^(申请人|发起人|报销人|提交人|员工姓名|申请人姓名)$/)
|
||||
?? applicantFromTitle(firstDirectString(records, ["title", "processInstanceTitle"]));
|
||||
const applicantUserId = firstDirectString(records, [
|
||||
"originatorUserId", "originatorId", "applicantUserId", "creatorUserId", "createUserId", "submitterId",
|
||||
]);
|
||||
const applicantDepartment = firstDirectString(records, [
|
||||
"originatorDeptName", "originatorDepartmentName", "applicantDepartment", "departmentName", "deptName",
|
||||
"aplDepName", "billDepName",
|
||||
]) ?? formValue(fields, /^(申请部门|发起部门|报销部门|所属部门|部门)$/);
|
||||
const submittedAt = dateValue(firstDirect(records, [
|
||||
"createTime", "startTime", "submittedAt", "applyTime", "applyTim", "createTimeGMT", "gmtCreate", "createdAt", "createDate",
|
||||
]));
|
||||
const directAmount = amountValue(firstDirect(records, [
|
||||
"totalAmount", "applyAmount", "reimbursementAmount", "expenseAmount", "paymentAmount", "amount", "expTotalCnt",
|
||||
]));
|
||||
const amountField = bestAmountField(fields);
|
||||
const amount = directAmount?.value ?? amountField?.value;
|
||||
const currency = directAmount?.currency ?? amountField?.currency ?? (amount !== undefined ? "CNY" : undefined);
|
||||
const summary = cleanSummary(firstDirectString(records, [
|
||||
"reason", "applyReason", "expenseReason", "description", "summary", "remark", "billRemark",
|
||||
]) ?? formValue(fields, /^(申请事由|报销事由|费用事由|用途|申请原因|原因|费用说明|说明|摘要|备注)$/));
|
||||
return {
|
||||
...(applicantName ? { applicantName } : {}),
|
||||
...(applicantUserId ? { applicantUserId } : {}),
|
||||
...(applicantDepartment ? { applicantDepartment } : {}),
|
||||
...(submittedAt ? { submittedAt } : {}),
|
||||
...(amount !== undefined ? { amount } : {}),
|
||||
...(currency ? { currency } : {}),
|
||||
...(summary ? { summary } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function knownRecords(value: unknown): Record<string, unknown>[] {
|
||||
const root = objectValue(value);
|
||||
if (!root) return [];
|
||||
const records = [root];
|
||||
for (const key of ["result", "data", "body", "formData", "originator", "applicant", "creator"]) {
|
||||
const child = objectValue(root[key]);
|
||||
if (child) records.push(child);
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
function formFields(record: Record<string, unknown>): FormField[] {
|
||||
const output: FormField[] = [];
|
||||
for (const key of ["forms", "formComponentValues", "formValues", "components"]) {
|
||||
const list = record[key];
|
||||
if (!Array.isArray(list)) continue;
|
||||
for (const item of list) {
|
||||
const field = objectValue(item);
|
||||
if (!field) continue;
|
||||
const label = stringFrom(field.name) ?? stringFrom(field.title) ?? stringFrom(field.label);
|
||||
const value = displayValue(field.value ?? field.content ?? field.displayValue ?? field.extValue);
|
||||
if (label && value && !sensitiveLabel(label)) output.push({ label: normalizeLabel(label), value });
|
||||
}
|
||||
}
|
||||
const formData = objectValue(record.formData);
|
||||
if (formData) {
|
||||
for (const [label, raw] of Object.entries(formData)) {
|
||||
const value = displayValue(raw);
|
||||
if (value && !sensitiveLabel(label)) output.push({ label: normalizeLabel(label), value });
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function firstDirect(records: Record<string, unknown>[], keys: string[]): unknown {
|
||||
for (const key of keys) {
|
||||
for (const record of records) {
|
||||
const value = record[key];
|
||||
if (value !== undefined && value !== null && value !== "") return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function firstDirectString(records: Record<string, unknown>[], keys: string[]): string | undefined {
|
||||
return displayValue(firstDirect(records, keys));
|
||||
}
|
||||
|
||||
function formValue(fields: FormField[], pattern: RegExp): string | undefined {
|
||||
return fields.find((field) => pattern.test(field.label))?.value;
|
||||
}
|
||||
|
||||
function bestAmountField(fields: FormField[]): { value: number; currency?: string } | undefined {
|
||||
const candidates = fields.flatMap((field) => {
|
||||
const parsed = amountValue(field.value);
|
||||
if (!parsed || !/(金额|总额|合计|费用)/.test(field.label) || /(单价|税率|数量)/.test(field.label)) return [];
|
||||
const score = /^(报销金额|申请金额|付款金额|支付金额|总金额|费用合计|合计金额)$/.test(field.label)
|
||||
? 3
|
||||
: /(总|合计|报销|申请|付款)/.test(field.label) ? 2 : 1;
|
||||
return [{ ...parsed, score }];
|
||||
});
|
||||
candidates.sort((left, right) => right.score - left.score);
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
function amountValue(value: unknown): { value: number; currency?: string } | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return { value };
|
||||
const text = displayValue(value);
|
||||
if (!text) return undefined;
|
||||
const match = text.replace(/,/g, "").match(/-?\d+(?:\.\d+)?/);
|
||||
if (!match) return undefined;
|
||||
const parsed = Number(match[0]);
|
||||
if (!Number.isFinite(parsed)) return undefined;
|
||||
const currency = /(?:USD|美元|\$)/i.test(text) ? "USD"
|
||||
: /(?:EUR|欧元|€)/i.test(text) ? "EUR"
|
||||
: /(?:HKD|港币)/i.test(text) ? "HKD"
|
||||
: /(?:CNY|RMB|人民币|[¥¥])/i.test(text) ? "CNY" : undefined;
|
||||
return { value: parsed, ...(currency ? { currency } : {}) };
|
||||
}
|
||||
|
||||
function dateValue(value: unknown): string | undefined {
|
||||
let milliseconds: number | undefined;
|
||||
if (typeof value === "number" && Number.isFinite(value)) milliseconds = value < 10_000_000_000 ? value * 1000 : value;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const trimmed = value.trim();
|
||||
if (/^\d{10,13}$/.test(trimmed)) {
|
||||
const numeric = Number(trimmed);
|
||||
milliseconds = trimmed.length === 10 ? numeric * 1000 : numeric;
|
||||
} else {
|
||||
const parsed = Date.parse(trimmed);
|
||||
if (Number.isFinite(parsed)) milliseconds = parsed;
|
||||
else return trimmed.slice(0, 100);
|
||||
}
|
||||
}
|
||||
if (milliseconds === undefined) return undefined;
|
||||
const date = new Date(milliseconds);
|
||||
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
||||
}
|
||||
|
||||
function displayValue(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
if ((trimmed.startsWith("{") || trimmed.startsWith("[")) && trimmed.length < 10_000) {
|
||||
try { return displayValue(JSON.parse(trimmed)); } catch {}
|
||||
}
|
||||
return trimmed.slice(0, 1000);
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
if (Array.isArray(value)) {
|
||||
const values = value.flatMap((item) => {
|
||||
const displayed = displayValue(item);
|
||||
return displayed ? [displayed] : [];
|
||||
});
|
||||
return values.length ? values.join("、").slice(0, 1000) : undefined;
|
||||
}
|
||||
const record = objectValue(value);
|
||||
if (!record) return undefined;
|
||||
for (const key of ["label", "name", "text", "value", "displayValue"]) {
|
||||
const displayed = displayValue(record[key]);
|
||||
if (displayed) return displayed;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function cleanSummary(value: string | undefined): string | undefined {
|
||||
const cleaned = value?.replace(/\s+/g, " ").trim();
|
||||
return cleaned ? cleaned.slice(0, 500) : undefined;
|
||||
}
|
||||
|
||||
function applicantFromTitle(value: string | undefined): string | undefined {
|
||||
const match = value?.match(/^(.{1,30}?)(?:提交的|发起的)/);
|
||||
return match?.[1]?.trim() || undefined;
|
||||
}
|
||||
|
||||
export function normalizeLabel(value: string): string {
|
||||
return value.replace(/[::\s]/g, "").trim();
|
||||
}
|
||||
|
||||
/** Shared vocabulary for sensitive field names/labels across metadata, detail and redaction layers. */
|
||||
export function sensitiveLabel(value: string): boolean {
|
||||
if (/招待地址|消费地址|商户地址|酒店地址|办公地址|出差地址|活动地址|费用地址|entertainmentAddress|merchantAddress|hotelAddress|businessAddress|expenseAddress/i.test(value)) {
|
||||
return false;
|
||||
}
|
||||
return /bank.*(account|card|no|name)|account.*(number|no)|cardNo|taxNo|idcard|identity.*(number|no)|mobile|phone|address|recipientAccount|paymentAccount|银行卡|银行账号|卡号|身份证|手机号|账户号码|收款账户|付款账户|税号|地址|住址/i.test(value);
|
||||
}
|
||||
|
||||
function stringFrom(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
import type { AppConfig } from "./config.js";
|
||||
import { APPROVAL_BATCH_LIMIT } from "./approval-contract.js";
|
||||
import type {
|
||||
ApprovalAction,
|
||||
ApprovalInstance,
|
||||
ApprovalSource,
|
||||
DingTalkClient,
|
||||
OfficialTodoWorkRecord,
|
||||
OfficialSubmissionRequest,
|
||||
YidaApplication,
|
||||
YidaTask,
|
||||
} from "./dingtalk.js";
|
||||
import { ApprovalSafetyError, DingTalkApiError, errorMessage } from "./errors.js";
|
||||
import { extractApprovalListMetadata } from "./approval-metadata.js";
|
||||
import { isRecord } from "./json.js";
|
||||
import type { ApprovalHistoryRange, ApprovalHistoryScope } from "./xft.js";
|
||||
|
||||
export interface TodoItem {
|
||||
source: ApprovalSource;
|
||||
processInstanceId: string;
|
||||
taskId: string;
|
||||
title?: string;
|
||||
processCode?: string;
|
||||
clientUrl?: string;
|
||||
processName?: string;
|
||||
appType?: string;
|
||||
applicantName?: string;
|
||||
applicantUserId?: string;
|
||||
applicantDepartment?: string;
|
||||
submittedAt?: string;
|
||||
amount?: number;
|
||||
currency?: string;
|
||||
summary?: string;
|
||||
enrichmentStatus?: "complete" | "failed";
|
||||
raw: unknown;
|
||||
}
|
||||
|
||||
export interface ProcessApprovalItem {
|
||||
source: ApprovalSource;
|
||||
processInstanceId: string;
|
||||
taskId: string;
|
||||
appType?: string;
|
||||
}
|
||||
|
||||
export interface ApprovalHistoryItem extends Omit<TodoItem, "taskId"> {
|
||||
historyScope: ApprovalHistoryScope;
|
||||
taskId?: string;
|
||||
status?: string;
|
||||
result?: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export class ApprovalService {
|
||||
constructor(private readonly client: DingTalkClient, private readonly config: AppConfig) {}
|
||||
|
||||
async listMyTodos(userId: string, source: ApprovalSource | "all", limit: number) {
|
||||
const [official, yida] = await Promise.all([
|
||||
source === "yida" ? Promise.resolve([]) : this.listOfficialTodos(userId, limit),
|
||||
source === "official_oa" || !this.config.yida ? Promise.resolve([]) : this.listYidaTodos(userId, limit),
|
||||
]);
|
||||
return {
|
||||
items: [...official, ...yida].slice(0, limit),
|
||||
diagnostics: {
|
||||
officialOaCount: official.length,
|
||||
yidaCount: yida.length,
|
||||
officialEnrichmentFailures: official.filter((item) => item.enrichmentStatus === "failed").length,
|
||||
officialListingMode: "direct_todo_work_records",
|
||||
realtime: true,
|
||||
persistedBusinessData: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async listMyHistory(userId: string, scope: ApprovalHistoryScope, limit: number, range: ApprovalHistoryRange) {
|
||||
const history = scope === "handled"
|
||||
? await this.listOfficialHandled(userId, limit, range)
|
||||
: await this.listOfficialSubmitted(userId, limit, range);
|
||||
return {
|
||||
items: history.items,
|
||||
diagnostics: {
|
||||
officialOaCount: history.items.length,
|
||||
yidaHistorySupported: false,
|
||||
listingMode: scope === "handled" ? "completed_work_records" : "initiated_instances_by_visible_template",
|
||||
...(history.truncated ? { truncated: true, scanLimit: history.scanLimit } : {}),
|
||||
realtime: true,
|
||||
persistedBusinessData: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async listSubmissionTemplates(limit: number, search = "", allowedCategories: readonly string[] = []) {
|
||||
const query = search.trim().toLowerCase();
|
||||
return (await this.client.listOfficialTemplates())
|
||||
.filter((item) => allowedCategories.length === 0 || allowedCategories.includes(item.dirName ?? ""))
|
||||
.filter((item) => !query || JSON.stringify(item).toLowerCase().includes(query))
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
async getSubmissionSchema(processCode: string) {
|
||||
const template = (await this.client.listOfficialTemplates()).find((item) => item.processCode === processCode);
|
||||
if (!template) throw new ApprovalSafetyError("钉钉审批模板不可见、已停用或发生变化,请重新列出可发起模板");
|
||||
const schema = await this.client.getOfficialTemplateSchema(processCode);
|
||||
return { template, schema };
|
||||
}
|
||||
|
||||
async resolveSubmissionDepartment(userId: string, requestedDeptId = -1): Promise<number> {
|
||||
const departmentIds = await this.client.getUserDepartmentIds(userId);
|
||||
if (departmentIds.length === 0) {
|
||||
throw new ApprovalSafetyError("无法从钉钉通讯录解析当前登录用户的所属部门,请确认应用已开通通讯录个人信息读取权限");
|
||||
}
|
||||
const id = requestedDeptId === -1 ? departmentIds[0]! : requestedDeptId;
|
||||
if (!departmentIds.includes(id)) {
|
||||
throw new ApprovalSafetyError("所选发起部门不属于当前登录用户,禁止提交");
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
async forecastSubmission(userId: string, request: Omit<OfficialSubmissionRequest, "originatorUserId">) {
|
||||
await this.getSubmissionSchema(request.processCode);
|
||||
return this.client.forecastOfficialProcess({ ...request, originatorUserId: userId });
|
||||
}
|
||||
|
||||
async submitApproval(userId: string, request: Omit<OfficialSubmissionRequest, "originatorUserId">) {
|
||||
await this.getSubmissionSchema(request.processCode);
|
||||
return this.client.createOfficialInstance({ ...request, originatorUserId: userId });
|
||||
}
|
||||
|
||||
async revokeSubmission(userId: string, processInstanceId: string, remark = "") {
|
||||
const instance = await this.client.getOfficialInstance(processInstanceId);
|
||||
this.assertSubmissionOwner(instance, userId);
|
||||
if (!isRevocableInstance(instance)) throw new ApprovalSafetyError("该钉钉审批已不在运行中,不能撤销");
|
||||
try {
|
||||
await this.client.terminateOfficialInstance({
|
||||
processInstanceId,
|
||||
operatingUserId: userId,
|
||||
...(remark ? { remark } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof DingTalkApiError && (error.status === 0 || error.status >= 500)) {
|
||||
return {
|
||||
status: "outcome_uncertain" as const,
|
||||
processInstanceId,
|
||||
message: "撤销请求可能已经到达钉钉,但响应无法确认;禁止自动重试,请稍后查询客户端状态",
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||
const updated = await this.client.getOfficialInstance(processInstanceId);
|
||||
this.assertSubmissionOwner(updated, userId);
|
||||
if (isConfirmedRevokedInstance(updated)) {
|
||||
return { status: "revoked" as const, processInstanceId, upstreamStatus: instanceStatus(updated) };
|
||||
}
|
||||
if (!isRevocableInstance(updated)) {
|
||||
return {
|
||||
status: "failed" as const,
|
||||
processInstanceId,
|
||||
upstreamStatus: instanceStatus(updated),
|
||||
message: "钉钉实例已进入其他终态,未确认撤销成功;禁止自动重试",
|
||||
};
|
||||
}
|
||||
if (attempt < 3) await new Promise<void>((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
return {
|
||||
status: "outcome_uncertain" as const,
|
||||
processInstanceId,
|
||||
message: "钉钉已接受撤销请求,但实例仍显示运行中;禁止自动重试,请稍后查询客户端状态",
|
||||
};
|
||||
}
|
||||
|
||||
async getDetail(userId: string, item: Omit<ProcessApprovalItem, "taskId">) {
|
||||
if (item.source === "official_oa") {
|
||||
const instance = await this.client.getOfficialInstance(item.processInstanceId);
|
||||
if (this.currentOfficialPendingTasks(instance, userId).length === 0) {
|
||||
throw new ApprovalSafetyError("该实例当前没有属于登录用户的待审批任务");
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
if (!item.appType) throw new ApprovalSafetyError("查询宜搭详情必须提供 appType");
|
||||
const pending = await this.findCurrentYidaTasks(userId, new Set([yidaTaskKey(item.appType, item.processInstanceId)]));
|
||||
if (pending.size === 0) throw new ApprovalSafetyError("该宜搭实例当前没有属于登录用户的待审批任务");
|
||||
const app = await this.findYidaApp(item.appType);
|
||||
return this.client.getYidaInstance(item.processInstanceId, app, userId);
|
||||
}
|
||||
|
||||
async getHistoryDetail(
|
||||
userId: string,
|
||||
scope: ApprovalHistoryScope,
|
||||
processInstanceId: string,
|
||||
taskId?: string,
|
||||
) {
|
||||
const instance = await this.client.getOfficialInstance(processInstanceId);
|
||||
if (scope === "submitted") {
|
||||
this.assertSubmissionOwner(instance, userId);
|
||||
return instance;
|
||||
}
|
||||
const handled = (instance.tasks ?? []).some((task) => task.userId === userId
|
||||
&& isOfficialHandledStatus(task.status)
|
||||
&& (taskId === undefined || String(task.taskId) === taskId));
|
||||
if (!handled) throw new ApprovalSafetyError("该钉钉审批没有当前登录用户已处理完成的任务");
|
||||
return instance;
|
||||
}
|
||||
|
||||
async assertOfficialTaskPending(userId: string, processInstanceId: string, taskId: string): Promise<void> {
|
||||
const instance = await this.client.getOfficialInstance(processInstanceId);
|
||||
this.assertOfficialTask(instance, userId, taskId);
|
||||
}
|
||||
|
||||
private assertSubmissionOwner(instance: ApprovalInstance, userId: string): void {
|
||||
const originator = firstString(instance, ["originatorUserId", "originatorUserid", "originatorId", "creatorUserId"]);
|
||||
if (!originator || originator !== userId) {
|
||||
throw new ApprovalSafetyError("该钉钉审批不是当前登录用户本人发起");
|
||||
}
|
||||
}
|
||||
|
||||
async isOfficialTaskPending(userId: string, processInstanceId: string, taskId: string): Promise<boolean> {
|
||||
const instance = await this.client.getOfficialInstance(processInstanceId);
|
||||
return this.hasOfficialPendingTask(instance, userId, taskId);
|
||||
}
|
||||
|
||||
async processBatch(userId: string, action: ApprovalAction, remark: string, items: ProcessApprovalItem[]) {
|
||||
if (items.length === 0) throw new ApprovalSafetyError("审批列表不能为空");
|
||||
if (items.length > APPROVAL_BATCH_LIMIT) {
|
||||
throw new ApprovalSafetyError(`一次最多处理 ${APPROVAL_BATCH_LIMIT} 张审批单`);
|
||||
}
|
||||
const official = items.filter((item) => item.source === "official_oa");
|
||||
const yida = items.filter((item) => item.source === "yida");
|
||||
const results: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (const item of official) {
|
||||
let instance: ApprovalInstance;
|
||||
try {
|
||||
instance = await this.client.getOfficialInstance(item.processInstanceId);
|
||||
this.assertOfficialTask(instance, userId, item.taskId);
|
||||
} catch (error) {
|
||||
results.push({
|
||||
...item,
|
||||
status: "failed",
|
||||
success: false,
|
||||
failureConfirmed: true,
|
||||
error: errorMessage(error),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let response: unknown;
|
||||
try {
|
||||
response = await this.client.executeOfficialApproval({
|
||||
processInstanceId: item.processInstanceId,
|
||||
taskId: item.taskId,
|
||||
actionerUserId: userId,
|
||||
result: action,
|
||||
...(remark ? { remark } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (isUncertainDingTalkWriteError(error)) {
|
||||
results.push({
|
||||
...item,
|
||||
status: "outcome_uncertain",
|
||||
success: false,
|
||||
outcomeUncertain: true,
|
||||
message: "审批写请求可能已经到达钉钉,但响应无法确认;禁止自动重试,请稍后重新查询待办",
|
||||
error: "审批写请求可能已经到达钉钉,但响应无法确认;禁止自动重试,请稍后重新查询待办",
|
||||
});
|
||||
} else {
|
||||
results.push({
|
||||
...item,
|
||||
status: "failed",
|
||||
success: false,
|
||||
failureConfirmed: true,
|
||||
error: errorMessage(error),
|
||||
...(error instanceof DingTalkApiError && error.requestId ? { supportReference: error.requestId } : {}),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let updated: ApprovalInstance;
|
||||
try {
|
||||
updated = await this.client.getOfficialInstance(item.processInstanceId);
|
||||
} catch (error) {
|
||||
results.push({
|
||||
...item,
|
||||
status: "outcome_uncertain",
|
||||
success: false,
|
||||
outcomeUncertain: true,
|
||||
message: `钉钉已接收审批操作,但实时状态复核失败:${errorMessage(error)};禁止自动重试`,
|
||||
error: `钉钉已接收审批操作,但实时状态复核失败:${errorMessage(error)};禁止自动重试`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (this.hasOfficialPendingTask(updated, userId, item.taskId)) {
|
||||
const message = "钉钉已接收提交,但原任务仍在当前用户实时待办中;禁止自动重试,请稍后重新查询待办";
|
||||
results.push({
|
||||
...item,
|
||||
status: "outcome_uncertain",
|
||||
success: false,
|
||||
outcomeUncertain: true,
|
||||
message,
|
||||
error: message,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const followUp = this.currentOfficialPendingTasks(updated, userId)[0];
|
||||
results.push({
|
||||
...item,
|
||||
status: "succeeded",
|
||||
success: true,
|
||||
completed: followUp === undefined,
|
||||
...(followUp ? {
|
||||
followUpRequired: true,
|
||||
nextTaskId: String(followUp.taskId),
|
||||
message: "当前审批节点已处理,但同一审批实例生成了仍需当前用户处理的新节点",
|
||||
} : {}),
|
||||
response,
|
||||
});
|
||||
}
|
||||
|
||||
if (yida.length > 0) {
|
||||
const wanted = new Set(yida.map((item) => yidaTaskKey(item.appType ?? "", item.processInstanceId, item.taskId)));
|
||||
const currentTasks = await this.findCurrentYidaTasks(userId, wanted);
|
||||
const validYida = yida.filter((item) => {
|
||||
const valid = currentTasks.has(yidaTaskKey(item.appType ?? "", item.processInstanceId, item.taskId));
|
||||
if (!valid) results.push({ ...item, success: false, error: "任务不属于当前登录用户、不是待办状态或已经被处理" });
|
||||
return valid;
|
||||
});
|
||||
if (validYida.length === 0) {
|
||||
return batchResult(action, items.length, results);
|
||||
}
|
||||
const apps = await this.client.listYidaApplications();
|
||||
const appMap = new Map(apps.map((app) => [app.appType, app]));
|
||||
const groups = new Map<string, ProcessApprovalItem[]>();
|
||||
for (const item of validYida) {
|
||||
const key = item.appType ?? "";
|
||||
const group = groups.get(key) ?? [];
|
||||
group.push(item);
|
||||
groups.set(key, group);
|
||||
}
|
||||
for (const [appType, group] of groups) {
|
||||
const app = appMap.get(appType);
|
||||
if (!app) {
|
||||
for (const item of group) results.push({ ...item, success: false, error: `找不到宜搭应用 ${appType} 或无管理权限` });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const response = await this.client.executeYidaBatch(
|
||||
app,
|
||||
userId,
|
||||
action,
|
||||
remark,
|
||||
group.map((item) => ({ taskId: item.taskId, formInstId: item.processInstanceId })),
|
||||
);
|
||||
for (const item of group) results.push({ ...item, success: true, response });
|
||||
} catch (error) {
|
||||
for (const item of group) results.push({ ...item, success: false, error: errorMessage(error) });
|
||||
}
|
||||
}
|
||||
}
|
||||
return batchResult(action, items.length, results);
|
||||
}
|
||||
|
||||
private async listOfficialTodos(userId: string, limit: number): Promise<TodoItem[]> {
|
||||
const found: TodoItem[] = [];
|
||||
const seen = new Set<string>();
|
||||
let offset = 0;
|
||||
while (found.length < limit) {
|
||||
const pageSize = Math.min(20, limit - found.length);
|
||||
const page = await this.client.listOfficialTodoWorkRecords(userId, offset, pageSize);
|
||||
for (const record of page.list) {
|
||||
const item = officialWorkRecord(record);
|
||||
if (item) {
|
||||
const key = `${item.processInstanceId}\u0000${item.taskId}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
found.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
offset += page.list.length;
|
||||
if (!page.hasMore || page.list.length < pageSize || page.list.length === 0) break;
|
||||
}
|
||||
await mapConcurrent(found, this.config.officialScanConcurrency, async (item) => {
|
||||
try {
|
||||
const instance = await this.client.getOfficialInstance(item.processInstanceId);
|
||||
Object.assign(item, extractApprovalListMetadata(instance, item.raw));
|
||||
if (!item.title && instance.title) item.title = instance.title;
|
||||
if (!item.processCode && typeof instance.processCode === "string") item.processCode = instance.processCode;
|
||||
item.enrichmentStatus = "complete";
|
||||
} catch {
|
||||
item.enrichmentStatus = "failed";
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
private async listOfficialHandled(userId: string, limit: number, range: ApprovalHistoryRange) {
|
||||
const found: ApprovalHistoryItem[] = [];
|
||||
const seen = new Set<string>();
|
||||
const scanCap = Math.min(500, Math.max(limit * 3, 100));
|
||||
let offset = 0;
|
||||
let hasMore = false;
|
||||
while (found.length < scanCap) {
|
||||
const pageSize = Math.min(20, scanCap - found.length);
|
||||
const page = await this.client.listOfficialTodoWorkRecords(userId, offset, pageSize, 1);
|
||||
hasMore = page.hasMore;
|
||||
for (const record of page.list) {
|
||||
const item = officialWorkRecord(record);
|
||||
if (!item || seen.has(item.processInstanceId)) continue;
|
||||
seen.add(item.processInstanceId);
|
||||
found.push({ ...item, historyScope: "handled" });
|
||||
}
|
||||
offset += page.list.length;
|
||||
if (!page.hasMore || page.list.length < pageSize || page.list.length === 0) break;
|
||||
}
|
||||
await this.enrichOfficialHistory(found);
|
||||
const matched = found.filter((item) => historyInRange(item, "handled", range));
|
||||
return {
|
||||
items: sortHistory(matched).slice(0, limit),
|
||||
truncated: hasMore || matched.length > limit,
|
||||
scanLimit: scanCap,
|
||||
};
|
||||
}
|
||||
|
||||
private async listOfficialSubmitted(userId: string, limit: number, range: ApprovalHistoryRange) {
|
||||
const templates = await this.client.listOfficialTemplates();
|
||||
const ids = new Map<string, string>();
|
||||
const scanCap = Math.min(500, Math.max(limit * 3, 100));
|
||||
let truncated = false;
|
||||
await mapConcurrent(templates, this.config.officialScanConcurrency, async (template) => {
|
||||
if (!template.processCode) return;
|
||||
if (ids.size >= scanCap) { truncated = true; return; }
|
||||
let cursor = 0;
|
||||
while (ids.size < scanCap) {
|
||||
const page = await this.client.listOfficialInitiatedInstanceIds(
|
||||
template.processCode,
|
||||
userId,
|
||||
range.startTime,
|
||||
range.endTime,
|
||||
cursor,
|
||||
Math.min(20, scanCap - ids.size),
|
||||
);
|
||||
for (const id of page.instanceIds) {
|
||||
if (ids.size >= scanCap) { truncated = true; break; }
|
||||
ids.set(id, template.processCode);
|
||||
}
|
||||
if (page.nextCursor === undefined || page.instanceIds.length === 0) break;
|
||||
if (ids.size >= scanCap) { truncated = true; break; }
|
||||
cursor = page.nextCursor;
|
||||
}
|
||||
});
|
||||
const found: ApprovalHistoryItem[] = [...ids].map(([processInstanceId, processCode]) => ({
|
||||
source: "official_oa",
|
||||
historyScope: "submitted",
|
||||
processInstanceId,
|
||||
processCode,
|
||||
raw: { processInstanceId, processCode },
|
||||
}));
|
||||
await this.enrichOfficialHistory(found);
|
||||
const matched = found.filter((item) => historyInRange(item, "submitted", range));
|
||||
return {
|
||||
items: sortHistory(matched).slice(0, limit),
|
||||
truncated: truncated || matched.length > limit,
|
||||
scanLimit: scanCap,
|
||||
};
|
||||
}
|
||||
|
||||
private async enrichOfficialHistory(items: ApprovalHistoryItem[]): Promise<void> {
|
||||
await mapConcurrent(items, this.config.officialScanConcurrency, async (item) => {
|
||||
try {
|
||||
const instance = await this.client.getOfficialInstance(item.processInstanceId);
|
||||
Object.assign(item, extractApprovalListMetadata(instance, item.raw));
|
||||
if (!item.title && instance.title) item.title = instance.title;
|
||||
if (!item.processCode && typeof instance.processCode === "string") item.processCode = instance.processCode;
|
||||
item.status = firstString(instance, ["status", "processStatus"]);
|
||||
item.result = firstString(instance, ["result", "processResult"]);
|
||||
item.completedAt = historyCompletionTime(instance, item.taskId);
|
||||
item.raw = instance;
|
||||
item.enrichmentStatus = "complete";
|
||||
} catch {
|
||||
item.enrichmentStatus = "failed";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async listYidaTodos(userId: string, limit: number): Promise<TodoItem[]> {
|
||||
const output: TodoItem[] = [];
|
||||
for (let page = 1; page <= 100 && output.length < limit; page += 1) {
|
||||
const data = await this.client.listYidaCorpTasks(userId, page, Math.min(100, limit));
|
||||
const list = yidaTaskPage(data);
|
||||
for (const value of list) {
|
||||
const task = value as YidaTask;
|
||||
const status = task.taskStatus ?? task.status;
|
||||
const processInstanceId = task.processInstanceId ?? task.formInstId;
|
||||
if (!isPendingYidaStatus(status) || !processInstanceId || !task.taskId || !task.appType) continue;
|
||||
output.push({
|
||||
source: "yida",
|
||||
processInstanceId,
|
||||
taskId: task.taskId,
|
||||
appType: task.appType,
|
||||
...(task.title || task.processInstanceTitle ? { title: task.title ?? task.processInstanceTitle } : {}),
|
||||
...extractApprovalListMetadata(task),
|
||||
raw: task,
|
||||
});
|
||||
if (output.length >= limit) break;
|
||||
}
|
||||
if (list.length < Math.min(100, limit)) break;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
private async findCurrentYidaTasks(userId: string, wanted: Set<string>): Promise<Set<string>> {
|
||||
const found = new Set<string>();
|
||||
for (let page = 1; page <= 100 && found.size < wanted.size; page += 1) {
|
||||
const list = yidaTaskPage(await this.client.listYidaCorpTasks(userId, page, 100));
|
||||
for (const value of list) {
|
||||
const task = value as YidaTask;
|
||||
const processInstanceId = task.processInstanceId ?? task.formInstId;
|
||||
const status = task.taskStatus ?? task.status;
|
||||
if (!task.appType || !processInstanceId || !isPendingYidaStatus(status)) continue;
|
||||
const instanceKey = yidaTaskKey(task.appType, processInstanceId);
|
||||
const exactKey = task.taskId ? yidaTaskKey(task.appType, processInstanceId, task.taskId) : "";
|
||||
if (wanted.has(instanceKey)) found.add(instanceKey);
|
||||
if (exactKey && wanted.has(exactKey)) found.add(exactKey);
|
||||
}
|
||||
if (list.length < 100) break;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
private assertOfficialTask(instance: ApprovalInstance, userId: string, taskId: string): void {
|
||||
if (instance.status && instance.status !== "RUNNING") throw new ApprovalSafetyError(`审批实例状态为 ${instance.status},不能处理`);
|
||||
const found = this.hasOfficialPendingTask(instance, userId, taskId);
|
||||
if (!found) throw new ApprovalSafetyError("任务不属于当前登录用户、不是待审批状态或已经被处理");
|
||||
}
|
||||
|
||||
private hasOfficialPendingTask(instance: ApprovalInstance, userId: string, taskId: string): boolean {
|
||||
return this.currentOfficialPendingTasks(instance, userId).some((task) => String(task.taskId) === taskId);
|
||||
}
|
||||
|
||||
private currentOfficialPendingTasks(instance: ApprovalInstance, userId: string) {
|
||||
if (instance.status && instance.status !== "RUNNING") return [];
|
||||
return (instance.tasks ?? []).filter(
|
||||
(task) => task.userId === userId && isOfficialPendingStatus(task.status) && task.taskId !== undefined,
|
||||
);
|
||||
}
|
||||
|
||||
private async findYidaApp(appType: string): Promise<YidaApplication> {
|
||||
const app = (await this.client.listYidaApplications()).find((item) => item.appType === appType);
|
||||
if (!app) throw new ApprovalSafetyError(`找不到宜搭应用 ${appType} 或管理员无权访问`);
|
||||
return app;
|
||||
}
|
||||
}
|
||||
|
||||
function isUncertainDingTalkWriteError(error: unknown): boolean {
|
||||
return !(error instanceof DingTalkApiError) || error.status === 0 || error.status >= 500;
|
||||
}
|
||||
|
||||
function instanceStatus(instance: ApprovalInstance): string | undefined {
|
||||
return firstString(instance, ["status", "processStatus", "result"]);
|
||||
}
|
||||
|
||||
function isRevocableInstance(instance: ApprovalInstance): boolean {
|
||||
return ["RUNNING", "NEW", "PENDING"].includes((instanceStatus(instance) ?? "").toUpperCase());
|
||||
}
|
||||
|
||||
function isConfirmedRevokedInstance(instance: ApprovalInstance): boolean {
|
||||
return ["TERMINATED", "WITHDRAWN", "CANCELED", "CANCELLED", "DELETED"].includes((instanceStatus(instance) ?? "").toUpperCase());
|
||||
}
|
||||
|
||||
function firstString(value: Record<string, unknown>, keys: string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const item = value[key];
|
||||
if (typeof item === "string" && item.trim()) return item.trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function mapConcurrent<T>(items: T[], concurrency: number, worker: (item: T) => Promise<void>): Promise<void> {
|
||||
let cursor = 0;
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
||||
while (cursor < items.length) {
|
||||
const index = cursor++;
|
||||
await worker(items[index]!);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
function yidaTaskPage(data: Record<string, unknown>): unknown[] {
|
||||
if (Array.isArray(data.data)) return data.data;
|
||||
const result = data.result;
|
||||
if (Array.isArray(result)) return result;
|
||||
if (isRecord(result) && Array.isArray(result.data)) return result.data;
|
||||
return [];
|
||||
}
|
||||
|
||||
function isPendingYidaStatus(status: string | undefined): boolean {
|
||||
return status !== undefined && ["running", "new", "todo", "pending"].includes(status.toLowerCase());
|
||||
}
|
||||
|
||||
function isOfficialPendingStatus(status: string | undefined): boolean {
|
||||
return status !== undefined && ["new", "running"].includes(status.toLowerCase());
|
||||
}
|
||||
|
||||
function isOfficialHandledStatus(status: string | undefined): boolean {
|
||||
return status !== undefined && ["completed", "finished", "done", "executed"].includes(status.toLowerCase());
|
||||
}
|
||||
|
||||
function historyCompletionTime(instance: ApprovalInstance, taskId?: string): string | undefined {
|
||||
const task = taskId === undefined ? undefined : (instance.tasks ?? []).find((item) => String(item.taskId) === taskId);
|
||||
const value = task?.finishTime ?? task?.completedAt ?? task?.endTime ?? task?.operateTime ?? task?.operationTime
|
||||
?? task?.modifiedTime ?? task?.updateTime
|
||||
?? instance.finishTime ?? instance.completedAt ?? instance.endTime;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const numeric = Number(value);
|
||||
if (/^\d+(?:\.\d+)?$/.test(value.trim()) && Number.isFinite(numeric)) return epochIso(numeric);
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : value.trim();
|
||||
}
|
||||
if (typeof value === "number" && Number.isFinite(value)) return epochIso(value);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function sortHistory<T extends ApprovalHistoryItem>(items: T[]): T[] {
|
||||
return items.sort((left, right) => historyTime(right) - historyTime(left));
|
||||
}
|
||||
|
||||
function historyTime(item: ApprovalHistoryItem): number {
|
||||
const parsed = Date.parse(item.completedAt ?? item.submittedAt ?? "");
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function historyInRange(item: ApprovalHistoryItem, scope: ApprovalHistoryScope, range: ApprovalHistoryRange): boolean {
|
||||
const raw = scope === "submitted" ? item.submittedAt : item.completedAt ?? item.submittedAt;
|
||||
const timestamp = Date.parse(raw ?? "");
|
||||
return Number.isFinite(timestamp) && timestamp >= range.startTime && timestamp <= range.endTime;
|
||||
}
|
||||
|
||||
function epochIso(value: number): string {
|
||||
return new Date(value < 10_000_000_000 ? value * 1000 : value).toISOString();
|
||||
}
|
||||
|
||||
function yidaTaskKey(appType: string, processInstanceId: string, taskId?: string): string {
|
||||
return `${appType}\u0000${processInstanceId}${taskId === undefined ? "" : `\u0000${taskId}`}`;
|
||||
}
|
||||
|
||||
function batchResult(action: ApprovalAction, total: number, results: Array<Record<string, unknown>>) {
|
||||
return {
|
||||
action,
|
||||
total,
|
||||
succeeded: results.filter((value) => value.success).length,
|
||||
failed: results.filter((value) => !value.success).length,
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
function officialWorkRecord(record: OfficialTodoWorkRecord): TodoItem | undefined {
|
||||
if (!record.instance_id || record.task_id === undefined) return undefined;
|
||||
const clientUrl = safeDingTalkClientUrl(record.url);
|
||||
return {
|
||||
source: "official_oa",
|
||||
processInstanceId: record.instance_id,
|
||||
taskId: String(record.task_id),
|
||||
...(record.title ? { title: record.title } : {}),
|
||||
...(clientUrl ? { clientUrl } : {}),
|
||||
...extractApprovalListMetadata(record),
|
||||
raw: record,
|
||||
};
|
||||
}
|
||||
|
||||
function safeDingTalkClientUrl(value: string | undefined): string | undefined {
|
||||
if (!value) return undefined;
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return parsed.protocol === "https:" && parsed.hostname === "aflow.dingtalk.com" ? parsed.toString() : undefined;
|
||||
} catch { return undefined; }
|
||||
}
|
||||
+505
@@ -0,0 +1,505 @@
|
||||
#!/usr/bin/env node
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
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 { isDirectEntrypoint } from "./entrypoint.js";
|
||||
|
||||
interface BridgeState {
|
||||
client?: { client_id: string; client_secret?: string };
|
||||
tokens?: { access_token: string; refresh_token?: string; expires_in?: number; savedAt: number };
|
||||
device?: {
|
||||
device_code: string;
|
||||
user_code: string;
|
||||
verification_uri_complete: string;
|
||||
expiresAt: number;
|
||||
interval: number;
|
||||
};
|
||||
onboarding?: {
|
||||
schemaVersion: 1;
|
||||
status: "pending" | "delivered";
|
||||
deliveredAt?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const APPROVAL_ONBOARDING_EXAMPLES = [
|
||||
"帮我列出当前待处理的审批",
|
||||
"帮我查看我处理过或我发起的审批",
|
||||
"总结费用类待办,按金额和费用发生日期整理",
|
||||
"列出我可以发起的审批模板",
|
||||
"先列出待办,我确认后同意指定的几张",
|
||||
] as const;
|
||||
|
||||
class AuthRequired extends Error {
|
||||
constructor(readonly challenge: NonNullable<BridgeState["device"]>) { super("需要钉钉登录"); }
|
||||
}
|
||||
|
||||
export class RemoteBridge {
|
||||
private state: BridgeState;
|
||||
private client?: Client;
|
||||
private connecting?: Promise<Client>;
|
||||
private activeCalls = 0;
|
||||
private readonly callWaiters: Array<() => void> = [];
|
||||
readonly endpoint: URL;
|
||||
readonly origin: URL;
|
||||
|
||||
constructor(endpoint: string, private readonly statePath: string, private readonly fetchFn: typeof fetch = fetch) {
|
||||
this.endpoint = normalizeEndpoint(endpoint);
|
||||
this.origin = new URL(this.endpoint.origin);
|
||||
this.state = this.loadState();
|
||||
}
|
||||
|
||||
ensureOnboardingPending(): void {
|
||||
const diskState = this.loadState();
|
||||
this.state = diskState;
|
||||
if (this.state.onboarding) return;
|
||||
this.state.onboarding = { schemaVersion: 1, status: "pending" };
|
||||
this.saveState();
|
||||
}
|
||||
|
||||
async loginChallenge() {
|
||||
await this.tryCompleteDeviceLogin();
|
||||
if (!this.usableAccessToken()) await this.refreshAccessToken();
|
||||
if (this.usableAccessToken()) {
|
||||
const onboarding = this.takeOnboarding();
|
||||
return {
|
||||
authenticated: true,
|
||||
message: onboarding ? `已登录,可以读取审批。\n\n${onboardingMessage()}` : "已登录,可以读取审批。",
|
||||
...(onboarding ? { onboarding } : {}),
|
||||
};
|
||||
}
|
||||
const challenge = await this.startDeviceLogin();
|
||||
return this.presentChallenge(challenge);
|
||||
}
|
||||
|
||||
async call(name: string, args: Record<string, unknown>): Promise<any> {
|
||||
return this.withCallSlot(async () => {
|
||||
try {
|
||||
const client = await this.remoteClient();
|
||||
return this.appendOnboarding(await client.callTool({ name, arguments: args }));
|
||||
} 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));
|
||||
try { return await work(); }
|
||||
finally {
|
||||
const next = this.callWaiters.shift();
|
||||
if (next) next();
|
||||
else this.activeCalls -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
private async remoteClient(): Promise<Client> {
|
||||
if (this.client && this.usableAccessToken()) return this.client;
|
||||
if (this.client) {
|
||||
const stale = this.client;
|
||||
this.client = undefined;
|
||||
try { await stale.close(); } catch { /* stale sessions are best-effort cleanup */ }
|
||||
}
|
||||
if (this.connecting) return this.connecting;
|
||||
this.connecting = this.connectRemote();
|
||||
try { return await this.connecting; } finally { this.connecting = undefined; }
|
||||
}
|
||||
|
||||
private async connectRemote(allowRefreshRetry = true): Promise<Client> {
|
||||
await this.tryCompleteDeviceLogin();
|
||||
if (!this.usableAccessToken()) await this.refreshAccessToken();
|
||||
if (!this.usableAccessToken()) throw new AuthRequired(await this.startDeviceLogin());
|
||||
const client = new Client({ name: "dingtalk-approval-stdio-bridge", version: "1.1.0-rc.8" });
|
||||
const transport = new StreamableHTTPClientTransport(this.endpoint, {
|
||||
requestInit: { headers: { authorization: `Bearer ${this.state.tokens!.access_token}` } },
|
||||
});
|
||||
try { await client.connect(transport); }
|
||||
catch (error) {
|
||||
// A gateway restart, reverse-proxy reload or short network outage must
|
||||
// not destroy an otherwise refreshable login session.
|
||||
if (!isAuthenticationFailure(error)) {
|
||||
throw new Error("审批网关暂时无法连接,已保留登录状态,请稍后重试", { cause: error });
|
||||
}
|
||||
const refreshToken = this.state.tokens?.refresh_token;
|
||||
if (refreshToken && allowRefreshRetry) {
|
||||
this.state.tokens = { access_token: "", refresh_token: refreshToken, expires_in: 0, savedAt: 0 };
|
||||
this.saveState();
|
||||
await this.refreshAccessToken();
|
||||
if (this.usableAccessToken()) return this.connectRemote(false);
|
||||
}
|
||||
this.state.tokens = undefined;
|
||||
this.saveState();
|
||||
throw new AuthRequired(await this.startDeviceLogin());
|
||||
}
|
||||
this.client = client;
|
||||
return client;
|
||||
}
|
||||
|
||||
private usableAccessToken(): boolean {
|
||||
const token = this.state.tokens;
|
||||
return Boolean(token?.access_token && token.savedAt + (token.expires_in ?? 900) * 1000 > Date.now() + 30_000);
|
||||
}
|
||||
|
||||
private async ensureClient(): Promise<NonNullable<BridgeState["client"]>> {
|
||||
if (this.state.client?.client_id) return this.state.client;
|
||||
const response = await this.fetchFn(new URL("/register", this.origin), {
|
||||
method: "POST", headers: { "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify({
|
||||
client_name: "DingTalk Approval stdio bridge",
|
||||
redirect_uris: ["https://localhost.invalid/dingtalk-approval-device"],
|
||||
token_endpoint_auth_method: "none",
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
response_types: ["code"],
|
||||
}),
|
||||
});
|
||||
const value = await response.json() as { client_id?: string; client_secret?: string };
|
||||
if (!response.ok || !value.client_id) throw new Error("远程 MCP 拒绝注册兼容桥客户端");
|
||||
this.state.client = { client_id: value.client_id, ...(value.client_secret ? { client_secret: value.client_secret } : {}) };
|
||||
this.saveState();
|
||||
return this.state.client;
|
||||
}
|
||||
|
||||
private async startDeviceLogin(): Promise<NonNullable<BridgeState["device"]>> {
|
||||
if (this.state.device && this.state.device.expiresAt > Date.now()) return this.state.device;
|
||||
const client = await this.ensureClient();
|
||||
const response = await this.fetchFn(new URL("/oauth/device/authorize", this.origin), {
|
||||
method: "POST", headers: { "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify({ client_id: client.client_id, scope: "mcp:tools", resource: this.endpoint.toString() }),
|
||||
});
|
||||
const value = await response.json() as Record<string, unknown>;
|
||||
if (!response.ok || typeof value.device_code !== "string" || typeof value.verification_uri_complete !== "string") {
|
||||
throw new Error("远程 MCP 无法创建钉钉登录链接");
|
||||
}
|
||||
this.state.device = {
|
||||
device_code: value.device_code,
|
||||
user_code: String(value.user_code ?? ""),
|
||||
verification_uri_complete: value.verification_uri_complete,
|
||||
expiresAt: Date.now() + Number(value.expires_in ?? 600) * 1000,
|
||||
interval: Number(value.interval ?? 5),
|
||||
};
|
||||
this.saveState();
|
||||
return this.state.device;
|
||||
}
|
||||
|
||||
private async tryCompleteDeviceLogin(): Promise<void> {
|
||||
const device = this.state.device;
|
||||
const client = this.state.client;
|
||||
if (!device || !client || device.expiresAt <= Date.now()) {
|
||||
if (device) { this.state.device = undefined; this.saveState(); }
|
||||
return;
|
||||
}
|
||||
const response = await this.fetchFn(new URL("/oauth/device/token", this.origin), {
|
||||
method: "POST", headers: { "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify({ client_id: client.client_id, device_code: device.device_code }),
|
||||
});
|
||||
const value = await response.json() as Record<string, unknown>;
|
||||
if (!response.ok) {
|
||||
if (value.error === "authorization_pending" || value.error === "slow_down") return;
|
||||
if (isExplicitOAuthInvalidation(response.status, value)) {
|
||||
this.state.device = undefined;
|
||||
this.saveState();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof value.access_token !== "string") return;
|
||||
this.state.tokens = {
|
||||
access_token: value.access_token,
|
||||
...(typeof value.refresh_token === "string" ? { refresh_token: value.refresh_token } : {}),
|
||||
expires_in: Number(value.expires_in ?? 900), savedAt: Date.now(),
|
||||
};
|
||||
this.state.device = undefined;
|
||||
this.saveState();
|
||||
}
|
||||
|
||||
private async refreshAccessToken(): Promise<void> {
|
||||
const diskState = this.loadState();
|
||||
if ((diskState.tokens?.savedAt ?? 0) > (this.state.tokens?.savedAt ?? 0)) {
|
||||
this.state.tokens = diskState.tokens;
|
||||
if (diskState.client?.client_id) this.state.client = diskState.client;
|
||||
if (this.usableAccessToken()) return;
|
||||
}
|
||||
const refreshToken = this.state.tokens?.refresh_token;
|
||||
const client = this.state.client;
|
||||
if (!refreshToken || !client) return;
|
||||
const body = new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: client.client_id });
|
||||
const response = await this.fetchFn(new URL("/token", this.origin), {
|
||||
method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" }, body,
|
||||
});
|
||||
const value = await response.json() as Record<string, unknown>;
|
||||
if (!response.ok || typeof value.access_token !== "string") {
|
||||
// Another bridge process may have rotated the single-use refresh token
|
||||
// while this request was in flight. Prefer its newer saved state.
|
||||
const latest = this.loadState();
|
||||
if ((latest.tokens?.savedAt ?? 0) > (this.state.tokens?.savedAt ?? 0)) {
|
||||
this.state.tokens = latest.tokens;
|
||||
if (latest.client?.client_id) this.state.client = latest.client;
|
||||
if (this.usableAccessToken()) return;
|
||||
}
|
||||
if (isExplicitOAuthInvalidation(response.status, value)) {
|
||||
this.state.tokens = undefined;
|
||||
this.saveState();
|
||||
return;
|
||||
}
|
||||
throw new Error("审批网关暂时无法刷新登录,已保留登录状态,请稍后重试");
|
||||
}
|
||||
this.state.tokens = {
|
||||
access_token: value.access_token,
|
||||
refresh_token: typeof value.refresh_token === "string" ? value.refresh_token : refreshToken,
|
||||
expires_in: Number(value.expires_in ?? 900), savedAt: Date.now(),
|
||||
};
|
||||
this.saveState();
|
||||
}
|
||||
|
||||
private presentChallenge(challenge: NonNullable<BridgeState["device"]>) {
|
||||
const directUrl = challenge.verification_uri_complete;
|
||||
const expiresAt = new Date(challenge.expiresAt).toISOString();
|
||||
const message = [
|
||||
"需要钉钉授权。系统不会另行推送或弹出登录链接。",
|
||||
`请直接打开:[打开钉钉授权登录](${directUrl})`,
|
||||
`备用原始链接:${directUrl}`,
|
||||
...(challenge.user_code ? [`授权码:${challenge.user_code}`] : []),
|
||||
`链接有效期至:${expiresAt}`,
|
||||
"Agent 必须在当前回复正文中原样显示以上链接,不得只说“已生成”或“已推送”。",
|
||||
"登录完成后重新调用原工具。",
|
||||
].join("\n");
|
||||
return {
|
||||
authenticated: false,
|
||||
authChallenge: {
|
||||
provider: "dingtalk", type: "link", directUrl,
|
||||
userCode: challenge.user_code, expiresAt,
|
||||
pollAfterSeconds: challenge.interval,
|
||||
},
|
||||
// Some stdio MCP hosts render only content.text and discard
|
||||
// structuredContent. Keep the structured challenge for capable agents,
|
||||
// but always put the short-lived URL in the portable text fallback.
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
private authResult(challenge: NonNullable<BridgeState["device"]>) {
|
||||
return bridgeAuthToolResult(this.presentChallenge(challenge));
|
||||
}
|
||||
|
||||
private takeOnboarding() {
|
||||
const diskState = this.loadState();
|
||||
if (diskState.onboarding?.status === "delivered") {
|
||||
this.state.onboarding = diskState.onboarding;
|
||||
return undefined;
|
||||
}
|
||||
if (diskState.onboarding?.status === "pending") this.state.onboarding = diskState.onboarding;
|
||||
if (this.state.onboarding?.status !== "pending") return undefined;
|
||||
const onboarding = {
|
||||
firstRun: true as const,
|
||||
examples: [...APPROVAL_ONBOARDING_EXAMPLES],
|
||||
writeSafety: "审批写操作只会在用户明确指定动作和目标后执行。",
|
||||
};
|
||||
this.state.onboarding = { schemaVersion: 1, status: "delivered", deliveredAt: new Date().toISOString() };
|
||||
this.saveState();
|
||||
return onboarding;
|
||||
}
|
||||
|
||||
private appendOnboarding(result: any): any {
|
||||
if (result?.isError === true) return result;
|
||||
const onboarding = this.takeOnboarding();
|
||||
if (!onboarding) return result;
|
||||
const record = result && typeof result === "object" && !Array.isArray(result) ? result : {};
|
||||
const content = Array.isArray(record.content) ? [...record.content] : [];
|
||||
content.push({ type: "text", text: onboardingMessage() });
|
||||
const structured = record.structuredContent && typeof record.structuredContent === "object" && !Array.isArray(record.structuredContent)
|
||||
? record.structuredContent : {};
|
||||
return { ...record, content, structuredContent: { ...structured, onboarding } };
|
||||
}
|
||||
|
||||
private loadState(): BridgeState {
|
||||
if (!existsSync(this.statePath)) return {};
|
||||
try { return JSON.parse(readFileSync(this.statePath, "utf8")) as BridgeState; } catch { return {}; }
|
||||
}
|
||||
|
||||
private saveState(): void {
|
||||
mkdirSync(dirname(this.statePath), { recursive: true, mode: 0o700 });
|
||||
chmodSync(dirname(this.statePath), 0o700);
|
||||
const temporary = `${this.statePath}.tmp-${process.pid}`;
|
||||
writeFileSync(temporary, JSON.stringify(this.state), { mode: 0o600 });
|
||||
chmodSync(temporary, 0o600);
|
||||
renameSync(temporary, this.statePath);
|
||||
}
|
||||
}
|
||||
|
||||
type BridgeLoginResult = Awaited<ReturnType<RemoteBridge["loginChallenge"]>>;
|
||||
|
||||
function onboardingMessage(): string {
|
||||
return [
|
||||
"首次登录已完成。你可以直接这样使用钉钉审批:",
|
||||
...APPROVAL_ONBOARDING_EXAMPLES.map((example) => `- “${example}”`),
|
||||
"审批写操作只会在你明确指定动作和目标后执行。",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function bridgeAuthToolResult(result: BridgeLoginResult) {
|
||||
const content: Array<
|
||||
{ type: "text"; text: string }
|
||||
| { type: "resource_link"; name: string; title: string; uri: string; description: string }
|
||||
> = [{ type: "text", text: result.message }];
|
||||
if ("authChallenge" in result) {
|
||||
content.push({
|
||||
type: "resource_link",
|
||||
name: "dingtalk-approval-login",
|
||||
title: "打开钉钉授权登录",
|
||||
uri: result.authChallenge.directUrl,
|
||||
description: "当前用户的短期钉钉授权登录链接",
|
||||
});
|
||||
}
|
||||
return { content, structuredContent: { result } };
|
||||
}
|
||||
|
||||
function isAuthenticationFailure(error: unknown): boolean {
|
||||
const record = error && typeof error === "object" ? error as Record<string, unknown> : {};
|
||||
const status = Number(record.status ?? record.statusCode ?? record.code);
|
||||
if (status === 401 || status === 403) return true;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return /^(?:MCP error[^:]*:\s*)?(?:Unauthorized|Forbidden|Invalid[_ -]?token)\b/i.test(message)
|
||||
|| /\bHTTP\s+(?:401|403)\b/i.test(message);
|
||||
}
|
||||
|
||||
function isExplicitOAuthInvalidation(status: number, value: Record<string, unknown>): boolean {
|
||||
const code = typeof value.error === "string" ? value.error : "";
|
||||
return status >= 400 && status < 500
|
||||
&& ["invalid_grant", "invalid_client", "unauthorized_client", "access_denied", "expired_token"].includes(code);
|
||||
}
|
||||
|
||||
function normalizeEndpoint(value: string): URL {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "https:" || url.pathname !== "/mcp") throw new Error("远程 MCP 必须是 HTTPS /mcp 地址");
|
||||
return url;
|
||||
}
|
||||
|
||||
function parseEndpoint(argv: string[]): string {
|
||||
const index = argv.indexOf("--url");
|
||||
return (index >= 0 ? argv[index + 1] : undefined) ?? process.env.DINGTALK_APPROVAL_MCP_URL ?? "";
|
||||
}
|
||||
|
||||
export function createBridgeMcpServer(bridge: RemoteBridge): McpServer {
|
||||
const server = new McpServer({ name: "dingtalk-approval-stdio-bridge", version: "1.1.0-rc.8" }, {
|
||||
instructions: `首次使用或登录过期时,工具不会通过其他消息通道自动发送链接。必须把content.text中的登录URL原样写入当前面向用户的回复,不得只声称已生成或已推送。处理整组查询结果优先使用selectionId;同意/拒绝无需逐张查询动作,只有退回需要targetId。每次最多处理 ${APPROVAL_BATCH_LIMIT} 条;超过时可从同一次列表中分组,不能自动补偿或重试。任一引用预检失败时整批零写入。`,
|
||||
});
|
||||
server.registerTool("approval_login", {
|
||||
title: "登录审批服务", description: "创建或检查唯一的钉钉登录链接;同一身份同时用于钉钉OA和薪福通。", inputSchema: {},
|
||||
annotations: { readOnlyHint: true, destructiveHint: false },
|
||||
}, async () => {
|
||||
const result = await bridge.loginChallenge();
|
||||
return bridgeAuthToolResult(result);
|
||||
});
|
||||
server.registerTool("approval_list_todos", {
|
||||
title: "列出我的审批待办", description: "实时列出当前登录用户本人的钉钉OA和薪福通待办。", inputSchema: {
|
||||
source: z.enum(["all", "dingtalk", "xft"]).default("all"), query: z.string().max(200).default(""),
|
||||
limit: z.number().int().min(1).max(200).default(50),
|
||||
}, annotations: { readOnlyHint: true, destructiveHint: false },
|
||||
}, async (args) => bridge.call("approval_list_todos", args));
|
||||
server.registerTool("approval_list_history", {
|
||||
title: "列出我的审批历史", description: "实时列出当前用户处理过或本人发起的钉钉OA和薪福通审批;默认最近30天,可指定最长120天的时间窗口;结果仅用于读取。", inputSchema: {
|
||||
scope: z.enum(["handled", "submitted"]),
|
||||
source: z.enum(["all", "dingtalk", "xft"]).default("all"), query: z.string().max(200).default(""),
|
||||
limit: z.number().int().min(1).max(200).default(50),
|
||||
startDate: z.string().max(40).default("").describe("可选;YYYY-MM-DD(上海时区)或带时区 ISO 8601;默认最近30天"),
|
||||
endDate: z.string().max(40).default("").describe("可选且包含结束日;单次跨度最多120天"),
|
||||
}, annotations: { readOnlyHint: true, destructiveHint: false },
|
||||
}, async (args) => bridge.call("approval_list_history", args));
|
||||
server.registerTool("approval_get_detail", {
|
||||
title: "获取审批详情", description: "读取待办或历史列表返回的审批引用对应的实时详情。", inputSchema: {
|
||||
approvalId: z.string().min(20).max(4096), view: z.enum(["summary", "expense", "raw"]).default("summary"),
|
||||
rawPath: z.string().max(1024).default(""), rawCursor: z.number().int().min(0).default(0),
|
||||
rawLimit: z.number().int().min(1).max(50).default(20),
|
||||
rawQuery: z.string().max(200).default("").describe("raw 视图可选的有界字段搜索"),
|
||||
}, annotations: { readOnlyHint: true, destructiveHint: false },
|
||||
}, async (args) => bridge.call("approval_get_detail", args));
|
||||
server.registerTool("approval_get_actions", {
|
||||
title: "获取审批可执行动作", description: "实时检查同意、拒绝和退回能力。", inputSchema: { approvalId: z.string().min(20).max(4096) },
|
||||
annotations: { readOnlyHint: true, destructiveHint: false },
|
||||
}, async (args) => bridge.call("approval_get_actions", args));
|
||||
server.registerTool("approval_process_batch", {
|
||||
title: "处理审批", description: `仅在用户明确授权后处理本人待办;一次最多 ${APPROVAL_BATCH_LIMIT} 条。整组使用selectionId,部分项目使用items;写前校验整批引用,任一无效则零写入,不自动重试。`, inputSchema: {
|
||||
action: z.enum(["approve", "reject", "return"]), remark: z.string().max(1000).default(""),
|
||||
selectionId: z.string().min(20).max(4096).optional(),
|
||||
items: z.array(z.object({ approvalId: z.string().min(20).max(4096), targetId: z.string().min(20).max(4096).optional() }))
|
||||
.max(APPROVAL_BATCH_LIMIT, `一次最多处理 ${APPROVAL_BATCH_LIMIT} 条审批`).default([]),
|
||||
}, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
|
||||
}, async (args) => bridge.call("approval_process_batch", args));
|
||||
server.registerTool("approval_list_submission_templates", {
|
||||
title: "列出发起审批候选模板", description: "实时列出候选模板;是否可由 API 构造需继续读取 submissionCapability。", inputSchema: {
|
||||
source: z.enum(["all", "dingtalk", "xft"]).default("all"), query: z.string().max(200).default(""),
|
||||
limit: z.number().int().min(1).max(100).default(50),
|
||||
}, annotations: { readOnlyHint: true, destructiveHint: false },
|
||||
}, async (args) => bridge.call("approval_list_submission_templates", args));
|
||||
server.registerTool("approval_get_submission_schema", {
|
||||
title: "获取发起审批表单结构", description: "实时读取模板字段、必填项、复杂字段格式和 API 构造能力。", inputSchema: {
|
||||
templateId: z.string().min(20).max(4096),
|
||||
}, annotations: { readOnlyHint: true, destructiveHint: false },
|
||||
}, async (args) => bridge.call("approval_get_submission_schema", args));
|
||||
server.registerTool("approval_get_submission_options", {
|
||||
title: "查询发起审批可用选项", description: "实时查询当前登录用户本人的薪福通承担部门,或搜索城市编码。", inputSchema: {
|
||||
templateId: z.string().min(20).max(4096), kind: z.enum(["department", "city"]),
|
||||
query: z.string().max(100).default(""), limit: z.number().int().min(1).max(100).default(50),
|
||||
}, annotations: { readOnlyHint: true, destructiveHint: false },
|
||||
}, async (args) => bridge.call("approval_get_submission_options", args));
|
||||
const submissionInputSchema = {
|
||||
templateId: z.string().min(20).max(4096),
|
||||
values: z.record(z.string().min(1).max(200), z.unknown()),
|
||||
deptId: z.number().int().default(-1).describe("钉钉发起部门;默认-1,由网关按当前登录用户实时解析并验证"),
|
||||
targetSelectActioners: z.array(z.object({
|
||||
actionerKey: z.string().min(1).max(500),
|
||||
actionerStaffIds: z.array(z.string().min(1).max(500)).min(1).max(50),
|
||||
})).max(20).default([]),
|
||||
allowDuplicateInvoices: z.boolean().default(false).describe("仅在网关已提示重复风险或查重不完整、且用户明确确认仍要提交后设为 true"),
|
||||
};
|
||||
server.registerTool("approval_prepare_submission", {
|
||||
title: "校验并预览新审批", description: "无副作用校验全部字段并自动查重本人近30天钉钉/薪福通发票;有重复风险或查重不完整时要求用户明确确认,不会直接生成提交引用。", inputSchema: submissionInputSchema,
|
||||
annotations: { readOnlyHint: true, destructiveHint: false },
|
||||
}, async (args) => bridge.call("approval_prepare_submission", 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));
|
||||
server.registerTool("approval_revoke_submission", {
|
||||
title: "撤销本人发起的审批", description: "使用提单成功后返回的 revokeId 撤销本人发起的审批;禁止自动重试。薪福通 recall 当前为实验能力,必须以写后状态复核为准。", inputSchema: {
|
||||
revokeId: z.string().min(20).max(4096), remark: z.string().max(1000).default(""),
|
||||
}, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
|
||||
}, async (args) => bridge.call("approval_revoke_submission", args));
|
||||
server.registerTool("approval_get_submission_status", {
|
||||
title: "核验本人已发起审批状态", description: "只读核验本人发起的薪福通单据状态;仍可撤回时返回新的短期revokeId。", inputSchema: {
|
||||
source: z.literal("xft"), businessId: z.string().regex(/^\d{10,20}$/),
|
||||
}, annotations: { readOnlyHint: true, destructiveHint: false },
|
||||
}, async (args) => bridge.call("approval_get_submission_status", args));
|
||||
return server;
|
||||
}
|
||||
|
||||
export async function startBridge(endpoint: string, statePath = process.env.DINGTALK_APPROVAL_BRIDGE_STATE
|
||||
?? join(homedir(), ".config", "dingtalk-approval-bridge", "session.json")): Promise<void> {
|
||||
const bridge = new RemoteBridge(endpoint, resolve(statePath));
|
||||
const server = createBridgeMcpServer(bridge);
|
||||
await server.connect(new StdioServerTransport());
|
||||
}
|
||||
|
||||
async function runPostInstallLogin(endpoint: string, statePath: string): Promise<void> {
|
||||
const bridge = new RemoteBridge(endpoint, resolve(statePath));
|
||||
bridge.ensureOnboardingPending();
|
||||
process.stdout.write(`${JSON.stringify(await bridge.loginChallenge())}\n`);
|
||||
}
|
||||
|
||||
if (isDirectEntrypoint(import.meta.url, process.argv[1])) {
|
||||
const args = process.argv.slice(2);
|
||||
const endpoint = parseEndpoint(args);
|
||||
const statePath = process.env.DINGTALK_APPROVAL_BRIDGE_STATE
|
||||
?? join(homedir(), ".config", "dingtalk-approval-bridge", "session.json");
|
||||
const task = args.includes("--post-install-login")
|
||||
? runPostInstallLogin(endpoint, statePath)
|
||||
: startBridge(endpoint, statePath);
|
||||
task.catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });
|
||||
}
|
||||
@@ -0,0 +1,809 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash, createPublicKey, randomUUID, verify as verifySignature } from "node:crypto";
|
||||
import {
|
||||
chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync,
|
||||
readdirSync, realpathSync, renameSync, rmSync, unlinkSync, writeFileSync,
|
||||
} from "node:fs";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep, win32 } from "node:path";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export interface ReleaseArtifact {
|
||||
url: string;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface ClientReleaseManifest {
|
||||
schemaVersion: 3;
|
||||
version: string;
|
||||
createdAt: string;
|
||||
gatewayUrl: string;
|
||||
signing: { algorithm: "Ed25519"; keyId: string };
|
||||
requirements: { node: string };
|
||||
artifacts: {
|
||||
installer: ReleaseArtifact;
|
||||
publicKey: ReleaseArtifact & { keyId: string };
|
||||
client: ReleaseArtifact;
|
||||
skill: ReleaseArtifact;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ClientInstallState {
|
||||
schemaVersion: 1;
|
||||
version: string;
|
||||
installedAt: string;
|
||||
manifestUrl: string;
|
||||
gatewayUrl: string;
|
||||
clientSha256: string;
|
||||
skillSha256: string;
|
||||
runtimeDir: string;
|
||||
skillFile: string;
|
||||
}
|
||||
|
||||
export interface ClientPaths {
|
||||
root: string;
|
||||
stateFile: string;
|
||||
publicKeyFile: string;
|
||||
versionsDir: string;
|
||||
binDir: string;
|
||||
skillDir: string;
|
||||
skillFile: string;
|
||||
launcherFile: string;
|
||||
cliCommand: string;
|
||||
bridgeCommand: string;
|
||||
sessionFile: string;
|
||||
}
|
||||
|
||||
interface PostInstallResult {
|
||||
required: true;
|
||||
attempted: true;
|
||||
status: "authenticated" | "authorization_required" | "unavailable";
|
||||
authenticated: boolean;
|
||||
message: string;
|
||||
authChallenge?: {
|
||||
directUrl: string;
|
||||
userCode?: string;
|
||||
expiresAt?: string;
|
||||
};
|
||||
onboarding?: {
|
||||
firstRun: true;
|
||||
examples: string[];
|
||||
writeSafety?: string;
|
||||
};
|
||||
confirmTool: { name: "approval_login"; arguments: Record<string, never> };
|
||||
verifyTool: { name: "approval_list_todos"; arguments: { source: "all"; query: ""; limit: 1 } };
|
||||
completionCriteria: string;
|
||||
}
|
||||
|
||||
interface SignatureEnvelope {
|
||||
schemaVersion: 1;
|
||||
algorithm: "Ed25519";
|
||||
keyId: string;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
interface CommandContext {
|
||||
argv: string[];
|
||||
env: NodeJS.ProcessEnv;
|
||||
platform: NodeJS.Platform;
|
||||
home: string;
|
||||
fetchFn: typeof fetch;
|
||||
stdout: (value: string) => void;
|
||||
stderr: (value: string) => void;
|
||||
}
|
||||
|
||||
const LEGACY_BIN_NAMES = ["dingtalk-admin", "dingtalk-approval-bridge", "dingtalk-approval-mcp", "dingtalk-mcp"];
|
||||
const VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$/;
|
||||
const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024;
|
||||
const DOWNLOAD_TIMEOUT_MS = 30_000;
|
||||
const MANIFEST_MAX_AGE_MS = 180 * 24 * 60 * 60_000;
|
||||
|
||||
export function clientPaths(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
home = homedir(),
|
||||
): ClientPaths {
|
||||
const pathApi = platform === "win32" ? win32 : posix;
|
||||
const override = env.DINGTALK_APPROVAL_CLIENT_HOME?.trim();
|
||||
const root = pathApi.resolve(override || (platform === "win32"
|
||||
? pathApi.join(env.LOCALAPPDATA?.trim() || pathApi.join(home, "AppData", "Local"), "DingTalkApproval")
|
||||
: platform === "darwin"
|
||||
? pathApi.join(home, "Library", "Application Support", "dingtalk-approval")
|
||||
: pathApi.join(env.XDG_DATA_HOME?.trim() || pathApi.join(home, ".local", "share"), "dingtalk-approval")));
|
||||
const binDir = pathApi.join(root, "bin");
|
||||
const executableSuffix = platform === "win32" ? ".cmd" : "";
|
||||
return {
|
||||
root,
|
||||
stateFile: pathApi.join(root, "install-state.json"),
|
||||
publicKeyFile: pathApi.join(root, "release-signing-public.pem"),
|
||||
versionsDir: pathApi.join(root, "versions"),
|
||||
binDir,
|
||||
skillDir: pathApi.join(root, "skill", "dingtalk-approval"),
|
||||
skillFile: pathApi.join(root, "skill", "dingtalk-approval", "SKILL.md"),
|
||||
launcherFile: pathApi.join(binDir, "launcher.mjs"),
|
||||
cliCommand: pathApi.join(binDir, `dingtalk-approval${executableSuffix}`),
|
||||
bridgeCommand: pathApi.join(binDir, `dingtalk-approval-bridge${executableSuffix}`),
|
||||
sessionFile: pathApi.join(root, "state", "session.json"),
|
||||
};
|
||||
}
|
||||
|
||||
export function releaseKeyId(publicKeyPem: string | Buffer): string {
|
||||
const der = createPublicKey(publicKeyPem).export({ type: "spki", format: "der" });
|
||||
return createHash("sha256").update(der).digest("hex");
|
||||
}
|
||||
|
||||
export function verifySignedManifest(
|
||||
manifestBytes: Buffer,
|
||||
signatureBytes: Buffer,
|
||||
publicKeyPem: string | Buffer,
|
||||
manifestUrl: string,
|
||||
): ClientReleaseManifest {
|
||||
let envelope: SignatureEnvelope;
|
||||
try { envelope = JSON.parse(signatureBytes.toString("utf8")) as SignatureEnvelope; }
|
||||
catch { throw new Error("发布清单签名文件不是有效 JSON"); }
|
||||
const keyId = releaseKeyId(publicKeyPem);
|
||||
if (envelope.schemaVersion !== 1 || envelope.algorithm !== "Ed25519" || envelope.keyId !== keyId) {
|
||||
throw new Error("发布清单签名的算法或密钥编号不受信任");
|
||||
}
|
||||
let signature: Buffer;
|
||||
try { signature = Buffer.from(envelope.signature, "base64"); }
|
||||
catch { throw new Error("发布清单签名编码无效"); }
|
||||
if (signature.length === 0 || !verifySignature(null, manifestBytes, publicKeyPem, signature)) {
|
||||
throw new Error("发布清单 Ed25519 签名无效");
|
||||
}
|
||||
let manifest: ClientReleaseManifest;
|
||||
try { manifest = JSON.parse(manifestBytes.toString("utf8")) as ClientReleaseManifest; }
|
||||
catch { throw new Error("发布清单不是有效 JSON"); }
|
||||
validateManifest(manifest, manifestUrl, keyId);
|
||||
return manifest;
|
||||
}
|
||||
|
||||
export async function runClientInstaller(
|
||||
argv = process.argv.slice(2),
|
||||
overrides: Partial<Omit<CommandContext, "argv">> = {},
|
||||
): Promise<number> {
|
||||
const context: CommandContext = {
|
||||
argv,
|
||||
env: overrides.env ?? process.env,
|
||||
platform: overrides.platform ?? process.platform,
|
||||
home: overrides.home ?? homedir(),
|
||||
fetchFn: overrides.fetchFn ?? fetch,
|
||||
stdout: overrides.stdout ?? ((value) => { process.stdout.write(value); }),
|
||||
stderr: overrides.stderr ?? ((value) => { process.stderr.write(value); }),
|
||||
};
|
||||
try {
|
||||
const args = parseClientArgs(context.argv);
|
||||
const paths = clientPaths(context.env, context.platform, context.home);
|
||||
if (args.appRoot) Object.assign(paths, pathsForRoot(resolve(args.appRoot), context.platform));
|
||||
assertSafeClientRoot(paths.root, context.home);
|
||||
let result: Record<string, unknown>;
|
||||
if (args.command === "install" || args.command === "update") result = await installClient(paths, args, context);
|
||||
else if (args.command === "uninstall") result = uninstallClient(paths, args, context);
|
||||
else if (args.command === "print-path") result = printPath(paths, args);
|
||||
else if (args.command === "print-config") result = printConfig(paths, args);
|
||||
else if (args.command === "doctor") result = doctor(paths, context);
|
||||
else throw new Error(usage());
|
||||
writeResult(result, args, context);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (context.argv.includes("--json") || context.argv.includes("mcp-json")) context.stdout(`${JSON.stringify({ ok: false, error: message })}\n`);
|
||||
else context.stderr(`${message}\n`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
interface ParsedArgs {
|
||||
command: string;
|
||||
manifestUrl?: string;
|
||||
publicKey?: string;
|
||||
json: boolean;
|
||||
yes: boolean;
|
||||
allowDowngrade: boolean;
|
||||
appRoot?: string;
|
||||
pathKind?: string;
|
||||
format?: "mcp-json";
|
||||
}
|
||||
|
||||
function parseClientArgs(argv: string[]): ParsedArgs {
|
||||
const values = [...argv];
|
||||
if (values[0] === "--print-path") values.splice(0, 1, "print-path");
|
||||
const command = values.shift() ?? "";
|
||||
const strings = new Map<string, string>();
|
||||
const flags = new Set<string>();
|
||||
let pathKind: string | undefined;
|
||||
const stringOptions = new Set(["--manifest-url", "--public-key", "--app-root", "--format"]);
|
||||
const flagOptions = new Set(["--json", "--yes", "--allow-downgrade"]);
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
const value = values[index]!;
|
||||
if (stringOptions.has(value)) {
|
||||
const next = values[index + 1];
|
||||
if (!next || next.startsWith("--")) throw new Error(`${value} 缺少参数`);
|
||||
if (strings.has(value)) throw new Error(`${value} 不能重复`);
|
||||
strings.set(value, next);
|
||||
index += 1;
|
||||
} else if (flagOptions.has(value)) {
|
||||
if (flags.has(value)) throw new Error(`${value} 不能重复`);
|
||||
flags.add(value);
|
||||
} else if (value.startsWith("--")) throw new Error(`未知参数:${value}`);
|
||||
else if (command === "print-path" && !pathKind) pathKind = value;
|
||||
else throw new Error(`多余参数:${value}`);
|
||||
}
|
||||
const allowed = new Set(command === "install" || command === "update"
|
||||
? ["--manifest-url", "--public-key", "--app-root", "--json", "--allow-downgrade"]
|
||||
: command === "uninstall" ? ["--app-root", "--json", "--yes"]
|
||||
: command === "print-path" ? ["--app-root", "--json"]
|
||||
: command === "print-config" ? ["--app-root", "--json", "--format"]
|
||||
: command === "doctor" ? ["--app-root", "--json"] : []);
|
||||
for (const name of [...strings.keys(), ...flags]) {
|
||||
if (!allowed.has(name)) throw new Error(`${command || "该命令"} 不支持参数 ${name}`);
|
||||
}
|
||||
const format = strings.get("--format");
|
||||
if (format && format !== "mcp-json") throw new Error("--format 只支持 mcp-json");
|
||||
return {
|
||||
command,
|
||||
manifestUrl: strings.get("--manifest-url"),
|
||||
publicKey: strings.get("--public-key"),
|
||||
json: flags.has("--json"),
|
||||
yes: flags.has("--yes"),
|
||||
allowDowngrade: flags.has("--allow-downgrade"),
|
||||
appRoot: strings.get("--app-root"),
|
||||
pathKind,
|
||||
format: format as "mcp-json" | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function installClient(paths: ClientPaths, args: ParsedArgs, context: CommandContext): Promise<Record<string, unknown>> {
|
||||
requireNodeVersion();
|
||||
const npmCheck = checkNpm(context);
|
||||
if (!npmCheck.ok) throw new Error(`未找到可用的 npm:${npmCheck.message}`);
|
||||
const previous = readState(paths.stateFile);
|
||||
const manifestUrl = normalizedHttpsUrl(args.manifestUrl ?? previous?.manifestUrl ?? "", "manifest URL");
|
||||
if (!manifestUrl.pathname.endsWith("/manifest.json")) throw new Error("manifest URL 必须以 /manifest.json 结尾");
|
||||
const publicKeyPath = resolvePublicKeyPath(paths, args.publicKey);
|
||||
const publicKey = readFileSync(publicKeyPath);
|
||||
const [manifestBytes, signatureBytes] = await Promise.all([
|
||||
download(context.fetchFn, manifestUrl),
|
||||
download(context.fetchFn, new URL("manifest.sig", manifestUrl)),
|
||||
]);
|
||||
const manifest = verifySignedManifest(manifestBytes, signatureBytes, publicKey, manifestUrl.toString());
|
||||
if (previous && compareVersions(manifest.version, previous.version) < 0 && !args.allowDowngrade) {
|
||||
throw new Error(`拒绝从 ${previous.version} 降级到 ${manifest.version};如确需降级请显式使用 --allow-downgrade`);
|
||||
}
|
||||
const temporary = mkdtempSync(join(tmpdir(), "dingtalk-approval-install-"));
|
||||
try {
|
||||
const clientArchive = join(temporary, "client.tgz");
|
||||
await downloadArtifact(context.fetchFn, manifestUrl, manifest.artifacts.client, clientArchive);
|
||||
const skillBody = await downloadArtifact(context.fetchFn, manifestUrl, manifest.artifacts.skill, join(temporary, "SKILL.md"));
|
||||
const publicArtifact = await downloadArtifact(context.fetchFn, manifestUrl, manifest.artifacts.publicKey, join(temporary, "release-public.pem"));
|
||||
if (releaseKeyId(publicArtifact) !== manifest.signing.keyId || !publicArtifact.equals(publicKey)) {
|
||||
throw new Error("发布公钥与本机信任的公钥不一致,拒绝安装");
|
||||
}
|
||||
mkdirPrivate(paths.root);
|
||||
mkdirPrivate(paths.versionsDir);
|
||||
const versionName = `${safeName(manifest.version)}-${manifest.artifacts.client.sha256.slice(0, 12)}`;
|
||||
const versionDir = join(paths.versionsDir, versionName);
|
||||
const clientEntry = join(versionDir, "node_modules", "dingtalk-approval-client", "dist", "src", "bridge.js");
|
||||
const cliEntry = join(versionDir, "node_modules", "dingtalk-approval-client", "dist", "src", "client-installer.js");
|
||||
if (!existsSync(clientEntry) || !existsSync(cliEntry)) {
|
||||
if (existsSync(versionDir)) rmSync(versionDir, { recursive: true, force: true });
|
||||
const staging = join(paths.versionsDir, `.staging-${process.pid}-${randomUUID()}`);
|
||||
mkdirPrivate(staging);
|
||||
const installed = runNpm(context, ["install", "--prefix", staging, "--offline", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund", clientArchive]);
|
||||
if (installed.status !== 0) {
|
||||
rmSync(staging, { recursive: true, force: true });
|
||||
throw new Error(`客户端运行时安装失败:${(installed.stderr || installed.stdout || "npm 执行失败").trim()}`);
|
||||
}
|
||||
if (!existsSync(join(staging, "node_modules", "dingtalk-approval-client", "dist", "src", "bridge.js"))) {
|
||||
rmSync(staging, { recursive: true, force: true });
|
||||
throw new Error("客户端包缺少 Bridge 入口");
|
||||
}
|
||||
renameSync(staging, versionDir);
|
||||
}
|
||||
mkdirPrivate(paths.skillDir);
|
||||
writeAtomic(paths.skillFile, skillBody, 0o600);
|
||||
writeAtomic(paths.publicKeyFile, publicArtifact, 0o600);
|
||||
writeLaunchers(paths, context.platform);
|
||||
const state: ClientInstallState = {
|
||||
schemaVersion: 1,
|
||||
version: manifest.version,
|
||||
installedAt: new Date().toISOString(),
|
||||
manifestUrl: manifestUrl.toString(),
|
||||
gatewayUrl: manifest.gatewayUrl,
|
||||
clientSha256: manifest.artifacts.client.sha256,
|
||||
skillSha256: manifest.artifacts.skill.sha256,
|
||||
runtimeDir: relative(paths.root, versionDir),
|
||||
skillFile: relative(paths.root, paths.skillFile),
|
||||
};
|
||||
writeAtomic(paths.stateFile, Buffer.from(`${JSON.stringify(state, null, 2)}\n`), 0o600);
|
||||
const legacyCleanupFailed: string[] = [];
|
||||
const legacyRemoved = cleanupLegacyInstallations(context.env, context.platform, context.home, legacyCleanupFailed);
|
||||
cleanupOldVersions(paths, versionDir);
|
||||
const postInstall = attemptPostInstallLogin(clientEntry, manifest.gatewayUrl, paths, context);
|
||||
return installResult(paths, state, legacyRemoved, legacyCleanupFailed, postInstall);
|
||||
} finally {
|
||||
rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function uninstallClient(paths: ClientPaths, args: ParsedArgs, context: CommandContext): Record<string, unknown> {
|
||||
if (!args.yes) throw new Error("卸载会删除运行时、Skill 和 OAuth 登录会话;请使用 --yes 明确确认");
|
||||
const state = readState(paths.stateFile);
|
||||
if (existsSync(paths.root) && !state) throw new Error("客户端目录缺少有效安装记录,拒绝递归删除;请人工核对该目录");
|
||||
const legacyCleanupFailed: string[] = [];
|
||||
const legacyRemoved = cleanupLegacyInstallations(context.env, context.platform, context.home, legacyCleanupFailed);
|
||||
const legacySession = join(context.env.XDG_CONFIG_HOME?.trim() || join(context.home, ".config"), "dingtalk-approval-bridge");
|
||||
if (isSafeLegacySessionPath(legacySession, context.home)) rmSync(legacySession, { recursive: true, force: true });
|
||||
const runningInsideRoot = isInside(paths.root, process.argv[1] ?? "");
|
||||
if (existsSync(paths.root)) {
|
||||
if (runningInsideRoot) scheduleSelfCleanup(paths.root);
|
||||
else rmSync(paths.root, { recursive: true, force: true });
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: runningInsideRoot ? "cleanup_scheduled" : "uninstalled",
|
||||
removedVersion: state?.version,
|
||||
legacyEntriesRemoved: legacyRemoved,
|
||||
legacyCleanupFailed,
|
||||
sessionRemoved: true,
|
||||
hostCleanupRequired: true,
|
||||
hostCleanup: "请按当前 Agent 的官方方式删除名为 dingtalk-approval 的 MCP/连接器条目。",
|
||||
};
|
||||
}
|
||||
|
||||
function printPath(paths: ClientPaths, args: ParsedArgs): Record<string, unknown> {
|
||||
assertInstalled(paths);
|
||||
if (args.pathKind && args.pathKind !== "bridge" && args.pathKind !== "skill" && args.pathKind !== "cli") {
|
||||
throw new Error("print-path 只支持 bridge、skill 或 cli");
|
||||
}
|
||||
const values = { bridge: paths.bridgeCommand, skill: paths.skillDir, cli: paths.cliCommand };
|
||||
const kind = args.pathKind as keyof typeof values | undefined;
|
||||
return { ok: true, ...(kind ? { path: values[kind] } : values) };
|
||||
}
|
||||
|
||||
function printConfig(paths: ClientPaths, args: ParsedArgs): Record<string, unknown> {
|
||||
const state = assertInstalled(paths);
|
||||
if (args.format === "mcp-json") return mcpConfig(paths);
|
||||
return { ok: true, version: state.version, mcpConfig: mcpConfig(paths), skillPath: paths.skillDir };
|
||||
}
|
||||
|
||||
function doctor(paths: ClientPaths, context: CommandContext): Record<string, unknown> {
|
||||
const checks: Array<Record<string, unknown>> = [];
|
||||
const majorMinor = process.versions.node.split(".").slice(0, 2).map(Number);
|
||||
checks.push({ name: "node", ok: (majorMinor[0] ?? 0) > 22 || ((majorMinor[0] ?? 0) === 22 && (majorMinor[1] ?? 0) >= 13), value: process.version });
|
||||
const npm = checkNpm(context);
|
||||
checks.push({ name: "npm", ...npm });
|
||||
const state = readState(paths.stateFile);
|
||||
checks.push({ name: "state", ok: Boolean(state), path: paths.stateFile });
|
||||
checks.push({ name: "bridge", ok: existsSync(paths.bridgeCommand), path: paths.bridgeCommand });
|
||||
checks.push({ name: "skill", ok: existsSync(paths.skillFile), path: paths.skillFile });
|
||||
checks.push({ name: "publicKey", ok: existsSync(paths.publicKeyFile), path: paths.publicKeyFile });
|
||||
if (state) {
|
||||
const runtime = resolveInside(paths.root, state.runtimeDir);
|
||||
checks.push({ name: "runtime", ok: existsSync(join(runtime, "node_modules", "dingtalk-approval-client", "dist", "src", "bridge.js")), path: runtime });
|
||||
try { normalizedHttpsUrl(state.gatewayUrl, "gateway URL"); checks.push({ name: "gateway", ok: true, value: state.gatewayUrl }); }
|
||||
catch (error) { checks.push({ name: "gateway", ok: false, message: error instanceof Error ? error.message : String(error) }); }
|
||||
}
|
||||
const ok = checks.every((check) => check.ok === true);
|
||||
return { ok, platform: context.platform, root: paths.root, checks };
|
||||
}
|
||||
|
||||
function checkNpm(context: Pick<CommandContext, "env" | "platform">): { ok: boolean; value?: string; message?: string } {
|
||||
const result = runNpm(context, ["--version"]);
|
||||
if (result.error) return { ok: false, message: result.error.message };
|
||||
if (result.status !== 0) return { ok: false, message: (result.stderr || result.stdout || `退出码 ${result.status}`).trim() };
|
||||
const version = result.stdout.trim();
|
||||
return version ? { ok: true, value: version } : { ok: false, message: "npm --version 未返回版本号" };
|
||||
}
|
||||
|
||||
function runNpm(context: Pick<CommandContext, "env" | "platform">, args: string[]) {
|
||||
const invocation = npmInvocation(context, args);
|
||||
return spawnSync(invocation.command, invocation.args, {
|
||||
encoding: "utf8",
|
||||
env: { ...context.env, npm_config_update_notifier: "false" },
|
||||
windowsHide: true,
|
||||
});
|
||||
}
|
||||
|
||||
function npmInvocation(context: Pick<CommandContext, "env" | "platform">, args: string[]): { command: string; args: string[] } {
|
||||
if (context.platform !== "win32") return { command: "npm", args };
|
||||
const candidates = [
|
||||
context.env.npm_execpath,
|
||||
...(context.env.PATH ?? "").split(";").filter(Boolean).map((entry) => join(entry.replace(/^"|"$/g, ""), "node_modules", "npm", "bin", "npm-cli.js")),
|
||||
join(dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js"),
|
||||
];
|
||||
const npmCli = candidates.find((candidate) => candidate && existsSync(candidate));
|
||||
if (!npmCli) throw new Error("Windows 环境未找到可由 Node 安全启动的 npm-cli.js;请修复 Node/npm 安装后重试");
|
||||
return { command: process.execPath, args: [npmCli, ...args] };
|
||||
}
|
||||
|
||||
export function cleanupLegacyInstallations(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
home = homedir(),
|
||||
failed: string[] = [],
|
||||
): string[] {
|
||||
const removed: string[] = [];
|
||||
for (const binDir of legacyBinDirectories(env, platform, home)) {
|
||||
for (const name of LEGACY_BIN_NAMES) {
|
||||
const candidates = platform === "win32" ? [name, `${name}.cmd`, `${name}.ps1`] : [name];
|
||||
for (const candidate of candidates) {
|
||||
const path = join(binDir, candidate);
|
||||
if (!existsOrSymlink(path) || !legacyEntryOwned(path)) continue;
|
||||
try { unlinkSync(path); removed.push(path); }
|
||||
catch { failed.push(path); }
|
||||
}
|
||||
}
|
||||
const packageRoot = platform === "win32"
|
||||
? join(binDir, "node_modules", "dingtalk-approval-mcp")
|
||||
: resolve(binDir, "..", "lib", "node_modules", "dingtalk-approval-mcp");
|
||||
if (exactPackage(packageRoot, "dingtalk-approval-mcp")) {
|
||||
try { rmSync(packageRoot, { recursive: true, force: true }); removed.push(packageRoot); }
|
||||
catch { failed.push(packageRoot); }
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
function legacyBinDirectories(env: NodeJS.ProcessEnv, platform: NodeJS.Platform, home: string): string[] {
|
||||
const paths = new Set((env.PATH ?? "").split(platform === "win32" ? ";" : ":").filter(Boolean).map((value) => resolve(value)));
|
||||
if (platform === "win32") {
|
||||
if (env.APPDATA) paths.add(resolve(env.APPDATA, "npm"));
|
||||
} else {
|
||||
paths.add(resolve(home, ".local", "bin"));
|
||||
paths.add(resolve(home, ".volta", "bin"));
|
||||
const nvmVersions = join(home, ".nvm", "versions", "node");
|
||||
if (existsSync(nvmVersions)) {
|
||||
for (const version of readdirSync(nvmVersions)) paths.add(resolve(nvmVersions, version, "bin"));
|
||||
}
|
||||
}
|
||||
return [...paths].filter((path) => existsSync(path));
|
||||
}
|
||||
|
||||
function legacyEntryOwned(path: string): boolean {
|
||||
try {
|
||||
const stat = lstatSync(path);
|
||||
if (stat.isSymbolicLink()) return /(?:^|[\\/])dingtalk-approval-mcp(?:[\\/]|$)/.test(readlinkSync(path));
|
||||
if (stat.isFile() && /\.(?:cmd|ps1)$/i.test(path)) {
|
||||
return /(?:^|[\\/])dingtalk-approval-mcp(?:[\\/]|$)/.test(readFileSync(path, "utf8"));
|
||||
}
|
||||
} catch { return false; }
|
||||
return false;
|
||||
}
|
||||
|
||||
function installResult(
|
||||
paths: ClientPaths,
|
||||
state: ClientInstallState,
|
||||
legacyRemoved: string[],
|
||||
legacyCleanupFailed: string[],
|
||||
postInstall: PostInstallResult,
|
||||
): Record<string, unknown> {
|
||||
const nextSteps = [
|
||||
"按当前 Agent 的官方方式安装 skillPath,并添加名为 dingtalk-approval 的 stdio MCP。",
|
||||
"立即处理 postInstall:若有 authChallenge.directUrl,必须在当前回复中原样展示,不得声称已另行推送,也不得等待用户首次查询审批。",
|
||||
"用户授权后调用 approval_login;登录成功后按 verifyTool 做一次双来源只读验证,并展示一次性使用示例。",
|
||||
"宿主若不能热加载 MCP,请新建会话或完整重启;恢复上述登录流程后再宣告安装完成,不得执行任何审批写操作。",
|
||||
];
|
||||
return {
|
||||
ok: true,
|
||||
status: "installed",
|
||||
version: state.version,
|
||||
root: paths.root,
|
||||
bridgePath: paths.bridgeCommand,
|
||||
cliPath: paths.cliCommand,
|
||||
skillPath: paths.skillDir,
|
||||
mcpConfig: mcpConfig(paths),
|
||||
legacyEntriesRemoved: legacyRemoved,
|
||||
legacyCleanupFailed,
|
||||
configurationModified: false,
|
||||
postInstall,
|
||||
nextSteps,
|
||||
};
|
||||
}
|
||||
|
||||
function attemptPostInstallLogin(
|
||||
clientEntry: string,
|
||||
gatewayUrl: string,
|
||||
paths: ClientPaths,
|
||||
context: Pick<CommandContext, "env">,
|
||||
): PostInstallResult {
|
||||
const shared = {
|
||||
required: true as const,
|
||||
attempted: true as const,
|
||||
confirmTool: { name: "approval_login" as const, arguments: {} },
|
||||
verifyTool: { name: "approval_list_todos" as const, arguments: { source: "all" as const, query: "" as const, limit: 1 as const } },
|
||||
completionCriteria: "登录确认并完成钉钉 OA 与薪福通双来源只读验证",
|
||||
};
|
||||
const result = spawnSync(process.execPath, [clientEntry, "--url", gatewayUrl, "--post-install-login"], {
|
||||
encoding: "utf8",
|
||||
timeout: 30_000,
|
||||
maxBuffer: 256 * 1024,
|
||||
env: { ...context.env, DINGTALK_APPROVAL_BRIDGE_STATE: paths.sessionFile },
|
||||
});
|
||||
if (result.error || result.status !== 0) {
|
||||
return {
|
||||
...shared,
|
||||
status: "unavailable",
|
||||
authenticated: false,
|
||||
message: "客户端已安装,但暂时无法连接登录服务;请加载 MCP 后调用 approval_login 重试。",
|
||||
};
|
||||
}
|
||||
const line = result.stdout.trim().split(/\r?\n/).filter(Boolean).at(-1);
|
||||
let value: Record<string, unknown>;
|
||||
try { value = JSON.parse(line ?? "") as Record<string, unknown>; }
|
||||
catch {
|
||||
return {
|
||||
...shared,
|
||||
status: "unavailable",
|
||||
authenticated: false,
|
||||
message: "客户端已安装,但登录准备结果无法识别;请加载 MCP 后调用 approval_login 重试。",
|
||||
};
|
||||
}
|
||||
const onboarding = safeOnboarding(value.onboarding);
|
||||
if (value.authenticated === true) {
|
||||
return {
|
||||
...shared,
|
||||
status: "authenticated",
|
||||
authenticated: true,
|
||||
message: typeof value.message === "string" ? value.message : "已复用现有登录,可以读取审批。",
|
||||
...(onboarding ? { onboarding } : {}),
|
||||
};
|
||||
}
|
||||
const challenge = value.authChallenge && typeof value.authChallenge === "object" && !Array.isArray(value.authChallenge)
|
||||
? value.authChallenge as Record<string, unknown> : undefined;
|
||||
if (challenge && typeof challenge.directUrl === "string") {
|
||||
return {
|
||||
...shared,
|
||||
status: "authorization_required",
|
||||
authenticated: false,
|
||||
message: typeof value.message === "string" ? value.message : "需要完成钉钉授权登录。",
|
||||
authChallenge: {
|
||||
directUrl: challenge.directUrl,
|
||||
...(typeof challenge.userCode === "string" ? { userCode: challenge.userCode } : {}),
|
||||
...(typeof challenge.expiresAt === "string" ? { expiresAt: challenge.expiresAt } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...shared,
|
||||
status: "unavailable",
|
||||
authenticated: false,
|
||||
message: "客户端已安装,但登录服务未返回可用授权链接;请加载 MCP 后调用 approval_login 重试。",
|
||||
};
|
||||
}
|
||||
|
||||
function safeOnboarding(value: unknown): PostInstallResult["onboarding"] | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.firstRun !== true || !Array.isArray(record.examples)) return undefined;
|
||||
const examples = record.examples.filter((item): item is string => typeof item === "string").slice(0, 8);
|
||||
if (!examples.length) return undefined;
|
||||
return {
|
||||
firstRun: true,
|
||||
examples,
|
||||
...(typeof record.writeSafety === "string" ? { writeSafety: record.writeSafety } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function mcpConfig(paths: ClientPaths): Record<string, unknown> {
|
||||
return { mcpServers: { "dingtalk-approval": { type: "stdio", command: paths.bridgeCommand, args: [] } } };
|
||||
}
|
||||
|
||||
function writeLaunchers(paths: ClientPaths, platform: NodeJS.Platform): void {
|
||||
mkdirPrivate(paths.binDir);
|
||||
const launcher = `#!/usr/bin/env node\nimport { readFileSync } from "node:fs";\nimport { dirname, join, resolve } from "node:path";\nimport { fileURLToPath } from "node:url";\nimport { spawn } from "node:child_process";\nconst mode = process.argv[2];\nconst root = resolve(dirname(fileURLToPath(import.meta.url)), "..");\nconst state = JSON.parse(readFileSync(join(root, "install-state.json"), "utf8"));\nconst runtime = resolve(root, state.runtimeDir);\nconst target = join(runtime, "node_modules", "dingtalk-approval-client", "dist", "src", mode === "bridge" ? "bridge.js" : "client-installer.js");\nconst args = mode === "bridge" ? ["--url", state.gatewayUrl, ...process.argv.slice(3)] : [...process.argv.slice(3), "--app-root", root];\nconst env = mode === "bridge" ? { ...process.env, DINGTALK_APPROVAL_BRIDGE_STATE: join(root, "state", "session.json") } : process.env;\nconst child = spawn(process.execPath, [target, ...args], { stdio: "inherit", env });\nchild.on("exit", (code, signal) => { if (signal) process.kill(process.pid, signal); else process.exitCode = code ?? 1; });\nchild.on("error", (error) => { console.error(error.message); process.exitCode = 1; });\n`;
|
||||
writeAtomic(paths.launcherFile, Buffer.from(launcher), 0o700);
|
||||
if (platform === "win32") {
|
||||
writeAtomic(paths.cliCommand, Buffer.from(`@echo off\r\nnode "%~dp0launcher.mjs" cli %*\r\n`), 0o700);
|
||||
writeAtomic(paths.bridgeCommand, Buffer.from(`@echo off\r\nnode "%~dp0launcher.mjs" bridge %*\r\n`), 0o700);
|
||||
} else {
|
||||
writeAtomic(paths.cliCommand, Buffer.from(`#!/usr/bin/env sh\nexec node "$(dirname "$0")/launcher.mjs" cli "$@"\n`), 0o700);
|
||||
writeAtomic(paths.bridgeCommand, Buffer.from(`#!/usr/bin/env sh\nexec node "$(dirname "$0")/launcher.mjs" bridge "$@"\n`), 0o700);
|
||||
}
|
||||
}
|
||||
|
||||
function validateManifest(manifest: ClientReleaseManifest, manifestUrl: string, keyId: string): void {
|
||||
if (manifest.schemaVersion !== 3 || !VERSION_PATTERN.test(manifest.version)) throw new Error("发布清单版本或版本号无效");
|
||||
if (manifest.signing?.algorithm !== "Ed25519" || manifest.signing.keyId !== keyId) throw new Error("发布清单未绑定当前发布公钥");
|
||||
const base = normalizedHttpsUrl(manifestUrl, "manifest URL");
|
||||
const gateway = normalizedHttpsUrl(manifest.gatewayUrl, "gateway URL");
|
||||
if (gateway.pathname !== "/mcp" || gateway.search || gateway.hash) throw new Error("gateway URL 必须是 HTTPS /mcp 地址");
|
||||
for (const [name, artifact] of Object.entries(manifest.artifacts ?? {})) {
|
||||
if (!artifact || !/^[a-f0-9]{64}$/.test(artifact.sha256)) throw new Error(`发布物 ${name} 缺少有效 SHA-256`);
|
||||
const url = normalizedHttpsUrl(artifact.url, `${name} URL`);
|
||||
if (url.origin !== base.origin) throw new Error(`发布物 ${name} 必须与清单同源`);
|
||||
}
|
||||
if (manifest.artifacts.publicKey.keyId !== keyId) throw new Error("发布公钥 artifact 的 keyId 不匹配");
|
||||
const createdAt = Date.parse(manifest.createdAt);
|
||||
if (!Number.isFinite(createdAt)) throw new Error("发布清单 createdAt 无效");
|
||||
if (createdAt > Date.now() + 24 * 60 * 60_000) throw new Error("发布清单 createdAt 来自未来,拒绝安装");
|
||||
if (createdAt < Date.now() - MANIFEST_MAX_AGE_MS) throw new Error("发布清单已超过 180 天有效期,请获取新的安装链接");
|
||||
}
|
||||
|
||||
async function download(fetchFn: typeof fetch, url: URL): Promise<Buffer> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(new Error("下载超时")), DOWNLOAD_TIMEOUT_MS);
|
||||
timeout.unref();
|
||||
try {
|
||||
const response = await fetchFn(url, {
|
||||
headers: { accept: "application/octet-stream, application/json;q=0.9, */*;q=0.8" },
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) throw new Error(`下载失败 ${response.status}:${url}`);
|
||||
const length = Number(response.headers.get("content-length") ?? 0);
|
||||
if (Number.isFinite(length) && length > MAX_DOWNLOAD_BYTES) throw new Error(`下载文件超过 25MB 限制:${url}`);
|
||||
if (!response.body) return Buffer.alloc(0);
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > MAX_DOWNLOAD_BYTES) {
|
||||
try { await reader.cancel("download size limit exceeded"); } catch { /* size rejection still wins */ }
|
||||
throw new Error(`下载文件超过 25MB 限制:${url}`);
|
||||
}
|
||||
chunks.push(Buffer.from(value));
|
||||
}
|
||||
return Buffer.concat(chunks, total);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) throw new Error(`下载超时(30 秒):${url}`, { cause: error });
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadArtifact(fetchFn: typeof fetch, manifestUrl: URL, artifact: ReleaseArtifact, target: string): Promise<Buffer> {
|
||||
const url = normalizedHttpsUrl(artifact.url, "artifact URL");
|
||||
if (url.origin !== manifestUrl.origin) throw new Error("发布物必须与清单同源");
|
||||
const body = await download(fetchFn, url);
|
||||
if (sha256(body) !== artifact.sha256) throw new Error(`发布物 SHA-256 不匹配:${basename(url.pathname)}`);
|
||||
writeAtomic(target, body, 0o600);
|
||||
return body;
|
||||
}
|
||||
|
||||
function resolvePublicKeyPath(paths: ClientPaths, explicit: string | undefined): string {
|
||||
if (explicit) return resolve(explicit);
|
||||
if (existsSync(paths.publicKeyFile)) return paths.publicKeyFile;
|
||||
const adjacent = resolve(dirname(process.argv[1] ?? fileURLToPath(import.meta.url)), "release-signing-public.pem");
|
||||
if (existsSync(adjacent)) return adjacent;
|
||||
throw new Error("找不到发布公钥;首次安装必须提供 --public-key,更新则使用已固定的本地公钥");
|
||||
}
|
||||
|
||||
function requireNodeVersion(): void {
|
||||
const [major = 0, minor = 0] = process.versions.node.split(".").map(Number);
|
||||
if (major < 22 || (major === 22 && minor < 13)) throw new Error(`需要 Node.js >=22.13.0,当前为 ${process.version}`);
|
||||
}
|
||||
|
||||
function compareVersions(left: string, right: string): number {
|
||||
const a = VERSION_PATTERN.exec(left);
|
||||
const b = VERSION_PATTERN.exec(right);
|
||||
if (!a || !b) throw new Error("无法比较非法版本号");
|
||||
for (let index = 1; index <= 3; index += 1) {
|
||||
const difference = Number(a[index]) - Number(b[index]);
|
||||
if (difference !== 0) return Math.sign(difference);
|
||||
}
|
||||
const arc = a[4] === undefined ? Number.POSITIVE_INFINITY : Number(a[4]);
|
||||
const brc = b[4] === undefined ? Number.POSITIVE_INFINITY : Number(b[4]);
|
||||
return Math.sign(arc - brc);
|
||||
}
|
||||
|
||||
function readState(path: string): ClientInstallState | undefined {
|
||||
if (!existsSync(path)) return undefined;
|
||||
try {
|
||||
const value = JSON.parse(readFileSync(path, "utf8")) as ClientInstallState;
|
||||
return value.schemaVersion === 1 && VERSION_PATTERN.test(value.version) ? value : undefined;
|
||||
} catch { return undefined; }
|
||||
}
|
||||
|
||||
function assertInstalled(paths: ClientPaths): ClientInstallState {
|
||||
const state = readState(paths.stateFile);
|
||||
if (!state) throw new Error("客户端尚未安装或安装状态已损坏,请重新运行 install");
|
||||
return state;
|
||||
}
|
||||
|
||||
function cleanupOldVersions(paths: ClientPaths, keep: string): void {
|
||||
if (!existsSync(paths.versionsDir)) return;
|
||||
for (const entry of readdirSync(paths.versionsDir)) {
|
||||
const path = join(paths.versionsDir, entry);
|
||||
if (resolve(path) === resolve(keep)) continue;
|
||||
try { rmSync(path, { recursive: true, force: true }); } catch { /* Windows may keep the currently running old CLI open. */ }
|
||||
}
|
||||
}
|
||||
|
||||
function exactPackage(root: string, name: string): boolean {
|
||||
try {
|
||||
const metadata = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { name?: string };
|
||||
return metadata.name === name;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
function existsOrSymlink(path: string): boolean {
|
||||
try { lstatSync(path); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
function normalizedHttpsUrl(value: string, label: string): URL {
|
||||
let url: URL;
|
||||
try { url = new URL(value); } catch { throw new Error(`${label} 无效`); }
|
||||
if (url.protocol !== "https:" || url.username || url.password) throw new Error(`${label} 必须是无内嵌凭证的 HTTPS URL`);
|
||||
return url;
|
||||
}
|
||||
|
||||
function sha256(value: Buffer): string { return createHash("sha256").update(value).digest("hex"); }
|
||||
function safeName(value: string): string { return value.replace(/[^a-zA-Z0-9.-]/g, "-"); }
|
||||
|
||||
function mkdirPrivate(path: string): void {
|
||||
mkdirSync(path, { recursive: true, mode: 0o700 });
|
||||
if (process.platform !== "win32") chmodSync(path, 0o700);
|
||||
}
|
||||
|
||||
function writeAtomic(path: string, body: Buffer, mode: number): void {
|
||||
mkdirPrivate(dirname(path));
|
||||
const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`;
|
||||
writeFileSync(temporary, body, { mode });
|
||||
if (process.platform !== "win32") chmodSync(temporary, mode);
|
||||
renameSync(temporary, path);
|
||||
}
|
||||
|
||||
function pathsForRoot(root: string, platform: NodeJS.Platform): ClientPaths {
|
||||
return clientPaths({ DINGTALK_APPROVAL_CLIENT_HOME: root }, platform, dirname(root));
|
||||
}
|
||||
|
||||
function resolveInside(root: string, child: string): string {
|
||||
const result = resolve(root, child);
|
||||
if (!isInside(root, result)) throw new Error("安装状态包含越界路径");
|
||||
return result;
|
||||
}
|
||||
|
||||
function isInside(root: string, child: string): boolean {
|
||||
if (!child) return false;
|
||||
const value = relative(resolve(root), resolve(child));
|
||||
return value === "" || (!value.startsWith(`..${sep}`) && value !== ".." && !isAbsolute(value));
|
||||
}
|
||||
|
||||
function isSafeLegacySessionPath(path: string, home: string): boolean {
|
||||
return isInside(home, path) && basename(path) === "dingtalk-approval-bridge";
|
||||
}
|
||||
|
||||
function assertSafeClientRoot(root: string, home: string): void {
|
||||
const resolved = resolve(root);
|
||||
const name = basename(resolved).toLowerCase();
|
||||
if (resolved === resolve(home) || dirname(resolved) === resolved || !["dingtalk-approval", "dingtalkapproval"].includes(name)) {
|
||||
throw new Error("客户端目录必须是专用的 dingtalk-approval 用户数据目录");
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSelfCleanup(root: string): void {
|
||||
const cleanupRoot = mkdtempSync(join(tmpdir(), "dingtalk-approval-cleanup-"));
|
||||
const helper = join(cleanupRoot, "cleanup.mjs");
|
||||
writeFileSync(helper, `import { rmSync } from "node:fs";\nsetTimeout(() => { rmSync(${JSON.stringify(root)}, { recursive: true, force: true }); rmSync(${JSON.stringify(cleanupRoot)}, { recursive: true, force: true }); }, 750);\n`, { mode: 0o700 });
|
||||
const child = spawn(process.execPath, [helper], { detached: true, stdio: "ignore" });
|
||||
child.unref();
|
||||
}
|
||||
|
||||
function writeResult(value: Record<string, unknown>, args: ParsedArgs, context: Pick<CommandContext, "stdout">): void {
|
||||
if (args.command === "print-path" && args.pathKind && !args.json) {
|
||||
context.stdout(`${String(value.path ?? "")}\n`);
|
||||
return;
|
||||
}
|
||||
if (args.json) context.stdout(`${JSON.stringify(value)}\n`);
|
||||
else context.stdout(`${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function usage(): string {
|
||||
return "用法:dingtalk-approval install|update|uninstall|print-path|print-config|doctor [--manifest-url URL] [--format mcp-json] [--json]";
|
||||
}
|
||||
|
||||
if (isClientDirectEntrypoint(import.meta.url, process.argv[1])) {
|
||||
runClientInstaller().then((code) => { process.exitCode = code; });
|
||||
}
|
||||
|
||||
function isClientDirectEntrypoint(moduleUrl: string, argv1: string | undefined): boolean {
|
||||
if (!argv1) return false;
|
||||
const modulePath = fileURLToPath(moduleUrl);
|
||||
try { return realpathSync(modulePath) === realpathSync(argv1); }
|
||||
catch { return resolve(modulePath) === resolve(argv1); }
|
||||
}
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
import { readFileSync, statSync } from "node:fs";
|
||||
import { APPROVAL_BATCH_LIMIT } from "./approval-contract.js";
|
||||
|
||||
export interface AppConfig {
|
||||
appMode?: "gateway";
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
corpId?: string;
|
||||
apiBaseUrl: string;
|
||||
oapiBaseUrl: string;
|
||||
publicBaseUrl: string;
|
||||
bindHost?: string;
|
||||
port: number;
|
||||
requestTimeoutMs: number;
|
||||
officialLookbackDays: number;
|
||||
officialScanConcurrency: number;
|
||||
officialClientOnlyProcessCodes: string[];
|
||||
batchLimit: number;
|
||||
approvalRefSecret: string;
|
||||
/** Internal identity used only to bind local opaque references; never selected by Agent input. */
|
||||
trustedLocalUser?: { userId: string; name?: string };
|
||||
remoteAuth?: {
|
||||
issuerUrl: string;
|
||||
databasePath: string;
|
||||
sessionSecret: string;
|
||||
accessTokenTtlSeconds: number;
|
||||
refreshTokenTtlSeconds: number;
|
||||
};
|
||||
yida?: {
|
||||
adminUserId: string;
|
||||
organizationToken: string;
|
||||
};
|
||||
xftOpenApi?: {
|
||||
appId: string;
|
||||
authoritySecret: string;
|
||||
enterpriseId: string;
|
||||
apiBaseUrl: string;
|
||||
encrypted: boolean;
|
||||
dingTalkAppCode: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CredentialsFile {
|
||||
clientId?: unknown;
|
||||
clientSecret?: unknown;
|
||||
}
|
||||
|
||||
function required(env: NodeJS.ProcessEnv, name: string): string {
|
||||
const value = env[name]?.trim();
|
||||
if (!value) throw new Error(`缺少必填环境变量 ${name}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function optional(env: NodeJS.ProcessEnv, name: string): string | undefined {
|
||||
const value = env[name]?.trim();
|
||||
return value || undefined;
|
||||
}
|
||||
|
||||
function dingTalkCredential(env: NodeJS.ProcessEnv, envName: string, fileKey: keyof CredentialsFile): string {
|
||||
const direct = env[envName]?.trim();
|
||||
if (direct) return direct;
|
||||
const path = env.DINGTALK_CREDENTIALS_FILE?.trim();
|
||||
if (!path) throw new Error(`缺少必填环境变量 ${envName}(或配置 DINGTALK_CREDENTIALS_FILE)`);
|
||||
if (process.platform !== "win32" && (statSync(path).mode & 0o077) !== 0) {
|
||||
throw new Error("DINGTALK_CREDENTIALS_FILE 权限必须为 0600 或更严格");
|
||||
}
|
||||
let parsed: CredentialsFile;
|
||||
try {
|
||||
parsed = JSON.parse(readFileSync(path, "utf8")) as CredentialsFile;
|
||||
} catch (error) {
|
||||
throw new Error(`无法读取钉钉凭证文件 ${path}:${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
const value = parsed[fileKey];
|
||||
if (typeof value !== "string" || !value.trim()) throw new Error(`钉钉凭证文件缺少 ${fileKey}`);
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function secretFileValue(env: NodeJS.ProcessEnv, name: string): string | undefined {
|
||||
const path = env[name]?.trim();
|
||||
if (!path) return undefined;
|
||||
const value = readFileSync(path, "utf8").trim();
|
||||
if (!value) throw new Error(`${name} 指向的密钥文件为空`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function privateSecretFileValue(env: NodeJS.ProcessEnv, name: string): string | undefined {
|
||||
const path = env[name]?.trim();
|
||||
if (!path) return undefined;
|
||||
if (process.platform !== "win32" && (statSync(path).mode & 0o077) !== 0) {
|
||||
throw new Error(`${name} 指向的密钥文件必须为 0600 或更严格权限`);
|
||||
}
|
||||
return secretFileValue(env, name);
|
||||
}
|
||||
|
||||
function strongSecret(value: string | undefined, name: string): string {
|
||||
if (!value) throw new Error(`缺少必填密钥 ${name}`);
|
||||
if (Buffer.byteLength(value, "utf8") < 32) throw new Error(`${name} 必须至少包含 32 字节高熵随机值`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function protectedAuthoritySecretFileValue(env: NodeJS.ProcessEnv, name: string): string | undefined {
|
||||
const path = env[name]?.trim();
|
||||
if (!path) return undefined;
|
||||
const mode = statSync(path).mode & 0o777;
|
||||
if ((mode & 0o077) !== 0) throw new Error(`${name} 指向的密钥文件必须为 0600 或更严格权限`);
|
||||
const value = secretFileValue(env, name);
|
||||
if (!value) return undefined;
|
||||
const lines = value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
// The XFT portal exports API Key on line 1 and AuthoritySecret on line 2.
|
||||
// A legacy file containing only AuthoritySecret remains supported.
|
||||
return lines.length >= 2 ? lines[1] : lines[0];
|
||||
}
|
||||
|
||||
function positiveInteger(env: NodeJS.ProcessEnv, name: string, fallback: number): number {
|
||||
const raw = env[name]?.trim();
|
||||
if (!raw) return fallback;
|
||||
const value = Number(raw);
|
||||
if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name} 必须是正整数`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function booleanValue(env: NodeJS.ProcessEnv, name: string, fallback: boolean): boolean {
|
||||
const raw = env[name]?.trim().toLowerCase();
|
||||
if (!raw) return fallback;
|
||||
if (["1", "true", "yes"].includes(raw)) return true;
|
||||
if (["0", "false", "no"].includes(raw)) return false;
|
||||
throw new Error(`${name} 必须是 true 或 false`);
|
||||
}
|
||||
|
||||
function commaSeparated(env: NodeJS.ProcessEnv, name: string): string[] {
|
||||
return [...new Set((env[name] ?? "").split(",").map((value) => value.trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||
const configuredMode = optional(env, "APP_MODE");
|
||||
if (!configuredMode || !["gateway", "xft-gateway"].includes(configuredMode)) {
|
||||
throw new Error("必须显式设置 APP_MODE=gateway");
|
||||
}
|
||||
const appMode = "gateway" as const;
|
||||
const port = positiveInteger(env, "PORT", 3000);
|
||||
if (port > 65535) throw new Error("PORT 必须在 1 到 65535 之间");
|
||||
const publicBaseUrl = (optional(env, "MCP_PUBLIC_BASE_URL") ?? `http://localhost:${port}`).replace(/\/$/, "");
|
||||
const parsedUrl = new URL(publicBaseUrl);
|
||||
if (parsedUrl.protocol !== "https:" && !(parsedUrl.protocol === "http:" && isLoopbackHost(parsedUrl.hostname))) {
|
||||
throw new Error("MCP_PUBLIC_BASE_URL 必须使用 HTTPS;仅本机开发允许 HTTP 回环地址");
|
||||
}
|
||||
if (parsedUrl.username || parsedUrl.password || parsedUrl.pathname !== "/" || parsedUrl.search || parsedUrl.hash) {
|
||||
throw new Error("MCP_PUBLIC_BASE_URL 只能包含协议、域名和可选端口");
|
||||
}
|
||||
|
||||
const yidaEnabled = booleanValue(env, "YIDA_ENABLED", false);
|
||||
const xftOpenApiEnabled = booleanValue(env, "XFT_OPEN_API_ENABLED", false);
|
||||
const bindHost = optional(env, "MCP_BIND_HOST") ?? "127.0.0.1";
|
||||
const remoteAuthEnabled = booleanValue(env, "MCP_REMOTE_AUTH_ENABLED", !isLoopbackHost(parsedUrl.hostname));
|
||||
if (optional(env, "MCP_TRUSTED_LOCAL_USER_ID") || optional(env, "MCP_TRUSTED_LOCAL_USER_NAME")) {
|
||||
throw new Error("新架构禁止配置 MCP_TRUSTED_LOCAL_USER_ID/NAME");
|
||||
}
|
||||
if (!isLoopbackHost(bindHost)) {
|
||||
throw new Error("MCP_BIND_HOST 仅允许回环地址");
|
||||
}
|
||||
if (remoteAuthEnabled && !optional(env, "DINGTALK_CORP_ID")) {
|
||||
throw new Error("远程 OAuth 模式必须配置 DINGTALK_CORP_ID");
|
||||
}
|
||||
if (booleanValue(env, "DWS_ENABLED", false) || optional(env, "DWS_CLI_PATH") || optional(env, "XFT_GATEWAY_URL")) {
|
||||
throw new Error("统一网关架构禁止配置本地 DWS 或二级 XFT 网关");
|
||||
}
|
||||
if (!remoteAuthEnabled || !xftOpenApiEnabled) {
|
||||
throw new Error("gateway 模式必须启用 MCP 远程 OAuth 和薪福通正式 OpenAPI");
|
||||
}
|
||||
return {
|
||||
appMode,
|
||||
clientId: dingTalkCredential(env, "DINGTALK_CLIENT_ID", "clientId"),
|
||||
clientSecret: dingTalkCredential(env, "DINGTALK_CLIENT_SECRET", "clientSecret"),
|
||||
...(optional(env, "DINGTALK_CORP_ID") ? { corpId: optional(env, "DINGTALK_CORP_ID") } : {}),
|
||||
apiBaseUrl: (env.DINGTALK_API_BASE_URL?.trim() || "https://api.dingtalk.com").replace(/\/$/, ""),
|
||||
oapiBaseUrl: (env.DINGTALK_OAPI_BASE_URL?.trim() || "https://oapi.dingtalk.com").replace(/\/$/, ""),
|
||||
publicBaseUrl,
|
||||
bindHost,
|
||||
port,
|
||||
requestTimeoutMs: positiveInteger(env, "DINGTALK_REQUEST_TIMEOUT_MS", 15_000),
|
||||
officialLookbackDays: Math.min(120, positiveInteger(env, "OFFICIAL_OA_LOOKBACK_DAYS", 120)),
|
||||
officialScanConcurrency: positiveInteger(env, "OFFICIAL_OA_SCAN_CONCURRENCY", 2),
|
||||
officialClientOnlyProcessCodes: commaSeparated(env, "DINGTALK_CLIENT_ONLY_PROCESS_CODES"),
|
||||
batchLimit: APPROVAL_BATCH_LIMIT,
|
||||
approvalRefSecret: strongSecret(
|
||||
privateSecretFileValue(env, "MCP_APPROVAL_REF_SECRET_FILE") ?? optional(env, "MCP_APPROVAL_REF_SECRET"),
|
||||
"MCP_APPROVAL_REF_SECRET(或 MCP_APPROVAL_REF_SECRET_FILE)",
|
||||
),
|
||||
...(remoteAuthEnabled ? {
|
||||
remoteAuth: {
|
||||
issuerUrl: parsedUrl.origin,
|
||||
databasePath: optional(env, "MCP_AUTH_DATABASE") ?? ".runtime/auth.sqlite",
|
||||
sessionSecret: strongSecret(
|
||||
privateSecretFileValue(env, "MCP_SESSION_SECRET_FILE") ?? optional(env, "MCP_SESSION_SECRET"),
|
||||
"MCP_SESSION_SECRET(或 MCP_SESSION_SECRET_FILE)",
|
||||
),
|
||||
accessTokenTtlSeconds: positiveInteger(env, "MCP_ACCESS_TOKEN_TTL_SECONDS", 15 * 60),
|
||||
refreshTokenTtlSeconds: positiveInteger(env, "MCP_REFRESH_TOKEN_TTL_SECONDS", 30 * 24 * 60 * 60),
|
||||
},
|
||||
} : {}),
|
||||
...(yidaEnabled
|
||||
? {
|
||||
yida: {
|
||||
adminUserId: required(env, "YIDA_ADMIN_USER_ID"),
|
||||
organizationToken: required(env, "YIDA_ORGANIZATION_TOKEN"),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(xftOpenApiEnabled
|
||||
? {
|
||||
xftOpenApi: {
|
||||
appId: required(env, "XFT_OPEN_API_APP_ID"),
|
||||
authoritySecret: protectedAuthoritySecretFileValue(env, "XFT_OPEN_API_AUTHORITY_SECRET_FILE")
|
||||
?? required(env, "XFT_OPEN_API_AUTHORITY_SECRET"),
|
||||
enterpriseId: required(env, "XFT_OPEN_API_ENTERPRISE_ID"),
|
||||
apiBaseUrl: (env.XFT_OPEN_API_BASE_URL?.trim() || "https://api.cmbchina.com").replace(/\/$/, ""),
|
||||
encrypted: booleanValue(env, "XFT_OPEN_API_ENCRYPTED", true),
|
||||
dingTalkAppCode: optional(env, "XFT_OPEN_API_DINGTALK_APP_CODE") ?? "DDingTalk",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function isLoopbackHost(value: string): boolean {
|
||||
return value === "localhost" || value === "127.0.0.1" || value === "::1" || value === "[::1]";
|
||||
}
|
||||
+494
@@ -0,0 +1,494 @@
|
||||
import type { AppConfig } from "./config.js";
|
||||
import { DingTalkApiError } from "./errors.js";
|
||||
import { numberishStringValue, numericValue, objectValue, stringValue } from "./json.js";
|
||||
|
||||
export type ApprovalAction = "agree" | "refuse";
|
||||
export type ApprovalSource = "official_oa" | "yida";
|
||||
export type FetchLike = typeof fetch;
|
||||
|
||||
export interface ApprovalTask {
|
||||
taskId?: number | string;
|
||||
userId?: string;
|
||||
status?: string;
|
||||
result?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ApprovalInstance {
|
||||
processInstanceId?: string;
|
||||
title?: string;
|
||||
status?: string;
|
||||
result?: string;
|
||||
tasks?: ApprovalTask[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface OfficialTemplate {
|
||||
name?: string;
|
||||
processCode: string;
|
||||
dirName?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface OfficialFormValue { name: string; value: string }
|
||||
|
||||
export interface OfficialSubmissionRequest {
|
||||
processCode: string;
|
||||
originatorUserId: string;
|
||||
deptId: number;
|
||||
formComponentValues: OfficialFormValue[];
|
||||
targetSelectActioners?: Array<{ actionerKey: string; actionerStaffIds: string[] }>;
|
||||
}
|
||||
|
||||
export interface OfficialTodoWorkRecord {
|
||||
instance_id?: string;
|
||||
task_id?: number | string;
|
||||
title?: string;
|
||||
url?: string;
|
||||
forms?: Array<{ title?: string; content?: string }>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface OfficialInitiatedInstancePage {
|
||||
instanceIds: string[];
|
||||
nextCursor?: number;
|
||||
}
|
||||
|
||||
export interface YidaTask {
|
||||
processInstanceId?: string;
|
||||
formInstId?: string;
|
||||
taskId?: string;
|
||||
appType?: string;
|
||||
title?: string;
|
||||
processInstanceTitle?: string;
|
||||
status?: string;
|
||||
taskStatus?: string;
|
||||
originatorId?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface YidaApplication {
|
||||
appType: string;
|
||||
systemToken: string;
|
||||
name?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface CachedToken { value: string; expiresAt: number }
|
||||
|
||||
export class DingTalkClient {
|
||||
private token?: CachedToken;
|
||||
private tokenPromise: Promise<string> | undefined;
|
||||
|
||||
constructor(private readonly config: AppConfig, private readonly fetchFn: FetchLike = fetch) {}
|
||||
|
||||
async getAppAccessToken(): Promise<string> {
|
||||
if (this.token && this.token.expiresAt > Date.now() + 60_000) return this.token.value;
|
||||
if (this.tokenPromise) return this.tokenPromise;
|
||||
this.tokenPromise = this.fetchAppAccessToken();
|
||||
try { return await this.tokenPromise; } finally { this.tokenPromise = undefined; }
|
||||
}
|
||||
|
||||
async listOfficialTemplates(): Promise<OfficialTemplate[]> {
|
||||
const output: OfficialTemplate[] = [];
|
||||
let nextToken = 0;
|
||||
for (let page = 0; page < 100; page += 1) {
|
||||
const query = new URLSearchParams({ maxResults: "100", nextToken: String(nextToken) });
|
||||
const data = await this.appRequest<Record<string, unknown>>(
|
||||
`/v1.0/workflow/processes/userVisibilities/templates?${query}`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
const result = objectValue(data.result) ?? data;
|
||||
const list = Array.isArray(result.processList) ? result.processList : [];
|
||||
for (const item of list) {
|
||||
const value = objectValue(item);
|
||||
const processCode = stringValue(value?.processCode);
|
||||
if (value && processCode) output.push({ ...value, processCode } as OfficialTemplate);
|
||||
}
|
||||
const next = numericValue(result.nextToken);
|
||||
if (!next || list.length === 0) break;
|
||||
nextToken = next;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
async getOfficialTemplateSchema(processCode: string): Promise<Record<string, unknown>> {
|
||||
const query = new URLSearchParams({ processCode });
|
||||
const data = await this.appRequest<Record<string, unknown>>(
|
||||
`/v1.0/workflow/forms/schemas/processCodes?${query}`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
return objectValue(data.result) ?? data;
|
||||
}
|
||||
|
||||
async getUserDepartmentIds(userId: string): Promise<number[]> {
|
||||
const accessToken = await this.getAppAccessToken();
|
||||
const data = await this.publicRequest<Record<string, unknown>>(
|
||||
`${this.config.oapiBaseUrl}/topapi/v2/user/get?access_token=${encodeURIComponent(accessToken)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ userid: userId, language: "zh_CN" }),
|
||||
},
|
||||
);
|
||||
const result = objectValue(data.result) ?? data;
|
||||
const raw = Array.isArray(result.deptIdList) ? result.deptIdList
|
||||
: Array.isArray(result.dept_id_list) ? result.dept_id_list : [];
|
||||
return [...new Set(raw.map((value) => Number(value)).filter((value) => Number.isSafeInteger(value) && value > 0))];
|
||||
}
|
||||
|
||||
async forecastOfficialProcess(input: OfficialSubmissionRequest): Promise<Record<string, unknown>> {
|
||||
const data = await this.appRequest<Record<string, unknown>>("/v1.0/workflow/processes/forecast", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
processCode: input.processCode,
|
||||
userId: input.originatorUserId,
|
||||
deptId: input.deptId,
|
||||
formComponentValues: input.formComponentValues,
|
||||
}),
|
||||
});
|
||||
const result = objectValue(data.result) ?? data;
|
||||
const forecastSuccess = booleanField(result, ["isForecastSuccess", "forecastSuccess"]);
|
||||
if (forecastSuccess === undefined) {
|
||||
throw new DingTalkApiError("钉钉审批流程预测响应缺少预测结果", 502, "INVALID_FORECAST_RESPONSE");
|
||||
}
|
||||
if (!forecastSuccess) {
|
||||
const detail = stringValue(result.message) ?? stringValue(result.errorMessage);
|
||||
throw new DingTalkApiError(
|
||||
`钉钉审批流程预测失败${detail ? `:${detail}` : ",请核对表单字段和发起部门"}`,
|
||||
422,
|
||||
"FORECAST_FAILED",
|
||||
);
|
||||
}
|
||||
const rules = recordArrayField(result, ["workflowActivityRules", "workflowActivityRuleVOs"])
|
||||
.map((rule) => ({
|
||||
...rule,
|
||||
targetSelect: booleanField(rule, ["isTargetSelect", "targetSelect"]) === true,
|
||||
}));
|
||||
const staticWorkflow = booleanField(result, ["isStaticWorkflow", "staticWorkflow"]);
|
||||
const {
|
||||
isForecastSuccess: _isForecastSuccess,
|
||||
isStaticWorkflow: _isStaticWorkflow,
|
||||
workflowActivityRules: _workflowActivityRules,
|
||||
workflowActivityRuleVOs: _workflowActivityRuleVOs,
|
||||
...rest
|
||||
} = result;
|
||||
return {
|
||||
...rest,
|
||||
supported: true,
|
||||
forecastSuccess,
|
||||
...(staticWorkflow !== undefined ? { staticWorkflow } : {}),
|
||||
workflowActivityRuleVOs: rules,
|
||||
};
|
||||
}
|
||||
|
||||
async createOfficialInstance(input: OfficialSubmissionRequest): Promise<string> {
|
||||
const { targetSelectActioners, ...request } = input;
|
||||
const data = await this.appRequest<Record<string, unknown>>("/v1.0/workflow/processInstances", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
...request,
|
||||
...(targetSelectActioners?.length ? {
|
||||
targetSelectActioners: targetSelectActioners.map((item) => ({
|
||||
actionerKey: item.actionerKey,
|
||||
actionerUserIds: item.actionerStaffIds,
|
||||
})),
|
||||
} : {}),
|
||||
}),
|
||||
});
|
||||
const result = data.result;
|
||||
const id = typeof result === "string" ? result
|
||||
: stringValue(objectValue(result)?.processInstanceId)
|
||||
?? stringValue(objectValue(result)?.id)
|
||||
?? stringValue(data.processInstanceId);
|
||||
if (!id) throw new DingTalkApiError("钉钉已返回成功,但响应缺少审批实例 ID", 502);
|
||||
return id;
|
||||
}
|
||||
|
||||
async terminateOfficialInstance(input: {
|
||||
processInstanceId: string;
|
||||
operatingUserId: string;
|
||||
remark?: string;
|
||||
}): Promise<void> {
|
||||
await this.appRequest("/v1.0/workflow/processInstances/terminate", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...input, isSystem: false }),
|
||||
});
|
||||
}
|
||||
|
||||
async getOfficialInstance(processInstanceId: string): Promise<ApprovalInstance> {
|
||||
const query = new URLSearchParams({ processInstanceId });
|
||||
const data = await this.appRequest<Record<string, unknown>>(
|
||||
`/v1.0/workflow/processInstances?${query}`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
return (objectValue(data.result) ?? data) as ApprovalInstance;
|
||||
}
|
||||
|
||||
async executeOfficialApproval(input: {
|
||||
processInstanceId: string;
|
||||
taskId: number | string;
|
||||
actionerUserId: string;
|
||||
result: ApprovalAction;
|
||||
remark?: string;
|
||||
}): Promise<unknown> {
|
||||
const taskId = officialTaskId(input.taskId);
|
||||
return this.appRequest("/v1.0/workflow/processInstances/execute", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...input, taskId }),
|
||||
});
|
||||
}
|
||||
|
||||
async listOfficialTodoWorkRecords(
|
||||
userId: string,
|
||||
offset = 0,
|
||||
count = 50,
|
||||
status: 0 | 1 = 0,
|
||||
): Promise<{ list: OfficialTodoWorkRecord[]; hasMore: boolean }> {
|
||||
const accessToken = await this.getAppAccessToken();
|
||||
const data = await this.publicRequest<Record<string, unknown>>(
|
||||
`${this.config.oapiBaseUrl}/topapi/process/workrecord/task/query?access_token=${encodeURIComponent(accessToken)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ userid: userId, offset, count: Math.min(count, 20), status }),
|
||||
},
|
||||
);
|
||||
const result = objectValue(data.result) ?? {};
|
||||
const list = Array.isArray(result.list)
|
||||
? result.list.filter((item): item is OfficialTodoWorkRecord => objectValue(item) !== undefined)
|
||||
: [];
|
||||
return { list, hasMore: result.has_more === true };
|
||||
}
|
||||
|
||||
async listOfficialInitiatedInstanceIds(
|
||||
processCode: string,
|
||||
userId: string,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
cursor = 0,
|
||||
size = 20,
|
||||
): Promise<OfficialInitiatedInstancePage> {
|
||||
const accessToken = await this.getAppAccessToken();
|
||||
const data = await this.publicRequest<Record<string, unknown>>(
|
||||
`${this.config.oapiBaseUrl}/topapi/processinstance/listids?access_token=${encodeURIComponent(accessToken)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
process_code: processCode,
|
||||
userid: userId,
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
cursor,
|
||||
size: Math.min(size, 20),
|
||||
}),
|
||||
},
|
||||
);
|
||||
const result = objectValue(data.result) ?? {};
|
||||
const values = Array.isArray(result.list) ? result.list : [];
|
||||
const instanceIds = values.flatMap((value) => {
|
||||
const record = objectValue(value);
|
||||
const id = numberishStringValue(value)
|
||||
?? (record ? numberishStringValue(
|
||||
record.process_instance_id ?? record.processInstanceId ?? record.instance_id ?? record.instanceId ?? record.id,
|
||||
) : undefined);
|
||||
return id ? [id] : [];
|
||||
});
|
||||
const nextCursor = numericValue(result.next_cursor ?? result.nextCursor);
|
||||
return { instanceIds, ...(nextCursor !== undefined && nextCursor > cursor ? { nextCursor } : {}) };
|
||||
}
|
||||
|
||||
async listYidaCorpTasks(userId: string, pageNumber = 1, pageSize = 100): Promise<Record<string, unknown>> {
|
||||
const yida = this.requireYida();
|
||||
const end = Date.now();
|
||||
const start = end - this.config.officialLookbackDays * 86_400_000;
|
||||
const query = new URLSearchParams({
|
||||
corpId: this.requireCorpId(),
|
||||
pageSize: String(Math.min(pageSize, 100)),
|
||||
pageNumber: String(pageNumber),
|
||||
language: "zh_CN",
|
||||
createFromTimeGMT: String(start),
|
||||
createToTimeGMT: String(end),
|
||||
userId,
|
||||
token: yida.organizationToken,
|
||||
});
|
||||
return this.appRequest(`/v1.0/yida/corpTasks?${query}`, { method: "GET" });
|
||||
}
|
||||
|
||||
async listYidaApplications(): Promise<YidaApplication[]> {
|
||||
const yida = this.requireYida();
|
||||
const output: YidaApplication[] = [];
|
||||
for (let page = 1; page <= 100; page += 1) {
|
||||
const query = new URLSearchParams({
|
||||
appFilter: "managedByMe",
|
||||
pageNumber: String(page),
|
||||
corpId: this.requireCorpId(),
|
||||
pageSize: "100",
|
||||
userId: yida.adminUserId,
|
||||
token: yida.organizationToken,
|
||||
});
|
||||
const data = await this.appRequest<Record<string, unknown>>(
|
||||
`/v1.0/yida/organizations/applications?${query}`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
const list = Array.isArray(data.data) ? data.data : [];
|
||||
for (const item of list) {
|
||||
const value = objectValue(item);
|
||||
const appType = stringValue(value?.appType);
|
||||
const systemToken = stringValue(value?.systemToken);
|
||||
if (value && appType && systemToken) output.push({ ...value, appType, systemToken } as YidaApplication);
|
||||
}
|
||||
if (list.length < 100) break;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
async getYidaInstance(id: string, app: YidaApplication, userId: string): Promise<unknown> {
|
||||
const query = new URLSearchParams({ appType: app.appType, systemToken: app.systemToken, userId, language: "zh_CN" });
|
||||
return this.appRequest(`/v1.0/yida/processes/instancesInfos/${encodeURIComponent(id)}?${query}`, { method: "GET" });
|
||||
}
|
||||
|
||||
async executeYidaBatch(
|
||||
app: YidaApplication,
|
||||
userId: string,
|
||||
action: ApprovalAction,
|
||||
remark: string,
|
||||
tasks: Array<{ taskId: string; formInstId: string }>,
|
||||
): Promise<unknown> {
|
||||
return this.appRequest("/v1.0/yida/tasks/batches/execute", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
outResult: action === "agree" ? "agree" : "disagree",
|
||||
appType: app.appType,
|
||||
systemToken: app.systemToken,
|
||||
remark,
|
||||
userId,
|
||||
taskInformationList: JSON.stringify(tasks),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
private requireYida() {
|
||||
if (!this.config.yida) throw new DingTalkApiError("服务器未启用宜搭接口", 503);
|
||||
return this.config.yida;
|
||||
}
|
||||
|
||||
private requireCorpId(): string {
|
||||
const corpId = this.config.corpId;
|
||||
if (!corpId) throw new DingTalkApiError("宜搭功能需要显式配置 DINGTALK_CORP_ID", 503);
|
||||
return corpId;
|
||||
}
|
||||
|
||||
private async fetchAppAccessToken(): Promise<string> {
|
||||
const data = await this.publicRequest<Record<string, unknown>>(
|
||||
`${this.config.apiBaseUrl}/v1.0/oauth2/accessToken`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ appKey: this.config.clientId, appSecret: this.config.clientSecret }),
|
||||
},
|
||||
);
|
||||
const value = stringValue(data.accessToken);
|
||||
const expireIn = numericValue(data.expireIn);
|
||||
if (!value || !expireIn) throw new DingTalkApiError("钉钉应用 accessToken 响应格式无效", 502);
|
||||
this.token = { value, expiresAt: Date.now() + expireIn * 1_000 };
|
||||
return value;
|
||||
}
|
||||
|
||||
private async appRequest<T>(path: string, init: RequestInit, allowTokenRetry = true): Promise<T> {
|
||||
const token = await this.getAppAccessToken();
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set("x-acs-dingtalk-access-token", token);
|
||||
headers.set("accept", "application/json");
|
||||
if (init.body) headers.set("content-type", "application/json");
|
||||
try {
|
||||
return await this.publicRequest<T>(`${this.config.apiBaseUrl}${path}`, { ...init, headers });
|
||||
} catch (error) {
|
||||
if (allowTokenRetry && isInvalidAppAccessToken(error)) {
|
||||
this.token = undefined;
|
||||
return this.appRequest<T>(path, init, false);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async publicRequest<T>(url: string, init: RequestInit): Promise<T> {
|
||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await this.fetchFn(url, { ...init, signal: AbortSignal.timeout(this.config.requestTimeoutMs) });
|
||||
} catch (error) {
|
||||
throw new DingTalkApiError(`请求钉钉接口失败:${error instanceof Error ? error.message : String(error)}`, 0);
|
||||
}
|
||||
let text: string;
|
||||
try { text = await response.text(); }
|
||||
catch (error) {
|
||||
throw new DingTalkApiError(
|
||||
`读取钉钉接口响应失败:${error instanceof Error ? error.message : String(error)}`,
|
||||
0,
|
||||
"RESPONSE_STREAM_ERROR",
|
||||
);
|
||||
}
|
||||
let data: unknown;
|
||||
try { data = text ? JSON.parse(text) : {}; }
|
||||
catch { throw new DingTalkApiError(`钉钉接口返回非 JSON 响应(HTTP ${response.status})`, response.status); }
|
||||
const body = objectValue(data) ?? {};
|
||||
const legacyError = numericValue(body.errcode);
|
||||
const code = stringValue(body.code) ?? (legacyError === undefined ? undefined : String(legacyError));
|
||||
const message = stringValue(body.message) ?? stringValue(body.errmsg) ?? `钉钉接口请求失败(HTTP ${response.status})`;
|
||||
if (!response.ok || (legacyError !== undefined && legacyError !== 0) || body.success === false) {
|
||||
if (attempt < 3 && isRateLimited(response.status, code, message)) {
|
||||
await delay(300 * (2 ** attempt) + Math.floor(Math.random() * 100));
|
||||
continue;
|
||||
}
|
||||
throw new DingTalkApiError(
|
||||
message,
|
||||
response.status,
|
||||
code,
|
||||
stringValue(body.requestid) ?? stringValue(body.requestId),
|
||||
);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
throw new DingTalkApiError("钉钉接口限流重试失败", 429);
|
||||
}
|
||||
}
|
||||
|
||||
function isInvalidAppAccessToken(error: unknown): boolean {
|
||||
if (!(error instanceof DingTalkApiError)) return false;
|
||||
if (error.status === 401) return true;
|
||||
return ["40014", "42001", "InvalidAccessToken", "InvalidAuthentication"].includes(error.code ?? "");
|
||||
}
|
||||
|
||||
function officialTaskId(value: number | string): number {
|
||||
const parsed = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
|
||||
throw new DingTalkApiError("钉钉审批任务 ID 必须是安全正整数", 400);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
function booleanField(record: Record<string, unknown>, keys: string[]): boolean | undefined {
|
||||
for (const key of keys) {
|
||||
if (typeof record[key] === "boolean") return record[key];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
function recordArrayField(record: Record<string, unknown>, keys: string[]): Array<Record<string, unknown>> {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap((item) => {
|
||||
const object = objectValue(item);
|
||||
return object ? [object] : [];
|
||||
});
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
function isRateLimited(status: number, code: string | undefined, message: string): boolean {
|
||||
return status === 429 || /qps|throttl|rate.?limit|流控|限流/i.test(`${code ?? ""} ${message}`);
|
||||
}
|
||||
function delay(milliseconds: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { realpathSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/**
|
||||
* npm global bins are symlinks. Compare real paths so packaged CLI entrypoints
|
||||
* still start when process.argv[1] is the generated bin link.
|
||||
*/
|
||||
export function isDirectEntrypoint(moduleUrl: string, argv1: string | undefined): boolean {
|
||||
if (!argv1) return false;
|
||||
const modulePath = fileURLToPath(moduleUrl);
|
||||
try {
|
||||
return realpathSync(modulePath) === realpathSync(argv1);
|
||||
} catch {
|
||||
return resolve(modulePath) === resolve(argv1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export class DingTalkApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
readonly code?: string,
|
||||
readonly requestId?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "DingTalkApiError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ApprovalSafetyError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ApprovalSafetyError";
|
||||
}
|
||||
}
|
||||
|
||||
export class XftApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
readonly code?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "XftApiError";
|
||||
}
|
||||
}
|
||||
|
||||
export class XftSessionExpiredError extends XftApiError {
|
||||
constructor(message = "薪福通登录已失效,请重新扫码登录") {
|
||||
super(message, 401, "MSCOM04");
|
||||
this.name = "XftSessionExpiredError";
|
||||
}
|
||||
}
|
||||
|
||||
export function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env node
|
||||
import { loadConfig } from "./config.js";
|
||||
import { startHttpServer } from "./server.js";
|
||||
|
||||
const config = loadConfig();
|
||||
startHttpServer(config).catch((error: unknown) => {
|
||||
console.error(`[dingtalk-approval-mcp] ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,828 @@
|
||||
import { createHmac, randomBytes } from "node:crypto";
|
||||
import { chmodSync, mkdirSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import type { AppConfig } from "./config.js";
|
||||
import type { ApprovalService, ApprovalHistoryItem } from "./approval-service.js";
|
||||
import { ApprovalSafetyError, errorMessage } from "./errors.js";
|
||||
import { isRecord } from "./json.js";
|
||||
import { sensitiveLabel } from "./approval-metadata.js";
|
||||
import type {
|
||||
ApprovalHistoryRange,
|
||||
AuthenticatedDingTalkUser,
|
||||
XftApprovalProvider,
|
||||
XftHistoryItem,
|
||||
} from "./xft.js";
|
||||
|
||||
export type InvoiceEvidenceKind =
|
||||
| "invoice_code_number"
|
||||
| "digital_invoice_number"
|
||||
| "invoice_number"
|
||||
| "attachment_hash"
|
||||
| "expense_signature";
|
||||
|
||||
export interface InvoiceEvidence {
|
||||
kind: InvoiceEvidenceKind;
|
||||
strength: "exact" | "auxiliary" | "suspected";
|
||||
normalized: string;
|
||||
maskedDisplay: string;
|
||||
}
|
||||
|
||||
export interface InvoiceDuplicateMatch {
|
||||
source: "dingtalk" | "xft";
|
||||
businessNo: string;
|
||||
submittedAt?: string;
|
||||
status: string;
|
||||
matchKind: InvoiceEvidenceKind;
|
||||
matchStrength: InvoiceEvidence["strength"];
|
||||
invoice: string;
|
||||
disposition: "blocking" | "warning";
|
||||
}
|
||||
|
||||
export interface InvoiceDuplicateCheck {
|
||||
status: "not_applicable" | "clear" | "confirmation_required" | "proceeding_after_confirmation" | "check_unavailable";
|
||||
checkedSources: Array<"dingtalk" | "xft">;
|
||||
coverageComplete: boolean;
|
||||
invoiceEvidence: Array<Pick<InvoiceEvidence, "kind" | "strength" | "maskedDisplay">>;
|
||||
matches: InvoiceDuplicateMatch[];
|
||||
requiresUserConfirmation: boolean;
|
||||
message: string;
|
||||
sourceStatus: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
interface StoredMatch {
|
||||
invoice_hash: string;
|
||||
match_kind: InvoiceEvidenceKind;
|
||||
source: "dingtalk" | "xft";
|
||||
business_no: string;
|
||||
status: string;
|
||||
submitted_at: number | null;
|
||||
}
|
||||
|
||||
interface HistoryRecord {
|
||||
source: "dingtalk" | "xft";
|
||||
businessNo: string;
|
||||
status: string;
|
||||
submittedAt?: string;
|
||||
detail: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
interface SyncResult {
|
||||
checkedSources: Array<"dingtalk" | "xft">;
|
||||
complete: boolean;
|
||||
sourceStatus: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
interface PreparedDeduplication {
|
||||
duplicateCheck: InvoiceDuplicateCheck;
|
||||
reservationId?: string;
|
||||
}
|
||||
|
||||
const HISTORY_LIMIT = 200;
|
||||
const HISTORY_DAYS = 30;
|
||||
const DETAIL_CONCURRENCY = 3;
|
||||
const MAX_VISITED_NODES = 75_000;
|
||||
const MAX_EVIDENCE = 100;
|
||||
const EVIDENCE_EXTRACTOR_VERSION = 1;
|
||||
const FINGERPRINT_RETENTION_DAYS = 35;
|
||||
const ACTIVE_STATUSES = new Set(["pending", "approved", "unknown", "outcome_uncertain"]);
|
||||
|
||||
/**
|
||||
* Stores only keyed, irreversible invoice fingerprints and minimal approval
|
||||
* metadata. Approval bodies, invoice numbers and attachments never reach SQL.
|
||||
*/
|
||||
export class InvoiceDedupStore {
|
||||
private readonly db: DatabaseSync;
|
||||
private readonly hmacKey: string;
|
||||
|
||||
constructor(databasePath: string, secret: string) {
|
||||
const path = resolve(databasePath);
|
||||
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
||||
this.db = new DatabaseSync(path);
|
||||
chmodSync(path, 0o600);
|
||||
this.db.exec("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;");
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS invoice_indexed_approvals (
|
||||
user_hash TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
business_no TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
submitted_at INTEGER,
|
||||
detail_indexed_at INTEGER,
|
||||
extractor_version INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_hash, source, business_no)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS invoice_fingerprints (
|
||||
user_hash TEXT NOT NULL,
|
||||
invoice_hash TEXT NOT NULL,
|
||||
match_kind TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
business_no TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
submitted_at INTEGER,
|
||||
indexed_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_hash, invoice_hash, source, business_no),
|
||||
FOREIGN KEY (user_hash, source, business_no)
|
||||
REFERENCES invoice_indexed_approvals(user_hash, source, business_no)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS invoice_fingerprints_lookup
|
||||
ON invoice_fingerprints(user_hash, invoice_hash);
|
||||
CREATE INDEX IF NOT EXISTS invoice_approvals_retention
|
||||
ON invoice_indexed_approvals(COALESCE(submitted_at, updated_at));
|
||||
CREATE TABLE IF NOT EXISTS invoice_scan_state (
|
||||
user_hash TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
last_scan_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_hash, source)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS invoice_scan_state_retention ON invoice_scan_state(last_scan_at);
|
||||
CREATE TABLE IF NOT EXISTS invoice_reservations (
|
||||
user_hash TEXT NOT NULL,
|
||||
invoice_hash TEXT NOT NULL,
|
||||
reservation_id TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_hash, invoice_hash)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS invoice_reservations_expiry ON invoice_reservations(expires_at);
|
||||
`);
|
||||
this.hmacKey = createHmac("sha256", secret).update("dingtalk-approval:invoice-dedup:v1").digest("base64url");
|
||||
this.cleanupExpired();
|
||||
}
|
||||
|
||||
userHash(userId: string): string { return this.hmac(`user\u0000${userId}`); }
|
||||
|
||||
fingerprint(evidence: InvoiceEvidence): string {
|
||||
return this.hmac(`invoice\u0000${evidence.kind}\u0000${evidence.normalized}`);
|
||||
}
|
||||
|
||||
hasIndexedDetail(userHash: string, source: "dingtalk" | "xft", businessNo: string): boolean {
|
||||
const row = this.db.prepare(`
|
||||
SELECT detail_indexed_at, extractor_version FROM invoice_indexed_approvals
|
||||
WHERE user_hash = ? AND source = ? AND business_no = ?
|
||||
`).get(userHash, source, businessNo) as { detail_indexed_at?: number | null; extractor_version?: number } | undefined;
|
||||
return typeof row?.detail_indexed_at === "number" && row.extractor_version === EVIDENCE_EXTRACTOR_VERSION;
|
||||
}
|
||||
|
||||
upsertApproval(
|
||||
userHash: string,
|
||||
source: "dingtalk" | "xft",
|
||||
businessNo: string,
|
||||
status: string,
|
||||
submittedAt?: string,
|
||||
detailIndexed = false,
|
||||
): void {
|
||||
const submitted = submittedAt ? Date.parse(submittedAt) : Number.NaN;
|
||||
const now = Date.now();
|
||||
this.db.prepare(`
|
||||
INSERT INTO invoice_indexed_approvals
|
||||
(user_hash, source, business_no, status, submitted_at, detail_indexed_at, extractor_version, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_hash, source, business_no) DO UPDATE SET
|
||||
status = excluded.status,
|
||||
submitted_at = COALESCE(excluded.submitted_at, submitted_at),
|
||||
detail_indexed_at = CASE
|
||||
WHEN excluded.detail_indexed_at IS NOT NULL THEN excluded.detail_indexed_at
|
||||
ELSE detail_indexed_at
|
||||
END,
|
||||
extractor_version = CASE
|
||||
WHEN excluded.detail_indexed_at IS NOT NULL THEN excluded.extractor_version
|
||||
ELSE extractor_version
|
||||
END,
|
||||
updated_at = excluded.updated_at
|
||||
`).run(
|
||||
userHash, source, businessNo, status,
|
||||
Number.isFinite(submitted) ? submitted : null,
|
||||
detailIndexed ? now : null,
|
||||
detailIndexed ? EVIDENCE_EXTRACTOR_VERSION : 0,
|
||||
now,
|
||||
);
|
||||
this.db.prepare(`
|
||||
UPDATE invoice_fingerprints SET status = ?, submitted_at = COALESCE(?, submitted_at)
|
||||
WHERE user_hash = ? AND source = ? AND business_no = ?
|
||||
`).run(status, Number.isFinite(submitted) ? submitted : null, userHash, source, businessNo);
|
||||
}
|
||||
|
||||
indexDetail(
|
||||
userHash: string,
|
||||
source: "dingtalk" | "xft",
|
||||
businessNo: string,
|
||||
status: string,
|
||||
submittedAt: string | undefined,
|
||||
evidence: InvoiceEvidence[],
|
||||
): void {
|
||||
this.transaction(() => this.indexDetailRows(userHash, source, businessNo, status, submittedAt, evidence));
|
||||
}
|
||||
|
||||
findMatches(userHash: string, evidence: InvoiceEvidence[]): StoredMatch[] {
|
||||
const hashes = [...new Set(evidence.map((item) => this.fingerprint(item)))];
|
||||
if (hashes.length === 0) return [];
|
||||
const placeholders = hashes.map(() => "?").join(",");
|
||||
return this.db.prepare(`
|
||||
SELECT invoice_hash, match_kind, source, business_no, status, submitted_at
|
||||
FROM invoice_fingerprints
|
||||
WHERE user_hash = ? AND invoice_hash IN (${placeholders})
|
||||
ORDER BY submitted_at DESC, indexed_at DESC
|
||||
`).all(userHash, ...hashes) as unknown as StoredMatch[];
|
||||
}
|
||||
|
||||
reserve(userHash: string, evidence: InvoiceEvidence[], reservationId: string, expiresAt: number): boolean {
|
||||
const hashes = [...new Set(evidence.map((item) => this.fingerprint(item)))];
|
||||
if (hashes.length === 0) return true;
|
||||
return this.transaction(() => {
|
||||
this.db.prepare("DELETE FROM invoice_reservations WHERE expires_at < ?").run(Date.now());
|
||||
const lookup = this.db.prepare(`
|
||||
SELECT reservation_id FROM invoice_reservations WHERE user_hash = ? AND invoice_hash = ?
|
||||
`);
|
||||
if (hashes.some((hash) => lookup.get(userHash, hash) !== undefined)) return false;
|
||||
const insert = this.db.prepare(`
|
||||
INSERT INTO invoice_reservations(user_hash, invoice_hash, reservation_id, expires_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
for (const hash of hashes) insert.run(userHash, hash, reservationId, expiresAt);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
ownsReservation(userHash: string, reservationId: string): boolean {
|
||||
const row = this.db.prepare(`
|
||||
SELECT 1 AS found FROM invoice_reservations
|
||||
WHERE user_hash = ? AND reservation_id = ? AND expires_at >= ? LIMIT 1
|
||||
`).get(userHash, reservationId, Date.now()) as { found?: number } | undefined;
|
||||
return row?.found === 1;
|
||||
}
|
||||
|
||||
commitReservation(
|
||||
userHash: string,
|
||||
reservationId: string,
|
||||
evidence: InvoiceEvidence[],
|
||||
source: "dingtalk" | "xft",
|
||||
businessNo: string,
|
||||
status: string,
|
||||
submittedAt?: string,
|
||||
): void {
|
||||
this.transaction(() => {
|
||||
if (!this.ownsReservation(userHash, reservationId)) throw new Error("invoice reservation expired");
|
||||
this.indexDetailRows(userHash, source, businessNo, status, submittedAt, evidence);
|
||||
this.db.prepare(`
|
||||
DELETE FROM invoice_reservations WHERE user_hash = ? AND reservation_id = ?
|
||||
`).run(userHash, reservationId);
|
||||
});
|
||||
}
|
||||
|
||||
setScanTime(userHash: string, source: "dingtalk" | "xft", time = Date.now()): void {
|
||||
this.db.prepare(`
|
||||
INSERT INTO invoice_scan_state(user_hash, source, last_scan_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_hash, source) DO UPDATE SET last_scan_at = excluded.last_scan_at
|
||||
`).run(userHash, source, time);
|
||||
}
|
||||
|
||||
cleanupExpired(now = Date.now(), retentionDays = FINGERPRINT_RETENTION_DAYS): void {
|
||||
const cutoff = now - Math.max(HISTORY_DAYS, retentionDays) * 86_400_000;
|
||||
this.transaction(() => {
|
||||
this.db.prepare("DELETE FROM invoice_reservations WHERE expires_at < ?").run(now);
|
||||
this.db.prepare(`
|
||||
DELETE FROM invoice_indexed_approvals
|
||||
WHERE COALESCE(submitted_at, updated_at) < ?
|
||||
`).run(cutoff);
|
||||
this.db.prepare("DELETE FROM invoice_scan_state WHERE last_scan_at < ?").run(cutoff);
|
||||
});
|
||||
}
|
||||
|
||||
private hmac(value: string): string {
|
||||
return createHmac("sha256", this.hmacKey).update(value).digest("base64url");
|
||||
}
|
||||
|
||||
private indexDetailRows(
|
||||
userHash: string,
|
||||
source: "dingtalk" | "xft",
|
||||
businessNo: string,
|
||||
status: string,
|
||||
submittedAt: string | undefined,
|
||||
evidence: InvoiceEvidence[],
|
||||
): void {
|
||||
this.upsertApproval(userHash, source, businessNo, status, submittedAt, true);
|
||||
const submitted = submittedAt ? Date.parse(submittedAt) : Number.NaN;
|
||||
const insert = this.db.prepare(`
|
||||
INSERT INTO invoice_fingerprints
|
||||
(user_hash, invoice_hash, match_kind, source, business_no, status, submitted_at, indexed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_hash, invoice_hash, source, business_no) DO UPDATE SET
|
||||
status = excluded.status,
|
||||
submitted_at = COALESCE(excluded.submitted_at, submitted_at),
|
||||
indexed_at = excluded.indexed_at
|
||||
`);
|
||||
for (const item of evidence) {
|
||||
insert.run(
|
||||
userHash, this.fingerprint(item), item.kind, source, businessNo, status,
|
||||
Number.isFinite(submitted) ? submitted : null, Date.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private transaction<T>(operation: () => T): T {
|
||||
this.db.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
const result = operation();
|
||||
this.db.exec("COMMIT");
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.db.exec("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class InvoiceDeduplicationService {
|
||||
private readonly synchronizations = new Map<string, Promise<SyncResult>>();
|
||||
|
||||
constructor(
|
||||
private readonly config: AppConfig,
|
||||
private readonly store: InvoiceDedupStore,
|
||||
private readonly dingTalk: ApprovalService,
|
||||
private readonly xft?: XftApprovalProvider,
|
||||
) {}
|
||||
|
||||
async prepare(
|
||||
user: AuthenticatedDingTalkUser,
|
||||
values: Record<string, unknown>,
|
||||
allowDuplicateInvoices: boolean,
|
||||
expiresAt: number,
|
||||
invoiceEvidenceExpected = false,
|
||||
): Promise<PreparedDeduplication> {
|
||||
const evidence = extractInvoiceEvidence(values);
|
||||
if (evidence.length === 0) {
|
||||
if (!invoiceEvidenceExpected) return { duplicateCheck: noInvoiceCheck() };
|
||||
return {
|
||||
duplicateCheck: {
|
||||
...noInvoiceCheck(),
|
||||
status: allowDuplicateInvoices ? "proceeding_after_confirmation" : "check_unavailable",
|
||||
coverageComplete: false,
|
||||
requiresUserConfirmation: !allowDuplicateInvoices,
|
||||
message: allowDuplicateInvoices
|
||||
? "该报销表单未识别到可查重的发票唯一标识或完整费用组合;用户已明确知晓无法完成发票级查重并仍要继续。"
|
||||
: "该报销表单未识别到可查重的发票唯一标识或完整费用组合,无法确认是否重复;请补充发票信息,或向用户明确说明后由用户决定是否继续。",
|
||||
},
|
||||
};
|
||||
}
|
||||
const userHash = this.store.userHash(user.userId);
|
||||
const sync = await this.synchronize(user, userHash);
|
||||
const byHash = new Map(evidence.map((item) => [this.store.fingerprint(item), item]));
|
||||
const matches = deduplicatePublicMatches(this.store.findMatches(userHash, evidence).map((match) => publicMatch(match, byHash)));
|
||||
const historicalRisk = matches.length > 0;
|
||||
const requiresConfirmation = historicalRisk || !sync.complete;
|
||||
const sourceMessage = !sync.complete
|
||||
? "发票查重未能完整覆盖本人近30天的钉钉与薪福通历史,不能视为无重复。"
|
||||
: historicalRisk
|
||||
? "发现本人历史审批中存在相同或疑似相同发票/费用。"
|
||||
: "未在本人近30天的钉钉与薪福通已提交审批中发现重复发票。";
|
||||
const duplicateCheck: InvoiceDuplicateCheck = {
|
||||
status: requiresConfirmation
|
||||
? allowDuplicateInvoices ? "proceeding_after_confirmation" : sync.complete ? "confirmation_required" : "check_unavailable"
|
||||
: "clear",
|
||||
checkedSources: sync.checkedSources,
|
||||
coverageComplete: sync.complete,
|
||||
invoiceEvidence: evidence.map(({ kind, strength, maskedDisplay }) => ({ kind, strength, maskedDisplay })),
|
||||
matches,
|
||||
requiresUserConfirmation: requiresConfirmation && !allowDuplicateInvoices,
|
||||
message: requiresConfirmation && allowDuplicateInvoices
|
||||
? `${sourceMessage} 用户已明确确认仍要继续,本次确认引用将绑定该决定。`
|
||||
: sourceMessage,
|
||||
sourceStatus: sync.sourceStatus,
|
||||
};
|
||||
if (requiresConfirmation && !allowDuplicateInvoices) return { duplicateCheck };
|
||||
const reservationId = randomBytes(18).toString("base64url");
|
||||
if (!this.store.reserve(userHash, evidence, reservationId, expiresAt)) {
|
||||
return {
|
||||
duplicateCheck: {
|
||||
...duplicateCheck,
|
||||
status: "confirmation_required",
|
||||
requiresUserConfirmation: true,
|
||||
message: "相同发票正在另一个尚未过期的提单确认中。为避免并发重复提交,本次不会生成提交确认;请先处理或等待原确认过期。",
|
||||
},
|
||||
};
|
||||
}
|
||||
return { duplicateCheck, reservationId };
|
||||
}
|
||||
|
||||
assertReservation(user: AuthenticatedDingTalkUser, reservationId: string | undefined): void {
|
||||
if (!reservationId || !this.store.ownsReservation(this.store.userHash(user.userId), reservationId)) {
|
||||
throw new ApprovalSafetyError("发票查重预留已过期或不属于当前用户,请重新准备并确认提单");
|
||||
}
|
||||
}
|
||||
|
||||
async recordSubmission(
|
||||
user: AuthenticatedDingTalkUser,
|
||||
reservationId: string | undefined,
|
||||
values: Record<string, unknown>,
|
||||
source: "dingtalk" | "xft",
|
||||
businessNo: string,
|
||||
status: string,
|
||||
): Promise<"indexed" | "not_applicable" | "index_failed"> {
|
||||
const evidence = extractInvoiceEvidence(values);
|
||||
if (evidence.length === 0 || !reservationId) return "not_applicable";
|
||||
try {
|
||||
this.store.commitReservation(
|
||||
this.store.userHash(user.userId), reservationId, evidence, source, businessNo,
|
||||
normalizeApprovalStatus(status), new Date().toISOString(),
|
||||
);
|
||||
return "indexed";
|
||||
} catch {
|
||||
// The upstream write already happened. Never turn an index maintenance
|
||||
// failure into a submission failure that could invite a dangerous retry.
|
||||
return "index_failed";
|
||||
}
|
||||
}
|
||||
|
||||
private async synchronize(user: AuthenticatedDingTalkUser, userHash: string): Promise<SyncResult> {
|
||||
const existing = this.synchronizations.get(userHash);
|
||||
if (existing) return existing;
|
||||
const task = this.runSynchronization(user, userHash).finally(() => this.synchronizations.delete(userHash));
|
||||
this.synchronizations.set(userHash, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
private async runSynchronization(user: AuthenticatedDingTalkUser, userHash: string): Promise<SyncResult> {
|
||||
const now = Date.now();
|
||||
this.store.cleanupExpired(now);
|
||||
const range: ApprovalHistoryRange = {
|
||||
startTime: now - Math.min(HISTORY_DAYS, this.config.officialLookbackDays) * 86_400_000,
|
||||
endTime: now,
|
||||
};
|
||||
const sourceStatus: Record<string, Record<string, unknown>> = {};
|
||||
const checkedSources: Array<"dingtalk" | "xft"> = [];
|
||||
let complete = true;
|
||||
const sources: Array<Promise<void>> = [];
|
||||
sources.push(this.dingTalk.listMyHistory(user.userId, "submitted", HISTORY_LIMIT, range).then(async (result) => {
|
||||
const records = result.items.map((item) => dingTalkHistoryRecord(user, this.dingTalk, item));
|
||||
const indexed = await this.indexRecords(userHash, records);
|
||||
const truncated = result.diagnostics?.truncated === true;
|
||||
sourceStatus.dingtalk = { status: indexed.failed === 0 && !truncated ? "ok" : "partial", count: records.length, ...indexed, ...(truncated ? { truncated: true } : {}) };
|
||||
checkedSources.push("dingtalk");
|
||||
if (indexed.failed > 0 || truncated) complete = false;
|
||||
this.store.setScanTime(userHash, "dingtalk");
|
||||
}).catch((error) => {
|
||||
complete = false;
|
||||
sourceStatus.dingtalk = { status: "unavailable", message: errorMessage(error) };
|
||||
}));
|
||||
if (!this.xft) {
|
||||
complete = false;
|
||||
sourceStatus.xft = { status: "disabled" };
|
||||
} else {
|
||||
sources.push(this.xft.listMyHistory(user, "submitted", HISTORY_LIMIT, "", range).then(async (result) => {
|
||||
const records = result.items.map((item) => xftHistoryRecord(user, this.xft!, item));
|
||||
const indexed = await this.indexRecords(userHash, records);
|
||||
const truncated = result.diagnostics?.truncated === true;
|
||||
sourceStatus.xft = { status: indexed.failed === 0 && !truncated ? "ok" : "partial", count: records.length, ...indexed, ...(truncated ? { truncated: true } : {}) };
|
||||
checkedSources.push("xft");
|
||||
if (indexed.failed > 0 || truncated) complete = false;
|
||||
this.store.setScanTime(userHash, "xft");
|
||||
}).catch((error) => {
|
||||
complete = false;
|
||||
sourceStatus.xft = { status: "unavailable", message: errorMessage(error) };
|
||||
}));
|
||||
}
|
||||
await Promise.all(sources);
|
||||
return { checkedSources: checkedSources.sort(), complete, sourceStatus };
|
||||
}
|
||||
|
||||
private async indexRecords(userHash: string, records: HistoryRecord[]): Promise<{ indexed: number; cached: number; failed: number }> {
|
||||
let indexed = 0;
|
||||
let cached = 0;
|
||||
let failed = 0;
|
||||
const pending: HistoryRecord[] = [];
|
||||
for (const record of records) {
|
||||
this.store.upsertApproval(userHash, record.source, record.businessNo, record.status, record.submittedAt);
|
||||
if (this.store.hasIndexedDetail(userHash, record.source, record.businessNo)) cached += 1;
|
||||
else pending.push(record);
|
||||
}
|
||||
await mapConcurrent(pending, DETAIL_CONCURRENCY, async (record) => {
|
||||
try {
|
||||
const detail = await record.detail();
|
||||
this.store.indexDetail(
|
||||
userHash, record.source, record.businessNo, record.status, record.submittedAt,
|
||||
extractInvoiceEvidence(detail),
|
||||
);
|
||||
indexed += 1;
|
||||
} catch {
|
||||
failed += 1;
|
||||
}
|
||||
});
|
||||
return { indexed, cached, failed };
|
||||
}
|
||||
}
|
||||
|
||||
export function invoiceDedupDatabasePath(config: AppConfig): string {
|
||||
const authPath = config.remoteAuth?.databasePath ?? ".runtime/auth.sqlite";
|
||||
return `${authPath}.invoice-dedup.sqlite`;
|
||||
}
|
||||
|
||||
export function extractInvoiceEvidence(root: unknown): InvoiceEvidence[] {
|
||||
const found = new Map<string, InvoiceEvidence>();
|
||||
const labeledCodes: string[] = [];
|
||||
const labeledNumbers: string[] = [];
|
||||
const seen = new WeakSet<object>();
|
||||
let visited = 0;
|
||||
const add = (item: InvoiceEvidence | undefined) => {
|
||||
if (!item || found.size >= MAX_EVIDENCE) return;
|
||||
found.set(`${item.kind}\u0000${item.normalized}`, item);
|
||||
};
|
||||
const walk = (value: unknown, path: string[], depth: number): void => {
|
||||
if (depth > 18 || visited >= MAX_VISITED_NODES || found.size >= MAX_EVIDENCE) return;
|
||||
if (typeof value === "string") {
|
||||
const parsed = parseJson(value);
|
||||
if (parsed !== undefined) walk(parsed, path, depth + 1);
|
||||
return;
|
||||
}
|
||||
if (!value || typeof value !== "object" || seen.has(value)) return;
|
||||
seen.add(value);
|
||||
visited += 1;
|
||||
if (Array.isArray(value)) {
|
||||
for (const child of value) walk(child, path, depth + 1);
|
||||
return;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const context = path.join("/").toLocaleLowerCase();
|
||||
const label = fieldLabel(record);
|
||||
if (label && !sensitiveLabel(label)) {
|
||||
const labeledValue = scalarString(fieldValue(record));
|
||||
const normalizedLabel = normalizeLabel(label);
|
||||
if (/^(?:发票代码|invoicecode)$/.test(normalizedLabel) && labeledValue) labeledCodes.push(labeledValue);
|
||||
else if (/^(?:发票号码|发票号|票据号码|票据号|invoicenumber|invoiceno)$/.test(normalizedLabel) && labeledValue) labeledNumbers.push(labeledValue);
|
||||
add(labeledEvidence(label, fieldValue(record)));
|
||||
}
|
||||
|
||||
const code = directString(record, INVOICE_CODE_KEYS);
|
||||
const number = directString(record, INVOICE_NUMBER_KEYS);
|
||||
if (code && number) add(codeNumberEvidence(code, number));
|
||||
const digital = directString(record, DIGITAL_INVOICE_KEYS);
|
||||
if (digital) add(digitalEvidence(digital));
|
||||
if (number && looksInvoiceContext(context, record)) add(numberEvidence(number));
|
||||
|
||||
for (const [key, child] of Object.entries(record)) {
|
||||
const normalizedKey = normalizeKey(key);
|
||||
const childContext = `${context}/${normalizedKey}`;
|
||||
if (sensitiveLabel(key)) continue;
|
||||
if (DIGITAL_INVOICE_KEYS.has(normalizedKey)) add(digitalEvidence(scalarString(child)));
|
||||
else if (INVOICE_NUMBER_KEYS.has(normalizedKey) && looksInvoiceContext(childContext, record)) add(numberEvidence(scalarString(child)));
|
||||
else if (HASH_KEYS.has(normalizedKey) && looksInvoiceContext(childContext, record)) add(hashEvidence(scalarString(child)));
|
||||
walk(child, [...path, key], depth + 1);
|
||||
}
|
||||
add(expenseSignatureEvidence(record));
|
||||
};
|
||||
walk(root, [], 0);
|
||||
if (labeledCodes.length === labeledNumbers.length) {
|
||||
for (let index = 0; index < labeledCodes.length; index += 1) {
|
||||
add(codeNumberEvidence(labeledCodes[index], labeledNumbers[index]));
|
||||
}
|
||||
}
|
||||
return [...found.values()];
|
||||
}
|
||||
|
||||
function dingTalkHistoryRecord(
|
||||
user: AuthenticatedDingTalkUser,
|
||||
service: ApprovalService,
|
||||
item: ApprovalHistoryItem,
|
||||
): HistoryRecord {
|
||||
return {
|
||||
source: "dingtalk",
|
||||
businessNo: item.processInstanceId,
|
||||
status: normalizeApprovalStatus(item.status, item.result),
|
||||
...(item.submittedAt ? { submittedAt: item.submittedAt } : {}),
|
||||
detail: async () => cachedOwnedDingTalkDetail(user, item)
|
||||
?? service.getHistoryDetail(user.userId, "submitted", item.processInstanceId, item.taskId),
|
||||
};
|
||||
}
|
||||
|
||||
function cachedOwnedDingTalkDetail(user: AuthenticatedDingTalkUser, item: ApprovalHistoryItem): unknown | undefined {
|
||||
if (item.enrichmentStatus !== "complete" || !isRecord(item.raw)) return undefined;
|
||||
for (const key of ["originatorUserId", "originatorUserid", "originatorId", "creatorUserId"]) {
|
||||
const owner = item.raw[key];
|
||||
if ((typeof owner === "string" || typeof owner === "number") && String(owner) === user.userId) return item.raw;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function xftHistoryRecord(
|
||||
user: AuthenticatedDingTalkUser,
|
||||
service: XftApprovalProvider,
|
||||
item: XftHistoryItem,
|
||||
): HistoryRecord {
|
||||
return {
|
||||
source: "xft",
|
||||
businessNo: item.billId,
|
||||
status: normalizeApprovalStatus(item.status, item.statusName),
|
||||
...(item.applyTime ? { submittedAt: item.applyTime } : {}),
|
||||
detail: () => service.getHistoryDetail(user, "submitted", item.billId),
|
||||
};
|
||||
}
|
||||
|
||||
function publicMatch(match: StoredMatch, evidence: Map<string, InvoiceEvidence>): InvoiceDuplicateMatch {
|
||||
const item = evidence.get(match.invoice_hash);
|
||||
const strength = item?.strength ?? strengthForKind(match.match_kind);
|
||||
return {
|
||||
source: match.source,
|
||||
businessNo: match.business_no,
|
||||
...(typeof match.submitted_at === "number" ? { submittedAt: new Date(match.submitted_at).toISOString() } : {}),
|
||||
status: match.status,
|
||||
matchKind: match.match_kind,
|
||||
matchStrength: strength,
|
||||
invoice: item?.maskedDisplay ?? "已脱敏发票标识",
|
||||
disposition: strength === "suspected" || !ACTIVE_STATUSES.has(match.status) ? "warning" : "blocking",
|
||||
};
|
||||
}
|
||||
|
||||
function deduplicatePublicMatches(matches: InvoiceDuplicateMatch[]): InvoiceDuplicateMatch[] {
|
||||
const unique = new Map<string, InvoiceDuplicateMatch>();
|
||||
const rank = { suspected: 0, auxiliary: 1, exact: 2 } as const;
|
||||
for (const match of matches) {
|
||||
const key = `${match.source}\u0000${match.businessNo}`;
|
||||
const current = unique.get(key);
|
||||
if (!current || rank[match.matchStrength] > rank[current.matchStrength]) unique.set(key, match);
|
||||
}
|
||||
return [...unique.values()];
|
||||
}
|
||||
|
||||
function noInvoiceCheck(): InvoiceDuplicateCheck {
|
||||
return {
|
||||
status: "not_applicable",
|
||||
checkedSources: [],
|
||||
coverageComplete: true,
|
||||
invoiceEvidence: [],
|
||||
matches: [],
|
||||
requiresUserConfirmation: false,
|
||||
message: "本次表单未识别到发票唯一标识、附件哈希或可用的费用组合,未执行发票级查重。",
|
||||
sourceStatus: {},
|
||||
};
|
||||
}
|
||||
|
||||
function codeNumberEvidence(codeValue: string | undefined, numberValue: string | undefined): InvoiceEvidence | undefined {
|
||||
const code = normalizeIdentifier(codeValue);
|
||||
const number = normalizeIdentifier(numberValue);
|
||||
if (!validInvoiceIdentifier(code, 6) || !validInvoiceIdentifier(number, 6)) return undefined;
|
||||
return { kind: "invoice_code_number", strength: "exact", normalized: `${code}\u0000${number}`, maskedDisplay: `${mask(code)}/${mask(number)}` };
|
||||
}
|
||||
|
||||
function digitalEvidence(value: string | undefined): InvoiceEvidence | undefined {
|
||||
const normalized = normalizeIdentifier(value);
|
||||
if (!validInvoiceIdentifier(normalized, 12)) return undefined;
|
||||
return { kind: "digital_invoice_number", strength: "exact", normalized, maskedDisplay: mask(normalized) };
|
||||
}
|
||||
|
||||
function numberEvidence(value: string | undefined): InvoiceEvidence | undefined {
|
||||
const normalized = normalizeIdentifier(value);
|
||||
if (!validInvoiceIdentifier(normalized, 6)) return undefined;
|
||||
return { kind: "invoice_number", strength: "auxiliary", normalized, maskedDisplay: mask(normalized) };
|
||||
}
|
||||
|
||||
function hashEvidence(value: string | undefined): InvoiceEvidence | undefined {
|
||||
const normalized = value?.trim().toLocaleLowerCase() ?? "";
|
||||
if (!/^[0-9a-f]{32,128}$/.test(normalized)) return undefined;
|
||||
return { kind: "attachment_hash", strength: "auxiliary", normalized, maskedDisplay: `附件哈希…${normalized.slice(-6)}` };
|
||||
}
|
||||
|
||||
function labeledEvidence(labelValue: string, value: unknown): InvoiceEvidence | undefined {
|
||||
const label = normalizeLabel(labelValue);
|
||||
const valueString = scalarString(value);
|
||||
if (/^(?:发票代码|invoicecode)$/.test(label)) return undefined;
|
||||
if (/(?:数电票|全电票|电子发票).*(?:唯一|号码|编号)|发票唯一(?:号码|编号)|digitalinvoice/.test(label)) return digitalEvidence(valueString);
|
||||
if (/^(?:发票号码|发票号|票据号码|票据号|invoicenumber|invoiceno)$/.test(label)) return numberEvidence(valueString);
|
||||
if (/(?:发票|票据).*(?:哈希|hash|摘要)/.test(label)) return hashEvidence(valueString);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function expenseSignatureEvidence(record: Record<string, unknown>): InvoiceEvidence | undefined {
|
||||
const amount = directValue(record, AMOUNT_KEYS);
|
||||
const date = directValue(record, DATE_KEYS);
|
||||
const merchant = directValue(record, MERCHANT_KEYS);
|
||||
const normalizedAmount = normalizeAmount(amount);
|
||||
const normalizedDate = normalizeDate(date);
|
||||
const normalizedMerchant = normalizeMerchant(merchant);
|
||||
if (!normalizedAmount || !normalizedDate || !normalizedMerchant) return undefined;
|
||||
return {
|
||||
kind: "expense_signature",
|
||||
strength: "suspected",
|
||||
normalized: `${normalizedAmount}\u0000${normalizedDate}\u0000${normalizedMerchant}`,
|
||||
maskedDisplay: `费用组合 ${normalizedDate} / ¥${normalizedAmount} / ${maskMerchant(normalizedMerchant)}`,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeApprovalStatus(status?: string, result?: string): string {
|
||||
const value = `${result ?? ""} ${status ?? ""}`.trim().toLocaleLowerCase();
|
||||
if (/撤回|撤销|终止|取消|terminate|withdraw|revoke|cancel/.test(value)) return "revoked";
|
||||
if (/作废|void|deleted/.test(value)) return "void";
|
||||
if (/拒绝|驳回|不同意|reject|refuse|disagree/.test(value)) return "rejected";
|
||||
if (/待审批|审批中|进行中|running|pending|wait|apprv_pend/.test(value) || /^(?:0|1)$/.test(value)) return "pending";
|
||||
if (/同意|通过|完成|agree|approve|approved|pass|completed|finish|apprv/.test(value) || value === "2") return "approved";
|
||||
if (/uncertain/.test(value)) return "outcome_uncertain";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function strengthForKind(kind: InvoiceEvidenceKind): InvoiceEvidence["strength"] {
|
||||
if (kind === "invoice_code_number" || kind === "digital_invoice_number") return "exact";
|
||||
if (kind === "expense_signature") return "suspected";
|
||||
return "auxiliary";
|
||||
}
|
||||
|
||||
const INVOICE_CODE_KEYS = new Set(["invoicecode", "invcode", "fapiaocode"]);
|
||||
const INVOICE_NUMBER_KEYS = new Set(["invoicenumber", "invoiceno", "invnumber", "invno", "billnumber", "ticketnumber", "fapiaonumber"]);
|
||||
const DIGITAL_INVOICE_KEYS = new Set(["digitalinvoicenumber", "electronicinvoicenumber", "allelectronicinvoicenumber", "invoiceuniquenumber", "invoiceuniqueid", "digitalinvoiceno"]);
|
||||
const HASH_KEYS = new Set(["attachmenthash", "filehash", "sha256", "sha512", "md5", "invoicehash"]);
|
||||
const AMOUNT_KEYS = new Set(["amount", "invoiceamount", "totalamount", "reimamount", "reimamt", "expenseamount", "taxinclusiveamount"]);
|
||||
const DATE_KEYS = new Set(["invoicedate", "occurredon", "expensedate", "wrtim", "transactiondate", "date"]);
|
||||
const MERCHANT_KEYS = new Set(["merchant", "merchantname", "merchantnam", "sellername", "payee", "vendor", "suppliername"]);
|
||||
|
||||
function directString(record: Record<string, unknown>, keys: Set<string>): string | undefined {
|
||||
return scalarString(directValue(record, keys));
|
||||
}
|
||||
|
||||
function directValue(record: Record<string, unknown>, keys: Set<string>): unknown {
|
||||
for (const [key, value] of Object.entries(record)) if (keys.has(normalizeKey(key))) return unwrapScalar(value);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function unwrapScalar(value: unknown): unknown {
|
||||
if (!isRecord(value)) return value;
|
||||
for (const key of ["value", "fieldValue", "content", "displayValue"]) if (key in value) return value[key];
|
||||
return value;
|
||||
}
|
||||
|
||||
function scalarString(value: unknown): string | undefined {
|
||||
const unwrapped = unwrapScalar(value);
|
||||
return typeof unwrapped === "string" || typeof unwrapped === "number" ? String(unwrapped).trim() || undefined : undefined;
|
||||
}
|
||||
|
||||
function fieldLabel(record: Record<string, unknown>): string | undefined {
|
||||
for (const key of ["label", "name", "fieldName", "title", "bizAlias"]) {
|
||||
if (typeof record[key] === "string" && record[key].trim()) return record[key].trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function fieldValue(record: Record<string, unknown>): unknown {
|
||||
for (const key of ["value", "fieldValue", "content", "displayValue", "extValue"]) if (key in record) return record[key];
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function looksInvoiceContext(context: string, record: Record<string, unknown>): boolean {
|
||||
return /invoice|发票|fapiao|certificate|票据/.test(context)
|
||||
|| Object.keys(record).some((key) => /invoice|发票|fapiao|certificate|票据/i.test(key));
|
||||
}
|
||||
|
||||
function validInvoiceIdentifier(value: string, minimum: number): boolean {
|
||||
if (value.length < minimum || value.length > 40 || !/^[0-9A-Z]+$/.test(value)) return false;
|
||||
return !/^(?:0+|1+)$/.test(value);
|
||||
}
|
||||
|
||||
function normalizeIdentifier(value: string | undefined): string {
|
||||
return (value ?? "").normalize("NFKC").toLocaleUpperCase().replace(/[^0-9A-Z]/g, "");
|
||||
}
|
||||
|
||||
function normalizeKey(value: string): string { return value.normalize("NFKC").toLocaleLowerCase().replace(/[^a-z0-9]/g, ""); }
|
||||
function normalizeLabel(value: string): string { return value.normalize("NFKC").toLocaleLowerCase().replace(/[\s_\-::()()]/g, ""); }
|
||||
|
||||
function normalizeAmount(value: unknown): string | undefined {
|
||||
const text = scalarString(value)?.replace(/[,,¥¥\s]/g, "");
|
||||
if (!text || !/^-?\d+(?:\.\d{1,4})?$/.test(text)) return undefined;
|
||||
const number = Number(text);
|
||||
return Number.isFinite(number) && number > 0 ? number.toFixed(2) : undefined;
|
||||
}
|
||||
|
||||
function normalizeDate(value: unknown): string | undefined {
|
||||
const text = scalarString(value);
|
||||
if (!text) return undefined;
|
||||
const match = text.match(/(20\d{2})[-/.年]?(\d{1,2})[-/.月]?(\d{1,2})/);
|
||||
if (match) return `${match[1]}-${match[2]!.padStart(2, "0")}-${match[3]!.padStart(2, "0")}`;
|
||||
const timestamp = Number(text);
|
||||
if (/^\d{10,13}$/.test(text) && Number.isFinite(timestamp)) {
|
||||
const date = new Date(text.length === 10 ? timestamp * 1000 : timestamp);
|
||||
if (Number.isFinite(date.getTime())) return date.toISOString().slice(0, 10);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeMerchant(value: unknown): string | undefined {
|
||||
const text = scalarString(value)?.normalize("NFKC").toLocaleLowerCase().replace(/\s+/g, "").slice(0, 120);
|
||||
return text && text.length >= 2 ? text : undefined;
|
||||
}
|
||||
|
||||
function mask(value: string): string { return value.length <= 4 ? "****" : `****${value.slice(-4)}`; }
|
||||
function maskMerchant(value: string): string { return value.length <= 2 ? "**" : `${value.slice(0, 1)}***${value.slice(-1)}`; }
|
||||
|
||||
function parseJson(value: string): unknown {
|
||||
const trimmed = value.trim();
|
||||
if (!(trimmed.startsWith("{") || trimmed.startsWith("["))) return undefined;
|
||||
if (trimmed.length > 500_000) return undefined;
|
||||
try { return JSON.parse(trimmed); } catch { return undefined; }
|
||||
}
|
||||
|
||||
async function mapConcurrent<T>(items: T[], concurrency: number, operation: (item: T) => Promise<void>): Promise<void> {
|
||||
let cursor = 0;
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
||||
while (cursor < items.length) {
|
||||
const item = items[cursor++];
|
||||
if (item !== undefined) await operation(item);
|
||||
}
|
||||
}));
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function objectValue(value: unknown): Record<string, unknown> | undefined {
|
||||
return isRecord(value) ? value : undefined;
|
||||
}
|
||||
|
||||
export function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
/** Like stringValue, but also stringifies finite numbers (XFT payloads mix numeric and string ids). */
|
||||
export function numberishStringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : typeof value === "number" && Number.isFinite(value) ? String(value) : undefined;
|
||||
}
|
||||
|
||||
export function numericValue(value: unknown): number | undefined {
|
||||
const parsed = typeof value === "number" ? value : typeof value === "string" && value.trim() ? Number(value) : NaN;
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
export function filterQuery<T>(items: T[], query: string): T[] {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) return items;
|
||||
return items.filter((item) => JSON.stringify(item).toLowerCase().includes(normalized));
|
||||
}
|
||||
+684
@@ -0,0 +1,684 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID } from "node:crypto";
|
||||
import { chmodSync, mkdirSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import type { Response } from "express";
|
||||
import type { OAuthRegisteredClientsStore } from "@modelcontextprotocol/sdk/server/auth/clients.js";
|
||||
import {
|
||||
InvalidClientMetadataError,
|
||||
InvalidGrantError,
|
||||
InvalidRequestError,
|
||||
} from "@modelcontextprotocol/sdk/server/auth/errors.js";
|
||||
import type { AuthorizationParams, OAuthServerProvider } from "@modelcontextprotocol/sdk/server/auth/provider.js";
|
||||
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
|
||||
import type {
|
||||
OAuthClientInformationFull,
|
||||
OAuthTokenRevocationRequest,
|
||||
OAuthTokens,
|
||||
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
import type { AppConfig } from "./config.js";
|
||||
import type { AuthenticatedDingTalkUser } from "./xft.js";
|
||||
|
||||
export const DINGTALK_OAUTH_AUTHORIZE_ORIGIN = "https://login.dingtalk.com";
|
||||
|
||||
type JsonRow = Record<string, unknown>;
|
||||
|
||||
interface StoredAuthorization {
|
||||
flow: "authorization_code";
|
||||
client: OAuthClientInformationFull;
|
||||
params: AuthorizationParams;
|
||||
consentApproved: boolean;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface StoredDeviceLogin {
|
||||
flow: "device";
|
||||
deviceCodeHash: string;
|
||||
clientId: string;
|
||||
scopes: string[];
|
||||
userCode: string;
|
||||
consentApproved: boolean;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface StoredDeviceCode {
|
||||
clientId: string;
|
||||
scopes: string[];
|
||||
resource?: string;
|
||||
user?: AuthenticatedDingTalkUser;
|
||||
status: "pending" | "authorized";
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface StoredCode extends StoredAuthorization {
|
||||
user: AuthenticatedDingTalkUser;
|
||||
}
|
||||
|
||||
interface StoredToken {
|
||||
clientId: string;
|
||||
user: AuthenticatedDingTalkUser;
|
||||
scopes: string[];
|
||||
resource?: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface DingTalkTokenResponse {
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
expireIn?: number;
|
||||
expiresIn?: number;
|
||||
corpId?: string;
|
||||
}
|
||||
|
||||
/** Persistent, single-enterprise OAuth provider for remote MCP clients. */
|
||||
export class DingTalkMcpOAuthProvider implements OAuthServerProvider {
|
||||
readonly clientsStore: OAuthRegisteredClientsStore;
|
||||
private readonly db: DatabaseSync;
|
||||
private readonly auth: NonNullable<AppConfig["remoteAuth"]>;
|
||||
private readonly encryptionKey: Buffer;
|
||||
private appToken?: { value: string; expiresAt: number };
|
||||
private readonly userRefreshes = new Map<string, Promise<string>>();
|
||||
private readonly cleanupTimer: NodeJS.Timeout;
|
||||
|
||||
constructor(private readonly config: AppConfig, private readonly fetchFn: typeof fetch = fetch) {
|
||||
if (!config.remoteAuth) throw new Error("远程 OAuth 未启用");
|
||||
if (!config.corpId) throw new Error("远程 OAuth 必须配置钉钉 CorpId");
|
||||
this.auth = config.remoteAuth;
|
||||
const databasePath = resolve(this.auth.databasePath);
|
||||
mkdirSync(dirname(databasePath), { recursive: true, mode: 0o700 });
|
||||
this.db = new DatabaseSync(databasePath);
|
||||
chmodSync(databasePath, 0o600);
|
||||
this.db.exec("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;");
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS oauth_clients (
|
||||
client_id TEXT PRIMARY KEY,
|
||||
metadata TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS login_requests (
|
||||
login_state TEXT PRIMARY KEY,
|
||||
payload TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS authorization_codes (
|
||||
code_hash TEXT PRIMARY KEY,
|
||||
payload TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS access_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
payload TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
payload TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS identities (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
token_cipher TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS device_codes (
|
||||
device_code_hash TEXT PRIMARY KEY,
|
||||
user_code TEXT UNIQUE NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
last_poll_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS audit_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
reference_hash TEXT,
|
||||
outcome TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS audit_events_created_at ON audit_events(created_at);
|
||||
`);
|
||||
this.ensureOAuthClientLastUsedColumn();
|
||||
this.encryptionKey = createHash("sha256").update(this.auth.sessionSecret).digest();
|
||||
this.clientsStore = {
|
||||
getClient: async (clientId) => this.getClient(clientId),
|
||||
registerClient: async (client) => this.registerClient(client as OAuthClientInformationFull),
|
||||
};
|
||||
this.cleanup();
|
||||
this.cleanupTimer = setInterval(() => this.cleanup(), 60 * 60_000);
|
||||
this.cleanupTimer.unref();
|
||||
}
|
||||
|
||||
async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> {
|
||||
if (!client.redirect_uris.includes(params.redirectUri) && !loopbackRedirectMatches(client.redirect_uris, params.redirectUri)) {
|
||||
throw new InvalidRequestError("Unregistered redirect_uri");
|
||||
}
|
||||
const loginState = randomToken(32);
|
||||
const payload: StoredAuthorization = {
|
||||
flow: "authorization_code", client, params, consentApproved: false,
|
||||
expiresAt: Date.now() + 10 * 60_000,
|
||||
};
|
||||
this.db.prepare("INSERT INTO login_requests(login_state,payload,expires_at) VALUES(?,?,?)")
|
||||
.run(loginState, serialize(payload), payload.expiresAt);
|
||||
const consent = new URL("/oauth/consent", this.auth.issuerUrl);
|
||||
consent.searchParams.set("login_state", loginState);
|
||||
res.redirect(302, consent.toString());
|
||||
}
|
||||
|
||||
consentDetails(loginState: string) {
|
||||
const pending = this.readPendingLogin(loginState);
|
||||
if (pending.flow === "device") {
|
||||
const client = this.getClient(pending.clientId);
|
||||
if (!client) throw new InvalidGrantError("OAuth 客户端已失效");
|
||||
return {
|
||||
clientName: client.client_name ?? "未命名 OAuth 客户端",
|
||||
scopes: pending.scopes,
|
||||
userCode: pending.userCode,
|
||||
};
|
||||
}
|
||||
return {
|
||||
clientName: pending.client.client_name ?? "未命名 OAuth 客户端",
|
||||
scopes: pending.params.scopes ?? [],
|
||||
redirectOrigin: new URL(pending.params.redirectUri).origin,
|
||||
};
|
||||
}
|
||||
|
||||
approveConsent(loginState: string): string {
|
||||
const pending = this.readPendingLogin(loginState);
|
||||
const approved = { ...pending, consentApproved: true };
|
||||
this.db.prepare("UPDATE login_requests SET payload=? WHERE login_state=?")
|
||||
.run(serialize(approved), loginState);
|
||||
return this.dingTalkAuthorizeUrl(loginState);
|
||||
}
|
||||
|
||||
async completeDingTalkLogin(code: string, loginState: string): Promise<string> {
|
||||
const row = this.db.prepare("SELECT payload,expires_at FROM login_requests WHERE login_state=?").get(loginState) as JsonRow | undefined;
|
||||
this.db.prepare("DELETE FROM login_requests WHERE login_state=?").run(loginState);
|
||||
if (!row || Number(row.expires_at) < Date.now()) throw new InvalidGrantError("登录请求无效或已过期");
|
||||
const pending = deserialize<StoredAuthorization | StoredDeviceLogin>(String(row.payload));
|
||||
if (!pending.consentApproved) throw new InvalidGrantError("用户尚未确认 OAuth 客户端授权");
|
||||
const upstream = await this.exchangeDingTalkCode(code);
|
||||
const user = await this.resolveDingTalkIdentity(upstream);
|
||||
this.saveIdentity(user, upstream);
|
||||
if (pending.flow === "device") {
|
||||
const deviceRow = this.db.prepare("SELECT payload,expires_at FROM device_codes WHERE device_code_hash=?")
|
||||
.get(pending.deviceCodeHash) as JsonRow | undefined;
|
||||
if (!deviceRow || Number(deviceRow.expires_at) < Date.now()) throw new InvalidGrantError("设备授权请求已过期");
|
||||
const device = deserialize<StoredDeviceCode>(String(deviceRow.payload));
|
||||
const authorized: StoredDeviceCode = { ...device, status: "authorized", user };
|
||||
this.db.prepare("UPDATE device_codes SET payload=? WHERE device_code_hash=?")
|
||||
.run(serialize(authorized), pending.deviceCodeHash);
|
||||
return new URL("/oauth/device/success", this.auth.issuerUrl).toString();
|
||||
}
|
||||
const authorizationCode = randomToken(32);
|
||||
const stored: StoredCode = { ...pending, user, expiresAt: Date.now() + 5 * 60_000 };
|
||||
this.db.prepare("INSERT INTO authorization_codes(code_hash,payload,expires_at) VALUES(?,?,?)")
|
||||
.run(hashToken(authorizationCode), serialize(stored), stored.expiresAt);
|
||||
const redirect = new URL(pending.params.redirectUri);
|
||||
redirect.searchParams.set("code", authorizationCode);
|
||||
if (pending.params.state) redirect.searchParams.set("state", pending.params.state);
|
||||
return redirect.toString();
|
||||
}
|
||||
|
||||
startDeviceAuthorization(clientId: string, scopes: string[], resource?: string) {
|
||||
if (!this.getClient(clientId)) throw new InvalidGrantError("未知 OAuth 客户端");
|
||||
this.db.prepare("DELETE FROM device_codes WHERE expires_at < ?").run(Date.now());
|
||||
const pending = this.db.prepare("SELECT COUNT(*) AS count FROM device_codes WHERE payload LIKE ?")
|
||||
.get(`%\"clientId\":\"${clientId.replace(/[\\%_]/g, "")}\"%`) as JsonRow;
|
||||
if (Number(pending.count) >= 10) throw new InvalidRequestError("该客户端的待登录请求过多,请稍后重试");
|
||||
const total = this.db.prepare("SELECT COUNT(*) AS count FROM device_codes").get() as JsonRow;
|
||||
if (Number(total.count) >= 1000) throw new InvalidRequestError("登录服务繁忙,请稍后重试");
|
||||
const deviceCode = randomToken(32);
|
||||
const userCode = readableUserCode();
|
||||
const expiresAt = Date.now() + 10 * 60_000;
|
||||
const stored: StoredDeviceCode = { clientId, scopes, ...(resource ? { resource } : {}), status: "pending", expiresAt };
|
||||
this.db.prepare("INSERT INTO device_codes(device_code_hash,user_code,payload,expires_at,last_poll_at) VALUES(?,?,?,?,0)")
|
||||
.run(hashToken(deviceCode), userCode, serialize(stored), expiresAt);
|
||||
const verificationUri = new URL("/oauth/device", this.auth.issuerUrl);
|
||||
const complete = new URL(verificationUri);
|
||||
complete.searchParams.set("user_code", userCode);
|
||||
return {
|
||||
device_code: deviceCode,
|
||||
user_code: userCode,
|
||||
verification_uri: verificationUri.toString(),
|
||||
verification_uri_complete: complete.toString(),
|
||||
expires_in: 600,
|
||||
interval: 5,
|
||||
};
|
||||
}
|
||||
|
||||
beginDeviceLogin(userCode: string): string {
|
||||
const row = this.db.prepare("SELECT device_code_hash,payload,expires_at FROM device_codes WHERE user_code=?")
|
||||
.get(normalizeUserCode(userCode)) as JsonRow | undefined;
|
||||
if (!row || Number(row.expires_at) < Date.now()) throw new InvalidGrantError("设备授权码无效或已过期");
|
||||
const device = deserialize<StoredDeviceCode>(String(row.payload));
|
||||
if (device.status !== "pending") return new URL("/oauth/device/success", this.auth.issuerUrl).toString();
|
||||
const loginState = randomToken(32);
|
||||
const pending: StoredDeviceLogin = {
|
||||
flow: "device", deviceCodeHash: String(row.device_code_hash), clientId: device.clientId,
|
||||
scopes: device.scopes, userCode: normalizeUserCode(userCode), consentApproved: false,
|
||||
expiresAt: Number(row.expires_at),
|
||||
};
|
||||
this.db.prepare("INSERT INTO login_requests(login_state,payload,expires_at) VALUES(?,?,?)")
|
||||
.run(loginState, serialize(pending), pending.expiresAt);
|
||||
const consent = new URL("/oauth/consent", this.auth.issuerUrl);
|
||||
consent.searchParams.set("login_state", loginState);
|
||||
return consent.toString();
|
||||
}
|
||||
|
||||
exchangeDeviceCode(clientId: string, deviceCode: string): OAuthTokens | "authorization_pending" | "slow_down" {
|
||||
const hash = hashToken(deviceCode);
|
||||
const row = this.db.prepare("SELECT payload,expires_at,last_poll_at FROM device_codes WHERE device_code_hash=?")
|
||||
.get(hash) as JsonRow | undefined;
|
||||
if (!row || Number(row.expires_at) < Date.now()) throw new InvalidGrantError("设备授权请求无效或已过期");
|
||||
const stored = deserialize<StoredDeviceCode>(String(row.payload));
|
||||
if (stored.clientId !== clientId) throw new InvalidGrantError("设备授权请求不属于当前客户端");
|
||||
const now = Date.now();
|
||||
if (stored.status === "pending") {
|
||||
if (Number(row.last_poll_at) > now - 4_000) return "slow_down";
|
||||
this.db.prepare("UPDATE device_codes SET last_poll_at=? WHERE device_code_hash=?").run(now, hash);
|
||||
return "authorization_pending";
|
||||
}
|
||||
if (!stored.user) throw new InvalidGrantError("设备授权缺少用户身份");
|
||||
this.db.prepare("DELETE FROM device_codes WHERE device_code_hash=?").run(hash);
|
||||
return this.issueTokens(stored.clientId, stored.user, stored.scopes, stored.resource);
|
||||
}
|
||||
|
||||
async challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise<string> {
|
||||
const stored = this.readCode(authorizationCode);
|
||||
if (stored.client.client_id !== client.client_id) throw new InvalidGrantError("授权码不属于当前客户端");
|
||||
return stored.params.codeChallenge;
|
||||
}
|
||||
|
||||
async exchangeAuthorizationCode(
|
||||
client: OAuthClientInformationFull,
|
||||
authorizationCode: string,
|
||||
_codeVerifier?: string,
|
||||
redirectUri?: string,
|
||||
resource?: URL,
|
||||
): Promise<OAuthTokens> {
|
||||
const stored = this.readCode(authorizationCode);
|
||||
if (stored.client.client_id !== client.client_id) throw new InvalidGrantError("授权码不属于当前客户端");
|
||||
if (redirectUri && redirectUri !== stored.params.redirectUri) throw new InvalidGrantError("redirect_uri 不匹配");
|
||||
if (!resourceMatches(stored.params.resource, resource)) throw new InvalidGrantError("resource 不匹配");
|
||||
this.db.prepare("DELETE FROM authorization_codes WHERE code_hash=?").run(hashToken(authorizationCode));
|
||||
return this.issueTokens(client.client_id, stored.user, stored.params.scopes ?? [], stored.params.resource?.toString());
|
||||
}
|
||||
|
||||
async exchangeRefreshToken(
|
||||
client: OAuthClientInformationFull,
|
||||
refreshToken: string,
|
||||
scopes?: string[],
|
||||
resource?: URL,
|
||||
): Promise<OAuthTokens> {
|
||||
const hash = hashToken(refreshToken);
|
||||
const row = this.db.prepare("SELECT payload,expires_at FROM refresh_tokens WHERE token_hash=?").get(hash) as JsonRow | undefined;
|
||||
if (!row || Number(row.expires_at) < Date.now()) throw new InvalidGrantError("Refresh Token 无效或已过期");
|
||||
const stored = deserialize<StoredToken>(String(row.payload));
|
||||
if (stored.clientId !== client.client_id) throw new InvalidGrantError("Refresh Token 不属于当前客户端");
|
||||
if (resource && stored.resource && resource.toString() !== stored.resource) throw new InvalidGrantError("resource 不匹配");
|
||||
const requestedScopes = scopes ?? stored.scopes;
|
||||
if (requestedScopes.some((scope) => !stored.scopes.includes(scope))) throw new InvalidGrantError("不能扩大授权范围");
|
||||
this.db.prepare("DELETE FROM refresh_tokens WHERE token_hash=?").run(hash);
|
||||
return this.issueTokens(client.client_id, stored.user, requestedScopes, stored.resource);
|
||||
}
|
||||
|
||||
async verifyAccessToken(token: string): Promise<AuthInfo> {
|
||||
const row = this.db.prepare("SELECT payload,expires_at FROM access_tokens WHERE token_hash=?").get(hashToken(token)) as JsonRow | undefined;
|
||||
if (!row || Number(row.expires_at) < Date.now()) throw new InvalidGrantError("Access Token 无效或已过期");
|
||||
const stored = deserialize<StoredToken>(String(row.payload));
|
||||
return {
|
||||
token,
|
||||
clientId: stored.clientId,
|
||||
scopes: stored.scopes,
|
||||
expiresAt: Math.floor(stored.expiresAt / 1000),
|
||||
...(stored.resource ? { resource: new URL(stored.resource) } : {}),
|
||||
extra: { userId: stored.user.userId, ...(stored.user.name ? { userName: stored.user.name } : {}) },
|
||||
};
|
||||
}
|
||||
|
||||
async revokeToken(client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise<void> {
|
||||
const hash = hashToken(request.token);
|
||||
const refresh = this.db.prepare("SELECT payload FROM refresh_tokens WHERE token_hash=?").get(hash) as JsonRow | undefined;
|
||||
if (refresh) {
|
||||
const stored = deserialize<StoredToken>(String(refresh.payload));
|
||||
if (stored.clientId === client.client_id) {
|
||||
this.deleteGrantTokens(stored.clientId, stored.user.userId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const access = this.db.prepare("SELECT payload FROM access_tokens WHERE token_hash=?").get(hash) as JsonRow | undefined;
|
||||
if (access && deserialize<StoredToken>(String(access.payload)).clientId === client.client_id) {
|
||||
this.db.prepare("DELETE FROM access_tokens WHERE token_hash=?").run(hash);
|
||||
}
|
||||
}
|
||||
|
||||
userFromAuth(auth: AuthInfo): AuthenticatedDingTalkUser {
|
||||
const userId = auth.extra?.userId;
|
||||
if (typeof userId !== "string" || !userId) throw new InvalidGrantError("会话缺少钉钉用户身份");
|
||||
const name = auth.extra?.userName;
|
||||
return { userId, ...(typeof name === "string" && name ? { name } : {}) };
|
||||
}
|
||||
|
||||
hasDingTalkIdentity(userId: string): boolean {
|
||||
return Boolean(this.db.prepare("SELECT 1 FROM identities WHERE user_id=?").get(userId));
|
||||
}
|
||||
|
||||
/** Returns a current upstream user token for server-side per-user operations. */
|
||||
async getDingTalkUserAccessToken(userId: string): Promise<string> {
|
||||
const row = this.db.prepare("SELECT name,token_cipher,expires_at FROM identities WHERE user_id=?").get(userId) as JsonRow | undefined;
|
||||
if (!row) throw new InvalidGrantError("当前用户尚未完成钉钉授权");
|
||||
let token = this.decryptTokenCipher(String(row.token_cipher));
|
||||
if (Number(row.expires_at) <= Date.now() + 60_000) {
|
||||
if (!token.refreshToken) throw new InvalidGrantError("钉钉用户授权已过期,请重新登录");
|
||||
const active = this.userRefreshes.get(userId);
|
||||
if (active) return active;
|
||||
const refresh = (async () => {
|
||||
const refreshed = await this.refreshDingTalkToken(token.refreshToken!);
|
||||
this.saveIdentity({ userId, ...(typeof row.name === "string" && row.name ? { name: row.name } : {}) }, refreshed);
|
||||
if (!refreshed.accessToken) throw new InvalidGrantError("钉钉用户授权缺少 Access Token");
|
||||
return refreshed.accessToken;
|
||||
})();
|
||||
this.userRefreshes.set(userId, refresh);
|
||||
try { return await refresh; } finally { this.userRefreshes.delete(userId); }
|
||||
}
|
||||
if (!token.accessToken) throw new InvalidGrantError("钉钉用户授权缺少 Access Token");
|
||||
return token.accessToken;
|
||||
}
|
||||
|
||||
audit(userId: string, action: string, reference: string | undefined, outcome: string): void {
|
||||
this.db.prepare("INSERT INTO audit_events(user_id,action,reference_hash,outcome,created_at) VALUES(?,?,?,?,?)")
|
||||
.run(userId, action, reference ? hashToken(reference) : null, outcome, Date.now());
|
||||
}
|
||||
|
||||
close(): void { clearInterval(this.cleanupTimer); this.db.close(); }
|
||||
|
||||
private getClient(clientId: string): OAuthClientInformationFull | undefined {
|
||||
const row = this.db.prepare("SELECT metadata FROM oauth_clients WHERE client_id=?").get(clientId) as JsonRow | undefined;
|
||||
if (row) this.db.prepare("UPDATE oauth_clients SET last_used_at=? WHERE client_id=?").run(Date.now(), clientId);
|
||||
return row ? deserialize<OAuthClientInformationFull>(String(row.metadata)) : undefined;
|
||||
}
|
||||
|
||||
private registerClient(client: OAuthClientInformationFull): OAuthClientInformationFull {
|
||||
if (!client.client_id) client.client_id = randomUUID();
|
||||
if (!client.redirect_uris.every(safeRedirectUri)) throw new InvalidClientMetadataError("仅允许 HTTPS 或本机回环 redirect_uri");
|
||||
const normalized: OAuthClientInformationFull = {
|
||||
...client,
|
||||
token_endpoint_auth_method: client.token_endpoint_auth_method ?? "none",
|
||||
grant_types: client.grant_types ?? ["authorization_code", "refresh_token"],
|
||||
response_types: client.response_types ?? ["code"],
|
||||
client_id_issued_at: client.client_id_issued_at ?? Math.floor(Date.now() / 1000),
|
||||
};
|
||||
const existing = this.db.prepare("SELECT 1 FROM oauth_clients WHERE client_id=?").get(normalized.client_id);
|
||||
const count = this.db.prepare("SELECT COUNT(*) AS count FROM oauth_clients").get() as JsonRow;
|
||||
if (!existing && Number(count.count) >= 10_000) throw new InvalidClientMetadataError("OAuth 客户端注册数量已达安全上限");
|
||||
const now = Date.now();
|
||||
this.db.prepare("INSERT OR REPLACE INTO oauth_clients(client_id,metadata,created_at,last_used_at) VALUES(?,?,?,?)")
|
||||
.run(normalized.client_id, serialize(normalized), now, now);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private readCode(code: string): StoredCode {
|
||||
const row = this.db.prepare("SELECT payload,expires_at FROM authorization_codes WHERE code_hash=?").get(hashToken(code)) as JsonRow | undefined;
|
||||
if (!row || Number(row.expires_at) < Date.now()) throw new InvalidGrantError("授权码无效或已过期");
|
||||
return deserialize<StoredCode>(String(row.payload));
|
||||
}
|
||||
|
||||
private readPendingLogin(loginState: string): StoredAuthorization | StoredDeviceLogin {
|
||||
const row = this.db.prepare("SELECT payload,expires_at FROM login_requests WHERE login_state=?")
|
||||
.get(loginState) as JsonRow | undefined;
|
||||
if (!row || Number(row.expires_at) < Date.now()) throw new InvalidGrantError("登录请求无效或已过期");
|
||||
return deserialize<StoredAuthorization | StoredDeviceLogin>(String(row.payload));
|
||||
}
|
||||
|
||||
private issueTokens(clientId: string, user: AuthenticatedDingTalkUser, scopes: string[], resource?: string): OAuthTokens {
|
||||
const accessToken = randomToken(32);
|
||||
const refreshToken = randomToken(48);
|
||||
const access: StoredToken = {
|
||||
clientId, user, scopes, ...(resource ? { resource } : {}),
|
||||
expiresAt: Date.now() + this.auth.accessTokenTtlSeconds * 1_000,
|
||||
};
|
||||
const refresh: StoredToken = {
|
||||
...access,
|
||||
expiresAt: Date.now() + this.auth.refreshTokenTtlSeconds * 1_000,
|
||||
};
|
||||
this.db.prepare("INSERT INTO access_tokens(token_hash,payload,expires_at) VALUES(?,?,?)")
|
||||
.run(hashToken(accessToken), serialize(access), access.expiresAt);
|
||||
this.db.prepare("INSERT INTO refresh_tokens(token_hash,payload,expires_at) VALUES(?,?,?)")
|
||||
.run(hashToken(refreshToken), serialize(refresh), refresh.expiresAt);
|
||||
return {
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
token_type: "bearer",
|
||||
expires_in: this.auth.accessTokenTtlSeconds,
|
||||
scope: scopes.join(" "),
|
||||
};
|
||||
}
|
||||
|
||||
private async exchangeDingTalkCode(code: string): Promise<DingTalkTokenResponse> {
|
||||
const response = await this.fetchFn(`${this.config.apiBaseUrl}/v1.0/oauth2/userAccessToken`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify({ clientId: this.config.clientId, clientSecret: this.config.clientSecret, code, grantType: "authorization_code" }),
|
||||
signal: AbortSignal.timeout(this.config.requestTimeoutMs),
|
||||
});
|
||||
const data = await response.json() as DingTalkTokenResponse & { message?: string };
|
||||
if (!response.ok || !data.accessToken) throw new InvalidGrantError(data.message ?? "钉钉授权码交换失败");
|
||||
if (data.corpId && data.corpId !== this.config.corpId) throw new InvalidGrantError("该钉钉账号不属于允许的企业");
|
||||
return data;
|
||||
}
|
||||
|
||||
private dingTalkAuthorizeUrl(loginState: string): string {
|
||||
const callback = new URL("/oauth/dingtalk/callback", this.auth.issuerUrl);
|
||||
const target = new URL("/oauth2/auth", DINGTALK_OAUTH_AUTHORIZE_ORIGIN);
|
||||
target.searchParams.set("redirect_uri", callback.toString());
|
||||
target.searchParams.set("response_type", "code");
|
||||
target.searchParams.set("client_id", this.config.clientId);
|
||||
target.searchParams.set("scope", "openid corpid");
|
||||
target.searchParams.set("corpId", this.config.corpId!);
|
||||
target.searchParams.set("state", loginState);
|
||||
target.searchParams.set("prompt", "consent");
|
||||
return target.toString();
|
||||
}
|
||||
|
||||
private async refreshDingTalkToken(refreshToken: string): Promise<DingTalkTokenResponse> {
|
||||
const response = await this.fetchFn(`${this.config.apiBaseUrl}/v1.0/oauth2/userAccessToken`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify({
|
||||
clientId: this.config.clientId,
|
||||
clientSecret: this.config.clientSecret,
|
||||
refreshToken,
|
||||
grantType: "refresh_token",
|
||||
}),
|
||||
signal: AbortSignal.timeout(this.config.requestTimeoutMs),
|
||||
});
|
||||
const data = await response.json() as DingTalkTokenResponse & { message?: string };
|
||||
if (!response.ok || !data.accessToken) throw new InvalidGrantError(data.message ?? "钉钉用户授权刷新失败,请重新登录");
|
||||
if (data.corpId && data.corpId !== this.config.corpId) throw new InvalidGrantError("刷新后的钉钉账号不属于允许的企业");
|
||||
return { ...data, refreshToken: data.refreshToken ?? refreshToken };
|
||||
}
|
||||
|
||||
private async resolveDingTalkIdentity(token: DingTalkTokenResponse): Promise<AuthenticatedDingTalkUser> {
|
||||
const response = await this.fetchFn(`${this.config.apiBaseUrl}/v1.0/contact/users/me`, {
|
||||
headers: { "x-acs-dingtalk-access-token": token.accessToken!, accept: "application/json" },
|
||||
signal: AbortSignal.timeout(this.config.requestTimeoutMs),
|
||||
});
|
||||
const me = await response.json() as Record<string, unknown>;
|
||||
if (!response.ok) throw new InvalidGrantError("无法读取钉钉登录用户身份");
|
||||
const corpId = stringField(me, "corpId", "corp_id");
|
||||
if (corpId && corpId !== this.config.corpId) throw new InvalidGrantError("该钉钉账号不属于允许的企业");
|
||||
const directUserId = stringField(me, "userId", "userid");
|
||||
const unionId = stringField(me, "unionId", "unionid");
|
||||
let userId: string | undefined;
|
||||
if (unionId) {
|
||||
userId = await this.userIdByUnionId(unionId);
|
||||
if (!userId) throw new InvalidGrantError("该钉钉账号不是允许企业的成员");
|
||||
if (directUserId && directUserId !== userId) throw new InvalidGrantError("钉钉登录身份与企业成员映射不一致");
|
||||
} else if (corpId === this.config.corpId) {
|
||||
userId = directUserId;
|
||||
} else {
|
||||
throw new InvalidGrantError("无法确认该钉钉账号属于允许的企业");
|
||||
}
|
||||
if (!userId) throw new InvalidGrantError("钉钉登录结果缺少企业 userId");
|
||||
const name = stringField(me, "nick", "name");
|
||||
return { userId, ...(name ? { name } : {}) };
|
||||
}
|
||||
|
||||
private async userIdByUnionId(unionId: string): Promise<string | undefined> {
|
||||
const appToken = await this.getAppAccessToken();
|
||||
const response = await this.fetchFn(`${this.config.oapiBaseUrl}/topapi/user/getbyunionid?access_token=${encodeURIComponent(appToken)}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ unionid: unionId }),
|
||||
signal: AbortSignal.timeout(this.config.requestTimeoutMs),
|
||||
});
|
||||
const value = await response.json() as Record<string, unknown>;
|
||||
const result = value.result && typeof value.result === "object" ? value.result as Record<string, unknown> : value;
|
||||
return stringField(result, "userid", "userId");
|
||||
}
|
||||
|
||||
private async getAppAccessToken(): Promise<string> {
|
||||
if (this.appToken && this.appToken.expiresAt > Date.now() + 60_000) return this.appToken.value;
|
||||
const response = await this.fetchFn(`${this.config.apiBaseUrl}/v1.0/oauth2/accessToken`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ appKey: this.config.clientId, appSecret: this.config.clientSecret }),
|
||||
signal: AbortSignal.timeout(this.config.requestTimeoutMs),
|
||||
});
|
||||
const data = await response.json() as { accessToken?: string; expireIn?: number };
|
||||
if (!response.ok || !data.accessToken) throw new InvalidGrantError("无法取得钉钉应用 Access Token");
|
||||
this.appToken = { value: data.accessToken, expiresAt: Date.now() + (data.expireIn ?? 7200) * 1_000 };
|
||||
return data.accessToken;
|
||||
}
|
||||
|
||||
private saveIdentity(user: AuthenticatedDingTalkUser, token: DingTalkTokenResponse): void {
|
||||
const expiresAt = Date.now() + (token.expireIn ?? token.expiresIn ?? 7200) * 1_000;
|
||||
const cipher = this.encrypt(JSON.stringify(token));
|
||||
this.db.prepare(`INSERT INTO identities(user_id,name,token_cipher,expires_at,updated_at) VALUES(?,?,?,?,?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
name=COALESCE(excluded.name, identities.name), token_cipher=excluded.token_cipher,
|
||||
expires_at=excluded.expires_at, updated_at=excluded.updated_at`)
|
||||
.run(user.userId, user.name ?? null, cipher, expiresAt, Date.now());
|
||||
}
|
||||
|
||||
private encrypt(plaintext: string): string {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv("aes-256-gcm", this.encryptionKey, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||
return `${iv.toString("base64url")}.${encrypted.toString("base64url")}.${cipher.getAuthTag().toString("base64url")}`;
|
||||
}
|
||||
|
||||
decryptIdentityToken(userId: string): DingTalkTokenResponse | undefined {
|
||||
const row = this.db.prepare("SELECT token_cipher FROM identities WHERE user_id=?").get(userId) as JsonRow | undefined;
|
||||
if (!row) return undefined;
|
||||
return this.decryptTokenCipher(String(row.token_cipher));
|
||||
}
|
||||
|
||||
private decryptTokenCipher(value: string): DingTalkTokenResponse {
|
||||
const [ivValue, payloadValue, tagValue] = value.split(".");
|
||||
if (!ivValue || !payloadValue || !tagValue) throw new InvalidGrantError("钉钉用户令牌存储损坏,请重新登录");
|
||||
const decipher = createDecipheriv("aes-256-gcm", this.encryptionKey, Buffer.from(ivValue, "base64url"));
|
||||
decipher.setAuthTag(Buffer.from(tagValue, "base64url"));
|
||||
const plaintext = Buffer.concat([decipher.update(Buffer.from(payloadValue, "base64url")), decipher.final()]);
|
||||
return JSON.parse(plaintext.toString("utf8")) as DingTalkTokenResponse;
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
const now = Date.now();
|
||||
for (const table of ["login_requests", "authorization_codes", "access_tokens", "refresh_tokens", "device_codes"] as const) {
|
||||
this.db.prepare(`DELETE FROM ${table} WHERE expires_at < ?`).run(now);
|
||||
}
|
||||
this.db.prepare("DELETE FROM audit_events WHERE created_at < ?").run(now - 30 * 24 * 60 * 60_000);
|
||||
const staleClients = this.db.prepare("SELECT client_id FROM oauth_clients WHERE last_used_at < ? LIMIT 1000")
|
||||
.all(now - 180 * 24 * 60 * 60_000) as Array<Record<string, unknown>>;
|
||||
for (const row of staleClients) {
|
||||
const clientId = String(row.client_id);
|
||||
if (!this.clientHasLiveArtifacts(clientId, now)) {
|
||||
this.db.prepare("DELETE FROM oauth_clients WHERE client_id=?").run(clientId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ensureOAuthClientLastUsedColumn(): void {
|
||||
const columns = this.db.prepare("PRAGMA table_info(oauth_clients)").all() as Array<Record<string, unknown>>;
|
||||
if (!columns.some((column) => column.name === "last_used_at")) {
|
||||
this.db.exec("ALTER TABLE oauth_clients ADD COLUMN last_used_at INTEGER NOT NULL DEFAULT 0");
|
||||
this.db.prepare("UPDATE oauth_clients SET last_used_at=created_at WHERE last_used_at=0").run();
|
||||
}
|
||||
}
|
||||
|
||||
private clientHasLiveArtifacts(clientId: string, now: number): boolean {
|
||||
const needle = `%${clientId}%`;
|
||||
for (const table of ["access_tokens", "refresh_tokens", "device_codes", "authorization_codes", "login_requests"] as const) {
|
||||
const row = this.db.prepare(`SELECT 1 FROM ${table} WHERE expires_at >= ? AND payload LIKE ? LIMIT 1`).get(now, needle);
|
||||
if (row) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private deleteGrantTokens(clientId: string, userId: string): void {
|
||||
for (const table of ["access_tokens", "refresh_tokens"] as const) {
|
||||
const rows = this.db.prepare(`SELECT token_hash,payload FROM ${table}`).all() as Array<Record<string, unknown>>;
|
||||
for (const row of rows) {
|
||||
const token = deserialize<StoredToken>(String(row.payload));
|
||||
if (token.clientId === clientId && token.user.userId === userId) {
|
||||
this.db.prepare(`DELETE FROM ${table} WHERE token_hash=?`).run(String(row.token_hash));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function serialize(value: unknown): string {
|
||||
return JSON.stringify(value, (_key, child) => child instanceof URL ? child.toString() : child);
|
||||
}
|
||||
|
||||
function deserialize<T>(value: string): T { return JSON.parse(value) as T; }
|
||||
function randomToken(bytes: number): string { return randomBytes(bytes).toString("base64url"); }
|
||||
function hashToken(value: string): string { return createHash("sha256").update(value).digest("hex"); }
|
||||
|
||||
function safeRedirectUri(value: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return parsed.protocol === "https:" || (parsed.protocol === "http:" && ["localhost", "127.0.0.1", "::1", "[::1]"].includes(parsed.hostname));
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
function loopbackRedirectMatches(registered: string[], requested: string): boolean {
|
||||
try {
|
||||
const target = new URL(requested);
|
||||
if (target.protocol !== "http:" || !["localhost", "127.0.0.1", "::1", "[::1]"].includes(target.hostname)) return false;
|
||||
return registered.some((value) => {
|
||||
const expected = new URL(value);
|
||||
return expected.protocol === target.protocol && expected.hostname === target.hostname
|
||||
&& expected.pathname === target.pathname && expected.search === target.search;
|
||||
});
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
function resourceMatches(expected: URL | undefined, actual: URL | undefined): boolean {
|
||||
return expected === undefined ? actual === undefined : actual?.toString() === expected.toString();
|
||||
}
|
||||
|
||||
function stringField(record: Record<string, unknown>, ...keys: string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string" && value.trim()) return value.trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readableUserCode(): string {
|
||||
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
const bytes = randomBytes(8);
|
||||
const value = [...bytes].map((byte) => alphabet[byte % alphabet.length]).join("");
|
||||
return `${value.slice(0, 4)}-${value.slice(4)}`;
|
||||
}
|
||||
|
||||
function normalizeUserCode(value: string): string {
|
||||
const compact = value.toUpperCase().replace(/[^A-Z2-9]/g, "");
|
||||
return compact.length === 8 ? `${compact.slice(0, 4)}-${compact.slice(4)}` : value.trim().toUpperCase();
|
||||
}
|
||||
+652
@@ -0,0 +1,652 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import express, { type NextFunction, type Request, type Response } from "express";
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||
import { hostHeaderValidation } from "@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js";
|
||||
import { getOAuthProtectedResourceMetadataUrl, mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
|
||||
import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
|
||||
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 { ApprovalService } from "./approval-service.js";
|
||||
import { errorMessage } from "./errors.js";
|
||||
import type { AppConfig } from "./config.js";
|
||||
import { DingTalkClient } from "./dingtalk.js";
|
||||
import type { XftApprovalProvider } from "./xft.js";
|
||||
import { XftOpenApiClient } from "./xft-open-api.js";
|
||||
import { UnifiedApprovalService, type XftChallengeProvider } from "./unified-approval.js";
|
||||
import type { OfficialOaReturnClient } from "./unified-approval.js";
|
||||
import { DINGTALK_OAUTH_AUTHORIZE_ORIGIN, DingTalkMcpOAuthProvider } from "./oauth.js";
|
||||
import type { AuthenticatedDingTalkUser } from "./xft.js";
|
||||
import { InvoiceDeduplicationService, InvoiceDedupStore, invoiceDedupDatabasePath } from "./invoice-dedup.js";
|
||||
|
||||
type TransportEntry =
|
||||
| { kind: "streamable"; transport: StreamableHTTPServerTransport; userId: string; createdAt: number; lastSeenAt: number }
|
||||
| { kind: "sse"; transport: SSEServerTransport; userId: string; createdAt: number; lastSeenAt: number };
|
||||
|
||||
const MCP_SESSION_IDLE_TTL_MS = 2 * 60 * 60_000;
|
||||
const MCP_SESSION_MAX_AGE_MS = 24 * 60 * 60_000;
|
||||
const MCP_SESSION_MAX_PER_USER = 8;
|
||||
const MCP_SESSION_MAX_TOTAL = 1000;
|
||||
|
||||
export const APPROVAL_MCP_INSTRUCTIONS = [
|
||||
`先调用 approval_list_todos 获取当前待办。处理单张或部分待办时使用 approvalId;用户明确要求处理本次查询返回的整组时优先使用 selectionId。每次最多处理 ${APPROVAL_BATCH_LIMIT} 条;超过时可使用同一次原始列表中的 approvalId 分组,但每组都必须符合用户授权范围。`,
|
||||
"查询本人处理过或本人发起的审批时调用 approval_list_history;默认最近30天,可用 startDate/endDate 指定最长120天的时间窗口。用其只读 approvalId 获取详情;历史引用不能用于 approval_get_actions 或 approval_process_batch,也不会返回 selectionId。",
|
||||
"费用复核优先调用 approval_get_detail(view=expense);summary 用于一般详情,raw 仅用于分页补充字段。",
|
||||
"用于审批动作的 approvalId 和 selectionId 必须来自 approval_list_todos;approval_list_history 返回的 approvalId 只能读取详情。同意/拒绝无需逐张调用 approval_get_actions,网关会在写入前实时复核。只有退回必须先调用 approval_get_actions 并使用其 targetId,禁止自行构造。",
|
||||
"只有用户明确要求处理审批时,才调用 approval_process_batch;写操作不会二次确认。开始写入前会先校验整批 approvalId 和 targetId;任一引用无效则整批零写入。重新拉取待办后必须重新确认目标,禁止自动补偿或自动重试。",
|
||||
"钉钉开放接口无法预检部分模板的签字等业务规则;要求手写签名时返回客户端处理链接。处理结果中 failed 表示操作被拒绝且未生效;除非用户看过原因后再次明确要求,否则不要重试。succeeded 表示节点提交成功;completed 才表示无需当前用户继续审批。uncertain/outcome_uncertain 表示已经提交但未确认,禁止自动重试,应稍后重新列出待办。followUpRequired 大于 0 时应重新列出待办并告知用户。",
|
||||
"出现 authChallenge 时,工具不会通过其他消息通道自动发送内容。必须把 directUrl 原样写入当前面向用户的回复;有二维码图片时直接作为当前回复附件,禁止转成 ASCII,也不得只声称已生成或已推送。",
|
||||
"发起审批范围仅包括薪福通已支持模板,以及 6 个钉钉候选模板:北极大的出差申请、外出、业务招待申请,捷康的差旅费用报销、业务招待费用报销、业务招待申请。禁止绕过范围限制。固定使用 approval_list_submission_templates → approval_get_submission_schema → approval_prepare_submission。薪福通部门或城市编码必须用 approval_get_submission_options 实时查询,不得猜测。必须先检查 submissionCapability;client_only 时停止 API 构造并引导钉钉客户端。读取 Schema 后,逐项核对用户已提供的信息;必填字段缺失或含糊时必须继续询问且不得猜测,直到全部完整。必须列出尚未填写的非必填字段并询问用户是否补充,用户可以明确跳过。prepare 若返回缺失、无效或需自选处理人的字段,继续询问后重新 prepare。prepare 成功后必须向用户展示完整预览并另行取得明确确认;确认后才用完全相同的内容调用 approval_submit。不要让用户或 Agent 提供发起人 userId、薪福通员工号或审批状态。",
|
||||
"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");
|
||||
|
||||
export function createApprovalMcpServer(
|
||||
config: AppConfig,
|
||||
service: ApprovalService,
|
||||
xft?: XftApprovalProvider,
|
||||
xftChallenges?: XftChallengeProvider,
|
||||
officialOaReturns?: OfficialOaReturnClient,
|
||||
authenticatedUser?: AuthenticatedDingTalkUser,
|
||||
audit?: (userId: string, action: string, reference: string | undefined, outcome: string) => void,
|
||||
invoiceDedup?: InvoiceDeduplicationService,
|
||||
): McpServer {
|
||||
const server = new McpServer(
|
||||
{ name: "dingtalk-approval-mcp", version: "1.1.0-rc.8" },
|
||||
{ instructions: APPROVAL_MCP_INSTRUCTIONS },
|
||||
);
|
||||
const unified = new UnifiedApprovalService(config, service, xft, xftChallenges, officialOaReturns, invoiceDedup);
|
||||
const user = authenticatedUser ?? config.trustedLocalUser;
|
||||
if (!user) throw new Error("MCP 会话缺少已认证用户");
|
||||
|
||||
server.registerTool("approval_list_todos", {
|
||||
title: "列出我的审批待办",
|
||||
description: "审批操作的入口。通过同一登录身份实时列出当前用户在钉钉OA和薪福通中的待办,并取得后续接口必须使用的approvalId;列表尽可能直接包含金额、UTC提交时间及Asia/Shanghai本地时间。",
|
||||
inputSchema: {
|
||||
source: z.enum(["all", "dingtalk", "xft"]).default("all"),
|
||||
query: z.string().max(200).default(""),
|
||||
limit: z.number().int().min(1).max(200).default(50),
|
||||
},
|
||||
annotations: { readOnlyHint: true, destructiveHint: false },
|
||||
}, async ({ source, query, limit }) => compactResult(
|
||||
"已实时查询审批待办。",
|
||||
await unified.listTodos(user, source, limit, query),
|
||||
xftChallenges,
|
||||
));
|
||||
|
||||
server.registerTool("approval_list_history", {
|
||||
title: "列出我的审批历史",
|
||||
description: "通过同一登录身份实时查询钉钉OA和薪福通历史。scope=handled表示当前用户已经处理过的审批;scope=submitted表示当前用户本人发起的审批。默认最近30天,可指定最长120天的时间窗口。返回只读approvalId,可继续读取详情但不能执行审批动作。",
|
||||
inputSchema: {
|
||||
scope: z.enum(["handled", "submitted"]),
|
||||
source: z.enum(["all", "dingtalk", "xft"]).default("all"),
|
||||
query: z.string().max(200).default(""),
|
||||
limit: z.number().int().min(1).max(200).default(50),
|
||||
startDate: z.string().max(40).default("").describe("可选;YYYY-MM-DD(按 Asia/Shanghai)或带时区 ISO 8601;不填时默认最近30天"),
|
||||
endDate: z.string().max(40).default("").describe("可选且包含结束日;格式同 startDate;单次跨度最多120天"),
|
||||
},
|
||||
annotations: { readOnlyHint: true, destructiveHint: false },
|
||||
}, async ({ scope, source, query, limit, startDate, endDate }) => compactResult(
|
||||
scope === "handled" ? "已实时查询我处理过的审批。" : "已实时查询我发起的审批。",
|
||||
await unified.listHistory(user, scope, source, limit, query, startDate, endDate),
|
||||
xftChallenges,
|
||||
));
|
||||
|
||||
server.registerTool("approval_get_detail", {
|
||||
title: "获取审批详情",
|
||||
description: "使用approval_list_todos或approval_list_history返回的approvalId读取实时详情。历史引用会重新校验本人处理或本人发起关系且保持只读。一般查看用summary,费用复核优先用expense读取结构化expenseItems;只有缺少字段时才用经过过滤和硬限额保护的raw分页视图。",
|
||||
inputSchema: {
|
||||
approvalId: z.string().min(20).max(4096).describe("必须原样使用 approval_list_todos 或 approval_list_history 返回的短期签名引用"),
|
||||
view: z.enum(["summary", "expense", "raw"]).default("summary"),
|
||||
rawPath: z.string().max(1024).default(""),
|
||||
rawCursor: z.number().int().min(0).default(0),
|
||||
rawLimit: z.number().int().min(1).max(50).default(20),
|
||||
rawQuery: z.string().max(200).default("").describe("raw 视图可选的有界字段搜索;例如‘关联申请单’"),
|
||||
},
|
||||
annotations: { readOnlyHint: true, destructiveHint: false },
|
||||
}, async ({ approvalId, view, rawPath, rawCursor, rawLimit, rawQuery }) => compactResult(
|
||||
"已返回审批详情。",
|
||||
await unified.getDetail(user, approvalId, { view, rawPath, rawCursor, rawLimit, rawQuery }),
|
||||
xftChallenges,
|
||||
));
|
||||
|
||||
server.registerTool("approval_get_actions", {
|
||||
title: "获取审批可执行动作",
|
||||
description: "使用approval_list_todos返回的approvalId实时检查任务与退回目标。钉钉开放接口无法预检部分签字等业务规则;要求客户端处理时会返回明确链接。执行退回前必须先调用本工具,并将其返回的targetId原样传给approval_process_batch。",
|
||||
inputSchema: { approvalId: z.string().min(20).max(4096).describe("必须原样使用 approval_list_todos 返回的短期签名引用") },
|
||||
annotations: { readOnlyHint: true, destructiveHint: false },
|
||||
}, async ({ approvalId }) => compactResult(
|
||||
"已返回当前可执行审批动作。",
|
||||
await unified.getActions(user, approvalId),
|
||||
xftChallenges,
|
||||
));
|
||||
|
||||
server.registerTool("approval_process_batch", {
|
||||
title: "处理审批",
|
||||
description: `仅在用户明确要求时调用,写操作不二次确认。一次最多 ${APPROVAL_BATCH_LIMIT} 条;处理整组查询结果时传selectionId,处理单张或部分待办时传items。网关先校验整批引用,任一无效则零写入;不会自动补偿或重试。同意/拒绝无需逐张预检;退回必须逐张传approval_get_actions返回的targetId。succeeded表示节点提交成功,completed表示无需继续审批,followUpRequired表示仍有本人后续节点,outcome_uncertain禁止自动重试。`,
|
||||
inputSchema: {
|
||||
action: z.enum(["approve", "reject", "return"]),
|
||||
remark: z.string().max(1000).default(""),
|
||||
selectionId: z.string().min(20).max(4096).optional().describe("approval_list_todos 返回、精确绑定整组列表结果的短期引用;仅用于整组同意或拒绝"),
|
||||
items: z.array(z.object({
|
||||
approvalId: z.string().min(20).max(4096).describe("approval_list_todos 返回的签名引用;不是业务编号或 taskId"),
|
||||
targetId: z.string().min(20).max(4096).optional().describe("仅退回时使用 approval_get_actions 返回的不透明签名引用;不是上游 taskId"),
|
||||
})).max(APPROVAL_BATCH_LIMIT, `一次最多处理 ${APPROVAL_BATCH_LIMIT} 条审批`).default([]),
|
||||
},
|
||||
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
|
||||
}, async ({ action, remark, selectionId, items }) => {
|
||||
try {
|
||||
const result = await unified.processBatch(user, action, remark, items, selectionId);
|
||||
audit?.(
|
||||
user.userId,
|
||||
action,
|
||||
selectionId ?? items.map((item) => item.approvalId).join(","),
|
||||
result.status === "preflight_failed" ? "preflight_failed" : "completed",
|
||||
);
|
||||
return compactResult(approvalBatchSummary(result), result, xftChallenges);
|
||||
} catch (error) {
|
||||
audit?.(user.userId, action, selectionId ?? items.map((item) => item.approvalId).join(","), "failed");
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
server.registerTool("approval_list_submission_templates", {
|
||||
title: "列出可发起审批模板",
|
||||
description: "实时列出允许检查发起能力的模板:薪福通已支持模板,以及北极大的出差申请、外出、业务招待申请和捷康的差旅费用报销、业务招待费用报销、业务招待申请;返回短期templateId。是否可由 API 构造必须再看 approval_get_submission_schema 的 submissionCapability。",
|
||||
inputSchema: {
|
||||
source: z.enum(["all", "dingtalk", "xft"]).default("all"),
|
||||
query: z.string().max(200).default(""),
|
||||
limit: z.number().int().min(1).max(100).default(50),
|
||||
},
|
||||
annotations: { readOnlyHint: true, destructiveHint: false },
|
||||
}, async ({ source, query, limit }) => compactResult(
|
||||
"已实时列出当前用户可发起的审批模板。",
|
||||
await unified.listSubmissionTemplates(user, source, limit, query),
|
||||
));
|
||||
|
||||
server.registerTool("approval_get_submission_schema", {
|
||||
title: "获取发起审批表单结构",
|
||||
description: "使用模板列表返回的templateId实时读取结构化字段、必填状态和填写约束。Agent必须持续询问直至必填字段齐全,并主动提醒可选字段;不会返回超大的上游原始Schema。",
|
||||
inputSchema: {
|
||||
templateId: z.string().min(20).max(4096).describe("必须原样使用 approval_list_submission_templates 返回的短期签名引用"),
|
||||
},
|
||||
annotations: { readOnlyHint: true, destructiveHint: false },
|
||||
}, async ({ templateId }) => compactResult(
|
||||
"已返回可填写字段和约束。",
|
||||
await unified.getSubmissionSchema(user, templateId),
|
||||
));
|
||||
|
||||
server.registerTool("approval_get_submission_options", {
|
||||
title: "查询发起审批可用选项",
|
||||
description: "使用薪福通templateId实时查询当前登录用户本人的承担部门,或按名称搜索城市编码。只返回可填写的code/label,不返回成员档案、员工号或原始上游数据。",
|
||||
inputSchema: {
|
||||
templateId: z.string().min(20).max(4096),
|
||||
kind: z.enum(["department", "city"]),
|
||||
query: z.string().max(100).default(""),
|
||||
limit: z.number().int().min(1).max(100).default(50),
|
||||
},
|
||||
annotations: { readOnlyHint: true, destructiveHint: false },
|
||||
}, async ({ templateId, kind, query, limit }) => compactResult(
|
||||
"已实时返回可填写选项。",
|
||||
await unified.getSubmissionOptions(user, templateId, kind, query, limit),
|
||||
));
|
||||
|
||||
const submissionInputSchema = {
|
||||
templateId: z.string().min(20).max(4096).describe("模板列表返回的短期签名引用"),
|
||||
values: z.record(z.string().min(1).max(200), z.unknown()).describe("按模板Schema填写的表单业务字段;不得包含发起人身份、员工号或审批状态"),
|
||||
deptId: z.number().int().default(-1).describe("仅钉钉OA使用;默认传-1,由服务器实时解析并验证当前登录用户所属部门"),
|
||||
targetSelectActioners: z.array(z.object({
|
||||
actionerKey: z.string().min(1).max(500),
|
||||
actionerStaffIds: z.array(z.string().min(1).max(500)).min(1).max(50),
|
||||
})).max(20).default([]),
|
||||
allowDuplicateInvoices: z.boolean().default(false).describe("仅当 prepare 已展示重复/疑似重复或查重覆盖不完整,且用户明确确认仍要提交时设为 true;不得由 Agent 自行开启"),
|
||||
};
|
||||
|
||||
server.registerTool("approval_prepare_submission", {
|
||||
title: "校验并预览新审批",
|
||||
description: "仅在用户已补齐所有必填字段并选择是否填写可选字段后调用。实时复核模板和字段构造能力,并自动查重本人近30天钉钉/薪福通发票。若返回缺失或无效字段,Agent必须继续询问;若为 client_only 则停止 API 构造。重复风险或查重不完整时不生成confirmationId,必须展示结果并询问用户;只有用户明确接受后才以allowDuplicateInvoices=true重试准备。成功预览仍需另行获得最终提交确认。",
|
||||
inputSchema: submissionInputSchema,
|
||||
annotations: { readOnlyHint: true, destructiveHint: false },
|
||||
}, async ({ templateId, values, deptId, targetSelectActioners, allowDuplicateInvoices }) => {
|
||||
const result = await unified.prepareSubmission(user, { templateId, values, deptId, targetSelectActioners, allowDuplicateInvoices });
|
||||
const summary = result.confirmationId
|
||||
? "审批尚未提交;已生成待用户确认的完整预览。"
|
||||
: "审批尚未提交;发票查重需要用户决定,本次未生成提交确认。";
|
||||
return compactResult(summary, result);
|
||||
});
|
||||
|
||||
server.registerTool("approval_submit", {
|
||||
title: "正式发起审批",
|
||||
description: "仅在用户看过approval_prepare_submission最终预览并明确确认后调用。必须原样重传相同表单内容、allowDuplicateInvoices决定和一次性confirmationId;服务端再次校验当前登录身份、模板和发票预留。薪福通流程可能进入待审批,也可能按企业规则即时完成并返回completed;两者都禁止自动重试。",
|
||||
inputSchema: {
|
||||
confirmationId: z.string().min(20).max(4096).describe("approval_prepare_submission返回的一次性确认引用"),
|
||||
...submissionInputSchema,
|
||||
},
|
||||
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
|
||||
}, async ({ confirmationId, templateId, values, deptId, targetSelectActioners, allowDuplicateInvoices }) => {
|
||||
try {
|
||||
const result = await unified.submitApproval(user, confirmationId, { templateId, values, deptId, targetSelectActioners, allowDuplicateInvoices });
|
||||
audit?.(user.userId, "submit_approval", confirmationId, "submitted");
|
||||
return compactResult(
|
||||
result.status === "completed" ? "审批已发起并按企业流程规则即时完成。"
|
||||
: "审批已正式提交。",
|
||||
result,
|
||||
);
|
||||
} catch (error) {
|
||||
audit?.(user.userId, "submit_approval", confirmationId, "failed_or_uncertain");
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
server.registerTool("approval_revoke_submission", {
|
||||
title: "撤销本人发起的审批",
|
||||
description: "仅在用户明确要求撤销时,使用 approval_submit 返回的用户绑定 revokeId 撤销本人刚通过 MCP 发起的审批。身份由服务器确定,禁止自动重试。钉钉撤销已实现;薪福通 recall 在当前企业仍属实验能力,真实调用可能返回 XFTSYS9999,必须以写后状态复核为准。",
|
||||
inputSchema: {
|
||||
revokeId: z.string().min(20).max(4096).describe("approval_submit 返回的短期、用户绑定撤销引用"),
|
||||
remark: z.string().max(1000).default("").describe("可选撤销原因"),
|
||||
},
|
||||
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
|
||||
}, async ({ revokeId, remark }) => {
|
||||
try {
|
||||
const result = await unified.revokeSubmission(user, revokeId, remark);
|
||||
audit?.(user.userId, "revoke_submission", revokeId, String(result.status));
|
||||
return compactResult(
|
||||
result.status === "revoked" ? "审批已撤销。"
|
||||
: result.status === "outcome_uncertain" ? "撤销结果暂不确定;禁止自动重试,请稍后查询状态。"
|
||||
: "审批未确认撤销成功;禁止自动重试。",
|
||||
result,
|
||||
);
|
||||
} catch (error) {
|
||||
audit?.(user.userId, "revoke_submission", revokeId, "failed_or_uncertain");
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
server.registerTool("approval_get_submission_status", {
|
||||
title: "核验本人已发起审批状态",
|
||||
description: "只读核验当前登录用户本人已发起的薪福通单据状态。服务端先用本人员工号实时验证归属,再返回精简状态;仍可撤回时签发新的短期revokeId,用于服务重启或原引用遗失后的安全恢复;不返回原始单据正文。",
|
||||
inputSchema: {
|
||||
source: z.literal("xft"),
|
||||
businessId: z.string().regex(/^\d{10,20}$/).describe("approval_submit 返回的薪福通 businessId"),
|
||||
},
|
||||
annotations: { readOnlyHint: true, destructiveHint: false },
|
||||
}, async ({ source, businessId }) => compactResult(
|
||||
"已实时核验本人已发起单据状态。",
|
||||
await unified.getSubmissionStatus(user, source, businessId),
|
||||
));
|
||||
return server;
|
||||
}
|
||||
|
||||
export async function startHttpServer(config: AppConfig): Promise<void> {
|
||||
if (config.appMode !== "gateway") throw new Error("HTTP 服务只允许以 APP_MODE=gateway 启动");
|
||||
const dingTalk = new DingTalkClient(config);
|
||||
const service = new ApprovalService(dingTalk, config);
|
||||
const xft = config.xftOpenApi ? new XftOpenApiClient(config) : undefined;
|
||||
const invoiceDedupStore = new InvoiceDedupStore(invoiceDedupDatabasePath(config), config.approvalRefSecret);
|
||||
const invoiceDedup = new InvoiceDeduplicationService(config, invoiceDedupStore, service, xft);
|
||||
const app = express();
|
||||
// The Node process only listens on loopback, while Nginx preserves the
|
||||
// public Host header. Keep DNS-rebinding protection and explicitly allow
|
||||
// both the configured public hostname and local health checks.
|
||||
app.use(hostHeaderValidation(mcpAllowedHosts(config.publicBaseUrl)));
|
||||
// The gateway only listens on loopback and is reached through the local
|
||||
// Nginx reverse proxy. Trust exactly that hop so OAuth rate limiting uses
|
||||
// the real client IP without accepting spoofed forwarding headers directly.
|
||||
app.set("trust proxy", "loopback");
|
||||
// Install exactly one JSON parser so the advertised 1MB limit is the real
|
||||
// limit; createMcpExpressApp installs an earlier 100KB parser.
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
app.use(express.urlencoded({ extended: false }));
|
||||
const mcpUrl = new URL("/mcp", config.publicBaseUrl);
|
||||
const oauth = config.remoteAuth ? new DingTalkMcpOAuthProvider(config) : undefined;
|
||||
const officialOaReturns: OfficialOaReturnClient | undefined = undefined;
|
||||
if (!oauth) app.use(loopbackOnly);
|
||||
|
||||
if (oauth && config.remoteAuth) {
|
||||
app.use(mcpAuthRouter({
|
||||
provider: oauth,
|
||||
issuerUrl: new URL(config.remoteAuth.issuerUrl),
|
||||
baseUrl: new URL(config.remoteAuth.issuerUrl),
|
||||
resourceServerUrl: mcpUrl,
|
||||
scopesSupported: ["mcp:tools"],
|
||||
resourceName: "钉钉审批 MCP",
|
||||
}));
|
||||
app.get("/oauth/consent", (req, res) => {
|
||||
try {
|
||||
const loginState = queryString(req.query.login_state);
|
||||
if (!loginState) { res.status(400).send("授权请求缺少状态参数"); return; }
|
||||
const details = oauth.consentDetails(loginState);
|
||||
setOAuthHtmlHeaders(res);
|
||||
res.type("html").send(renderConsentPage(loginState, details));
|
||||
} catch {
|
||||
setOAuthHtmlHeaders(res);
|
||||
res.status(400).type("html").send("<!doctype html><meta charset=utf-8><title>授权失败</title><p>授权请求无效或已过期,请返回 Agent 重试。</p>");
|
||||
}
|
||||
});
|
||||
app.post("/oauth/consent", (req, res) => {
|
||||
try {
|
||||
const loginState = bodyString(req.body, "login_state");
|
||||
const decision = bodyString(req.body, "decision");
|
||||
if (!loginState || decision !== "approve") { res.status(400).send("授权确认无效"); return; }
|
||||
res.redirect(302, oauth.approveConsent(loginState));
|
||||
} catch {
|
||||
res.status(400).send("授权请求无效或已过期,请返回 Agent 重试");
|
||||
}
|
||||
});
|
||||
app.get("/oauth/dingtalk/callback", async (req, res) => {
|
||||
try {
|
||||
const code = queryString(req.query.code);
|
||||
const state = queryString(req.query.state);
|
||||
if (!code || !state) { res.status(400).send("登录回调缺少 code 或 state"); return; }
|
||||
res.redirect(302, await oauth.completeDingTalkLogin(code, state));
|
||||
} catch (error) {
|
||||
console.error("[dingtalk-approval-mcp] OAuth callback failed", error);
|
||||
res.status(400).send("钉钉登录失败或已过期,请返回 Agent 后重试");
|
||||
}
|
||||
});
|
||||
app.post("/oauth/device/authorize", (req, res) => {
|
||||
try {
|
||||
const clientId = bodyString(req.body, "client_id");
|
||||
if (!clientId) { res.status(400).json({ error: "invalid_request" }); return; }
|
||||
const requested = bodyString(req.body, "scope")?.split(/\s+/).filter(Boolean) ?? ["mcp:tools"];
|
||||
if (requested.some((scope) => scope !== "mcp:tools")) {
|
||||
res.status(400).json({ error: "invalid_scope" }); return;
|
||||
}
|
||||
const resource = bodyString(req.body, "resource");
|
||||
if (resource && resource !== mcpUrl.toString()) {
|
||||
res.status(400).json({ error: "invalid_target" }); return;
|
||||
}
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.json(oauth.startDeviceAuthorization(clientId, requested, resource ?? mcpUrl.toString()));
|
||||
} catch {
|
||||
res.status(400).json({ error: "invalid_client" });
|
||||
}
|
||||
});
|
||||
app.post("/oauth/device/token", (req, res) => {
|
||||
try {
|
||||
const clientId = bodyString(req.body, "client_id");
|
||||
const deviceCode = bodyString(req.body, "device_code");
|
||||
if (!clientId || !deviceCode) { res.status(400).json({ error: "invalid_request" }); return; }
|
||||
const result = oauth.exchangeDeviceCode(clientId, deviceCode);
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
if (typeof result === "string") { res.status(400).json({ error: result }); return; }
|
||||
res.json(result);
|
||||
} catch {
|
||||
res.status(400).json({ error: "expired_token" });
|
||||
}
|
||||
});
|
||||
app.get("/oauth/device", (req, res) => {
|
||||
const userCode = queryString(req.query.user_code);
|
||||
if (!userCode) {
|
||||
res.status(400).type("html").send("<!doctype html><meta charset=utf-8><title>钉钉审批登录</title><p>请使用 Agent 提供的完整登录链接。</p>");
|
||||
return;
|
||||
}
|
||||
try { res.redirect(302, oauth.beginDeviceLogin(userCode)); }
|
||||
catch { res.status(400).type("html").send("<!doctype html><meta charset=utf-8><title>登录失败</title><p>登录链接无效或已过期,请返回 Agent 重试。</p>"); }
|
||||
});
|
||||
app.get("/oauth/device/success", (_req, res) => {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.type("html").send("<!doctype html><meta charset=utf-8><title>登录成功</title><p>钉钉审批已授权,可以关闭此页面并返回 Agent。</p>");
|
||||
});
|
||||
}
|
||||
|
||||
const authMiddleware = oauth ? requireBearerAuth({
|
||||
verifier: oauth,
|
||||
requiredScopes: ["mcp:tools"],
|
||||
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpUrl),
|
||||
}) : undefined;
|
||||
|
||||
const transports = new Map<string, TransportEntry>();
|
||||
let pendingTransportSessions = 0;
|
||||
const pendingTransportSessionsByUser = new Map<string, number>();
|
||||
const closeTransport = (id: string, entry: TransportEntry) => {
|
||||
transports.delete(id);
|
||||
void entry.transport.close().catch(() => undefined);
|
||||
};
|
||||
const cleanupTransports = () => {
|
||||
const now = Date.now();
|
||||
for (const [id, entry] of transports) {
|
||||
if (entry.lastSeenAt < now - MCP_SESSION_IDLE_TTL_MS || entry.createdAt < now - MCP_SESSION_MAX_AGE_MS) {
|
||||
closeTransport(id, entry);
|
||||
}
|
||||
}
|
||||
};
|
||||
const sessionCapacityAvailable = (userId: string) => transports.size + pendingTransportSessions < MCP_SESSION_MAX_TOTAL
|
||||
&& [...transports.values()].filter((entry) => entry.userId === userId).length
|
||||
+ (pendingTransportSessionsByUser.get(userId) ?? 0) < MCP_SESSION_MAX_PER_USER;
|
||||
const reserveSession = (userId: string) => {
|
||||
pendingTransportSessions += 1;
|
||||
pendingTransportSessionsByUser.set(userId, (pendingTransportSessionsByUser.get(userId) ?? 0) + 1);
|
||||
};
|
||||
const releaseSessionReservation = (userId: string) => {
|
||||
pendingTransportSessions = Math.max(0, pendingTransportSessions - 1);
|
||||
const remaining = Math.max(0, (pendingTransportSessionsByUser.get(userId) ?? 1) - 1);
|
||||
if (remaining === 0) pendingTransportSessionsByUser.delete(userId);
|
||||
else pendingTransportSessionsByUser.set(userId, remaining);
|
||||
};
|
||||
const transportCleanupTimer = setInterval(cleanupTransports, 10 * 60_000);
|
||||
transportCleanupTimer.unref();
|
||||
|
||||
const mcpHandler = async (req: Request, res: Response) => {
|
||||
let reservedUserId: string | undefined;
|
||||
try {
|
||||
cleanupTransports();
|
||||
const user = requestUser(config, oauth, req.auth);
|
||||
const sessionId = headerString(req.headers["mcp-session-id"]);
|
||||
let entry = sessionId ? transports.get(sessionId) : undefined;
|
||||
if (entry && entry.kind !== "streamable") {
|
||||
res.status(400).json({ error: "Invalid MCP session transport" });
|
||||
return;
|
||||
}
|
||||
if (entry && entry.userId !== user.userId) {
|
||||
res.status(403).json({ error: "MCP session belongs to another authenticated user" });
|
||||
return;
|
||||
}
|
||||
if (entry) entry.lastSeenAt = Date.now();
|
||||
if (!entry && req.method === "POST" && !sessionId && isInitializeRequest(req.body)) {
|
||||
if (!sessionCapacityAvailable(user.userId)) {
|
||||
res.status(429).json({ error: "MCP session limit reached; close an old Agent session and retry" });
|
||||
return;
|
||||
}
|
||||
reserveSession(user.userId);
|
||||
reservedUserId = user.userId;
|
||||
const createdAt = Date.now();
|
||||
const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
onsessioninitialized: (id) => {
|
||||
transports.set(id, { kind: "streamable", transport, userId: user.userId, createdAt, lastSeenAt: Date.now() });
|
||||
},
|
||||
});
|
||||
transport.onclose = () => { if (transport.sessionId) transports.delete(transport.sessionId); };
|
||||
entry = { kind: "streamable", transport, userId: user.userId, createdAt, lastSeenAt: createdAt };
|
||||
await createApprovalMcpServer(
|
||||
config, service, xft, undefined, officialOaReturns, user,
|
||||
oauth ? (userId, action, reference, outcome) => oauth.audit(userId, action, reference, outcome) : undefined,
|
||||
invoiceDedup,
|
||||
).connect(transport);
|
||||
}
|
||||
if (!entry || entry.kind !== "streamable") {
|
||||
res.status(sessionId ? 404 : 400).json({ error: "Missing or invalid MCP session" });
|
||||
return;
|
||||
}
|
||||
await entry.transport.handleRequest(req, res, req.body);
|
||||
} catch (error) { sendServerError(res, error); }
|
||||
finally { if (reservedUserId) releaseSessionReservation(reservedUserId); }
|
||||
};
|
||||
app.all("/mcp", ...(authMiddleware ? [authMiddleware, mcpHandler] : [mcpHandler]));
|
||||
|
||||
const sseHandler = async (req: Request, res: Response) => {
|
||||
const user = requestUser(config, oauth, req.auth);
|
||||
cleanupTransports();
|
||||
if (!sessionCapacityAvailable(user.userId)) {
|
||||
res.status(429).send("MCP session limit reached");
|
||||
return;
|
||||
}
|
||||
const transport = new SSEServerTransport("/messages", res);
|
||||
const createdAt = Date.now();
|
||||
transports.set(transport.sessionId, { kind: "sse", transport, userId: user.userId, createdAt, lastSeenAt: createdAt });
|
||||
res.on("close", () => transports.delete(transport.sessionId));
|
||||
await createApprovalMcpServer(config, service, xft, undefined, officialOaReturns, user, undefined, invoiceDedup).connect(transport);
|
||||
};
|
||||
app.get("/sse", ...(authMiddleware ? [authMiddleware, sseHandler] : [sseHandler]));
|
||||
const messagesHandler = async (req: Request, res: Response) => {
|
||||
const user = requestUser(config, oauth, req.auth);
|
||||
const entry = transports.get(String(req.query.sessionId ?? ""));
|
||||
if (!entry || entry.kind !== "sse") {
|
||||
res.status(404).send("Invalid SSE session");
|
||||
return;
|
||||
}
|
||||
if (entry.userId !== user.userId) {
|
||||
res.status(403).send("Invalid SSE session");
|
||||
return;
|
||||
}
|
||||
entry.lastSeenAt = Date.now();
|
||||
await entry.transport.handlePostMessage(req, res, req.body);
|
||||
};
|
||||
app.post("/messages", ...(authMiddleware ? [authMiddleware, messagesHandler] : [messagesHandler]));
|
||||
app.get("/healthz", (_req, res) => res.json({ status: "ok", mode: config.appMode }));
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const server = app.listen(config.port, config.bindHost ?? "127.0.0.1", () => resolve());
|
||||
server.on("error", reject);
|
||||
});
|
||||
console.error(`[dingtalk-approval-mcp] listening on :${config.port}; MCP endpoint ${mcpUrl}`);
|
||||
}
|
||||
|
||||
export function mcpAllowedHosts(publicBaseUrl: string): string[] {
|
||||
return [...new Set([new URL(publicBaseUrl).hostname, "127.0.0.1", "localhost", "[::1]"])];
|
||||
}
|
||||
|
||||
function requestUser(
|
||||
config: AppConfig,
|
||||
oauth: DingTalkMcpOAuthProvider | undefined,
|
||||
auth: AuthInfo | undefined,
|
||||
): AuthenticatedDingTalkUser {
|
||||
if (oauth) {
|
||||
if (!auth) throw new Error("MCP request is not authenticated");
|
||||
return oauth.userFromAuth(auth);
|
||||
}
|
||||
if (!config.trustedLocalUser) throw new Error("本机兼容模式缺少固定用户");
|
||||
return config.trustedLocalUser;
|
||||
}
|
||||
|
||||
function queryString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function bodyString(value: unknown, key: string): string | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
||||
return queryString((value as Record<string, unknown>)[key]);
|
||||
}
|
||||
|
||||
function setOAuthHtmlHeaders(res: Response): void {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.setHeader("Content-Security-Policy", oauthHtmlContentSecurityPolicy());
|
||||
res.setHeader("Referrer-Policy", "no-referrer");
|
||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||
res.setHeader("X-Frame-Options", "DENY");
|
||||
}
|
||||
|
||||
export function oauthHtmlContentSecurityPolicy(): string {
|
||||
return `default-src 'none'; style-src 'unsafe-inline'; form-action 'self' ${DINGTALK_OAUTH_AUTHORIZE_ORIGIN}; frame-ancestors 'none'`;
|
||||
}
|
||||
|
||||
function renderConsentPage(
|
||||
loginState: string,
|
||||
details: { clientName: string; scopes: string[]; redirectOrigin?: string; userCode?: string },
|
||||
): string {
|
||||
const rows = [
|
||||
["请求客户端", details.clientName],
|
||||
...(details.redirectOrigin ? [["授权后返回", details.redirectOrigin]] : []),
|
||||
...(details.userCode ? [["设备授权码", details.userCode]] : []),
|
||||
["权限范围", details.scopes.join(" ") || "mcp:tools"],
|
||||
].map(([label, value]) => `<dt>${htmlEscape(label!)}</dt><dd>${htmlEscape(value!)}</dd>`).join("");
|
||||
return `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>确认钉钉审批授权</title><style>body{font:16px system-ui;max-width:42rem;margin:3rem auto;padding:0 1rem;color:#1f2328}dl{display:grid;grid-template-columns:8rem 1fr;gap:.75rem}dt{font-weight:600}dd{margin:0;overflow-wrap:anywhere}.warning{padding:1rem;background:#fff8c5;border-radius:.5rem}button{font:inherit;padding:.7rem 1.2rem;border:0;border-radius:.4rem;background:#1677ff;color:white;cursor:pointer}</style><h1>确认钉钉审批授权</h1><p class="warning">只有当你刚刚要求当前 Agent 使用钉钉审批时才继续。客户端名称由客户端自行声明,请同时核对返回域名或设备授权码。</p><dl>${rows}</dl><form method="post" action="/oauth/consent"><input type="hidden" name="login_state" value="${htmlEscape(loginState)}"><button name="decision" value="approve" type="submit">确认并前往钉钉授权</button></form></html>`;
|
||||
}
|
||||
|
||||
function htmlEscape(value: string): string {
|
||||
return value.replace(/[&<>"']/g, (character) => ({
|
||||
"&": "&", "<": "<", ">": ">", "\"": """, "'": "'",
|
||||
})[character]!);
|
||||
}
|
||||
|
||||
function loopbackOnly(req: Request, res: Response, next: NextFunction): void {
|
||||
if (!isLoopbackAddress(req.socket.remoteAddress)) {
|
||||
res.status(403).json({ error: "Local MCP is available only on loopback" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
export function isLoopbackAddress(value: string | undefined): boolean {
|
||||
return value === "127.0.0.1" || value === "::1" || value?.startsWith("::ffff:127.") === true;
|
||||
}
|
||||
|
||||
function headerString(value: string | string[] | undefined): string | undefined { return Array.isArray(value) ? value[0] : value; }
|
||||
export function compactResult(summary: string, value: unknown, images?: XftChallengeProvider) {
|
||||
const visibleValue = agentVisibleResult(value);
|
||||
const serialized = JSON.stringify(visibleValue);
|
||||
const content: Array<{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }> = [
|
||||
{ type: "text", text: serialized === undefined ? summary : `${summary}\n${serialized}` },
|
||||
];
|
||||
const challengeId = findAuthChallengeId(value);
|
||||
const image = challengeId ? images?.getImage?.(challengeId) : undefined;
|
||||
if (image) {
|
||||
content.push({ type: "image", data: image.toString("base64"), mimeType: "image/png" });
|
||||
const textBlock = content[0] as { type: "text"; text: string };
|
||||
content[0] = { type: "text", text: `${textBlock.text}\n工具不会另行推送登录内容。请把 authChallenge.directUrl(如有)原样写入当前回复,并把附带二维码直接作为当前回复附件;禁止转成 ASCII,不得只声称已生成或已推送。` };
|
||||
}
|
||||
return { content, structuredContent: { result: visibleValue } };
|
||||
}
|
||||
|
||||
export function approvalBatchSummary(value: unknown): string {
|
||||
const result = value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
if (result.status === "preflight_failed" || result.writesStarted === false) {
|
||||
const invalid = Array.isArray(result.invalidItems) ? result.invalidItems.length : 0;
|
||||
return `审批批次未开始,0 张已写入;发现 ${invalid} 项引用无效。请重新列出待办并让用户重新确认目标,禁止自动重试。`;
|
||||
}
|
||||
const total = typeof result.total === "number" ? result.total : 0;
|
||||
const succeeded = typeof result.succeeded === "number" ? result.succeeded : 0;
|
||||
const uncertain = typeof result.uncertain === "number" ? result.uncertain : 0;
|
||||
if (uncertain > 0) return `审批批次有 ${uncertain} 项已提交但结果暂无法确认;禁止自动重试,请稍后重新列出待办。逐项结果如下。`;
|
||||
if (succeeded === 0) return "审批批次未有任何项目处理成功,逐项原因如下。";
|
||||
if (succeeded < total) return `审批批次部分成功(${succeeded}/${total}),逐项结果如下。`;
|
||||
return `审批批次已全部提交成功(${succeeded}/${total}),逐项结果如下。`;
|
||||
}
|
||||
|
||||
function agentVisibleResult(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(agentVisibleResult);
|
||||
if (!value || typeof value !== "object") return value;
|
||||
const hidden = new Set([
|
||||
"diagnostics", "response", "taskId", "nextTaskId", "userId", "employeeId", "employeeNumber", "empId",
|
||||
"enterpriseUserId", "originatorUserid", "originatorDeptId", "appType", "processCode", "processInstanceId",
|
||||
]);
|
||||
const record = value as Record<string, unknown>;
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const [key, child] of Object.entries(record)) {
|
||||
if (hidden.has(key)) continue;
|
||||
if (key === "billId" && record.businessId === undefined && record.businessNo === undefined) {
|
||||
output.businessId = agentVisibleResult(child);
|
||||
continue;
|
||||
}
|
||||
output[key] = agentVisibleResult(child);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function findAuthChallengeId(value: unknown): string | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) { const found = findAuthChallengeId(item); if (found) return found; }
|
||||
return undefined;
|
||||
}
|
||||
if (!value || typeof value !== "object") return undefined;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.provider === "xft" && record.type === "qr" && typeof record.challengeId === "string") return record.challengeId;
|
||||
for (const child of Object.values(record)) { const found = findAuthChallengeId(child); if (found) return found; }
|
||||
return undefined;
|
||||
}
|
||||
function sendServerError(res: Response, error: unknown): void {
|
||||
console.error("[dingtalk-approval-mcp] request failed", error);
|
||||
if (!res.headersSent) res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
declare module "sm-crypto" {
|
||||
const crypto: {
|
||||
sm2: {
|
||||
doSignature(
|
||||
message: string | number[],
|
||||
privateKey: string,
|
||||
options?: { hash?: boolean; userId?: string; der?: boolean; publicKey?: string },
|
||||
): string;
|
||||
};
|
||||
|
||||
sm3: (input: string | number[], options?: { mode?: "hmac"; key?: string | number[] }) => string;
|
||||
|
||||
sm4: {
|
||||
encrypt(
|
||||
input: string | number[],
|
||||
key: string | number[],
|
||||
options?: { mode?: "cbc"; iv?: string | number[]; padding?: "pkcs#5" | "pkcs#7" | "none"; output?: "array" },
|
||||
): string | number[];
|
||||
decrypt(
|
||||
input: string | number[],
|
||||
key: string | number[],
|
||||
options?: { mode?: "cbc"; iv?: string | number[]; padding?: "pkcs#5" | "pkcs#7" | "none"; output?: "array" },
|
||||
): string | number[];
|
||||
};
|
||||
};
|
||||
export default crypto;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+1676
File diff suppressed because it is too large
Load Diff
+197
@@ -0,0 +1,197 @@
|
||||
/** Shared contracts for the official XFT OpenAPI adapter. */
|
||||
export interface AuthenticatedDingTalkUser {
|
||||
userId: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface XftTodoItem {
|
||||
source: "xft";
|
||||
billId: string;
|
||||
taskId?: string;
|
||||
eventSeqId?: string;
|
||||
title?: string;
|
||||
applicantName?: string;
|
||||
applicantDepartment?: string;
|
||||
applyTime?: string;
|
||||
amount?: number;
|
||||
currency?: string;
|
||||
reason?: string;
|
||||
allowPass?: boolean;
|
||||
allowReject?: boolean;
|
||||
eventType?: string;
|
||||
}
|
||||
|
||||
export type ApprovalHistoryScope = "handled" | "submitted";
|
||||
|
||||
export interface ApprovalHistoryRange {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}
|
||||
|
||||
export interface XftHistoryItem extends Omit<XftTodoItem, "allowPass" | "allowReject"> {
|
||||
historyScope: ApprovalHistoryScope;
|
||||
status?: string;
|
||||
statusName?: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export type XftApprovalAction = "agree" | "reject" | "return";
|
||||
|
||||
export interface XftApprovalBatchItem {
|
||||
billId: string;
|
||||
targetNodeId?: string;
|
||||
}
|
||||
|
||||
export type XftApprovalBatchResultItem =
|
||||
| { billId: string; status: "succeeded"; success: true }
|
||||
| { billId: string; status: "failed"; success: false; failureConfirmed: true; error: string }
|
||||
| {
|
||||
billId: string;
|
||||
status: "outcome_uncertain";
|
||||
success: false;
|
||||
outcomeUncertain: true;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export type XftSubmissionKind =
|
||||
| "travel_apply"
|
||||
| "expense_apply"
|
||||
| "entertainment_apply"
|
||||
| "common_apply"
|
||||
| "use_car_apply"
|
||||
| "common_reimburse"
|
||||
| "travel_reimburse";
|
||||
|
||||
export interface XftSubmissionTemplate {
|
||||
eventSeqId: string;
|
||||
eventNumber: string;
|
||||
eventName: string;
|
||||
eventType: string;
|
||||
kind: XftSubmissionKind;
|
||||
}
|
||||
|
||||
export interface XftSubmissionField {
|
||||
fieldNumber: string;
|
||||
name: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
description?: string;
|
||||
multiple?: boolean;
|
||||
options?: unknown[];
|
||||
moduleType?: string;
|
||||
valueFormat?: string;
|
||||
itemFields?: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export type XftSubmissionOptionKind = "department" | "city";
|
||||
|
||||
export interface XftSubmissionOption {
|
||||
code: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface XftApprovalProvider {
|
||||
authStatus(user: AuthenticatedDingTalkUser): Promise<{
|
||||
authenticated: true;
|
||||
identity: Record<string, unknown>;
|
||||
binding: Record<string, unknown>;
|
||||
persistedBusinessData: false;
|
||||
}>;
|
||||
listMyTodos(user: AuthenticatedDingTalkUser, limit: number, search?: string): Promise<{
|
||||
items: XftTodoItem[];
|
||||
totalCount: number;
|
||||
identity: Record<string, unknown>;
|
||||
diagnostics: Record<string, unknown>;
|
||||
}>;
|
||||
listMyHistory(
|
||||
user: AuthenticatedDingTalkUser,
|
||||
scope: ApprovalHistoryScope,
|
||||
limit: number,
|
||||
search: string,
|
||||
range: ApprovalHistoryRange,
|
||||
): Promise<{
|
||||
items: XftHistoryItem[];
|
||||
totalCount: number;
|
||||
diagnostics: Record<string, unknown>;
|
||||
}>;
|
||||
getTodoDetail(user: AuthenticatedDingTalkUser, billId: string): Promise<Record<string, unknown>>;
|
||||
getHistoryDetail(
|
||||
user: AuthenticatedDingTalkUser,
|
||||
scope: ApprovalHistoryScope,
|
||||
billId: string,
|
||||
): Promise<Record<string, unknown>>;
|
||||
listReturnNodes(user: AuthenticatedDingTalkUser, billId: string): Promise<{
|
||||
billId: string;
|
||||
nodes: Array<{ nodeId: string; nodeName?: string; approverNames?: string[] }>;
|
||||
requirements: {
|
||||
commentRequired: boolean;
|
||||
signatureRequired: boolean;
|
||||
canSkipPreviouslyApprovedNodes: boolean;
|
||||
};
|
||||
diagnostics: Record<string, unknown>;
|
||||
}>;
|
||||
processApprovalBatch(
|
||||
user: AuthenticatedDingTalkUser,
|
||||
action: XftApprovalAction,
|
||||
remark: string,
|
||||
items: XftApprovalBatchItem[],
|
||||
): Promise<{
|
||||
action: XftApprovalAction;
|
||||
total: number;
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
results: XftApprovalBatchResultItem[];
|
||||
diagnostics: Record<string, unknown>;
|
||||
}>;
|
||||
listSubmissionTemplates(user: AuthenticatedDingTalkUser, limit: number, search?: string): Promise<XftSubmissionTemplate[]>;
|
||||
getSubmissionSchema(user: AuthenticatedDingTalkUser, template: XftSubmissionTemplate): Promise<{
|
||||
template: XftSubmissionTemplate;
|
||||
fields: XftSubmissionField[];
|
||||
inputContract: Record<string, unknown>;
|
||||
diagnostics: Record<string, unknown>;
|
||||
}>;
|
||||
getSubmissionOptions(
|
||||
user: AuthenticatedDingTalkUser,
|
||||
template: XftSubmissionTemplate,
|
||||
kind: XftSubmissionOptionKind,
|
||||
query?: string,
|
||||
limit?: number,
|
||||
): Promise<{
|
||||
kind: XftSubmissionOptionKind;
|
||||
items: XftSubmissionOption[];
|
||||
diagnostics: Record<string, unknown>;
|
||||
}>;
|
||||
validateSubmissionIdentity(user: AuthenticatedDingTalkUser): Promise<{
|
||||
validated: true;
|
||||
diagnostics: Record<string, unknown>;
|
||||
}>;
|
||||
submitApproval(
|
||||
user: AuthenticatedDingTalkUser,
|
||||
template: XftSubmissionTemplate,
|
||||
values: Record<string, unknown>,
|
||||
): Promise<{ billId: string; status: "submitted"; diagnostics: Record<string, unknown> }>;
|
||||
recallSubmission(
|
||||
user: AuthenticatedDingTalkUser,
|
||||
billId: string,
|
||||
reason?: string,
|
||||
): Promise<{ status: "revoked" | "failed" | "outcome_uncertain"; billId: string; message?: string; diagnostics: Record<string, unknown> }>;
|
||||
getMySubmissionStatus(
|
||||
user: AuthenticatedDingTalkUser,
|
||||
billId: string,
|
||||
): Promise<{
|
||||
billId: string;
|
||||
status: string;
|
||||
statusName: string;
|
||||
approvalStatus?: string;
|
||||
revoked: boolean;
|
||||
revokeAllowed: boolean;
|
||||
flowHistory?: Array<{ nodeName?: string; actions: string[] }>;
|
||||
diagnostics: Record<string, unknown>;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Success codes observed on the documented XFT OpenAPI endpoints. */
|
||||
export function isXftSuccessCode(code: string | undefined): boolean {
|
||||
if (code === undefined || code === "") return true;
|
||||
return ["0", "200", "SUCCESS", "SUC0000", "000000", "0000000"].includes(code.toUpperCase());
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { configureService, renderUnit, servicePaths, startService, uninstallService } from "../src/admin.js";
|
||||
|
||||
test("dingtalk-admin writes remote OAuth settings and secret-free hardened unit", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "ding-admin-"));
|
||||
const old = process.env.XDG_CONFIG_HOME;
|
||||
process.env.XDG_CONFIG_HOME = join(root, "config");
|
||||
try {
|
||||
mkdirSync(join(root, ".secrets"), { mode: 0o700 });
|
||||
writeFileSync(join(root, ".secrets", "dingtalk.json"), "{}", { mode: 0o600 });
|
||||
writeFileSync(join(root, ".secrets", "xft.key"), "secret", { mode: 0o600 });
|
||||
chmodSync(join(root, ".secrets", "dingtalk.json"), 0o600);
|
||||
const paths = configureService({
|
||||
projectRoot: root, publicBaseUrl: "https://approval.example.com", corpId: "ding-corp", port: 3000,
|
||||
credentialsFile: ".secrets/dingtalk.json", xftOpenApiAppId: "app",
|
||||
xftOpenApiSecretFile: ".secrets/xft.key", xftOpenApiEnterpriseId: "enterprise",
|
||||
dingtalkClientOnlyProcessCodes: "PROC-SIGN,PROC-CLIENT",
|
||||
});
|
||||
assert.equal(statSync(paths.env).mode & 0o777, 0o600);
|
||||
assert.equal(statSync(paths.refSecret).mode & 0o777, 0o600);
|
||||
assert.equal(statSync(paths.sessionSecret).mode & 0o777, 0o600);
|
||||
const runtime = readFileSync(paths.env, "utf8");
|
||||
assert.match(runtime, /MCP_REMOTE_AUTH_ENABLED="true"/);
|
||||
assert.match(runtime, /DINGTALK_CORP_ID="ding-corp"/);
|
||||
assert.match(runtime, /MCP_AUTH_DATABASE=/);
|
||||
assert.match(runtime, /APP_MODE="gateway"/);
|
||||
assert.match(runtime, /DINGTALK_CLIENT_ONLY_PROCESS_CODES="PROC-SIGN,PROC-CLIENT"/);
|
||||
assert.doesNotMatch(runtime, /DWS_/);
|
||||
assert.doesNotMatch(runtime, /MCP_TRUSTED_LOCAL_USER_ID|CLIENT_SECRET/);
|
||||
|
||||
const unit = renderUnit(paths, "/usr/bin/node");
|
||||
assert.match(unit, /ProtectSystem=strict/);
|
||||
assert.doesNotMatch(unit, /ding-corp|CLIENT_SECRET/);
|
||||
|
||||
mkdirSync(join(root, "dist", "src"), { recursive: true });
|
||||
writeFileSync(join(root, "dist", "src", "index.js"), "// built");
|
||||
const calls: string[][] = [];
|
||||
startService(root, (args) => calls.push(args));
|
||||
assert.deepEqual(calls.map((call) => call[0]), ["daemon-reload", "enable", "restart"]);
|
||||
uninstallService(root, (args) => calls.push(args));
|
||||
assert.equal(existsSync(paths.env), false);
|
||||
assert.equal(existsSync(paths.refSecret), true);
|
||||
assert.equal(existsSync(paths.sessionSecret), true);
|
||||
} finally {
|
||||
if (old === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = old;
|
||||
}
|
||||
});
|
||||
|
||||
test("dingtalk-admin requires HTTPS and references XFT secret files", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "ding-admin-openapi-"));
|
||||
mkdirSync(join(root, ".secrets"), { mode: 0o700 });
|
||||
writeFileSync(join(root, ".secrets", "dingtalk.json"), "{}", { mode: 0o600 });
|
||||
writeFileSync(join(root, ".secrets", "xft.key"), "1".repeat(32), { mode: 0o600 });
|
||||
assert.throws(() => configureService({
|
||||
projectRoot: root, publicBaseUrl: "http://approval.example.com", corpId: "ding-corp", port: 3000,
|
||||
credentialsFile: ".secrets/dingtalk.json",
|
||||
}), /HTTPS/);
|
||||
const paths = configureService({
|
||||
projectRoot: root, publicBaseUrl: "https://approval.example.com", corpId: "ding-corp", port: 3000,
|
||||
credentialsFile: ".secrets/dingtalk.json", xftOpenApiAppId: "open-app",
|
||||
xftOpenApiSecretFile: ".secrets/xft.key", xftOpenApiEnterpriseId: "TEST0001",
|
||||
});
|
||||
const runtime = readFileSync(paths.env, "utf8");
|
||||
assert.match(runtime, /XFT_OPEN_API_ENABLED="true"/);
|
||||
assert.match(runtime, /XFT_OPEN_API_AUTHORITY_SECRET_FILE=/);
|
||||
assert.doesNotMatch(runtime, new RegExp("1".repeat(32)));
|
||||
});
|
||||
@@ -0,0 +1,308 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { browseRawDetail, compactApprovalDetail, extractExpenseItems } from "../src/approval-detail.js";
|
||||
|
||||
test("extracts DingTalk TableField expense rows into stable agent fields", () => {
|
||||
const raw = {
|
||||
title: "测试用户甲提交的通用费用报销_北极大",
|
||||
createTime: 1_786_179_600_000,
|
||||
formComponentValues: [
|
||||
{ name: "报销金额", value: "600" },
|
||||
{
|
||||
name: "报销明细",
|
||||
componentType: "TableField",
|
||||
value: JSON.stringify([{
|
||||
rowValue: [
|
||||
{ label: "费用发生日期", value: "2026-08-08" },
|
||||
{ label: "费用类型", value: "员工团队保障经费/误餐费" },
|
||||
{ label: "费用说明", value: "7月份误餐费" },
|
||||
{ label: "实际报销金额", value: "¥600.00" },
|
||||
{ label: "消费地点", value: "深圳" },
|
||||
{ label: "接待对象", value: "张三、李四" },
|
||||
{ label: "发票日期", value: "2026-06-19、2026-08-08" },
|
||||
],
|
||||
}]),
|
||||
},
|
||||
],
|
||||
};
|
||||
assert.deepEqual(extractExpenseItems(raw), [{
|
||||
occurredOn: "2026-08-08",
|
||||
expenseType: "员工团队保障经费/误餐费",
|
||||
amount: 600,
|
||||
currency: "CNY",
|
||||
description: "7月份误餐费",
|
||||
merchant: null,
|
||||
location: "深圳",
|
||||
customer: null,
|
||||
contacts: ["张三", "李四"],
|
||||
relatedTripId: null,
|
||||
invoiceDates: ["2026-06-19", "2026-08-08"],
|
||||
projectId: null,
|
||||
crmRecordId: null,
|
||||
}]);
|
||||
const compact = compactApprovalDetail(raw, "summary");
|
||||
assert.equal((compact.detail.expenseItems as unknown[]).length, 1);
|
||||
assert.equal(JSON.stringify(compact).length < 20_000, true);
|
||||
});
|
||||
|
||||
test("compact detail omits container placeholders and keeps invoice context in expense view", () => {
|
||||
const raw = {
|
||||
formComponentValues: [
|
||||
{ name: "[]", value: [] },
|
||||
{ name: "报销明细", value: [{ rowValue: [
|
||||
{ label: "费用发生日期", value: "2026-08-10" },
|
||||
{ label: "费用类型", value: "交通费" },
|
||||
{ label: "实际报销金额", value: 88 },
|
||||
] }] },
|
||||
{ name: "发票号码", value: "INV-100" },
|
||||
{ name: "付款主体", value: "深圳公司" },
|
||||
],
|
||||
};
|
||||
const summary = compactApprovalDetail(raw, "summary");
|
||||
assert.doesNotMatch(JSON.stringify(summary.detail.formFields), /\[0 items\]|\{\d+ fields\}|"\[\]"/);
|
||||
const expense = compactApprovalDetail(raw, "expense");
|
||||
assert.match(JSON.stringify(expense.detail.supportingFields), /发票号码|付款主体|INV-100|深圳公司/);
|
||||
assert.match(JSON.stringify(expense.detail.formFields), /实际报销金额|88/);
|
||||
assert.deepEqual(expense.detail.amount, { value: 88, currency: "CNY" });
|
||||
assert.equal(expense.detail.amountSource, "expense_items_sum");
|
||||
});
|
||||
|
||||
test("expense view safely sums multiple complete detail rows", () => {
|
||||
const raw = { formComponentValues: [{ name: "报销明细", value: [{ rowValue: [
|
||||
{ label: "费用类型", value: "交通费" }, { label: "实际报销金额", value: 100.11 },
|
||||
] }, { rowValue: [
|
||||
{ label: "费用类型", value: "住宿费" }, { label: "实际报销金额", value: 2504.38 },
|
||||
] }] }] };
|
||||
const expense = compactApprovalDetail(raw, "expense");
|
||||
assert.deepEqual(expense.detail.amount, { value: 2604.49, currency: "CNY" });
|
||||
assert.equal(expense.detail.amountSource, "expense_items_sum");
|
||||
});
|
||||
|
||||
test("extracts expense fields from source-native XFT business data", () => {
|
||||
const raw = {
|
||||
form: {
|
||||
values: { amount: 4812 },
|
||||
businessData: {
|
||||
expenseRows: [{
|
||||
"费用发生日期": "2026/08/07",
|
||||
"费用项目": "客户招待费",
|
||||
"费用金额": 4812,
|
||||
"消费地点": "上海",
|
||||
"接待客户": "示例客户",
|
||||
"项目编号": "P-001",
|
||||
"CRM记录ID": "CRM-99",
|
||||
}],
|
||||
},
|
||||
},
|
||||
};
|
||||
const items = extractExpenseItems(raw);
|
||||
assert.equal(items.length, 1);
|
||||
assert.equal(items[0]!.occurredOn, "2026-08-07");
|
||||
assert.equal(items[0]!.expenseType, "客户招待费");
|
||||
assert.equal(items[0]!.projectId, "P-001");
|
||||
assert.equal(items[0]!.crmRecordId, "CRM-99");
|
||||
});
|
||||
|
||||
test("epoch expense dates use Asia/Shanghai calendar boundaries", () => {
|
||||
const items = extractExpenseItems({ form: { businessData: { expenseRows: [{
|
||||
"费用发生日期": Date.parse("2026-08-13T16:30:00.000Z"),
|
||||
"费用类型": "交通费",
|
||||
"费用金额": 10,
|
||||
}] } } });
|
||||
assert.equal(items[0]?.occurredOn, "2026-08-14");
|
||||
});
|
||||
|
||||
test("joins real XFT expense schema keys with values without exposing its schema", () => {
|
||||
const raw = {
|
||||
todo: { applicantName: "李冬梅", applicantDepartment: "奋进队", applyTime: "2026-08-08", amount: 820 },
|
||||
form: {
|
||||
values: {
|
||||
TripReimburseExpense: {
|
||||
value: {
|
||||
expenseList: [{
|
||||
values: {
|
||||
TripExpReimAmount: { value: { currencyCode: "CNY", reimAmt: 820 } },
|
||||
dateKey: { value: "2026-08-07" },
|
||||
descriptionKey: { value: "客户招待" },
|
||||
attachmentKey: { value: [{ fileNam: "secret.pdf" }] },
|
||||
},
|
||||
businessConfigDTO: { expenseSchema: { body: [
|
||||
{ key: "TripExpReimAmount", name: "报销金额" },
|
||||
{ key: "dateKey", name: "消费日期" },
|
||||
{ key: "descriptionKey", name: "费用说明" },
|
||||
{ key: "attachmentKey", name: "附件(支付记录、截图、小票)" },
|
||||
] } },
|
||||
businessDataDTO: { expBodyInsDto: {
|
||||
expNam: "餐饮费",
|
||||
wrTim: 1_786_032_000_000,
|
||||
reimAmt: 820,
|
||||
currencyCode: "CNY",
|
||||
remark: "客户招待",
|
||||
certificate: { invoiceCloudCertificates: [
|
||||
{ invoiceDate: "2026-08-07" },
|
||||
{ invoiceDate: "2026-08-06" },
|
||||
] },
|
||||
} },
|
||||
}],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const compact = compactApprovalDetail(raw, "summary");
|
||||
const items = compact.detail.expenseItems as Array<Record<string, unknown>>;
|
||||
assert.equal(items.length, 1);
|
||||
assert.equal(items[0]!.occurredOn, "2026-08-07");
|
||||
assert.equal(items[0]!.expenseType, "餐饮费");
|
||||
assert.equal(items[0]!.amount, 820);
|
||||
assert.equal(items[0]!.description, "客户招待");
|
||||
assert.deepEqual(items[0]!.invoiceDates, ["2026-08-07", "2026-08-06"]);
|
||||
assert.equal((compact.detail.applicant as Record<string, unknown>).name, "李冬梅");
|
||||
assert.doesNotMatch(JSON.stringify(compact), /secret\.pdf|expenseSchema|businessConfigDTO/);
|
||||
});
|
||||
|
||||
test("extracts official XFT OpenAPI totals and expense fields", () => {
|
||||
const raw = {
|
||||
billId: "2099010200000001",
|
||||
form: {
|
||||
eventType: "COMMON_REIM",
|
||||
eventName: "费用报销单",
|
||||
applyTim: 1_786_083_489_000,
|
||||
aplName: "李林峰",
|
||||
aplDepName: "奋进队",
|
||||
expTotalCnt: 2336,
|
||||
billRemark: "客户招待",
|
||||
billCustomFieldList: [
|
||||
{ fieldName: "成本中心", showValue: "销售一部", type: "SELECT" },
|
||||
{ fieldName: "附件(支付记录、截图、小票)", showValue: "secret.pdf", type: "ACCESSORIES" },
|
||||
],
|
||||
expenseList: [{
|
||||
expTypeCode: "00",
|
||||
expTypeName: "餐饮费",
|
||||
expAttribute: "MEAL",
|
||||
remark: "8月6日宴请联合电子张家万",
|
||||
reimAmount: 2336,
|
||||
wrTim: 1_785_974_400_000,
|
||||
currencyCode: "CNY",
|
||||
merchantName: null,
|
||||
payCity: "98",
|
||||
invoiceInfoList: [
|
||||
{ invoiceDate: "2026-08-06", sellerName: "上海靓小余餐饮管理有限公司", invoiceAmount: 970 },
|
||||
{ invoiceDate: "2026-08-05", sellerName: "另一商户", invoiceAmount: 1366 },
|
||||
],
|
||||
expCustomFieldList: [
|
||||
{ fieldName: "接待客户", showValue: "联合电子" },
|
||||
{ fieldName: "CRM记录ID", showValue: "CRM-100" },
|
||||
{ fieldName: "附件", showValue: "private-image-data" },
|
||||
],
|
||||
}],
|
||||
},
|
||||
};
|
||||
const compact = compactApprovalDetail(raw, "summary");
|
||||
assert.deepEqual(compact.detail.amount, { value: 2336, currency: "CNY" });
|
||||
assert.equal((compact.detail.applicant as Record<string, unknown>).name, "李林峰");
|
||||
assert.equal((compact.detail.applicant as Record<string, unknown>).department, "奋进队");
|
||||
assert.equal(compact.detail.summary, "客户招待");
|
||||
const items = compact.detail.expenseItems as Array<Record<string, unknown>>;
|
||||
assert.equal(items.length, 1);
|
||||
assert.equal(items[0]!.occurredOn, "2026-08-06");
|
||||
assert.equal(items[0]!.expenseType, "餐饮费");
|
||||
assert.equal(items[0]!.amount, 2336);
|
||||
assert.equal(items[0]!.currency, "CNY");
|
||||
assert.equal(items[0]!.description, "8月6日宴请联合电子张家万");
|
||||
assert.equal(items[0]!.merchant, "上海靓小余餐饮管理有限公司");
|
||||
assert.equal(items[0]!.location, "98");
|
||||
assert.equal(items[0]!.customer, "联合电子");
|
||||
assert.equal(items[0]!.crmRecordId, "CRM-100");
|
||||
assert.deepEqual(items[0]!.invoiceDates, ["2026-08-06", "2026-08-05"]);
|
||||
assert.match(JSON.stringify(compact.detail.formFields), /单据类型|报销总额|成本中心/);
|
||||
assert.doesNotMatch(JSON.stringify(compact), /secret\.pdf|private-image-data/);
|
||||
});
|
||||
|
||||
test("extracts XFT generic detail expList aliases used by expense applications", () => {
|
||||
const raw = {
|
||||
todo: { applicantName: "测试用户丁", applicantDepartment: "测试部门", amount: 3000 },
|
||||
form: {
|
||||
billInfo: {
|
||||
eventType: "EXPENSE_APPLY", applyTotalAmount: 3000,
|
||||
customFieldValues: [
|
||||
{ customFieldName: "招待地址", customFieldValue: "无锡", customFieldConfig: { name: "内部配置", value: "不应展示" } },
|
||||
{ customFieldName: "招待日期", customFieldValue: "2026-08-12" },
|
||||
{ customFieldName: "招待事由", customFieldValue: "客户招待" },
|
||||
{ customFieldName: "家庭住址", customFieldValue: "不应展示的私人地址" },
|
||||
],
|
||||
},
|
||||
expList: [{
|
||||
expNam: "招待客户烟、酒、水果等", expId: "000", wrTim: "2026-08-13 00:00",
|
||||
reimAmt: 3000, currencyCode: "CNY", remark: "买酒及招待费用", merchantNam: "公司", payCity: "深圳市",
|
||||
}],
|
||||
applyExpenseDtoList: [{ expTypeName: "招待客户烟、酒、水果等", applyAmount: 3000, remark: "买酒及招待费用" }],
|
||||
},
|
||||
};
|
||||
const compact = compactApprovalDetail(raw, "expense");
|
||||
const items = compact.detail.expenseItems as Array<Record<string, unknown>>;
|
||||
assert.equal(items.length, 1);
|
||||
assert.deepEqual(items[0], {
|
||||
occurredOn: "2026-08-13", expenseType: "招待客户烟、酒、水果等", amount: 3000, currency: "CNY",
|
||||
description: "买酒及招待费用", merchant: "公司", location: "深圳市", customer: null, contacts: [],
|
||||
relatedTripId: null, invoiceDates: [], projectId: null, crmRecordId: null,
|
||||
});
|
||||
assert.match(JSON.stringify(compact.detail.formFields), /招待地址.*无锡.*招待日期.*2026-08-12.*招待事由.*客户招待/);
|
||||
assert.match(JSON.stringify(compact.detail.supportingFields), /招待地址.*无锡.*招待日期.*2026-08-12.*招待事由.*客户招待/);
|
||||
assert.doesNotMatch(JSON.stringify(compact), /内部配置|不应展示/);
|
||||
});
|
||||
|
||||
test("official XFT expense date falls back to hotel check-in date", () => {
|
||||
const raw = { form: { eventType: "TRAVEL_REIM", expTotalCnt: 843, expenseList: [{
|
||||
expTypeName: "住宿费",
|
||||
reimAmount: 843,
|
||||
currencyCode: "CNY",
|
||||
remark: "7/21-8/8出差住宿费用",
|
||||
wrTim: null,
|
||||
bgnTim: null,
|
||||
checkInDate: 1_784_592_000_000,
|
||||
checkOutDate: 1_786_147_200_000,
|
||||
invoiceInfoList: [],
|
||||
}] } };
|
||||
const items = extractExpenseItems(raw);
|
||||
assert.equal(items.length, 1);
|
||||
assert.equal(items[0]!.occurredOn, "2026-07-21");
|
||||
assert.equal(items[0]!.expenseType, "住宿费");
|
||||
assert.equal(items[0]!.amount, 843);
|
||||
});
|
||||
|
||||
test("raw detail is filtered, paginated, bounded, and traverses embedded JSON", () => {
|
||||
const raw = {
|
||||
businessKey: "B-1",
|
||||
businessConfig: { hugeSchema: "must-not-be-visible" },
|
||||
mobile: "13800138000",
|
||||
formComponentValues: [{
|
||||
name: "报销明细",
|
||||
value: JSON.stringify([{ rowValue: [{ label: "费用说明", value: "x".repeat(10_000) }] }]),
|
||||
}],
|
||||
};
|
||||
const root = browseRawDetail(raw, "", 0, 50);
|
||||
assert.doesNotMatch(JSON.stringify(root), /businessConfig|must-not-be-visible|13800138000/);
|
||||
const embedded = browseRawDetail(raw, "/formComponentValues/0/value/0/rowValue/0", 0, 20) as Record<string, unknown>;
|
||||
assert.equal(embedded.kind, "container");
|
||||
assert.equal(embedded.encodedAsJsonString, true);
|
||||
assert.equal(JSON.stringify(embedded).length < 10_000, true);
|
||||
const scalar = browseRawDetail(raw, "/formComponentValues/0/value/0/rowValue/0/value", 0, 20) as Record<string, unknown>;
|
||||
assert.equal(scalar.kind, "string");
|
||||
assert.equal(scalar.valueTruncated, true);
|
||||
assert.equal(String(scalar.value).length, 4_000);
|
||||
assert.throws(() => browseRawDetail(raw, "/businessConfig"), /不存在/);
|
||||
assert.throws(() => browseRawDetail(raw, "/mobile"), /不存在/);
|
||||
});
|
||||
|
||||
test("raw detail supports bounded field search and hides internal user and department IDs", () => {
|
||||
const raw = { originatorUserid: "private-user", originatorDeptId: 123, formValueVOS: [
|
||||
{ name: "普通字段", value: "无关" },
|
||||
{ name: "关联申请单", value: "TRIP-20260811" },
|
||||
] };
|
||||
const result = browseRawDetail(raw, "/formValueVOS", 0, 20, "关联申请单") as Record<string, unknown>;
|
||||
assert.equal(result.kind, "search");
|
||||
assert.equal(result.total, 1);
|
||||
assert.match(JSON.stringify(result), /关联申请单|TRIP-20260811/);
|
||||
assert.doesNotMatch(JSON.stringify(browseRawDetail(raw)), /private-user|originatorUserid|originatorDeptId/);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { extractApprovalListMetadata } from "../src/approval-metadata.js";
|
||||
|
||||
test("approval metadata normalizes official instance identity, time, amount, and summary", () => {
|
||||
const metadata = extractApprovalListMetadata({
|
||||
originatorUserName: "测试用户丁",
|
||||
originatorUserId: "user-bin",
|
||||
originatorDeptName: "销售部",
|
||||
createTime: 1_786_179_600_000,
|
||||
formComponentValues: [
|
||||
{ name: "报销金额", value: "¥4,812.00" },
|
||||
{ name: "报销事由", value: "客户现场出差" },
|
||||
{ name: "银行卡号", value: "6222000012345678" },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(metadata, {
|
||||
applicantName: "测试用户丁",
|
||||
applicantUserId: "user-bin",
|
||||
applicantDepartment: "销售部",
|
||||
submittedAt: new Date(1_786_179_600_000).toISOString(),
|
||||
amount: 4812,
|
||||
currency: "CNY",
|
||||
summary: "客户现场出差",
|
||||
});
|
||||
assert.doesNotMatch(JSON.stringify(metadata), /6222000012345678/);
|
||||
});
|
||||
|
||||
test("approval metadata falls back to todo form summaries and chooses total amount", () => {
|
||||
const metadata = extractApprovalListMetadata({
|
||||
forms: [
|
||||
{ title: "发起人", content: "测试用户戊" },
|
||||
{ title: "部门", content: "产品部" },
|
||||
{ title: "单项费用", content: "100" },
|
||||
{ title: "合计金额", content: "USD 1,234.50" },
|
||||
{ title: "申请原因", content: "采购测试设备" },
|
||||
],
|
||||
});
|
||||
assert.equal(metadata.applicantName, "测试用户戊");
|
||||
assert.equal(metadata.applicantDepartment, "产品部");
|
||||
assert.equal(metadata.amount, 1234.5);
|
||||
assert.equal(metadata.currency, "USD");
|
||||
assert.equal(metadata.summary, "采购测试设备");
|
||||
});
|
||||
|
||||
test("approval metadata can derive the applicant from a standard DingTalk title", () => {
|
||||
assert.equal(
|
||||
extractApprovalListMetadata({ title: "测试用户甲提交的通用费用报销_北极大" }).applicantName,
|
||||
"测试用户甲",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||
import { APPROVAL_ONBOARDING_EXAMPLES, RemoteBridge, bridgeAuthToolResult, createBridgeMcpServer } from "../src/bridge.js";
|
||||
|
||||
test("stdio bridge creates a messenger-friendly device login challenge and protects local state", async () => {
|
||||
const calls: string[] = [];
|
||||
const fetchFn: typeof fetch = async (input) => {
|
||||
const url = String(input);
|
||||
calls.push(url);
|
||||
if (url.endsWith("/register")) return Response.json({ client_id: "bridge-client" }, { status: 201 });
|
||||
if (url.endsWith("/oauth/device/authorize")) return Response.json({
|
||||
device_code: "device-secret", user_code: "ABCD-2345",
|
||||
verification_uri_complete: "https://approval.example.test/oauth/device?user_code=ABCD-2345",
|
||||
expires_in: 600, interval: 5,
|
||||
});
|
||||
if (url.endsWith("/oauth/device/token")) return Response.json({ error: "authorization_pending" }, { status: 400 });
|
||||
throw new Error(`unexpected ${url}`);
|
||||
};
|
||||
const root = mkdtempSync(join(tmpdir(), "ding-bridge-"));
|
||||
const statePath = join(root, "private", "session.json");
|
||||
const bridge = new RemoteBridge("https://approval.example.test/mcp", statePath, fetchFn);
|
||||
const first = await bridge.loginChallenge();
|
||||
assert.equal(first.authenticated, false);
|
||||
assert.ok("authChallenge" in first);
|
||||
assert.equal(first.authChallenge.directUrl, "https://approval.example.test/oauth/device?user_code=ABCD-2345");
|
||||
assert.match(first.message, /https:\/\/approval\.example\.test\/oauth\/device\?user_code=ABCD-2345/);
|
||||
assert.match(first.message, /\[打开钉钉授权登录\]\(https:\/\/approval\.example\.test\/oauth\/device\?user_code=ABCD-2345\)/);
|
||||
assert.match(first.message, /不会另行推送或弹出/);
|
||||
assert.match(first.message, /当前回复正文中原样显示/);
|
||||
assert.match(first.message, /授权码:ABCD-2345/);
|
||||
const toolResult = bridgeAuthToolResult(first);
|
||||
assert.equal(toolResult.content[0]?.type, "text");
|
||||
assert.match(toolResult.content[0]!.type === "text" ? toolResult.content[0].text : "", /备用原始链接:https:\/\/approval\.example\.test/);
|
||||
assert.deepEqual(toolResult.content[1], {
|
||||
type: "resource_link",
|
||||
name: "dingtalk-approval-login",
|
||||
title: "打开钉钉授权登录",
|
||||
uri: "https://approval.example.test/oauth/device?user_code=ABCD-2345",
|
||||
description: "当前用户的短期钉钉授权登录链接",
|
||||
});
|
||||
assert.equal(statSync(statePath).mode & 0o777, 0o600);
|
||||
assert.equal(statSync(join(root, "private")).mode & 0o777, 0o700);
|
||||
assert.match(readFileSync(statePath, "utf8"), /device-secret/);
|
||||
|
||||
const second = await bridge.loginChallenge();
|
||||
assert.equal(second.authenticated, false);
|
||||
assert.deepEqual(calls.map((url) => new URL(url).pathname), ["/register", "/oauth/device/authorize", "/oauth/device/token"]);
|
||||
});
|
||||
|
||||
test("post-install onboarding waits for authentication and is visible exactly once in portable text", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "ding-bridge-onboarding-login-"));
|
||||
const statePath = join(root, "session.json");
|
||||
let authorized = false;
|
||||
const bridge = new RemoteBridge("https://approval.example.test/mcp", statePath, async (input) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
if (path === "/register") return Response.json({ client_id: "bridge-client" }, { status: 201 });
|
||||
if (path === "/oauth/device/authorize") return Response.json({
|
||||
device_code: "onboarding-device", user_code: "START-1234",
|
||||
verification_uri_complete: "https://approval.example.test/oauth/device?user_code=START-1234",
|
||||
expires_in: 600, interval: 5,
|
||||
});
|
||||
if (path === "/oauth/device/token") return authorized
|
||||
? Response.json({ access_token: "fresh-access", refresh_token: "fresh-refresh", expires_in: 900 })
|
||||
: Response.json({ error: "authorization_pending" }, { status: 400 });
|
||||
throw new Error(`unexpected ${path}`);
|
||||
});
|
||||
bridge.ensureOnboardingPending();
|
||||
|
||||
const challenge = await bridge.loginChallenge();
|
||||
assert.equal(challenge.authenticated, false);
|
||||
assert.equal(JSON.parse(readFileSync(statePath, "utf8")).onboarding.status, "pending", "showing a login URL must not consume onboarding");
|
||||
|
||||
authorized = true;
|
||||
const authenticated = await bridge.loginChallenge();
|
||||
assert.equal(authenticated.authenticated, true);
|
||||
assert.ok("onboarding" in authenticated && authenticated.onboarding);
|
||||
assert.deepEqual(authenticated.onboarding.examples, [...APPROVAL_ONBOARDING_EXAMPLES]);
|
||||
const portable = bridgeAuthToolResult(authenticated);
|
||||
const portableText = portable.content[0]?.type === "text" ? portable.content[0].text : "";
|
||||
assert.match(portableText, /首次登录已完成/);
|
||||
assert.match(portableText, /帮我列出当前待处理的审批/);
|
||||
assert.equal(JSON.parse(readFileSync(statePath, "utf8")).onboarding.status, "delivered");
|
||||
|
||||
const repeated = await bridge.loginChallenge();
|
||||
assert.equal("onboarding" in repeated, false);
|
||||
assert.doesNotMatch(repeated.message, /首次登录已完成/);
|
||||
});
|
||||
|
||||
test("the first successful business response can deliver onboarding without an explicit login tool call", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "ding-bridge-onboarding-call-"));
|
||||
const statePath = join(root, "session.json");
|
||||
writeFileSync(statePath, JSON.stringify({
|
||||
client: { client_id: "bridge-client" },
|
||||
tokens: { access_token: "fresh-access", savedAt: Date.now(), expires_in: 900 },
|
||||
}));
|
||||
const bridge = new RemoteBridge("https://approval.example.test/mcp", statePath, async () => {
|
||||
throw new Error("network should not be used by the injected client");
|
||||
});
|
||||
bridge.ensureOnboardingPending();
|
||||
(bridge as unknown as { client: { callTool(): Promise<unknown>; close(): Promise<void> } }).client = {
|
||||
async callTool() { return { isError: true, content: [{ type: "text", text: "upstream failed" }] }; },
|
||||
async close() {},
|
||||
};
|
||||
|
||||
const failed = await bridge.call("approval_list_todos", { source: "all", query: "", limit: 1 });
|
||||
assert.equal(failed.isError, true);
|
||||
assert.equal(JSON.parse(readFileSync(statePath, "utf8")).onboarding.status, "pending", "an error response must not consume onboarding");
|
||||
|
||||
(bridge as unknown as { client: { callTool(): Promise<unknown>; close(): Promise<void> } }).client = {
|
||||
async callTool() { return { content: [{ type: "text", text: "已实时查询审批待办。" }], structuredContent: { items: [] } }; },
|
||||
async close() {},
|
||||
};
|
||||
|
||||
const first = await bridge.call("approval_list_todos", { source: "all", query: "", limit: 1 });
|
||||
assert.match(first.content.at(-1).text, /首次登录已完成/);
|
||||
assert.deepEqual(first.structuredContent.onboarding.examples, [...APPROVAL_ONBOARDING_EXAMPLES]);
|
||||
const second = await bridge.call("approval_list_todos", { source: "all", query: "", limit: 1 });
|
||||
assert.equal(second.content.length, 1);
|
||||
assert.equal(second.structuredContent.onboarding, undefined);
|
||||
});
|
||||
|
||||
test("stdio bridge discards a connected client after its access token expires", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "ding-bridge-expired-"));
|
||||
const statePath = join(root, "session.json");
|
||||
writeFileSync(statePath, JSON.stringify({
|
||||
client: { client_id: "bridge-client" },
|
||||
tokens: { access_token: "expired-access", savedAt: Date.now() - 120_000, expires_in: 60 },
|
||||
}));
|
||||
const paths: string[] = [];
|
||||
const bridge = new RemoteBridge("https://approval.example.test/mcp", statePath, async (input) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
paths.push(path);
|
||||
if (path === "/oauth/device/authorize") return Response.json({
|
||||
device_code: "replacement-device", user_code: "NEXT-1234",
|
||||
verification_uri_complete: "https://approval.example.test/oauth/device?user_code=NEXT-1234",
|
||||
expires_in: 600, interval: 5,
|
||||
});
|
||||
throw new Error(`unexpected ${path}`);
|
||||
});
|
||||
let staleCalls = 0;
|
||||
let closed = 0;
|
||||
(bridge as unknown as { client: { callTool(): Promise<unknown>; close(): Promise<void> } }).client = {
|
||||
async callTool() { staleCalls += 1; return { structuredContent: { result: { stale: true } } }; },
|
||||
async close() { closed += 1; },
|
||||
};
|
||||
|
||||
const result = await bridge.call("approval_list_todos", { source: "xft", query: "", limit: 20 });
|
||||
assert.equal(staleCalls, 0);
|
||||
assert.equal(closed, 1);
|
||||
assert.deepEqual(paths, ["/oauth/device/authorize"]);
|
||||
assert.equal(result.structuredContent.result.authenticated, false);
|
||||
assert.equal(result.structuredContent.result.authChallenge.directUrl, "https://approval.example.test/oauth/device?user_code=NEXT-1234");
|
||||
assert.match(result.content[0].text, /https:\/\/approval\.example\.test\/oauth\/device\?user_code=NEXT-1234/);
|
||||
assert.match(result.content[0].text, /授权码:NEXT-1234/);
|
||||
assert.equal(result.content[1].type, "resource_link");
|
||||
assert.equal(result.content[1].uri, "https://approval.example.test/oauth/device?user_code=NEXT-1234");
|
||||
});
|
||||
|
||||
test("MCP approval_login returns both portable text and a standard resource link", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "ding-bridge-mcp-login-"));
|
||||
const bridge = new RemoteBridge("https://approval.example.test/mcp", join(root, "session.json"), async (input) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
if (path === "/register") return Response.json({ client_id: "bridge-client" }, { status: 201 });
|
||||
if (path === "/oauth/device/authorize") return Response.json({
|
||||
device_code: "device-secret", user_code: "LINK-2345",
|
||||
verification_uri_complete: "https://approval.example.test/oauth/device?user_code=LINK-2345",
|
||||
expires_in: 600, interval: 5,
|
||||
});
|
||||
throw new Error(`unexpected ${path}`);
|
||||
});
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
const server = createBridgeMcpServer(bridge);
|
||||
const client = new Client({ name: "bridge-login-test", version: "1.0.0" });
|
||||
await server.connect(serverTransport);
|
||||
try {
|
||||
await client.connect(clientTransport);
|
||||
const response = await client.callTool({ name: "approval_login", arguments: {} });
|
||||
const content = response.content as Array<{ type: string; text?: string; uri?: string }>;
|
||||
const text = content.find((item) => item.type === "text")?.text ?? "";
|
||||
const link = content.find((item) => item.type === "resource_link");
|
||||
assert.match(text, /\[打开钉钉授权登录\]\(https:\/\/approval\.example\.test\/oauth\/device\?user_code=LINK-2345\)/);
|
||||
assert.match(text, /备用原始链接:https:\/\/approval\.example\.test\/oauth\/device\?user_code=LINK-2345/);
|
||||
assert.equal(link?.uri, "https://approval.example.test/oauth/device?user_code=LINK-2345");
|
||||
assert.equal((response.structuredContent as any).result.authChallenge.directUrl, link?.uri);
|
||||
const tools = await client.listTools();
|
||||
const history = tools.tools.find((tool) => tool.name === "approval_list_history")!;
|
||||
assert.equal((history.inputSchema as any).properties.startDate.default, "");
|
||||
assert.equal((history.inputSchema as any).properties.endDate.default, "");
|
||||
const batch = tools.tools.find((tool) => tool.name === "approval_process_batch")!;
|
||||
assert.equal((batch.inputSchema as any).properties.items.maxItems, 20);
|
||||
assert.match(batch.description ?? "", /最多 20 条.*零写入/s);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("stdio bridge refreshes an expired login before creating a new challenge", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "ding-bridge-refresh-"));
|
||||
const statePath = join(root, "session.json");
|
||||
writeFileSync(statePath, JSON.stringify({
|
||||
client: { client_id: "bridge-client" },
|
||||
tokens: {
|
||||
access_token: "expired-access", refresh_token: "refresh-1",
|
||||
savedAt: Date.now() - 120_000, expires_in: 60,
|
||||
},
|
||||
}));
|
||||
const paths: string[] = [];
|
||||
const bridge = new RemoteBridge("https://approval.example.test/mcp", statePath, async (input) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
paths.push(path);
|
||||
if (path === "/token") return Response.json({
|
||||
access_token: "fresh-access", refresh_token: "refresh-2", expires_in: 900,
|
||||
});
|
||||
throw new Error(`unexpected ${path}`);
|
||||
});
|
||||
const result = await bridge.loginChallenge();
|
||||
assert.equal(result.authenticated, true);
|
||||
assert.deepEqual(paths, ["/token"]);
|
||||
const saved = readFileSync(statePath, "utf8");
|
||||
assert.match(saved, /fresh-access/);
|
||||
assert.doesNotMatch(saved, /expired-access/);
|
||||
});
|
||||
|
||||
test("stdio bridge preserves refresh credentials across a transient token endpoint failure", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "ding-bridge-transient-refresh-"));
|
||||
const statePath = join(root, "session.json");
|
||||
writeFileSync(statePath, JSON.stringify({
|
||||
client: { client_id: "bridge-client" },
|
||||
tokens: { access_token: "expired-access", refresh_token: "keep-refresh", savedAt: Date.now() - 120_000, expires_in: 60 },
|
||||
}));
|
||||
const bridge = new RemoteBridge("https://approval.example.test/mcp", statePath, async (input) => {
|
||||
assert.equal(new URL(String(input)).pathname, "/token");
|
||||
return Response.json({ error: "server_error" }, { status: 503 });
|
||||
});
|
||||
await assert.rejects(bridge.loginChallenge(), /暂时无法刷新登录/);
|
||||
const saved = readFileSync(statePath, "utf8");
|
||||
assert.match(saved, /keep-refresh/);
|
||||
assert.match(saved, /expired-access/);
|
||||
});
|
||||
|
||||
test("stdio bridge adopts a newer token saved by another bridge process", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "ding-bridge-shared-"));
|
||||
const statePath = join(root, "session.json");
|
||||
const oldSavedAt = Date.now() - 120_000;
|
||||
writeFileSync(statePath, JSON.stringify({
|
||||
client: { client_id: "bridge-client" },
|
||||
tokens: { access_token: "expired-access", refresh_token: "stale-refresh", savedAt: oldSavedAt, expires_in: 60 },
|
||||
}));
|
||||
const bridge = new RemoteBridge("https://approval.example.test/mcp", statePath, async () => {
|
||||
throw new Error("newer disk token should avoid refresh");
|
||||
});
|
||||
writeFileSync(statePath, JSON.stringify({
|
||||
client: { client_id: "bridge-client" },
|
||||
tokens: { access_token: "shared-fresh-access", refresh_token: "shared-refresh", savedAt: Date.now(), expires_in: 900 },
|
||||
}));
|
||||
const result = await bridge.loginChallenge();
|
||||
assert.equal(result.authenticated, true);
|
||||
});
|
||||
|
||||
test("stdio bridge bounds concurrent remote tool calls", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "ding-bridge-concurrency-"));
|
||||
const statePath = join(root, "session.json");
|
||||
writeFileSync(statePath, JSON.stringify({
|
||||
client: { client_id: "bridge-client" },
|
||||
tokens: { access_token: "fresh-access", savedAt: Date.now(), expires_in: 900 },
|
||||
}));
|
||||
const bridge = new RemoteBridge("https://approval.example.test/mcp", statePath, async () => {
|
||||
throw new Error("network should not be used by the injected client");
|
||||
});
|
||||
let active = 0;
|
||||
let maximum = 0;
|
||||
(bridge as unknown as { client: { callTool(): Promise<unknown>; close(): Promise<void> } }).client = {
|
||||
async callTool() {
|
||||
active += 1;
|
||||
maximum = Math.max(maximum, active);
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 5));
|
||||
active -= 1;
|
||||
return { content: [{ type: "text", text: "ok" }] };
|
||||
},
|
||||
async close() {},
|
||||
};
|
||||
await Promise.all(Array.from({ length: 7 }, () => bridge.call("approval_get_actions", { approvalId: "opaque" })));
|
||||
assert.equal(maximum, 3);
|
||||
});
|
||||
@@ -0,0 +1,351 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash, createPublicKey, generateKeyPairSync, sign } from "node:crypto";
|
||||
import {
|
||||
chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, symlinkSync, writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, delimiter, dirname, join } from "node:path";
|
||||
import test from "node:test";
|
||||
import {
|
||||
cleanupLegacyInstallations, clientPaths, releaseKeyId, runClientInstaller, verifySignedManifest,
|
||||
type ClientReleaseManifest,
|
||||
} from "../src/client-installer.js";
|
||||
|
||||
test("client paths stay in stable per-user locations on all supported platforms", () => {
|
||||
assert.equal(clientPaths({}, "linux", "/users/alice").root, "/users/alice/.local/share/dingtalk-approval");
|
||||
assert.equal(clientPaths({ XDG_DATA_HOME: "/data/alice" }, "linux", "/users/alice").root, "/data/alice/dingtalk-approval");
|
||||
assert.equal(clientPaths({}, "darwin", "/Users/alice").root, "/Users/alice/Library/Application Support/dingtalk-approval");
|
||||
assert.equal(clientPaths({ LOCALAPPDATA: "C:\\Users\\alice\\AppData\\Local" }, "win32", "C:\\Users\\alice").root, "C:\\Users\\alice\\AppData\\Local\\DingTalkApproval");
|
||||
assert.equal(clientPaths({}, "linux", "/users/alice").bridgeCommand, "/users/alice/.local/share/dingtalk-approval/bin/dingtalk-approval-bridge");
|
||||
});
|
||||
|
||||
test("signed manifest verification rejects tampering and a foreign key", () => {
|
||||
const trusted = generateKeyPairSync("ed25519");
|
||||
const foreign = generateKeyPairSync("ed25519");
|
||||
const publicPem = trusted.publicKey.export({ type: "spki", format: "pem" });
|
||||
const keyId = releaseKeyId(publicPem);
|
||||
const manifest = manifestFixture(keyId, publicPem);
|
||||
const bytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
|
||||
const signature = signatureEnvelope(keyId, sign(null, bytes, trusted.privateKey));
|
||||
assert.equal(verifySignedManifest(bytes, signature, publicPem, "https://approval.example.test/downloads/manifest.json").version, "1.1.0-rc.5");
|
||||
assert.throws(() => verifySignedManifest(Buffer.concat([bytes, Buffer.from(" ")]), signature, publicPem, "https://approval.example.test/downloads/manifest.json"), /签名无效/);
|
||||
assert.throws(() => verifySignedManifest(bytes, signature, foreign.publicKey.export({ type: "spki", format: "pem" }), "https://approval.example.test/downloads/manifest.json"), /密钥编号不受信任/);
|
||||
|
||||
const stale = { ...manifest, createdAt: new Date(Date.now() - 181 * 24 * 60 * 60_000).toISOString() };
|
||||
const staleBytes = Buffer.from(`${JSON.stringify(stale, null, 2)}\n`);
|
||||
const staleSignature = signatureEnvelope(keyId, sign(null, staleBytes, trusted.privateKey));
|
||||
assert.throws(() => verifySignedManifest(staleBytes, staleSignature, publicPem, "https://approval.example.test/downloads/manifest.json"), /超过 180 天/);
|
||||
});
|
||||
|
||||
test("legacy cleanup removes only owned broken bins and exact old package", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "ding-client-legacy-"));
|
||||
const home = join(root, "home");
|
||||
const bin = join(home, ".nvm", "versions", "node", "v22.22.2", "bin");
|
||||
const packageRoot = join(home, ".nvm", "versions", "node", "v22.22.2", "lib", "node_modules", "dingtalk-approval-mcp");
|
||||
mkdirSync(bin, { recursive: true });
|
||||
mkdirSync(packageRoot, { recursive: true });
|
||||
writeFileSync(join(packageRoot, "package.json"), JSON.stringify({ name: "dingtalk-approval-mcp" }));
|
||||
for (const name of ["dingtalk-admin", "dingtalk-approval-bridge", "dingtalk-approval-mcp", "dingtalk-mcp"]) {
|
||||
symlinkSync(`../lib/node_modules/dingtalk-approval-mcp/dist/src/${name}.js`, join(bin, name));
|
||||
}
|
||||
symlinkSync("../lib/node_modules/some-other-package/cli.js", join(bin, "unrelated"));
|
||||
const removed = cleanupLegacyInstallations({ PATH: bin }, "linux", home);
|
||||
assert.equal(removed.filter((path) => path.includes("dingtalk-")).length, 5);
|
||||
for (const name of ["dingtalk-admin", "dingtalk-approval-bridge", "dingtalk-approval-mcp", "dingtalk-mcp"]) assert.equal(existsSync(join(bin, name)), false);
|
||||
assert.equal(existsSync(packageRoot), false);
|
||||
assert.equal(existsSync(join(bin, "unrelated")), false, "existsSync is false for dangling links");
|
||||
assert.equal(lstatSync(join(bin, "unrelated")).isSymbolicLink(), true, "unrelated dangling link remains");
|
||||
});
|
||||
|
||||
test("legacy cleanup removes owned Windows npm shims without touching unrelated commands", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "ding-client-windows-legacy-"));
|
||||
const bin = join(root, "npm");
|
||||
const packageRoot = join(bin, "node_modules", "dingtalk-approval-mcp");
|
||||
mkdirSync(packageRoot, { recursive: true });
|
||||
writeFileSync(join(packageRoot, "package.json"), JSON.stringify({ name: "dingtalk-approval-mcp" }));
|
||||
writeFileSync(join(bin, "dingtalk-approval-bridge.cmd"), "@node C:\\npm\\node_modules\\dingtalk-approval-mcp\\dist\\src\\bridge.js %*\r\n");
|
||||
writeFileSync(join(bin, "dingtalk-mcp.ps1"), "& node C:\\npm\\node_modules\\dingtalk-approval-mcp\\dist\\src\\index.js $args\r\n");
|
||||
writeFileSync(join(bin, "dingtalk-admin.cmd"), "@node C:\\npm\\node_modules\\another-package\\admin.js %*\r\n");
|
||||
|
||||
cleanupLegacyInstallations({ PATH: bin, APPDATA: bin }, "win32", join(root, "home"));
|
||||
assert.equal(existsSync(join(bin, "dingtalk-approval-bridge.cmd")), false);
|
||||
assert.equal(existsSync(join(bin, "dingtalk-mcp.ps1")), false);
|
||||
assert.equal(existsSync(packageRoot), false);
|
||||
assert.equal(existsSync(join(bin, "dingtalk-admin.cmd")), true);
|
||||
});
|
||||
|
||||
test("generic installer preserves identity across safe updates, rollback, downgrade, and uninstall", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "ding-client-install-"));
|
||||
const appRoot = join(root, "dingtalk-approval");
|
||||
const fakeBin = join(root, "fake-bin");
|
||||
mkdirSync(fakeBin, { recursive: true });
|
||||
createFakeNpm(fakeBin);
|
||||
|
||||
const keys = generateKeyPairSync("ed25519");
|
||||
const publicPem = Buffer.from(keys.publicKey.export({ type: "spki", format: "pem" }));
|
||||
const keyId = releaseKeyId(publicPem);
|
||||
const bodies = {
|
||||
installer: Buffer.from("installer"),
|
||||
publicKey: publicPem,
|
||||
client: Buffer.from("fake-client-tgz"),
|
||||
skill: Buffer.from("---\nname: dingtalk-approval\ndescription: Test\n---\n\n# Test\n"),
|
||||
};
|
||||
const manifest = manifestFixture(keyId, publicPem, bodies);
|
||||
const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
|
||||
const signature = signatureEnvelope(keyId, sign(null, manifestBytes, keys.privateKey));
|
||||
const responses = new Map<string, Buffer>([
|
||||
["https://approval.example.test/downloads/manifest.json", manifestBytes],
|
||||
["https://approval.example.test/downloads/manifest.sig", signature],
|
||||
...Object.entries(manifest.artifacts).map(([name, artifact]) => [artifact.url, bodies[name as keyof typeof bodies]] as [string, Buffer]),
|
||||
]);
|
||||
const fetchFn = async (input: string | URL | Request) => {
|
||||
const url = String(input);
|
||||
const body = responses.get(url);
|
||||
return body ? new Response(new Uint8Array(body), { status: 200 }) : new Response("missing", { status: 404 });
|
||||
};
|
||||
const publicPath = join(root, "release-public.pem");
|
||||
writeFileSync(publicPath, publicPem, { mode: 0o600 });
|
||||
const env: NodeJS.ProcessEnv = { PATH: [fakeBin, process.env.PATH ?? ""].filter(Boolean).join(delimiter), DINGTALK_APPROVAL_CLIENT_HOME: appRoot };
|
||||
const output: string[] = [];
|
||||
const commandContext = {
|
||||
env, platform: process.platform, home: join(root, "home"), fetchFn: fetchFn as typeof fetch,
|
||||
stdout: (value: string) => { output.push(value); }, stderr: (value: string) => { output.push(value); },
|
||||
};
|
||||
const installed = await runClientInstaller([
|
||||
"install", "--manifest-url", "https://approval.example.test/downloads/manifest.json",
|
||||
"--public-key", publicPath, "--app-root", appRoot, "--json",
|
||||
], commandContext);
|
||||
assert.equal(installed, 0);
|
||||
const installOutput = JSON.parse(output.pop()!);
|
||||
assert.equal(installOutput.restartRequired, undefined);
|
||||
assert.match(installOutput.nextSteps.join(" "), /当前 Agent.*skillPath.*stdio MCP/);
|
||||
assert.match(installOutput.nextSteps.join(" "), /立即处理 postInstall.*不得等待用户首次查询审批/s);
|
||||
assert.equal(installOutput.postInstall.status, "authorization_required");
|
||||
assert.equal(installOutput.postInstall.authChallenge.directUrl, "https://approval.example.test/oauth/device?user_code=INSTALL-1234");
|
||||
assert.deepEqual(installOutput.postInstall.confirmTool, { name: "approval_login", arguments: {} });
|
||||
assert.deepEqual(installOutput.postInstall.verifyTool, {
|
||||
name: "approval_list_todos", arguments: { source: "all", query: "", limit: 1 },
|
||||
});
|
||||
assert.equal(installOutput.mcpConfig.mcpServers["dingtalk-approval"].args.length, 0);
|
||||
const paths = clientPaths({ DINGTALK_APPROVAL_CLIENT_HOME: appRoot }, process.platform, join(root, "home"));
|
||||
assert.equal(existsSync(paths.bridgeCommand), true);
|
||||
assert.equal(existsSync(paths.skillFile), true);
|
||||
const state = JSON.parse(readFileSync(paths.stateFile, "utf8"));
|
||||
assert.equal(state.version, "1.1.0-rc.5");
|
||||
assert.equal(state.host, undefined);
|
||||
assert.doesNotMatch(readFileSync(paths.bridgeCommand, "utf8"), /--url/);
|
||||
const stableBridgePath = paths.bridgeCommand;
|
||||
const initialRuntime = state.runtimeDir;
|
||||
mkdirSync(dirname(paths.sessionFile), { recursive: true });
|
||||
const sessionBody = Buffer.from(`${JSON.stringify({
|
||||
authenticated: true,
|
||||
marker: "preserve-across-updates",
|
||||
onboarding: { schemaVersion: 1, status: "delivered", deliveredAt: "2030-01-01T00:00:00.000Z" },
|
||||
})}\n`);
|
||||
writeFileSync(paths.sessionFile, sessionBody, { mode: 0o600 });
|
||||
|
||||
output.length = 0;
|
||||
assert.equal(await runClientInstaller(["print-path", "bridge", "--app-root", appRoot], commandContext), 0);
|
||||
assert.equal(output.pop(), `${paths.bridgeCommand}\n`);
|
||||
|
||||
output.length = 0;
|
||||
assert.equal(await runClientInstaller(["print-config", "--format", "mcp-json", "--app-root", appRoot, "--json"], commandContext), 0);
|
||||
const mcpConfig = JSON.parse(output.pop()!);
|
||||
assert.deepEqual(mcpConfig, { mcpServers: { "dingtalk-approval": { type: "stdio", command: paths.bridgeCommand, args: [] } } });
|
||||
|
||||
output.length = 0;
|
||||
assert.equal(await runClientInstaller(["doctor", "--app-root", appRoot, "--json"], commandContext), 0);
|
||||
const health = JSON.parse(output.pop()!);
|
||||
assert.equal(health.ok, true);
|
||||
assert.equal(health.checks.find((check: { name: string }) => check.name === "npm").ok, true);
|
||||
|
||||
output.length = 0;
|
||||
assert.equal(await runClientInstaller([
|
||||
"update", "--app-root", appRoot, "--json",
|
||||
], { ...commandContext, env: { ...env, FAKE_BRIDGE_FAIL: "1" } }), 0, "login preparation failure does not roll back an installed client");
|
||||
const sameVersionOutput = JSON.parse(output.pop()!);
|
||||
assert.equal(sameVersionOutput.postInstall.status, "unavailable");
|
||||
const sameVersionState = JSON.parse(readFileSync(paths.stateFile, "utf8"));
|
||||
assert.equal(sameVersionState.host, undefined);
|
||||
assert.equal(sameVersionState.runtimeDir, initialRuntime, "same-version update reuses the complete runtime");
|
||||
assert.deepEqual(readFileSync(paths.sessionFile), sessionBody, "same-version update preserves the OAuth session");
|
||||
assert.deepEqual(readdirSync(paths.versionsDir), [basename(initialRuntime)]);
|
||||
|
||||
output.length = 0;
|
||||
assert.equal(await runClientInstaller(["update", "--host", "workbuddy", "--app-root", appRoot, "--json"], commandContext), 1);
|
||||
assert.match(JSON.parse(output.pop()!).error, /未知参数:--host/);
|
||||
|
||||
const upgradedBodies = {
|
||||
...bodies,
|
||||
client: Buffer.from("fake-client-tgz-v2"),
|
||||
skill: Buffer.from("---\nname: dingtalk-approval\ndescription: Test v2\n---\n\n# Test v2\n"),
|
||||
};
|
||||
const upgraded = manifestFixture(keyId, publicPem, upgradedBodies, "1.1.1");
|
||||
const upgradedBytes = Buffer.from(`${JSON.stringify(upgraded, null, 2)}\n`);
|
||||
responses.set("https://approval.example.test/downloads/manifest.json", upgradedBytes);
|
||||
responses.set("https://approval.example.test/downloads/manifest.sig", signatureEnvelope(keyId, sign(null, upgradedBytes, keys.privateKey)));
|
||||
for (const [name, artifact] of Object.entries(upgraded.artifacts)) {
|
||||
responses.set(artifact.url, upgradedBodies[name as keyof typeof upgradedBodies]);
|
||||
}
|
||||
output.length = 0;
|
||||
assert.equal(await runClientInstaller([
|
||||
"update", "--app-root", appRoot, "--json",
|
||||
], commandContext), 0, "update reuses the pinned manifest URL and public key");
|
||||
const upgradedOutput = JSON.parse(output.pop()!);
|
||||
const upgradedState = JSON.parse(readFileSync(paths.stateFile, "utf8"));
|
||||
assert.equal(upgradedOutput.version, "1.1.1");
|
||||
assert.equal(upgradedOutput.restartRequired, undefined);
|
||||
assert.equal(upgradedState.host, undefined);
|
||||
assert.notEqual(upgradedState.runtimeDir, initialRuntime);
|
||||
assert.equal(existsSync(join(appRoot, initialRuntime)), false, "successful upgrade removes the obsolete runtime");
|
||||
assert.deepEqual(readdirSync(paths.versionsDir), [basename(upgradedState.runtimeDir)]);
|
||||
assert.equal(readFileSync(paths.skillFile, "utf8"), upgradedBodies.skill.toString("utf8"));
|
||||
assert.deepEqual(readFileSync(paths.sessionFile), sessionBody, "cross-version update preserves the OAuth session");
|
||||
assert.equal(paths.bridgeCommand, stableBridgePath, "the MCP command path remains stable across versions");
|
||||
|
||||
const beforeFailedState = readFileSync(paths.stateFile);
|
||||
const beforeFailedSkill = readFileSync(paths.skillFile);
|
||||
const beforeFailedVersions = readdirSync(paths.versionsDir);
|
||||
const failedBodies = { ...upgradedBodies, client: Buffer.from("fake-client-tgz-v3") };
|
||||
const failedUpgrade = manifestFixture(keyId, publicPem, failedBodies, "1.1.2");
|
||||
const failedUpgradeBytes = Buffer.from(`${JSON.stringify(failedUpgrade, null, 2)}\n`);
|
||||
responses.set("https://approval.example.test/downloads/manifest.json", failedUpgradeBytes);
|
||||
responses.set("https://approval.example.test/downloads/manifest.sig", signatureEnvelope(keyId, sign(null, failedUpgradeBytes, keys.privateKey)));
|
||||
responses.set(failedUpgrade.artifacts.client.url, Buffer.from("corrupted-client-tgz"));
|
||||
output.length = 0;
|
||||
assert.equal(await runClientInstaller(["update", "--app-root", appRoot, "--json"], commandContext), 1);
|
||||
assert.match(JSON.parse(output.pop()!).error, /SHA-256 不匹配/);
|
||||
assert.deepEqual(readFileSync(paths.stateFile), beforeFailedState, "failed update keeps the active install state");
|
||||
assert.deepEqual(readFileSync(paths.skillFile), beforeFailedSkill, "failed update keeps the active Skill");
|
||||
assert.deepEqual(readdirSync(paths.versionsDir), beforeFailedVersions, "failed update keeps the active runtime");
|
||||
assert.deepEqual(readFileSync(paths.sessionFile), sessionBody, "failed update keeps the OAuth session");
|
||||
|
||||
const nextRelease = manifestFixture(keyId, publicPem, failedBodies, "1.1.2");
|
||||
const nextBytes = Buffer.from(`${JSON.stringify(nextRelease, null, 2)}\n`);
|
||||
responses.set("https://approval.example.test/downloads/manifest.json", nextBytes);
|
||||
responses.set("https://approval.example.test/downloads/manifest.sig", signatureEnvelope(keyId, sign(null, nextBytes, keys.privateKey)));
|
||||
for (const [name, artifact] of Object.entries(nextRelease.artifacts)) {
|
||||
responses.set(artifact.url, failedBodies[name as keyof typeof failedBodies]);
|
||||
}
|
||||
output.length = 0;
|
||||
assert.equal(await runClientInstaller(["update", "--app-root", appRoot, "--json"], commandContext), 0);
|
||||
assert.equal(JSON.parse(output.pop()!).version, "1.1.2");
|
||||
assert.equal(JSON.parse(readFileSync(paths.stateFile, "utf8")).host, undefined);
|
||||
assert.deepEqual(readFileSync(paths.sessionFile), sessionBody);
|
||||
|
||||
const downgraded = manifestFixture(keyId, publicPem, bodies, "1.0.0");
|
||||
const downgradedBytes = Buffer.from(`${JSON.stringify(downgraded, null, 2)}\n`);
|
||||
responses.set("https://approval.example.test/downloads/manifest.json", downgradedBytes);
|
||||
responses.set("https://approval.example.test/downloads/manifest.sig", signatureEnvelope(keyId, sign(null, downgradedBytes, keys.privateKey)));
|
||||
output.length = 0;
|
||||
assert.equal(await runClientInstaller([
|
||||
"update", "--app-root", appRoot, "--json",
|
||||
], commandContext), 1);
|
||||
assert.match(JSON.parse(output.pop()!).error, /拒绝.*降级/);
|
||||
assert.equal(JSON.parse(readFileSync(paths.stateFile, "utf8")).version, "1.1.2");
|
||||
assert.deepEqual(readFileSync(paths.sessionFile), sessionBody);
|
||||
|
||||
for (const [name, artifact] of Object.entries(downgraded.artifacts)) {
|
||||
responses.set(artifact.url, bodies[name as keyof typeof bodies]);
|
||||
}
|
||||
output.length = 0;
|
||||
assert.equal(await runClientInstaller([
|
||||
"update", "--allow-downgrade", "--app-root", appRoot, "--json",
|
||||
], commandContext), 0, "an explicitly authorized downgrade remains possible");
|
||||
const downgradedState = JSON.parse(readFileSync(paths.stateFile, "utf8"));
|
||||
assert.equal(downgradedState.version, "1.0.0");
|
||||
assert.equal(downgradedState.host, undefined);
|
||||
assert.deepEqual(readFileSync(paths.sessionFile), sessionBody);
|
||||
assert.equal(readdirSync(paths.versionsDir).length, 1);
|
||||
|
||||
output.length = 0;
|
||||
assert.equal(await runClientInstaller(["print-config", "--bogus", "--app-root", appRoot, "--json"], commandContext), 1);
|
||||
assert.match(JSON.parse(output.pop()!).error, /未知参数/);
|
||||
|
||||
output.length = 0;
|
||||
assert.equal(await runClientInstaller(["uninstall", "--yes", "--app-root", root, "--json"], commandContext), 1);
|
||||
assert.match(JSON.parse(output.pop()!).error, /专用.*用户数据目录/);
|
||||
|
||||
output.length = 0;
|
||||
const removed = await runClientInstaller(["uninstall", "--yes", "--app-root", appRoot, "--json"], {
|
||||
...commandContext,
|
||||
});
|
||||
assert.equal(removed, 0);
|
||||
assert.equal(existsSync(appRoot), false);
|
||||
});
|
||||
|
||||
function manifestFixture(
|
||||
keyId: string,
|
||||
publicKey: string | Buffer,
|
||||
bodies = {
|
||||
installer: Buffer.from("installer"), publicKey: Buffer.from(publicKey), client: Buffer.from("client"),
|
||||
skill: Buffer.from("skill"),
|
||||
},
|
||||
version = "1.1.0-rc.5",
|
||||
): ClientReleaseManifest {
|
||||
const base = "https://approval.example.test/downloads/";
|
||||
const artifact = (name: keyof typeof bodies) => ({ url: `${base}${name}`, sha256: digest(bodies[name]) });
|
||||
return {
|
||||
schemaVersion: 3,
|
||||
version,
|
||||
createdAt: new Date().toISOString(),
|
||||
gatewayUrl: "https://approval.example.test/mcp",
|
||||
signing: { algorithm: "Ed25519", keyId },
|
||||
requirements: { node: ">=22.13.0" },
|
||||
artifacts: {
|
||||
installer: artifact("installer"),
|
||||
publicKey: { ...artifact("publicKey"), keyId },
|
||||
client: artifact("client"),
|
||||
skill: artifact("skill"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function signatureEnvelope(keyId: string, value: Buffer): Buffer {
|
||||
return Buffer.from(JSON.stringify({ schemaVersion: 1, algorithm: "Ed25519", keyId, signature: value.toString("base64") }));
|
||||
}
|
||||
|
||||
function digest(value: Buffer): string { return createHash("sha256").update(value).digest("hex"); }
|
||||
|
||||
function createFakeNpm(bin: string): void {
|
||||
const fakeBridge = `#!/usr/bin/env node
|
||||
if (process.env.FAKE_BRIDGE_FAIL === "1") process.exit(2);
|
||||
if (process.argv.includes("--post-install-login")) {
|
||||
console.log(JSON.stringify({
|
||||
authenticated: false,
|
||||
message: "需要完成钉钉授权登录。",
|
||||
authChallenge: {
|
||||
provider: "dingtalk",
|
||||
type: "link",
|
||||
directUrl: "https://approval.example.test/oauth/device?user_code=INSTALL-1234",
|
||||
userCode: "INSTALL-1234",
|
||||
expiresAt: "2030-01-01T00:00:00.000Z",
|
||||
pollAfterSeconds: 5
|
||||
}
|
||||
}));
|
||||
}
|
||||
`;
|
||||
const implementation = process.platform === "win32"
|
||||
? join(bin, "node_modules", "npm", "bin", "npm-cli.js")
|
||||
: join(bin, "fake-npm.mjs");
|
||||
mkdirSync(join(implementation, ".."), { recursive: true });
|
||||
writeFileSync(implementation, `#!/usr/bin/env node
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
if (process.argv[2] === "--version") { console.log("11.0.0"); process.exit(0); }
|
||||
const index = process.argv.indexOf("--prefix");
|
||||
if (index < 0 || !process.argv[index + 1]) { console.error("missing prefix"); process.exit(2); }
|
||||
const prefix = process.argv[index + 1];
|
||||
const target = join(prefix, "node_modules", "dingtalk-approval-client", "dist", "src");
|
||||
mkdirSync(target, { recursive: true });
|
||||
writeFileSync(join(target, "bridge.js"), ${JSON.stringify(fakeBridge)});
|
||||
writeFileSync(join(target, "client-installer.js"), "#!/usr/bin/env node\\n");
|
||||
`);
|
||||
if (process.platform === "win32") {
|
||||
writeFileSync(join(bin, "npm.cmd"), "@echo off\r\nexit /b 99\r\n");
|
||||
} else {
|
||||
const executable = join(bin, "npm");
|
||||
writeFileSync(executable, `#!/usr/bin/env sh\nexec "${process.execPath}" "$(dirname "$0")/fake-npm.mjs" "$@"\n`, { mode: 0o700 });
|
||||
chmodSync(executable, 0o700);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,703 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { ApprovalService } from "../src/approval-service.js";
|
||||
import { loadConfig, type AppConfig } from "../src/config.js";
|
||||
import { DingTalkClient, type FetchLike } from "../src/dingtalk.js";
|
||||
import { DingTalkApiError } from "../src/errors.js";
|
||||
|
||||
const config: AppConfig = {
|
||||
clientId: "app-key",
|
||||
clientSecret: "app-secret",
|
||||
corpId: "ding-corp",
|
||||
apiBaseUrl: "https://api.example.test",
|
||||
oapiBaseUrl: "https://oapi.example.test",
|
||||
publicBaseUrl: "http://localhost:3000",
|
||||
trustedLocalUser: { userId: "user-1", name: "测试用户" },
|
||||
port: 3000,
|
||||
requestTimeoutMs: 1_000,
|
||||
officialLookbackDays: 120,
|
||||
officialScanConcurrency: 2,
|
||||
officialClientOnlyProcessCodes: [],
|
||||
approvalRefSecret: "fixed-reference-secret-for-tests",
|
||||
batchLimit: 20,
|
||||
};
|
||||
|
||||
const yidaConfig: AppConfig = {
|
||||
...config,
|
||||
yida: { adminUserId: "admin-1", organizationToken: "org-token" },
|
||||
};
|
||||
|
||||
test("loadConfig can read DingTalk credentials from a protected JSON file", () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "ding-mcp-test-"));
|
||||
const credentialsPath = join(directory, "credentials.json");
|
||||
writeFileSync(credentialsPath, JSON.stringify({ clientId: "file-key", clientSecret: "file-secret" }), { mode: 0o600 });
|
||||
|
||||
const loaded = loadConfig({
|
||||
APP_MODE: "gateway",
|
||||
DINGTALK_CREDENTIALS_FILE: credentialsPath,
|
||||
DINGTALK_CORP_ID: "ding-corp",
|
||||
MCP_PUBLIC_BASE_URL: "https://approval.example.test",
|
||||
MCP_REMOTE_AUTH_ENABLED: "true",
|
||||
MCP_SESSION_SECRET: "session-secret-for-tests-at-least-32-bytes",
|
||||
MCP_APPROVAL_REF_SECRET: "approval-reference-secret-for-tests-32-bytes",
|
||||
XFT_OPEN_API_ENABLED: "true",
|
||||
XFT_OPEN_API_APP_ID: "xft-app",
|
||||
XFT_OPEN_API_AUTHORITY_SECRET: "1".repeat(64),
|
||||
XFT_OPEN_API_ENTERPRISE_ID: "enterprise",
|
||||
DINGTALK_CLIENT_ONLY_PROCESS_CODES: "PROC-SIGN, PROC-SIGN,PROC-CLIENT",
|
||||
APPROVAL_BATCH_LIMIT: "99",
|
||||
YIDA_ENABLED: "false",
|
||||
});
|
||||
|
||||
assert.equal(loaded.clientId, "file-key");
|
||||
assert.equal(loaded.clientSecret, "file-secret");
|
||||
assert.equal(loaded.yida, undefined);
|
||||
assert.deepEqual(loaded.officialClientOnlyProcessCodes, ["PROC-SIGN", "PROC-CLIENT"]);
|
||||
assert.equal(loaded.batchLimit, 20);
|
||||
});
|
||||
|
||||
test("loadConfig rejects the removed local DWS architecture", () => {
|
||||
assert.throws(() => loadConfig({ APP_MODE: "local", DWS_CLI_PATH: "/opt/bin/dws" }), /APP_MODE=gateway/);
|
||||
assert.throws(() => loadConfig({
|
||||
APP_MODE: "gateway", DINGTALK_CLIENT_ID: "key", DINGTALK_CLIENT_SECRET: "secret",
|
||||
DINGTALK_CORP_ID: "corp", MCP_PUBLIC_BASE_URL: "https://approval.example.test", MCP_SESSION_SECRET: "session",
|
||||
MCP_REMOTE_AUTH_ENABLED: "true", XFT_OPEN_API_ENABLED: "true", XFT_OPEN_API_APP_ID: "app",
|
||||
XFT_OPEN_API_AUTHORITY_SECRET: "1".repeat(64), XFT_OPEN_API_ENTERPRISE_ID: "enterprise",
|
||||
DWS_CLI_PATH: "/opt/bin/dws",
|
||||
}), /禁止配置本地 DWS/);
|
||||
});
|
||||
|
||||
test("loadConfig reads AuthoritySecret from the second line of an XFT portal credential file", () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "ding-mcp-xft-openapi-config-"));
|
||||
const secretPath = join(directory, "xft.key");
|
||||
writeFileSync(secretPath, `${"a".repeat(32)}\n${"b".repeat(64)}\n`, { mode: 0o600 });
|
||||
const loaded = loadConfig({
|
||||
APP_MODE: "gateway",
|
||||
DINGTALK_CLIENT_ID: "key",
|
||||
DINGTALK_CLIENT_SECRET: "secret",
|
||||
DINGTALK_CORP_ID: "corp",
|
||||
MCP_PUBLIC_BASE_URL: "https://approval.example.test",
|
||||
MCP_REMOTE_AUTH_ENABLED: "true",
|
||||
MCP_SESSION_SECRET: "session-secret-for-tests-at-least-32-bytes",
|
||||
MCP_APPROVAL_REF_SECRET: "approval-reference-secret-for-tests-32-bytes",
|
||||
XFT_OPEN_API_ENABLED: "true",
|
||||
XFT_OPEN_API_APP_ID: "open-app",
|
||||
XFT_OPEN_API_AUTHORITY_SECRET_FILE: secretPath,
|
||||
XFT_OPEN_API_ENTERPRISE_ID: "TEST0001",
|
||||
});
|
||||
assert.equal(loaded.xftOpenApi?.authoritySecret, "b".repeat(64));
|
||||
});
|
||||
|
||||
test("loadConfig requires identity configuration and rejects non-loopback binding", () => {
|
||||
assert.throws(() => loadConfig({}), /必须显式设置 APP_MODE/);
|
||||
assert.throws(() => loadConfig({
|
||||
APP_MODE: "gateway",
|
||||
DINGTALK_CLIENT_ID: "key",
|
||||
DINGTALK_CLIENT_SECRET: "secret",
|
||||
MCP_PUBLIC_BASE_URL: "https://mcp.example.test",
|
||||
}), /DINGTALK_CORP_ID/);
|
||||
assert.throws(() => loadConfig({
|
||||
APP_MODE: "gateway",
|
||||
MCP_BIND_HOST: "0.0.0.0",
|
||||
}), /MCP_BIND_HOST 仅允许回环地址/);
|
||||
});
|
||||
|
||||
test("unified gateway requires both remote identity and XFT OpenAPI", () => {
|
||||
assert.throws(() => loadConfig({
|
||||
APP_MODE: "gateway", DINGTALK_CLIENT_ID: "key", DINGTALK_CLIENT_SECRET: "secret",
|
||||
DINGTALK_CORP_ID: "corp", MCP_PUBLIC_BASE_URL: "https://approval.example.test", MCP_SESSION_SECRET: "session",
|
||||
}), /薪福通正式 OpenAPI/);
|
||||
assert.throws(() => loadConfig({
|
||||
APP_MODE: "gateway", DINGTALK_CLIENT_ID: "key", DINGTALK_CLIENT_SECRET: "secret",
|
||||
DINGTALK_CORP_ID: "corp", MCP_PUBLIC_BASE_URL: "https://approval.example.test", MCP_SESSION_SECRET: "session",
|
||||
MCP_REMOTE_AUTH_ENABLED: "true", MCP_TRUSTED_LOCAL_USER_ID: "victim",
|
||||
XFT_OPEN_API_ENABLED: "true", XFT_OPEN_API_APP_ID: "app", XFT_OPEN_API_AUTHORITY_SECRET: "1".repeat(64),
|
||||
XFT_OPEN_API_ENTERPRISE_ID: "enterprise",
|
||||
}), /禁止配置 MCP_TRUSTED/);
|
||||
});
|
||||
|
||||
test("DingTalkClient caches accessToken and unwraps official template response", async () => {
|
||||
const calls: Array<{ url: string; init: RequestInit }> = [];
|
||||
const fakeFetch: FetchLike = async (input, init = {}) => {
|
||||
const url = String(input);
|
||||
calls.push({ url, init });
|
||||
if (url.endsWith("/v1.0/oauth2/accessToken")) {
|
||||
return Response.json({ accessToken: "token-1", expireIn: 7200 });
|
||||
}
|
||||
return Response.json({
|
||||
success: true,
|
||||
result: { processList: [{ name: "采购", processCode: "PROC-1" }], nextToken: 0 },
|
||||
});
|
||||
};
|
||||
const client = new DingTalkClient(config, fakeFetch);
|
||||
|
||||
const first = await client.listOfficialTemplates();
|
||||
const second = await client.listOfficialTemplates();
|
||||
|
||||
assert.deepEqual(first.map((item) => item.processCode), ["PROC-1"]);
|
||||
assert.equal(second.length, 1);
|
||||
assert.equal(calls.filter((call) => call.url.endsWith("accessToken")).length, 1);
|
||||
assert.equal(
|
||||
new Headers(calls[1]!.init.headers).get("x-acs-dingtalk-access-token"),
|
||||
"token-1",
|
||||
);
|
||||
});
|
||||
|
||||
test("DingTalkClient invalidates and retries an explicitly rejected app access token once", async () => {
|
||||
let tokenCalls = 0;
|
||||
let apiCalls = 0;
|
||||
const client = new DingTalkClient(config, async (input, init = {}) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/v1.0/oauth2/accessToken")) {
|
||||
tokenCalls += 1;
|
||||
return Response.json({ accessToken: `token-${tokenCalls}`, expireIn: 7200 });
|
||||
}
|
||||
apiCalls += 1;
|
||||
const headers = new Headers(init.headers);
|
||||
if (headers.get("x-acs-dingtalk-access-token") === "token-1") {
|
||||
return Response.json({ code: "InvalidAccessToken", message: "expired" }, { status: 401 });
|
||||
}
|
||||
return Response.json({ success: true, result: { processList: [], nextToken: 0 } });
|
||||
});
|
||||
await client.listOfficialTemplates();
|
||||
assert.equal(tokenCalls, 2);
|
||||
assert.equal(apiCalls, 2);
|
||||
});
|
||||
|
||||
test("DingTalkClient serializes official approval taskId as a JSON number", async () => {
|
||||
const calls: Array<{ url: string; init: RequestInit }> = [];
|
||||
const fakeFetch: FetchLike = async (input, init = {}) => {
|
||||
const url = String(input);
|
||||
calls.push({ url, init });
|
||||
if (url.endsWith("/v1.0/oauth2/accessToken")) {
|
||||
return Response.json({ accessToken: "token-1", expireIn: 7200 });
|
||||
}
|
||||
return Response.json({ result: true });
|
||||
};
|
||||
const client = new DingTalkClient(config, fakeFetch);
|
||||
|
||||
await client.executeOfficialApproval({
|
||||
processInstanceId: "instance-1",
|
||||
taskId: "222222222222",
|
||||
actionerUserId: "manager-test",
|
||||
result: "agree",
|
||||
remark: "同意",
|
||||
});
|
||||
|
||||
const request = calls.find((call) => call.url.endsWith("/v1.0/workflow/processInstances/execute"));
|
||||
assert.ok(request);
|
||||
const body = JSON.parse(String(request.init.body)) as Record<string, unknown>;
|
||||
assert.equal(body.taskId, 222222222222);
|
||||
assert.equal(typeof body.taskId, "number");
|
||||
});
|
||||
|
||||
test("DingTalkClient reads and normalizes the authenticated user's department IDs", async () => {
|
||||
const calls: Array<{ url: string; init: RequestInit }> = [];
|
||||
const fakeFetch: FetchLike = async (input, init = {}) => {
|
||||
const url = String(input);
|
||||
calls.push({ url, init });
|
||||
if (url.endsWith("/v1.0/oauth2/accessToken")) return Response.json({ accessToken: "token-1", expireIn: 7200 });
|
||||
if (url.includes("/topapi/v2/user/get?")) return Response.json({ result: { dept_id_list: [123, "456", 123, 0, "invalid"] } });
|
||||
throw new Error(`unexpected ${url}`);
|
||||
};
|
||||
const client = new DingTalkClient(config, fakeFetch);
|
||||
assert.deepEqual(await client.getUserDepartmentIds("user-1"), [123, 456]);
|
||||
const request = calls.find((call) => call.url.includes("/topapi/v2/user/get?"));
|
||||
assert.deepEqual(JSON.parse(String(request?.init.body)), { userid: "user-1", language: "zh_CN" });
|
||||
});
|
||||
|
||||
test("DingTalkClient forecasts through the enterprise API and creates with the authenticated gateway user", async () => {
|
||||
const calls: Array<{ url: string; init: RequestInit }> = [];
|
||||
const fakeFetch: FetchLike = async (input, init = {}) => {
|
||||
const url = String(input);
|
||||
calls.push({ url, init });
|
||||
if (url.endsWith("/v1.0/oauth2/accessToken")) return Response.json({ accessToken: "token-1", expireIn: 7200 });
|
||||
if (url.endsWith("/v1.0/workflow/processes/forecast")) {
|
||||
return Response.json({ result: {
|
||||
isForecastSuccess: true,
|
||||
isStaticWorkflow: false,
|
||||
workflowActivityRules: [{
|
||||
activityId: "approve-1", activityName: "主管审批", activityType: "target_select", isTargetSelect: true,
|
||||
activityActioners: [{ name: "测试主管", userId: "manager-1" }],
|
||||
workflowActor: { actorKey: "manual-1", actorType: "approver", approvalMethod: "NONE" },
|
||||
}],
|
||||
workflowForecastNodes: [{ activityId: "approve-1", outId: "finish-1" }],
|
||||
} });
|
||||
}
|
||||
if (url.endsWith("/v1.0/workflow/processInstances")) return Response.json({ result: "instance-new" });
|
||||
throw new Error(`unexpected ${url}`);
|
||||
};
|
||||
const client = new DingTalkClient(config, fakeFetch);
|
||||
const request = {
|
||||
processCode: "PROC-1", originatorUserId: "user-1", deptId: -1,
|
||||
formComponentValues: [{ name: "事由", value: "测试" }],
|
||||
targetSelectActioners: [{ actionerKey: "manual-1", actionerStaffIds: ["manager-1"] }],
|
||||
};
|
||||
const forecast = await client.forecastOfficialProcess(request);
|
||||
const instanceId = await client.createOfficialInstance(request);
|
||||
assert.equal(forecast.forecastSuccess, true);
|
||||
assert.equal(forecast.staticWorkflow, false);
|
||||
assert.equal(forecast.supported, true);
|
||||
assert.equal((forecast.workflowActivityRuleVOs as Array<Record<string, unknown>>)[0]?.targetSelect, true);
|
||||
assert.equal(instanceId, "instance-new");
|
||||
const forecastBody = JSON.parse(String(calls.find((call) => call.url.endsWith("/processes/forecast"))?.init.body)) as Record<string, unknown>;
|
||||
assert.deepEqual(forecastBody, {
|
||||
processCode: "PROC-1", userId: "user-1", deptId: -1,
|
||||
formComponentValues: [{ name: "事由", value: "测试" }],
|
||||
});
|
||||
const createBody = JSON.parse(String(calls.find((call) => call.url.endsWith("/v1.0/workflow/processInstances"))?.init.body)) as Record<string, unknown>;
|
||||
assert.equal(createBody.originatorUserId, "user-1");
|
||||
assert.equal(createBody.processCode, "PROC-1");
|
||||
assert.deepEqual(createBody.formComponentValues, [{ name: "事由", value: "测试" }]);
|
||||
assert.deepEqual(createBody.targetSelectActioners, [{ actionerKey: "manual-1", actionerUserIds: ["manager-1"] }]);
|
||||
});
|
||||
|
||||
test("DingTalkClient rejects failed or malformed enterprise forecasts before submission confirmation", async () => {
|
||||
for (const result of [{ isForecastSuccess: false }, { processCode: "PROC-1" }]) {
|
||||
const client = new DingTalkClient(config, async (input) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/v1.0/oauth2/accessToken")) return Response.json({ accessToken: "token-1", expireIn: 7200 });
|
||||
if (url.endsWith("/v1.0/workflow/processes/forecast")) return Response.json({ result });
|
||||
throw new Error(`unexpected ${url}`);
|
||||
});
|
||||
await assert.rejects(client.forecastOfficialProcess({
|
||||
processCode: "PROC-1", originatorUserId: "user-1", deptId: 123,
|
||||
formComponentValues: [{ name: "事由", value: "测试" }],
|
||||
}), /流程预测失败|缺少预测结果/);
|
||||
}
|
||||
});
|
||||
|
||||
test("DingTalkClient terminates an approval with server-derived operator identity", async () => {
|
||||
const calls: Array<{ url: string; init: RequestInit }> = [];
|
||||
const fakeFetch: FetchLike = async (input, init = {}) => {
|
||||
const url = String(input);
|
||||
calls.push({ url, init });
|
||||
if (url.endsWith("/v1.0/oauth2/accessToken")) return Response.json({ accessToken: "token-1", expireIn: 7200 });
|
||||
if (url.endsWith("/v1.0/workflow/processInstances/terminate")) return Response.json({ success: true });
|
||||
throw new Error(`unexpected ${url}`);
|
||||
};
|
||||
const client = new DingTalkClient(config, fakeFetch);
|
||||
await client.terminateOfficialInstance({ processInstanceId: "instance-1", operatingUserId: "user-1", remark: "测试撤销" });
|
||||
const request = calls.find((call) => call.url.endsWith("/terminate"));
|
||||
const body = JSON.parse(String(request?.init.body)) as Record<string, unknown>;
|
||||
assert.deepEqual(body, { processInstanceId: "instance-1", operatingUserId: "user-1", remark: "测试撤销", isSystem: false });
|
||||
});
|
||||
|
||||
test("ApprovalService revokes only the current user's running submission and verifies the transition", async () => {
|
||||
let state = "RUNNING";
|
||||
let terminateCalls = 0;
|
||||
const fakeClient = {
|
||||
async getOfficialInstance() { return { processInstanceId: "instance-1", originatorUserId: "user-1", status: state }; },
|
||||
async terminateOfficialInstance(input: Record<string, unknown>) {
|
||||
assert.equal(input.operatingUserId, "user-1");
|
||||
terminateCalls += 1;
|
||||
state = "TERMINATED";
|
||||
},
|
||||
} as unknown as DingTalkClient;
|
||||
const service = new ApprovalService(fakeClient, config);
|
||||
const result = await service.revokeSubmission("user-1", "instance-1", "误发");
|
||||
assert.equal(result.status, "revoked");
|
||||
assert.equal(terminateCalls, 1);
|
||||
|
||||
state = "RUNNING";
|
||||
(fakeClient as unknown as { getOfficialInstance(): Promise<Record<string, unknown>> }).getOfficialInstance = async () => ({
|
||||
processInstanceId: "instance-1", originatorUserId: "other-user", status: state,
|
||||
});
|
||||
await assert.rejects(service.revokeSubmission("user-1", "instance-1"), /不是当前登录用户本人发起/);
|
||||
assert.equal(terminateCalls, 1);
|
||||
});
|
||||
|
||||
test("ApprovalService derives a submission department and rejects caller-selected foreign departments", async () => {
|
||||
const fakeClient = {
|
||||
async getUserDepartmentIds(userId: string) {
|
||||
assert.equal(userId, "user-1");
|
||||
return [123, 456];
|
||||
},
|
||||
} as unknown as DingTalkClient;
|
||||
const service = new ApprovalService(fakeClient, config);
|
||||
assert.equal(await service.resolveSubmissionDepartment("user-1", -1), 123);
|
||||
assert.equal(await service.resolveSubmissionDepartment("user-1", 456), 456);
|
||||
await assert.rejects(service.resolveSubmissionDepartment("user-1", 999), /不属于当前登录用户/);
|
||||
});
|
||||
|
||||
test("ApprovalService returns uncertain and does not retry an unconfirmed revoke write", async () => {
|
||||
let terminateCalls = 0;
|
||||
const fakeClient = {
|
||||
async getOfficialInstance() { return { processInstanceId: "instance-1", originatorUserId: "user-1", status: "RUNNING" }; },
|
||||
async terminateOfficialInstance() { terminateCalls += 1; throw new DingTalkApiError("timeout", 0); },
|
||||
} as unknown as DingTalkClient;
|
||||
const result = await new ApprovalService(fakeClient, config).revokeSubmission("user-1", "instance-1");
|
||||
assert.equal(result.status, "outcome_uncertain");
|
||||
assert.equal(terminateCalls, 1);
|
||||
});
|
||||
|
||||
test("DingTalkClient retries temporary DingTalk QPS throttling", async () => {
|
||||
let templateCalls = 0;
|
||||
const fakeFetch: FetchLike = async (input) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/v1.0/oauth2/accessToken")) {
|
||||
return Response.json({ accessToken: "app-token", expireIn: 7200 });
|
||||
}
|
||||
templateCalls += 1;
|
||||
if (templateCalls === 1) {
|
||||
return Response.json({ code: "Forbidden.AccessDenied.QpsLimitForAppkeyAndApi", message: "qps流控" }, { status: 403 });
|
||||
}
|
||||
return Response.json({ result: { processList: [], nextToken: 0 } });
|
||||
};
|
||||
const client = new DingTalkClient(config, fakeFetch);
|
||||
|
||||
assert.deepEqual(await client.listOfficialTemplates(), []);
|
||||
assert.equal(templateCalls, 2);
|
||||
});
|
||||
|
||||
test("ApprovalService filters submission templates before applying the result limit", async () => {
|
||||
const fakeClient = {
|
||||
async listOfficialTemplates() {
|
||||
return [
|
||||
{ processCode: "BLOCKED", name: "其他申请", dirName: "其他分类" },
|
||||
{ processCode: "ALLOWED-1", name: "请假", dirName: "北极大_人事假勤" },
|
||||
{ processCode: "ALLOWED-2", name: "报销", dirName: "捷康_费用报销" },
|
||||
];
|
||||
},
|
||||
} as unknown as DingTalkClient;
|
||||
const service = new ApprovalService(fakeClient, config);
|
||||
const templates = await service.listSubmissionTemplates(1, "", ["北极大_人事假勤", "捷康_费用报销"]);
|
||||
assert.deepEqual(templates.map((item) => item.processCode), ["ALLOWED-1"]);
|
||||
});
|
||||
|
||||
test("ApprovalService finds only NEW official tasks owned by current user", async () => {
|
||||
const fakeClient = {
|
||||
async listOfficialTodoWorkRecords() {
|
||||
return {
|
||||
list: [{ instance_id: "instance-1", task_id: 42, title: "采购电脑" }],
|
||||
hasMore: false,
|
||||
};
|
||||
},
|
||||
} as unknown as DingTalkClient;
|
||||
const service = new ApprovalService(fakeClient, config);
|
||||
|
||||
const result = await service.listMyTodos("user-1", "official_oa", 50);
|
||||
|
||||
assert.equal(result.items.length, 1);
|
||||
assert.equal(result.items[0]!.taskId, "42");
|
||||
assert.equal(result.items[0]!.source, "official_oa");
|
||||
});
|
||||
|
||||
test("ApprovalService enriches official todo summaries without dropping items when one detail fails", async () => {
|
||||
const fakeClient = {
|
||||
async listOfficialTodoWorkRecords() {
|
||||
return {
|
||||
list: [
|
||||
{ instance_id: "instance-1", task_id: 1, title: "费用审批", url: "https://aflow.dingtalk.com/dingtalk/mobile/homepage.htm?taskId=1" },
|
||||
{ instance_id: "instance-2", task_id: 2, title: "备用审批", forms: [{ title: "发起人", content: "测试用户戊" }] },
|
||||
],
|
||||
hasMore: false,
|
||||
};
|
||||
},
|
||||
async getOfficialInstance(processInstanceId: string) {
|
||||
if (processInstanceId === "instance-2") throw new Error("detail unavailable");
|
||||
return {
|
||||
processCode: "PROC-SIGN",
|
||||
originatorUserName: "测试用户丁",
|
||||
originatorUserId: "user-bin",
|
||||
originatorDeptName: "销售部",
|
||||
createTime: "2026-08-08T08:30:00+08:00",
|
||||
formComponentValues: [
|
||||
{ name: "报销金额", value: "4812元" },
|
||||
{ name: "报销事由", value: "客户出差" },
|
||||
],
|
||||
};
|
||||
},
|
||||
} as unknown as DingTalkClient;
|
||||
const service = new ApprovalService(fakeClient, config);
|
||||
const result = await service.listMyTodos("user-1", "official_oa", 10);
|
||||
assert.equal(result.items.length, 2);
|
||||
assert.equal(result.items[0]!.applicantName, "测试用户丁");
|
||||
assert.equal(result.items[0]!.applicantUserId, "user-bin");
|
||||
assert.equal(result.items[0]!.applicantDepartment, "销售部");
|
||||
assert.equal(result.items[0]!.amount, 4812);
|
||||
assert.equal(result.items[0]!.summary, "客户出差");
|
||||
assert.equal(result.items[0]!.processCode, "PROC-SIGN");
|
||||
assert.match(result.items[0]!.clientUrl ?? "", /^https:\/\/aflow\.dingtalk\.com\//);
|
||||
assert.equal(result.items[1]!.applicantName, "测试用户戊");
|
||||
assert.equal(result.diagnostics.officialEnrichmentFailures, 1);
|
||||
});
|
||||
|
||||
test("ApprovalService paginates direct official todo work records", async () => {
|
||||
const offsets: number[] = [];
|
||||
const firstPage = Array.from({ length: 20 }, (_value, index) => ({
|
||||
instance_id: `instance-${index + 1}`,
|
||||
task_id: index + 1,
|
||||
}));
|
||||
const fakeClient = {
|
||||
async listOfficialTodoWorkRecords(_userId: string, offset: number) {
|
||||
offsets.push(offset);
|
||||
return offset === 0
|
||||
? { list: firstPage, hasMore: true }
|
||||
: { list: [{ instance_id: "instance-21", task_id: 21 }], hasMore: false };
|
||||
},
|
||||
} as unknown as DingTalkClient;
|
||||
const service = new ApprovalService(fakeClient, config);
|
||||
|
||||
const result = await service.listMyTodos("user-1", "official_oa", 21);
|
||||
|
||||
assert.deepEqual(offsets, [0, 20]);
|
||||
assert.equal(result.items.length, 21);
|
||||
assert.equal(result.items.at(-1)!.processInstanceId, "instance-21");
|
||||
});
|
||||
|
||||
test("ApprovalService stops on a short official todo page and removes duplicates", async () => {
|
||||
let calls = 0;
|
||||
const fakeClient = {
|
||||
async listOfficialTodoWorkRecords() {
|
||||
calls += 1;
|
||||
return {
|
||||
list: [
|
||||
{ instance_id: "instance-1", task_id: 1 },
|
||||
{ instance_id: "instance-1", task_id: 1 },
|
||||
],
|
||||
hasMore: true,
|
||||
};
|
||||
},
|
||||
} as unknown as DingTalkClient;
|
||||
const service = new ApprovalService(fakeClient, config);
|
||||
|
||||
const result = await service.listMyTodos("user-1", "official_oa", 50);
|
||||
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(result.items.length, 1);
|
||||
});
|
||||
|
||||
test("DingTalkClient queries completed work records and initiated instance IDs with server-derived user filters", async () => {
|
||||
const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
|
||||
const client = new DingTalkClient(config, async (input, init = {}) => {
|
||||
const url = String(input);
|
||||
const body = init.body ? JSON.parse(String(init.body)) as Record<string, unknown> : {};
|
||||
calls.push({ url, body });
|
||||
if (url.endsWith("/v1.0/oauth2/accessToken")) return Response.json({ accessToken: "token-1", expireIn: 7200 });
|
||||
if (url.includes("/topapi/process/workrecord/task/query")) {
|
||||
return Response.json({ errcode: 0, result: { list: [{ instance_id: "handled-1", task_id: 91 }], has_more: false } });
|
||||
}
|
||||
if (url.includes("/topapi/processinstance/listids")) {
|
||||
return Response.json({ errcode: 0, result: { list: [{ process_instance_id: "submitted-1" }], next_cursor: "20" } });
|
||||
}
|
||||
throw new Error(`unexpected ${url}`);
|
||||
});
|
||||
|
||||
const handled = await client.listOfficialTodoWorkRecords("user-1", 0, 20, 1);
|
||||
const submitted = await client.listOfficialInitiatedInstanceIds("PROC-1", "user-1", 1000, 2000, 0, 20);
|
||||
|
||||
assert.equal(handled.list[0]?.instance_id, "handled-1");
|
||||
assert.deepEqual(submitted, { instanceIds: ["submitted-1"], nextCursor: 20 });
|
||||
assert.deepEqual(calls.find((call) => call.url.includes("workrecord/task/query"))?.body, {
|
||||
userid: "user-1", offset: 0, count: 20, status: 1,
|
||||
});
|
||||
assert.deepEqual(calls.find((call) => call.url.includes("processinstance/listids"))?.body, {
|
||||
process_code: "PROC-1", userid: "user-1", start_time: 1000, end_time: 2000, cursor: 0, size: 20,
|
||||
});
|
||||
});
|
||||
|
||||
test("ApprovalService lists and revalidates handled and submitted DingTalk history", async () => {
|
||||
const statuses: number[] = [];
|
||||
const initiatedRanges: Array<{ startTime: number; endTime: number }> = [];
|
||||
const fakeClient = {
|
||||
async listOfficialTodoWorkRecords(_userId: string, _offset: number, _count: number, status: number) {
|
||||
statuses.push(status);
|
||||
return { list: [{ instance_id: "handled-1", task_id: 91, title: "已处理采购" }], hasMore: false };
|
||||
},
|
||||
async listOfficialTemplates() { return [{ processCode: "PROC-1", name: "采购" }]; },
|
||||
async listOfficialInitiatedInstanceIds(_processCode: string, _userId: string, startTime: number, endTime: number) {
|
||||
initiatedRanges.push({ startTime, endTime });
|
||||
return { instanceIds: ["submitted-1"] };
|
||||
},
|
||||
async getOfficialInstance(id: string) {
|
||||
if (id === "handled-1") return {
|
||||
processInstanceId: id, title: "已处理采购", status: "COMPLETED", result: "agree",
|
||||
finishTime: "2026-08-15T02:00:00.000Z", originatorUserId: "other",
|
||||
tasks: [{ taskId: 91, userId: "user-1", status: "COMPLETED", finishTime: "2026-08-14T02:00:00.000Z" }],
|
||||
};
|
||||
return {
|
||||
processInstanceId: id, title: "本人差旅", status: "RUNNING", createTime: "2026-08-13T01:00:00.000Z",
|
||||
originatorUserId: "user-1", tasks: [{ taskId: 92, userId: "manager", status: "NEW" }],
|
||||
};
|
||||
},
|
||||
} as unknown as DingTalkClient;
|
||||
const service = new ApprovalService(fakeClient, config);
|
||||
const historyRange = { startTime: Date.parse("2026-08-01T00:00:00+08:00"), endTime: Date.parse("2026-08-31T23:59:59+08:00") };
|
||||
|
||||
const handled = await service.listMyHistory("user-1", "handled", 20, historyRange);
|
||||
const submitted = await service.listMyHistory("user-1", "submitted", 20, historyRange);
|
||||
assert.deepEqual(statuses, [1]);
|
||||
assert.deepEqual(initiatedRanges, [historyRange]);
|
||||
assert.equal(handled.items[0]?.historyScope, "handled");
|
||||
assert.equal(handled.items[0]?.completedAt, "2026-08-14T02:00:00.000Z");
|
||||
assert.equal(submitted.items[0]?.historyScope, "submitted");
|
||||
assert.equal(submitted.items[0]?.submittedAt, "2026-08-13T01:00:00.000Z");
|
||||
await service.getHistoryDetail("user-1", "handled", "handled-1", "91");
|
||||
await service.getHistoryDetail("user-1", "submitted", "submitted-1");
|
||||
await assert.rejects(service.getHistoryDetail("other", "submitted", "submitted-1"), /不是当前登录用户本人发起/);
|
||||
});
|
||||
|
||||
test("ApprovalService rechecks official task ownership and returns partial batch results", async () => {
|
||||
const executed: unknown[] = [];
|
||||
const completed = new Set<string>();
|
||||
const fakeClient = {
|
||||
async getOfficialInstance(id: string) {
|
||||
if (completed.has(id)) return { status: "COMPLETED", tasks: [{ taskId: 1, userId: "user-1", status: "COMPLETED" }] };
|
||||
return id === "allowed"
|
||||
? { status: "RUNNING", tasks: [{ taskId: 1, userId: "user-1", status: "NEW" }] }
|
||||
: { status: "RUNNING", tasks: [{ taskId: 2, userId: "other", status: "NEW" }] };
|
||||
},
|
||||
async executeOfficialApproval(input: unknown) {
|
||||
executed.push(input);
|
||||
completed.add((input as { processInstanceId: string }).processInstanceId);
|
||||
return { result: true };
|
||||
},
|
||||
} as unknown as DingTalkClient;
|
||||
const service = new ApprovalService(fakeClient, config);
|
||||
|
||||
const result = await service.processBatch("user-1", "agree", "同意", [
|
||||
{ source: "official_oa", processInstanceId: "allowed", taskId: "1" },
|
||||
{ source: "official_oa", processInstanceId: "denied", taskId: "2" },
|
||||
]);
|
||||
|
||||
assert.equal(result.succeeded, 1);
|
||||
assert.equal(result.failed, 1);
|
||||
assert.equal(executed.length, 1);
|
||||
assert.deepEqual(executed[0], {
|
||||
processInstanceId: "allowed",
|
||||
taskId: "1",
|
||||
actionerUserId: "user-1",
|
||||
result: "agree",
|
||||
remark: "同意",
|
||||
});
|
||||
});
|
||||
|
||||
test("ApprovalService accepts RUNNING as an official pending task status", async () => {
|
||||
const executed: unknown[] = [];
|
||||
let completed = false;
|
||||
const fakeClient = {
|
||||
async getOfficialInstance() {
|
||||
if (completed) return { status: "COMPLETED", tasks: [{ taskId: 9, userId: "user-1", status: "COMPLETED" }] };
|
||||
return { status: "RUNNING", tasks: [{ taskId: 9, userId: "user-1", status: "RUNNING", result: "NONE" }] };
|
||||
},
|
||||
async executeOfficialApproval(input: unknown) {
|
||||
executed.push(input);
|
||||
completed = true;
|
||||
return { result: true };
|
||||
},
|
||||
} as unknown as DingTalkClient;
|
||||
const service = new ApprovalService(fakeClient, config);
|
||||
|
||||
const result = await service.processBatch("user-1", "agree", "", [
|
||||
{ source: "official_oa", processInstanceId: "instance-1", taskId: "9" },
|
||||
]);
|
||||
|
||||
assert.equal(result.succeeded, 1);
|
||||
assert.equal(executed.length, 1);
|
||||
});
|
||||
|
||||
test("ApprovalService distinguishes a follow-up node from an unchanged original task after submit", async () => {
|
||||
const reads = new Map<string, number>();
|
||||
const fakeClient = {
|
||||
async getOfficialInstance(id: string) {
|
||||
const read = (reads.get(id) ?? 0) + 1;
|
||||
reads.set(id, read);
|
||||
if (read === 1) return { status: "RUNNING", tasks: [{ taskId: 1, userId: "user-1", status: "NEW" }] };
|
||||
if (id === "follow-up") return { status: "RUNNING", tasks: [{ taskId: 2, userId: "user-1", status: "NEW" }] };
|
||||
return { status: "RUNNING", tasks: [{ taskId: 1, userId: "user-1", status: "NEW" }] };
|
||||
},
|
||||
async executeOfficialApproval() { return { result: true }; },
|
||||
} as unknown as DingTalkClient;
|
||||
const service = new ApprovalService(fakeClient, config);
|
||||
|
||||
const result = await service.processBatch("user-1", "agree", "同意", [
|
||||
{ source: "official_oa", processInstanceId: "follow-up", taskId: "1" },
|
||||
{ source: "official_oa", processInstanceId: "unchanged", taskId: "1" },
|
||||
]);
|
||||
|
||||
assert.equal(result.succeeded, 1);
|
||||
assert.equal(result.failed, 1);
|
||||
assert.equal(result.results[0]!.followUpRequired, true);
|
||||
assert.equal(result.results[0]!.completed, false);
|
||||
assert.equal(result.results[0]!.nextTaskId, "2");
|
||||
assert.match(String(result.results[1]!.error), /原任务仍在当前用户实时待办/);
|
||||
});
|
||||
|
||||
test("ApprovalService reads Yida task field aliases and nested result pages", async () => {
|
||||
const fakeClient = {
|
||||
async listYidaCorpTasks() {
|
||||
return {
|
||||
result: {
|
||||
data: [{
|
||||
appType: "APP_A",
|
||||
formInstId: "form-1",
|
||||
taskId: "task-1",
|
||||
taskStatus: "RUNNING",
|
||||
processInstanceTitle: "宜搭采购",
|
||||
}],
|
||||
},
|
||||
};
|
||||
},
|
||||
} as unknown as DingTalkClient;
|
||||
const service = new ApprovalService(fakeClient, yidaConfig);
|
||||
|
||||
const result = await service.listMyTodos("user-1", "yida", 50);
|
||||
|
||||
assert.equal(result.items.length, 1);
|
||||
assert.equal(result.items[0]!.processInstanceId, "form-1");
|
||||
assert.equal(result.items[0]!.title, "宜搭采购");
|
||||
});
|
||||
|
||||
test("ApprovalService validates and groups Yida approvals before batch execution", async () => {
|
||||
const calls: unknown[] = [];
|
||||
const fakeClient = {
|
||||
async listYidaCorpTasks() {
|
||||
return {
|
||||
data: [
|
||||
{ appType: "APP_A", formInstId: "form-1", taskId: "task-1", taskStatus: "RUNNING" },
|
||||
{ appType: "APP_A", processInstanceId: "form-2", taskId: "task-2", status: "NEW" },
|
||||
],
|
||||
};
|
||||
},
|
||||
async listYidaApplications() {
|
||||
return [{ appType: "APP_A", systemToken: "system-token" }];
|
||||
},
|
||||
async executeYidaBatch(app: unknown, userId: string, action: string, remark: string, tasks: unknown[]) {
|
||||
calls.push({ app, userId, action, remark, tasks });
|
||||
return { success: true };
|
||||
},
|
||||
} as unknown as DingTalkClient;
|
||||
const service = new ApprovalService(fakeClient, yidaConfig);
|
||||
|
||||
const result = await service.processBatch("user-1", "agree", "同意", [
|
||||
{ source: "yida", appType: "APP_A", processInstanceId: "form-1", taskId: "task-1" },
|
||||
{ source: "yida", appType: "APP_A", processInstanceId: "form-2", taskId: "task-2" },
|
||||
{ source: "yida", appType: "APP_A", processInstanceId: "stale", taskId: "task-old" },
|
||||
]);
|
||||
|
||||
assert.equal(result.succeeded, 2);
|
||||
assert.equal(result.failed, 1);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.deepEqual((calls[0] as { tasks: unknown[] }).tasks, [
|
||||
{ taskId: "task-1", formInstId: "form-1" },
|
||||
{ taskId: "task-2", formInstId: "form-2" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("ApprovalService rejects official detail without a current task for the user", async () => {
|
||||
const fakeClient = {
|
||||
async getOfficialInstance() {
|
||||
return { status: "RUNNING", tasks: [{ taskId: 1, userId: "another-user", status: "NEW" }] };
|
||||
},
|
||||
} as unknown as DingTalkClient;
|
||||
const service = new ApprovalService(fakeClient, config);
|
||||
|
||||
await assert.rejects(
|
||||
service.getDetail("user-1", { source: "official_oa", processInstanceId: "instance-1" }),
|
||||
/当前没有属于登录用户的待审批任务/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
test("customer distribution is a generic single-login gateway bridge", () => {
|
||||
const packageMetadata = JSON.parse(readFileSync("package.json", "utf8")) as { version: string };
|
||||
assert.match(packageMetadata.version, /^\d+\.\d+\.\d+-rc\.\d+$/);
|
||||
assert.equal(existsSync("plugins/dingtalk-approval"), false);
|
||||
|
||||
const installer = readFileSync("distribution/agent-install.template.md", "utf8");
|
||||
assert.match(installer, /Ed25519/);
|
||||
assert.match(installer, /唯一通用安装入口/);
|
||||
assert.match(installer, /使用 `mcpConfig`/);
|
||||
assert.match(installer, /安装器本身不会猜测或修改 Agent 的私有配置/);
|
||||
assert.doesNotMatch(installer, /--host|WorkBuddy|Codex|Claude/);
|
||||
assert.match(installer, /不会通过其他消息通道自动推送链接.*完整登录 URL.*当前回复/s);
|
||||
assert.match(installer, /读取返回的 `postInstall`.*不得等到用户第一次查询审批/s);
|
||||
assert.match(installer, /onboarding\.firstRun=true.*简短示例.*不要自动执行/s);
|
||||
assert.doesNotMatch(installer, /通过当前 Messenger/);
|
||||
assert.doesNotMatch(installer, /DWS_CLI_PATH|dingtalk-workspace-cli|APP_MODE=local/);
|
||||
|
||||
const skill = readFileSync("skills/dingtalk-approval/SKILL.md", "utf8");
|
||||
assert.match(skill, /XFT recall is experimental.*XFTSYS9999/s);
|
||||
assert.match(skill, /does not send or push.*current user-facing reply/s);
|
||||
assert.match(skill, /handle the installer's `postInstall` result immediately.*waiting for the user's first approval request/s);
|
||||
assert.match(skill, /onboarding\.firstRun=true.*Do not execute an example automatically/s);
|
||||
|
||||
const builder = readFileSync("scripts/build-release.mjs", "utf8");
|
||||
assert.match(builder, /schemaVersion: 3,\s+version,/s);
|
||||
assert.match(builder, /signData\(null, manifestBytes/);
|
||||
assert.match(builder, /dingtalk-approval-client/);
|
||||
assert.doesNotMatch(builder, /codexPlugin|codex-plugin|\.codex-plugin|openai\.yaml/);
|
||||
assert.doesNotMatch(builder, /dingtalk-workspace-cli|DWS_URL|DWS_SHA256/);
|
||||
});
|
||||
|
||||
test("public install page explicitly declares UTF-8 for browser rendering", () => {
|
||||
const nginx = readFileSync("deploy/approval.justconn.top.nginx.conf", "utf8");
|
||||
const installLocation = nginx.match(/location = \/downloads\/dingmcp\/install\.md \{([\s\S]*?)\n \}/)?.[1] ?? "";
|
||||
assert.match(installLocation, /default_type text\/markdown;/);
|
||||
assert.match(installLocation, /charset utf-8;/);
|
||||
assert.match(installLocation, /charset_types text\/markdown;/);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import test from "node:test";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { isDirectEntrypoint } from "../src/entrypoint.js";
|
||||
|
||||
test("packaged CLI entrypoint recognizes an npm-style symlink", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "ding-entrypoint-"));
|
||||
const target = join(root, "dist", "bridge.js");
|
||||
const bin = join(root, "dingtalk-approval-bridge");
|
||||
mkdirSync(join(root, "dist"));
|
||||
writeFileSync(target, "", { flag: "w" });
|
||||
symlinkSync(target, bin);
|
||||
assert.equal(isDirectEntrypoint(pathToFileURL(target).href, bin), true);
|
||||
assert.equal(isDirectEntrypoint(pathToFileURL(target).href, join(root, "other")), false);
|
||||
assert.equal(isDirectEntrypoint(pathToFileURL(target).href, undefined), false);
|
||||
assert.equal(resolve(target), target);
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import test from "node:test";
|
||||
import type { AppConfig } from "../src/config.js";
|
||||
import type { ApprovalService } from "../src/approval-service.js";
|
||||
import {
|
||||
extractInvoiceEvidence,
|
||||
InvoiceDeduplicationService,
|
||||
InvoiceDedupStore,
|
||||
} from "../src/invoice-dedup.js";
|
||||
import type { XftApprovalProvider } from "../src/xft.js";
|
||||
import { UnifiedApprovalService } from "../src/unified-approval.js";
|
||||
|
||||
const secret = "invoice-dedup-test-secret-with-at-least-32-bytes";
|
||||
|
||||
function config(): AppConfig {
|
||||
return {
|
||||
clientId: "client", clientSecret: "secret", apiBaseUrl: "https://api.test", oapiBaseUrl: "https://oapi.test",
|
||||
publicBaseUrl: "http://localhost:3000", trustedLocalUser: { userId: "user-1" }, port: 3000,
|
||||
requestTimeoutMs: 1000, officialLookbackDays: 120, officialScanConcurrency: 2,
|
||||
officialClientOnlyProcessCodes: [], batchLimit: 20, approvalRefSecret: secret,
|
||||
};
|
||||
}
|
||||
|
||||
test("invoice evidence recognizes exact aliases and avoids unrelated sensitive numbers", () => {
|
||||
const evidence = extractInvoiceEvidence({
|
||||
invoiceInfoList: [{ invoiceCode: "044031900111", invoiceNumber: "87654321", invoiceDate: "2026-08-01" }],
|
||||
certificate: { invoiceCloudCertificates: [{ digitalInvoiceNumber: "25312000000012345678" }] },
|
||||
formFields: [
|
||||
{ label: "发票代码", value: "044031900222" },
|
||||
{ label: "发票号码", value: "11223344" },
|
||||
{ label: "银行卡号", value: "6222021234567890" },
|
||||
{ label: "身份证号", value: "440301199001011234" },
|
||||
],
|
||||
expenseList: [{ amount: 88.5, invoiceDate: "2026-08-01", merchantName: "深圳测试商户" }],
|
||||
});
|
||||
assert.ok(evidence.some((item) => item.kind === "invoice_code_number" && item.normalized.includes("87654321")));
|
||||
assert.ok(evidence.some((item) => item.kind === "invoice_code_number" && item.normalized.includes("11223344")));
|
||||
assert.ok(evidence.some((item) => item.kind === "digital_invoice_number"));
|
||||
assert.ok(evidence.some((item) => item.kind === "expense_signature"));
|
||||
assert.equal(evidence.some((item) => item.normalized.includes("6222021234567890")), false);
|
||||
assert.equal(evidence.some((item) => item.normalized.includes("440301199001011234")), false);
|
||||
assert.ok(evidence.every((item) => !item.maskedDisplay.includes("87654321")));
|
||||
});
|
||||
|
||||
test("invoice store persists only HMAC fingerprints and isolates users across restart", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "ding-invoice-dedup-"));
|
||||
const path = join(root, "dedup.sqlite");
|
||||
const evidence = extractInvoiceEvidence({ invoiceCode: "044031900111", invoiceNumber: "87654321" });
|
||||
const first = new InvoiceDedupStore(path, secret);
|
||||
const userOne = first.userHash("private-user-one");
|
||||
first.indexDetail(userOne, "xft", "business-1", "pending", "2026-08-01T01:00:00.000Z", evidence);
|
||||
assert.ok(first.findMatches(userOne, evidence).length >= 1);
|
||||
assert.equal(first.findMatches(first.userHash("private-user-two"), evidence).length, 0);
|
||||
|
||||
const restarted = new InvoiceDedupStore(path, secret);
|
||||
assert.ok(restarted.findMatches(restarted.userHash("private-user-one"), evidence).length >= 1);
|
||||
const databaseBytes = readFileSync(path).toString("latin1");
|
||||
assert.doesNotMatch(databaseBytes, /044031900111|87654321|private-user-one/);
|
||||
});
|
||||
|
||||
test("invoice fingerprints expire automatically after the bounded retention window", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "ding-invoice-retention-"));
|
||||
const store = new InvoiceDedupStore(join(root, "dedup.sqlite"), secret);
|
||||
const evidence = extractInvoiceEvidence({ invoiceCode: "044031900111", invoiceNumber: "87654321" });
|
||||
const userHash = store.userHash("user-1");
|
||||
const now = Date.now();
|
||||
store.indexDetail(userHash, "xft", "expired-business", "approved", new Date(now - 36 * 86_400_000).toISOString(), evidence);
|
||||
store.indexDetail(userHash, "xft", "retained-business", "approved", new Date(now - 34 * 86_400_000).toISOString(), evidence);
|
||||
assert.equal(store.findMatches(userHash, evidence).length, 4);
|
||||
store.cleanupExpired(now);
|
||||
const retained = store.findMatches(userHash, evidence);
|
||||
assert.equal(retained.length, 2);
|
||||
assert.ok(retained.every((item) => item.business_no === "retained-business"));
|
||||
|
||||
const expiredReservation = "expired-reservation";
|
||||
assert.equal(store.reserve(userHash, evidence, expiredReservation, now - 1), true);
|
||||
store.cleanupExpired(now);
|
||||
assert.equal(store.reserve(userHash, evidence, "replacement-reservation", now + 60_000), true);
|
||||
});
|
||||
|
||||
test("dedup preparation blocks historical duplicates and concurrent confirmations", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "ding-invoice-prepare-"));
|
||||
const store = new InvoiceDedupStore(join(root, "dedup.sqlite"), secret);
|
||||
const detailCalls: string[] = [];
|
||||
let observedRange: { startTime: number; endTime: number } | undefined;
|
||||
const ding = {
|
||||
async listMyHistory(_userId: string, _scope: string, _limit: number, range: { startTime: number; endTime: number }) {
|
||||
observedRange = range;
|
||||
return { items: [{
|
||||
source: "official_oa", historyScope: "submitted", processInstanceId: "oa-existing",
|
||||
status: "COMPLETED", result: "agree", submittedAt: "2026-08-01T01:00:00.000Z",
|
||||
}], diagnostics: {} };
|
||||
},
|
||||
async getHistoryDetail() {
|
||||
detailCalls.push("dingtalk");
|
||||
return { invoiceInfoList: [{ invoiceCode: "044031900111", invoiceNumber: "87654321" }] };
|
||||
},
|
||||
} as unknown as ApprovalService;
|
||||
const xft = {
|
||||
async listMyHistory() { return { items: [], totalCount: 0, diagnostics: {} }; },
|
||||
} as unknown as XftApprovalProvider;
|
||||
const service = new InvoiceDeduplicationService(config(), store, ding, xft);
|
||||
const user = { userId: "user-1" };
|
||||
const values = { invoiceInfoList: [{ invoiceCode: "044031900111", invoiceNumber: "87654321" }] };
|
||||
const first = await service.prepare(user, values, false, Date.now() + 600_000);
|
||||
assert.equal(first.duplicateCheck.status, "confirmation_required");
|
||||
assert.equal(first.duplicateCheck.matches[0]?.source, "dingtalk");
|
||||
assert.equal(first.duplicateCheck.matches[0]?.disposition, "blocking");
|
||||
assert.equal(first.reservationId, undefined);
|
||||
assert.ok(observedRange);
|
||||
assert.ok(Math.abs((observedRange!.endTime - observedRange!.startTime) - 30 * 86_400_000) < 1_000);
|
||||
|
||||
const confirmed = await service.prepare(user, values, true, Date.now() + 600_000);
|
||||
assert.equal(confirmed.duplicateCheck.status, "proceeding_after_confirmation");
|
||||
assert.ok(confirmed.reservationId);
|
||||
service.assertReservation(user, confirmed.reservationId);
|
||||
|
||||
const concurrent = await service.prepare(user, values, true, Date.now() + 600_000);
|
||||
assert.equal(concurrent.reservationId, undefined);
|
||||
assert.equal(concurrent.duplicateCheck.requiresUserConfirmation, true);
|
||||
assert.match(concurrent.duplicateCheck.message, /另一个尚未过期的提单确认/);
|
||||
assert.equal(detailCalls.length, 1, "already indexed history details are not downloaded again");
|
||||
});
|
||||
|
||||
test("incomplete cross-source coverage is explicit and requires an override", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "ding-invoice-partial-"));
|
||||
const store = new InvoiceDedupStore(join(root, "dedup.sqlite"), secret);
|
||||
const ding = { async listMyHistory() { return { items: [], diagnostics: {} }; } } as unknown as ApprovalService;
|
||||
const xft = { async listMyHistory() { throw new Error("temporary XFT outage"); } } as unknown as XftApprovalProvider;
|
||||
const service = new InvoiceDeduplicationService(config(), store, ding, xft);
|
||||
const values = { invoiceInfoList: [{ invoiceCode: "044031900111", invoiceNumber: "87654321" }] };
|
||||
|
||||
const blocked = await service.prepare({ userId: "user-1" }, values, false, Date.now() + 600_000);
|
||||
assert.equal(blocked.duplicateCheck.status, "check_unavailable");
|
||||
assert.equal(blocked.duplicateCheck.coverageComplete, false);
|
||||
assert.equal(blocked.reservationId, undefined);
|
||||
const confirmed = await service.prepare({ userId: "user-1" }, values, true, Date.now() + 600_000);
|
||||
assert.equal(confirmed.duplicateCheck.status, "proceeding_after_confirmation");
|
||||
assert.ok(confirmed.reservationId);
|
||||
});
|
||||
|
||||
test("a reimbursement with no usable invoice evidence never reports a clean check", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "ding-invoice-missing-"));
|
||||
const store = new InvoiceDedupStore(join(root, "dedup.sqlite"), secret);
|
||||
const service = new InvoiceDeduplicationService(config(), store, {} as ApprovalService);
|
||||
const blocked = await service.prepare(
|
||||
{ userId: "user-1" }, { reimAmount: 100 }, false, Date.now() + 600_000, true,
|
||||
);
|
||||
assert.equal(blocked.duplicateCheck.status, "check_unavailable");
|
||||
assert.equal(blocked.duplicateCheck.requiresUserConfirmation, true);
|
||||
const confirmed = await service.prepare(
|
||||
{ userId: "user-1" }, { reimAmount: 100 }, true, Date.now() + 600_000, true,
|
||||
);
|
||||
assert.equal(confirmed.duplicateCheck.status, "proceeding_after_confirmation");
|
||||
});
|
||||
|
||||
test("rejected prior bills warn instead of masquerading as active approvals", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "ding-invoice-rejected-"));
|
||||
const store = new InvoiceDedupStore(join(root, "dedup.sqlite"), secret);
|
||||
const evidence = extractInvoiceEvidence({ invoiceCode: "044031900111", invoiceNumber: "87654321" });
|
||||
store.indexDetail(store.userHash("user-1"), "xft", "xft-rejected", "rejected", "2026-08-01T00:00:00.000Z", evidence);
|
||||
const ding = { async listMyHistory() { return { items: [], diagnostics: {} }; } } as unknown as ApprovalService;
|
||||
const xft = { async listMyHistory() { return { items: [], totalCount: 0, diagnostics: {} }; } } as unknown as XftApprovalProvider;
|
||||
const service = new InvoiceDeduplicationService(config(), store, ding, xft);
|
||||
const result = await service.prepare(
|
||||
{ userId: "user-1" }, { invoiceCode: "044031900111", invoiceNumber: "87654321" }, false, Date.now() + 600_000,
|
||||
);
|
||||
assert.equal(result.duplicateCheck.status, "confirmation_required");
|
||||
assert.equal(result.duplicateCheck.matches[0]?.status, "rejected");
|
||||
assert.equal(result.duplicateCheck.matches[0]?.disposition, "warning");
|
||||
});
|
||||
|
||||
test("truncated history and detail failures make duplicate coverage incomplete", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "ding-invoice-truncated-"));
|
||||
const store = new InvoiceDedupStore(join(root, "dedup.sqlite"), secret);
|
||||
const ding = {
|
||||
async listMyHistory() {
|
||||
return { items: [{
|
||||
source: "official_oa", historyScope: "submitted", processInstanceId: "detail-fails", status: "RUNNING",
|
||||
}], diagnostics: {} };
|
||||
},
|
||||
async getHistoryDetail() { throw new Error("detail unavailable"); },
|
||||
} as unknown as ApprovalService;
|
||||
const xft = {
|
||||
async listMyHistory() { return { items: [], totalCount: 0, diagnostics: { truncated: true } }; },
|
||||
} as unknown as XftApprovalProvider;
|
||||
const service = new InvoiceDeduplicationService(config(), store, ding, xft);
|
||||
const result = await service.prepare(
|
||||
{ userId: "user-1" }, { invoiceCode: "044031900111", invoiceNumber: "87654321" }, false, Date.now() + 600_000,
|
||||
);
|
||||
assert.equal(result.duplicateCheck.status, "check_unavailable");
|
||||
assert.equal(result.duplicateCheck.sourceStatus.dingtalk?.status, "partial");
|
||||
assert.equal(result.duplicateCheck.sourceStatus.xft?.status, "partial");
|
||||
});
|
||||
|
||||
test("unified submission requires a duplicate override and commits the successful business number", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "ding-invoice-unified-"));
|
||||
const store = new InvoiceDedupStore(join(root, "dedup.sqlite"), secret);
|
||||
const ding = {
|
||||
async listMyHistory() {
|
||||
return { items: [{
|
||||
source: "official_oa", historyScope: "submitted", processInstanceId: "oa-prior",
|
||||
status: "RUNNING", submittedAt: "2026-08-01T01:00:00.000Z",
|
||||
}], diagnostics: {} };
|
||||
},
|
||||
async getHistoryDetail() {
|
||||
return { invoiceInfoList: [{ invoiceCode: "044031900111", invoiceNumber: "87654321" }] };
|
||||
},
|
||||
} as unknown as ApprovalService;
|
||||
const template = {
|
||||
eventSeqId: "event-1", eventNumber: "XFT-1", eventName: "费用申请",
|
||||
eventType: "EXPENSE_APPLY", kind: "expense_apply" as const,
|
||||
};
|
||||
let writes = 0;
|
||||
const xft = {
|
||||
async listMyHistory() { return { items: [], totalCount: 0, diagnostics: {} }; },
|
||||
async listSubmissionTemplates() { return [template]; },
|
||||
async getSubmissionSchema() { return { template, fields: [], inputContract: {}, diagnostics: {} }; },
|
||||
async validateSubmissionIdentity() { return { validated: true, diagnostics: {} }; },
|
||||
async submitApproval() { writes += 1; return { billId: "2026081500000001", status: "submitted", diagnostics: {} }; },
|
||||
async getMySubmissionStatus() {
|
||||
return { billId: "2026081500000001", status: "1", statusName: "待审批", revoked: false, revokeAllowed: true, diagnostics: {} };
|
||||
},
|
||||
} as unknown as XftApprovalProvider;
|
||||
const dedup = new InvoiceDeduplicationService(config(), store, ding, xft);
|
||||
const unified = new UnifiedApprovalService(config(), ding, xft, undefined, undefined, dedup);
|
||||
const user = { userId: "user-1" };
|
||||
const templateId = String((await unified.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
|
||||
const values = {
|
||||
applyExpenses: [{ expenseNumber: "MEAL", amount: 88.5 }],
|
||||
invoiceInfoList: [{ invoiceCode: "044031900111", invoiceNumber: "87654321" }],
|
||||
};
|
||||
const blocked = await unified.prepareSubmission(user, { templateId, values });
|
||||
assert.equal(blocked.status, "duplicate_confirmation_required");
|
||||
assert.equal(blocked.confirmationId, undefined);
|
||||
assert.equal(writes, 0);
|
||||
|
||||
const prepared = await unified.prepareSubmission(user, { templateId, values, allowDuplicateInvoices: true });
|
||||
assert.equal(prepared.status, "confirmation_required");
|
||||
assert.ok(prepared.confirmationId);
|
||||
const submitted = await unified.submitApproval(
|
||||
user, prepared.confirmationId!, { templateId, values, allowDuplicateInvoices: true },
|
||||
);
|
||||
assert.equal(submitted.status, "submitted");
|
||||
assert.equal(writes, 1);
|
||||
const matches = store.findMatches(store.userHash(user.userId), extractInvoiceEvidence(values));
|
||||
assert.ok(matches.some((item) => item.business_no === "2026081500000001"));
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import type { AppConfig } from "../src/config.js";
|
||||
import { DingTalkMcpOAuthProvider } from "../src/oauth.js";
|
||||
|
||||
function config(databasePath: string): AppConfig {
|
||||
return {
|
||||
clientId: "ding-app-key", clientSecret: "ding-app-secret", corpId: "ding-corp",
|
||||
apiBaseUrl: "https://api.example.test", oapiBaseUrl: "https://oapi.example.test",
|
||||
publicBaseUrl: "https://approval.example.test", bindHost: "127.0.0.1", port: 3000,
|
||||
requestTimeoutMs: 1000, officialLookbackDays: 120, officialScanConcurrency: 2,
|
||||
officialClientOnlyProcessCodes: [], batchLimit: 20, approvalRefSecret: "ref-secret",
|
||||
remoteAuth: {
|
||||
issuerUrl: "https://approval.example.test", databasePath, sessionSecret: "session-secret",
|
||||
accessTokenTtlSeconds: 900, refreshTokenTtlSeconds: 3600,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(value: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
test("OAuth provider performs DCR, DingTalk login, PKCE exchange and refresh rotation", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "ding-oauth-"));
|
||||
const database = join(root, "auth.sqlite");
|
||||
const requests: Array<{ url: string; body?: Record<string, unknown> }> = [];
|
||||
const fetchFn: typeof fetch = async (input, init) => {
|
||||
const url = String(input);
|
||||
const body = typeof init?.body === "string" ? JSON.parse(init.body) as Record<string, unknown> : undefined;
|
||||
requests.push({ url, body });
|
||||
if (url.endsWith("/v1.0/oauth2/userAccessToken") && body?.grantType === "authorization_code") {
|
||||
return jsonResponse({ accessToken: "ding-user-access", refreshToken: "ding-user-refresh", expireIn: 7200, corpId: "ding-corp" });
|
||||
}
|
||||
if (url.endsWith("/v1.0/contact/users/me")) {
|
||||
return jsonResponse({ userId: "user-1", nick: "测试用户", corpId: "ding-corp" });
|
||||
}
|
||||
throw new Error(`unexpected request ${url}`);
|
||||
};
|
||||
const provider = new DingTalkMcpOAuthProvider(config(database), fetchFn);
|
||||
try {
|
||||
const client = await provider.clientsStore.registerClient!({
|
||||
redirect_uris: ["http://127.0.0.1:49152/callback"], token_endpoint_auth_method: "none",
|
||||
grant_types: ["authorization_code", "refresh_token"], response_types: ["code"],
|
||||
client_name: "test-agent",
|
||||
});
|
||||
let upstream = "";
|
||||
await provider.authorize(client, {
|
||||
redirectUri: "http://127.0.0.1:55001/callback", codeChallenge: "pkce-challenge",
|
||||
scopes: ["mcp:tools"], state: "agent-state", resource: new URL("https://approval.example.test/mcp"),
|
||||
}, { redirect: (_status: number, value: string) => { upstream = value; } } as never);
|
||||
const loginState = new URL(upstream).searchParams.get("login_state");
|
||||
assert.ok(loginState);
|
||||
assert.deepEqual(provider.consentDetails(loginState!), {
|
||||
clientName: "test-agent",
|
||||
scopes: ["mcp:tools"],
|
||||
redirectOrigin: "http://127.0.0.1:55001",
|
||||
});
|
||||
upstream = provider.approveConsent(loginState!);
|
||||
assert.equal(new URL(upstream).searchParams.get("state"), loginState);
|
||||
const callback = await provider.completeDingTalkLogin("ding-code", loginState!);
|
||||
const returned = new URL(callback);
|
||||
const authorizationCode = returned.searchParams.get("code");
|
||||
assert.ok(authorizationCode);
|
||||
assert.equal(returned.searchParams.get("state"), "agent-state");
|
||||
assert.equal(await provider.challengeForAuthorizationCode(client, authorizationCode!), "pkce-challenge");
|
||||
|
||||
const tokens = await provider.exchangeAuthorizationCode(
|
||||
client, authorizationCode!, "verifier", returned.origin + returned.pathname,
|
||||
new URL("https://approval.example.test/mcp"),
|
||||
);
|
||||
const auth = await provider.verifyAccessToken(tokens.access_token);
|
||||
assert.equal(auth.extra?.userId, "user-1");
|
||||
assert.equal(await provider.getDingTalkUserAccessToken("user-1"), "ding-user-access");
|
||||
await assert.rejects(provider.exchangeAuthorizationCode(client, authorizationCode!), /无效/);
|
||||
|
||||
const rotated = await provider.exchangeRefreshToken(client, tokens.refresh_token!);
|
||||
await assert.rejects(provider.exchangeRefreshToken(client, tokens.refresh_token!), /无效/);
|
||||
assert.equal((await provider.verifyAccessToken(rotated.access_token)).extra?.userId, "user-1");
|
||||
await provider.revokeToken(client, { token: rotated.access_token });
|
||||
await assert.rejects(provider.verifyAccessToken(rotated.access_token), /无效/);
|
||||
|
||||
const file = readFileSync(database);
|
||||
assert.equal(file.includes(Buffer.from(tokens.access_token)), false);
|
||||
assert.equal(file.includes(Buffer.from(tokens.refresh_token!)), false);
|
||||
assert.equal(requests[0]!.body?.clientSecret, "ding-app-secret");
|
||||
} finally { provider.close(); }
|
||||
});
|
||||
|
||||
test("OAuth provider requires explicit client consent before DingTalk callback completion", async () => {
|
||||
const database = join(mkdtempSync(join(tmpdir(), "ding-oauth-consent-")), "auth.sqlite");
|
||||
let upstreamCalls = 0;
|
||||
const provider = new DingTalkMcpOAuthProvider(config(database), async () => {
|
||||
upstreamCalls += 1;
|
||||
throw new Error("consent bypass must not reach DingTalk");
|
||||
});
|
||||
try {
|
||||
const client = await provider.clientsStore.registerClient!({
|
||||
redirect_uris: ["https://agent.example/callback"], token_endpoint_auth_method: "none",
|
||||
grant_types: ["authorization_code"], response_types: ["code"], client_name: "untrusted-test-client",
|
||||
});
|
||||
let consentUrl = "";
|
||||
await provider.authorize(client, {
|
||||
redirectUri: "https://agent.example/callback", codeChallenge: "challenge", scopes: ["mcp:tools"],
|
||||
}, { redirect: (_status: number, value: string) => { consentUrl = value; } } as never);
|
||||
const loginState = new URL(consentUrl).searchParams.get("login_state");
|
||||
assert.ok(loginState);
|
||||
await assert.rejects(provider.completeDingTalkLogin("code", loginState!), /尚未确认/);
|
||||
assert.equal(upstreamCalls, 0);
|
||||
} finally { provider.close(); }
|
||||
});
|
||||
|
||||
test("OAuth provider refreshes a DingTalk identity once for concurrent callers", async () => {
|
||||
const database = join(mkdtempSync(join(tmpdir(), "ding-oauth-refresh-")), "auth.sqlite");
|
||||
let refreshCalls = 0;
|
||||
const provider = new DingTalkMcpOAuthProvider(config(database), async (input, init) => {
|
||||
const url = String(input);
|
||||
const body = typeof init?.body === "string" ? JSON.parse(init.body) as Record<string, unknown> : {};
|
||||
if (url.endsWith("/v1.0/oauth2/userAccessToken") && body.grantType === "authorization_code") {
|
||||
return jsonResponse({ accessToken: "initial-access", refreshToken: "refresh-1", expireIn: 1, corpId: "ding-corp" });
|
||||
}
|
||||
if (url.endsWith("/v1.0/oauth2/userAccessToken") && body.grantType === "refresh_token") {
|
||||
refreshCalls += 1;
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 20));
|
||||
return jsonResponse({ accessToken: "fresh-access", refreshToken: "refresh-2", expireIn: 7200, corpId: "ding-corp" });
|
||||
}
|
||||
if (url.endsWith("/v1.0/contact/users/me")) return jsonResponse({ userId: "user-1", nick: "测试用户", corpId: "ding-corp" });
|
||||
throw new Error(`unexpected request ${url}`);
|
||||
});
|
||||
try {
|
||||
const client = await provider.clientsStore.registerClient!({
|
||||
redirect_uris: ["https://agent.example/callback"], token_endpoint_auth_method: "none",
|
||||
grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], client_name: "agent",
|
||||
});
|
||||
let consentUrl = "";
|
||||
await provider.authorize(client, {
|
||||
redirectUri: "https://agent.example/callback", codeChallenge: "challenge", scopes: ["mcp:tools"],
|
||||
}, { redirect: (_status: number, value: string) => { consentUrl = value; } } as never);
|
||||
const loginState = new URL(consentUrl).searchParams.get("login_state")!;
|
||||
provider.approveConsent(loginState);
|
||||
await provider.completeDingTalkLogin("code", loginState);
|
||||
const tokens = await Promise.all([
|
||||
provider.getDingTalkUserAccessToken("user-1"),
|
||||
provider.getDingTalkUserAccessToken("user-1"),
|
||||
provider.getDingTalkUserAccessToken("user-1"),
|
||||
]);
|
||||
assert.deepEqual(tokens, ["fresh-access", "fresh-access", "fresh-access"]);
|
||||
assert.equal(refreshCalls, 1);
|
||||
} finally { provider.close(); }
|
||||
});
|
||||
|
||||
test("OAuth provider rejects unsafe redirects and users from another corp", async () => {
|
||||
const database = join(mkdtempSync(join(tmpdir(), "ding-oauth-corp-")), "auth.sqlite");
|
||||
const fetchFn: typeof fetch = async (input) => String(input).endsWith("/userAccessToken")
|
||||
? jsonResponse({ accessToken: "foreign-token", refreshToken: "refresh", expireIn: 7200, corpId: "other-corp" })
|
||||
: jsonResponse({});
|
||||
const provider = new DingTalkMcpOAuthProvider(config(database), fetchFn);
|
||||
try {
|
||||
await assert.rejects(async () => provider.clientsStore.registerClient!({
|
||||
redirect_uris: ["http://attacker.example/callback"],
|
||||
token_endpoint_auth_method: "none", grant_types: ["authorization_code"], response_types: ["code"],
|
||||
}), /HTTPS/);
|
||||
const client = await provider.clientsStore.registerClient!({
|
||||
redirect_uris: ["https://agent.example/callback"],
|
||||
token_endpoint_auth_method: "none", grant_types: ["authorization_code"], response_types: ["code"],
|
||||
});
|
||||
let upstream = "";
|
||||
await provider.authorize(client, {
|
||||
redirectUri: "https://agent.example/callback", codeChallenge: "challenge", scopes: ["mcp:tools"],
|
||||
}, { redirect: (_status: number, value: string) => { upstream = value; } } as never);
|
||||
const loginState = new URL(upstream).searchParams.get("login_state");
|
||||
assert.ok(loginState);
|
||||
provider.approveConsent(loginState!);
|
||||
await assert.rejects(
|
||||
provider.completeDingTalkLogin("code", loginState!),
|
||||
/不属于允许的企业/,
|
||||
);
|
||||
} finally { provider.close(); }
|
||||
});
|
||||
|
||||
test("device compatibility flow returns a link, polls, and issues a user-bound token", async () => {
|
||||
const database = join(mkdtempSync(join(tmpdir(), "ding-oauth-device-")), "auth.sqlite");
|
||||
const fetchFn: typeof fetch = async (input) => String(input).endsWith("/userAccessToken")
|
||||
? jsonResponse({ accessToken: "device-user-token", refreshToken: "device-refresh", expireIn: 7200, corpId: "ding-corp" })
|
||||
: jsonResponse({ userId: "device-user", nick: "设备用户", corpId: "ding-corp" });
|
||||
const provider = new DingTalkMcpOAuthProvider(config(database), fetchFn);
|
||||
try {
|
||||
const client = await provider.clientsStore.registerClient!({
|
||||
redirect_uris: ["https://localhost.invalid/device"], token_endpoint_auth_method: "none",
|
||||
grant_types: ["authorization_code", "refresh_token"], response_types: ["code"],
|
||||
});
|
||||
const challenge = provider.startDeviceAuthorization(client.client_id, ["mcp:tools"], "https://approval.example.test/mcp");
|
||||
assert.match(challenge.user_code, /^[A-Z2-9]{4}-[A-Z2-9]{4}$/);
|
||||
assert.equal(provider.exchangeDeviceCode(client.client_id, challenge.device_code), "authorization_pending");
|
||||
const upstream = provider.beginDeviceLogin(challenge.user_code.toLowerCase());
|
||||
const state = new URL(upstream).searchParams.get("login_state");
|
||||
assert.ok(state);
|
||||
provider.approveConsent(state!);
|
||||
assert.equal(await provider.completeDingTalkLogin("device-code", state!), "https://approval.example.test/oauth/device/success");
|
||||
const tokens = provider.exchangeDeviceCode(client.client_id, challenge.device_code);
|
||||
assert.notEqual(typeof tokens, "string");
|
||||
if (typeof tokens !== "string") {
|
||||
assert.equal((await provider.verifyAccessToken(tokens.access_token)).extra?.userId, "device-user");
|
||||
}
|
||||
assert.throws(() => provider.exchangeDeviceCode(client.client_id, challenge.device_code), /无效/);
|
||||
} finally { provider.close(); }
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
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";
|
||||
|
||||
function config(): AppConfig {
|
||||
return {
|
||||
clientId: "client",
|
||||
clientSecret: "secret",
|
||||
apiBaseUrl: "https://api.test",
|
||||
oapiBaseUrl: "https://oapi.test",
|
||||
publicBaseUrl: "http://localhost:3000",
|
||||
trustedLocalUser: { userId: "user-1" },
|
||||
port: 3000,
|
||||
requestTimeoutMs: 1_000,
|
||||
officialLookbackDays: 120,
|
||||
officialScanConcurrency: 2,
|
||||
officialClientOnlyProcessCodes: [],
|
||||
batchLimit: 20,
|
||||
approvalRefSecret: "fixed-reference-secret-for-tests",
|
||||
};
|
||||
}
|
||||
|
||||
test("MCP initialization teaches the workflow while tools/list stays compact", async () => {
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
const server = createApprovalMcpServer(config(), {} as ApprovalService);
|
||||
const client = new Client({ name: "instructions-test", version: "1.0.0" });
|
||||
await server.connect(serverTransport);
|
||||
try {
|
||||
await client.connect(clientTransport);
|
||||
assert.equal(client.getInstructions(), APPROVAL_MCP_INSTRUCTIONS);
|
||||
assert.match(client.getInstructions() ?? "", /approval_list_todos.*approval_get_detail/s);
|
||||
assert.match(client.getInstructions() ?? "", /approval_list_history.*只读/s);
|
||||
assert.match(client.getInstructions() ?? "", /approval_process_batch/);
|
||||
assert.match(client.getInstructions() ?? "", /completed.*followUpRequired/);
|
||||
assert.match(client.getInstructions() ?? "", /不会通过其他消息通道.*directUrl.*当前面向用户的回复/s);
|
||||
assert.doesNotMatch(client.getInstructions() ?? "", /通过当前 Messenger/);
|
||||
assert.match(client.getInstructions() ?? "", /必填字段缺失.*继续询问/s);
|
||||
assert.match(client.getInstructions() ?? "", /非必填字段.*是否补充/s);
|
||||
assert.match(client.getInstructions() ?? "", /出差申请.*外出.*业务招待申请.*差旅费用报销.*业务招待费用报销/s);
|
||||
assert.match(client.getInstructions() ?? "", /revokeId.*approval_revoke_submission/);
|
||||
assert.match(client.getInstructions() ?? "", /最多(?:处理 )?20 条.*零写入.*禁止自动.*重试/s);
|
||||
assert.match(client.getInstructions() ?? "", /近30天.*allowDuplicateInvoices=true/s);
|
||||
|
||||
const tools = await client.listTools();
|
||||
assert.deepEqual(tools.tools.map((tool) => tool.name), [
|
||||
"approval_list_todos",
|
||||
"approval_list_history",
|
||||
"approval_get_detail",
|
||||
"approval_get_actions",
|
||||
"approval_process_batch",
|
||||
"approval_list_submission_templates",
|
||||
"approval_get_submission_schema",
|
||||
"approval_get_submission_options",
|
||||
"approval_prepare_submission",
|
||||
"approval_submit",
|
||||
"approval_revoke_submission",
|
||||
"approval_get_submission_status",
|
||||
]);
|
||||
const descriptions = Object.fromEntries(tools.tools.map((tool) => [tool.name, tool.description ?? ""]));
|
||||
assert.match(descriptions.approval_list_todos ?? "", /入口.*approvalId/);
|
||||
assert.match(descriptions.approval_list_history ?? "", /handled.*处理过.*submitted.*本人发起.*只读/s);
|
||||
assert.match(descriptions.approval_get_detail ?? "", /expenseItems.*raw/);
|
||||
assert.match(descriptions.approval_process_batch ?? "", /outcome_uncertain|禁止自动重试/);
|
||||
assert.match(descriptions.approval_get_actions ?? "", /targetId.*approval_process_batch/);
|
||||
const historySchema = tools.tools.find((tool) => tool.name === "approval_list_history")?.inputSchema as any;
|
||||
assert.equal(historySchema.properties.startDate.default, "");
|
||||
assert.equal(historySchema.properties.endDate.default, "");
|
||||
assert.match(historySchema.properties.startDate.description, /最近30天/);
|
||||
assert.match(descriptions.approval_process_batch ?? "", /用户明确.*不二次确认/);
|
||||
assert.match(descriptions.approval_process_batch ?? "", /succeeded.*completed.*followUpRequired/);
|
||||
assert.match(descriptions.approval_process_batch ?? "", /最多 20 条.*任一无效.*零写入/s);
|
||||
assert.match(descriptions.approval_prepare_submission ?? "", /明确确认|确认/);
|
||||
assert.match(descriptions.approval_get_submission_schema ?? "", /必填字段齐全.*可选字段/);
|
||||
assert.match(descriptions.approval_get_submission_options ?? "", /承担部门.*城市编码/);
|
||||
assert.match(descriptions.approval_prepare_submission ?? "", /继续询问/);
|
||||
assert.match(descriptions.approval_prepare_submission ?? "", /查重.*allowDuplicateInvoices/s);
|
||||
const prepareSchema = tools.tools.find((tool) => tool.name === "approval_prepare_submission")?.inputSchema as any;
|
||||
assert.equal(prepareSchema.properties.allowDuplicateInvoices.default, false);
|
||||
assert.match(descriptions.approval_list_submission_templates ?? "", /出差申请.*外出.*差旅费用报销.*业务招待费用报销/);
|
||||
assert.match(descriptions.approval_submit ?? "", /禁止自动重试/);
|
||||
assert.match(descriptions.approval_revoke_submission ?? "", /禁止自动重试.*薪福通.*实验能力/);
|
||||
assert.match(descriptions.approval_get_submission_status ?? "", /本人.*归属/);
|
||||
const batchTool = tools.tools.find((tool) => tool.name === "approval_process_batch")!;
|
||||
const itemSchema = (batchTool.inputSchema as any).properties.items;
|
||||
assert.equal(itemSchema.maxItems, 20);
|
||||
const tooMany = await client.callTool({
|
||||
name: "approval_process_batch",
|
||||
arguments: {
|
||||
action: "approve",
|
||||
remark: "",
|
||||
items: Array.from({ length: 21 }, () => ({ approvalId: "v4.this-reference-is-long-enough" })),
|
||||
},
|
||||
});
|
||||
assert.equal(tooMany.isError, true);
|
||||
assert.match(JSON.stringify(tooMany.content), /一次最多处理 20 条审批/);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("local-only server recognizes loopback addresses", () => {
|
||||
assert.equal(isLoopbackAddress("127.0.0.1"), true);
|
||||
assert.equal(isLoopbackAddress("::1"), true);
|
||||
assert.equal(isLoopbackAddress("::ffff:127.0.0.1"), true);
|
||||
assert.equal(isLoopbackAddress("192.168.1.13"), false);
|
||||
assert.equal(isLoopbackAddress(undefined), false);
|
||||
});
|
||||
|
||||
test("HTTP MCP accepts the configured public host without dropping loopback health checks", () => {
|
||||
assert.deepEqual(mcpAllowedHosts("https://approval.justconn.top"), [
|
||||
"approval.justconn.top", "127.0.0.1", "localhost", "[::1]",
|
||||
]);
|
||||
});
|
||||
|
||||
test("OAuth consent CSP permits only the DingTalk login redirect after same-origin submit", () => {
|
||||
const policy = oauthHtmlContentSecurityPolicy();
|
||||
assert.match(policy, /form-action 'self' https:\/\/login\.dingtalk\.com(?:;|\s)/);
|
||||
assert.doesNotMatch(policy, /form-action[^;]*\*/);
|
||||
});
|
||||
|
||||
test("remote gateway lists DingTalk OA and XFT through one authenticated MCP session", async () => {
|
||||
const dingTalk = {
|
||||
async listMyTodos() {
|
||||
return { items: [{ source: "official_oa", processInstanceId: "oa-1", taskId: "task-1", title: "采购审批", raw: {} }] };
|
||||
},
|
||||
} as unknown as ApprovalService;
|
||||
const xft = {
|
||||
async listMyTodos() {
|
||||
return { items: [{ source: "xft", billId: "xft-1", title: "费用报销" }], totalCount: 1, identity: {}, diagnostics: {} };
|
||||
},
|
||||
} as unknown as XftApprovalProvider;
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
const server = createApprovalMcpServer(
|
||||
{ ...config(), appMode: "gateway", trustedLocalUser: undefined },
|
||||
dingTalk, xft, undefined, undefined, { userId: "user-1" },
|
||||
);
|
||||
const client = new Client({ name: "unified-gateway-test", version: "1.0.0" });
|
||||
await server.connect(serverTransport);
|
||||
try {
|
||||
await client.connect(clientTransport);
|
||||
const response = await client.callTool({ name: "approval_list_todos", arguments: { source: "all", query: "", limit: 20 } });
|
||||
const result = (response.structuredContent as { result: { items: Array<{ source: string; approvalId: string }>; selectionId: string } }).result;
|
||||
assert.deepEqual(result.items.map((item) => item.source).sort(), ["dingtalk", "xft"]);
|
||||
const blocks = response.content as Array<{ type: string; text?: string }>;
|
||||
const text = blocks.find((item) => item.type === "text")?.text ?? "";
|
||||
assert.match(text, /采购审批/);
|
||||
assert.match(text, /费用报销/);
|
||||
assert.ok(result.items.every((item) => text.includes(item.approvalId)));
|
||||
assert.ok(text.includes(result.selectionId));
|
||||
assert.doesNotMatch(text, /diagnostics|enterpriseUserId|processInstanceId|taskId|userId/);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("batch summaries distinguish total failure, partial success, and full success", () => {
|
||||
assert.match(approvalBatchSummary({ total: 2, succeeded: 0 }), /未有任何.*成功/);
|
||||
assert.match(approvalBatchSummary({ total: 2, succeeded: 1 }), /部分成功.*1\/2/);
|
||||
assert.match(approvalBatchSummary({ total: 2, succeeded: 2 }), /全部提交成功.*2\/2/);
|
||||
assert.match(approvalBatchSummary({ total: 2, succeeded: 0, uncertain: 1 }), /禁止自动重试/);
|
||||
assert.match(approvalBatchSummary({ status: "preflight_failed", writesStarted: false, invalidItems: [{}] }), /未开始.*0 张已写入.*禁止自动重试/);
|
||||
});
|
||||
|
||||
test("portable tool output exposes compact domain data without upstream internals", () => {
|
||||
const response = compactResult("已完成。", {
|
||||
action: "approve",
|
||||
total: 1,
|
||||
results: [{
|
||||
approvalId: "v3.portable.reference.value",
|
||||
processInstanceId: "internal-instance",
|
||||
taskId: "internal-task",
|
||||
userId: "internal-user",
|
||||
status: "processed",
|
||||
success: true,
|
||||
response: { traceId: "upstream-trace" },
|
||||
}],
|
||||
diagnostics: { realtimeValidation: true },
|
||||
});
|
||||
const text = (response.content[0] as { type: "text"; text: string }).text;
|
||||
assert.match(text, /v3\.portable\.reference\.value/);
|
||||
assert.match(text, /"status":"processed"/);
|
||||
assert.doesNotMatch(text, /internal-instance|internal-task|internal-user|upstream-trace|diagnostics/);
|
||||
assert.deepEqual(response.structuredContent.result, JSON.parse(text.split("\n")[1]!));
|
||||
});
|
||||
|
||||
test("QR auth challenges instruct the agent to reply directly instead of claiming a separate push", () => {
|
||||
const response = compactResult("需要重新登录。", {
|
||||
sourceStatus: {
|
||||
xft: {
|
||||
status: "auth_required",
|
||||
authChallenge: {
|
||||
provider: "xft", type: "qr", challengeId: "qr-1",
|
||||
directUrl: "https://approval.example.test/xft/login",
|
||||
},
|
||||
},
|
||||
},
|
||||
}, {
|
||||
async getOrStart() {
|
||||
return {
|
||||
provider: "xft" as const, type: "qr" as const, challengeId: "qr-1",
|
||||
qrImageUrl: "https://approval.example.test/xft/qr",
|
||||
directUrl: "https://approval.example.test/xft/login",
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(), pollAfterSeconds: 3,
|
||||
};
|
||||
},
|
||||
getImage: () => Buffer.from("png"),
|
||||
});
|
||||
const text = (response.content[0] as { type: "text"; text: string }).text;
|
||||
assert.match(text, /不会另行推送登录内容/);
|
||||
assert.match(text, /原样写入当前回复/);
|
||||
assert.doesNotMatch(text, /通过当前 Messenger/);
|
||||
assert.equal(response.content[1]?.type, "image");
|
||||
});
|
||||
|
||||
test("portable presentation preserves submission fields and opaque write references", () => {
|
||||
const response = compactResult("审批尚未提交。", {
|
||||
templateId: "v3.template.reference.value",
|
||||
fields: [{ fieldNumber: "trip_reason", name: "出差事由", required: true, valueFormat: "文本字符串" }],
|
||||
inputContract: { values: { required: ["businessTrip"] } },
|
||||
confirmationId: "v3.confirmation.reference.value",
|
||||
preview: {
|
||||
template: { name: "出差申请", processCode: "internal-process-code" },
|
||||
formSummary: { businessTrip: { reason: "客户拜访" } },
|
||||
identityValidation: { validated: true, employeeNumber: "internal-employee" },
|
||||
},
|
||||
revokeId: "v3.revoke.reference.value",
|
||||
billId: "2026081300000001",
|
||||
diagnostics: { persistedBusinessData: false },
|
||||
});
|
||||
const visible = response.structuredContent.result as Record<string, any>;
|
||||
assert.equal(visible.templateId, "v3.template.reference.value");
|
||||
assert.equal(visible.confirmationId, "v3.confirmation.reference.value");
|
||||
assert.equal(visible.revokeId, "v3.revoke.reference.value");
|
||||
assert.equal(visible.businessId, "2026081300000001");
|
||||
assert.equal(visible.fields[0].fieldNumber, "trip_reason");
|
||||
assert.equal(visible.preview.formSummary.businessTrip.reason, "客户拜访");
|
||||
assert.equal(visible.preview.template.processCode, undefined);
|
||||
assert.equal(visible.preview.identityValidation.employeeNumber, undefined);
|
||||
assert.equal(visible.diagnostics, undefined);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,706 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import smCrypto from "sm-crypto";
|
||||
import type { AppConfig } from "../src/config.js";
|
||||
import { XftOpenApiClient } from "../src/xft-open-api.js";
|
||||
|
||||
const privateKey = "1".padStart(64, "0");
|
||||
const { sm3, sm4 } = smCrypto;
|
||||
|
||||
function config(): AppConfig {
|
||||
return {
|
||||
clientId: "ding-app",
|
||||
clientSecret: "ding-secret",
|
||||
apiBaseUrl: "https://ding.test",
|
||||
oapiBaseUrl: "https://ding-oapi.test",
|
||||
publicBaseUrl: "http://localhost:3000",
|
||||
trustedLocalUser: { userId: "ding-u1" },
|
||||
port: 3000,
|
||||
requestTimeoutMs: 2_000,
|
||||
officialLookbackDays: 120,
|
||||
officialScanConcurrency: 2,
|
||||
officialClientOnlyProcessCodes: [],
|
||||
approvalRefSecret: "fixed-reference-secret-for-tests",
|
||||
batchLimit: 20,
|
||||
xftOpenApi: {
|
||||
appId: "app-id",
|
||||
authoritySecret: privateKey,
|
||||
enterpriseId: "TEST0001",
|
||||
apiBaseUrl: "https://xft-api.test",
|
||||
encrypted: true,
|
||||
dingTalkAppCode: "DDingTalk",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function encrypted(value: unknown): Response {
|
||||
return new Response(String(sm4.encrypt(JSON.stringify(value), privateKey.slice(0, 32))), {
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
}
|
||||
|
||||
function decodedBody(init?: RequestInit): Record<string, unknown> {
|
||||
const envelope = JSON.parse(String(init?.body)) as { secretMsg: string };
|
||||
return JSON.parse(String(sm4.decrypt(envelope.secretMsg, privateKey.slice(0, 32)))) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
test("Xft OpenAPI maps the authenticated DingTalk user and signs encrypted requests", async () => {
|
||||
const calls: Array<{ path: string; body: Record<string, unknown>; headers: Headers; url: URL }> = [];
|
||||
const fakeFetch: typeof fetch = async (input, init) => {
|
||||
const url = new URL(String(input));
|
||||
const body = decodedBody(init);
|
||||
const headers = new Headers(init?.headers);
|
||||
calls.push({ path: url.pathname, body, headers, url });
|
||||
if (url.pathname.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1", userName: "用户一" }] } });
|
||||
if (url.pathname.endsWith("todo-task/page/by-user")) return encrypted({ code: "0", data: { records: [{ todoTaskId: "TD-1", groupId: "TRIP_2099010100000001", appCode: "xft-bpm", businessCode: "OA000001" }], totalSize: 1 } });
|
||||
if (url.pathname.endsWith("todo-task/detail/by-ids")) return encrypted({ code: "0", data: [{ todoTaskId: "TD-1", businessId: "TRIP_2099010100000001", appCode: "TEST0001_TRIP", businessCode: "TEST0001_TRIP_TRIP_2099010100000002" }] });
|
||||
if (url.pathname.endsWith("bill-list")) return encrypted({ code: "0", data: { list: [{ billId: "2099010100000001", eventType: "COMMON_REIM", title: "费用报销", applicantName: "测试用户丁", totalAmount: 4812 }] } });
|
||||
throw new Error(`unexpected ${url.pathname}`);
|
||||
};
|
||||
const client = new XftOpenApiClient(config(), fakeFetch);
|
||||
const result = await client.listMyTodos({ userId: "ding-u1" }, 50);
|
||||
|
||||
assert.equal(result.items[0]?.billId, "2099010100000001");
|
||||
assert.equal(result.items[0]?.amount, 4812);
|
||||
assert.equal(result.identity.enterpriseUserId, "EMP-1");
|
||||
assert.deepEqual(calls[0]?.body.thirdStaffIds, ["ding-u1"]);
|
||||
assert.equal(calls[0]?.body.appCode, "DDingTalk");
|
||||
for (const call of calls) {
|
||||
assert.equal(call.url.searchParams.get("CSCAPPUID"), "app-id");
|
||||
assert.equal(call.url.searchParams.get("OPAUID"), "TEST0001");
|
||||
assert.equal(call.headers.get("x-alb-verify"), "sm3withsm2");
|
||||
assert.equal(call.headers.get("apisign")?.length, 128);
|
||||
assert.equal(call.headers.get("x-alb-digest"), sm3(JSON.stringify({ secretMsg: sm4.encrypt(JSON.stringify(call.body), privateKey.slice(0, 32)) })));
|
||||
}
|
||||
const todoCall = calls.find((call) => call.path.endsWith("todo-task/page/by-user"));
|
||||
assert.equal(todoCall?.url.searchParams.get("CSCUSRNBR"), "EMP-1");
|
||||
assert.equal(todoCall?.url.searchParams.get("CSCPRJCOD"), "TEST0001");
|
||||
});
|
||||
|
||||
test("Xft OpenAPI reads the official billAmount in todo summaries", async () => {
|
||||
const fakeFetch: typeof fetch = async (input, init) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
void decodedBody(init);
|
||||
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
|
||||
if (path.endsWith("todo-task/page/by-user")) return encrypted({ code: "0", data: { records: [{ todoTaskId: "TD-1", groupId: "TRIP_2099010200000001", appCode: "xft-bpm", businessCode: "OA000001" }] } });
|
||||
if (path.endsWith("todo-task/detail/by-ids")) return encrypted({ code: "0", data: [{ todoTaskId: "TD-1", businessId: "TRIP_2099010200000001", appCode: "TEST0001_TRIP" }] });
|
||||
if (path.endsWith("bill-list")) return encrypted({ code: "0", data: { list: [{ billId: "2099010200000001", eventType: "COMMON_REIM", billAmount: 2336 }] } });
|
||||
throw new Error(`unexpected ${path}`);
|
||||
};
|
||||
const client = new XftOpenApiClient(config(), fakeFetch);
|
||||
const result = await client.listMyTodos({ userId: "ding-u1" }, 50);
|
||||
assert.equal(result.items[0]?.amount, 2336);
|
||||
});
|
||||
|
||||
test("Xft OpenAPI lists handled tasks and本人 submitted bills as separate read-only history scopes", async () => {
|
||||
const calls: Array<{ path: string; body: Record<string, unknown> }> = [];
|
||||
const fakeFetch: typeof fetch = async (input, init) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
const body = decodedBody(init);
|
||||
calls.push({ path, body });
|
||||
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("todo-task/page/by-user")) return encrypted({ code: "0", data: { records: [{
|
||||
todoTaskId: "DONE-1", groupId: "TRIP_2026081400000001", appCode: "xft-bpm", businessCode: "OA000001",
|
||||
dealTime: "2026-08-14T04:00:00.000Z",
|
||||
}] } });
|
||||
if (path.endsWith("todo-task/detail/by-ids")) return encrypted({ code: "0", data: [{
|
||||
todoTaskId: "DONE-1", businessId: "TRIP_2026081400000001", appCode: "TEST0001_TRIP",
|
||||
}] });
|
||||
if (path.endsWith("bills/queryBillByCondition")) return encrypted({ code: "0", data: { pageData: {
|
||||
records: [
|
||||
{ billId: "2026081400000002", billSts: "1", eventType: "COMMON_REIM", applyTime: "2026-08-14T05:00:00.000Z" },
|
||||
{ billId: "2026070100000003", billSts: "2", eventType: "COMMON_REIM", applyTime: "2026-07-01T05:00:00.000Z" },
|
||||
], total: 2,
|
||||
} } });
|
||||
if (path.endsWith("bill-list")) {
|
||||
const ids = body.billIds as Array<string | number>;
|
||||
return encrypted({ code: "0", data: { list: ids.map((billId) => ({
|
||||
billId: String(billId), eventType: "COMMON_REIM",
|
||||
applyTime: String(billId).endsWith("3") ? "2026-07-01T05:00:00.000Z" : "2026-08-14T03:00:00.000Z",
|
||||
title: String(billId).endsWith("1") ? "已处理报销" : "本人发起报销",
|
||||
applicantName: String(billId).endsWith("1") ? "同事甲" : "用户一", billAmount: 88,
|
||||
})) } });
|
||||
}
|
||||
throw new Error(`unexpected ${path}`);
|
||||
};
|
||||
const client = new XftOpenApiClient(config(), fakeFetch);
|
||||
const historyRange = { startTime: Date.parse("2026-08-01T00:00:00+08:00"), endTime: Date.parse("2026-08-31T23:59:59+08:00") };
|
||||
|
||||
const handled = await client.listMyHistory({ userId: "ding-u1" }, "handled", 20, "", historyRange);
|
||||
const submitted = await client.listMyHistory({ userId: "ding-u1" }, "submitted", 20, "", historyRange);
|
||||
|
||||
assert.equal(handled.items[0]?.billId, "2026081400000001");
|
||||
assert.equal(handled.items[0]?.historyScope, "handled");
|
||||
assert.equal("allowPass" in handled.items[0]!, false);
|
||||
assert.equal(submitted.items[0]?.billId, "2026081400000002");
|
||||
assert.equal(submitted.items.length, 1, "submitted history excludes bills outside the requested time range");
|
||||
assert.equal(submitted.items[0]?.historyScope, "submitted");
|
||||
assert.equal(calls.find((call) => call.path.endsWith("todo-task/page/by-user"))?.body.dealStatus, "done");
|
||||
assert.deepEqual(calls.find((call) => call.path.endsWith("bills/queryBillByCondition"))?.body, {
|
||||
empId: "E001", start: 0, limit: 100,
|
||||
});
|
||||
});
|
||||
|
||||
test("Xft OpenAPI does not turn missing or blank amounts into zero", async () => {
|
||||
for (const billAmount of [null, ""]) {
|
||||
const fakeFetch: typeof fetch = async (input) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
|
||||
if (path.endsWith("todo-task/page/by-user")) return encrypted({ code: "0", data: { records: [{ todoTaskId: "TD-1", groupId: "TRIP_2099010200000001", appCode: "xft-bpm", businessCode: "OA000001" }] } });
|
||||
if (path.endsWith("todo-task/detail/by-ids")) return encrypted({ code: "0", data: [{ todoTaskId: "TD-1", businessId: "TRIP_2099010200000001", appCode: "TEST0001_TRIP" }] });
|
||||
if (path.endsWith("bill-list")) return encrypted({ code: "0", data: { list: [{ billId: "2099010200000001", eventType: "COMMON_REIM", billAmount }] } });
|
||||
throw new Error(`unexpected ${path}`);
|
||||
};
|
||||
const result = await new XftOpenApiClient(config(), fakeFetch).listMyTodos({ userId: "ding-u1" }, 50);
|
||||
assert.equal(result.items[0]?.amount, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("Xft OpenAPI continues past an irrelevant full first todo page", async () => {
|
||||
const pages: number[] = [];
|
||||
const irrelevant = Array.from({ length: 100 }, (_, index) => ({
|
||||
todoTaskId: `OTHER-${index}`, groupId: `OTHER-${index}`, appCode: "other-app",
|
||||
}));
|
||||
const client = new XftOpenApiClient(config(), async (input, init) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
|
||||
if (path.endsWith("todo-task/page/by-user")) {
|
||||
const page = Number(decodedBody(init).currentPage);
|
||||
pages.push(page);
|
||||
return encrypted({ code: "0", data: { records: page === 1 ? irrelevant : [{
|
||||
todoTaskId: "TD-TARGET", groupId: "2026081400000001", appCode: "xft-itrip",
|
||||
}] } });
|
||||
}
|
||||
if (path.endsWith("todo-task/detail/by-ids")) return encrypted({ code: "0", data: [] });
|
||||
if (path.endsWith("bill-list")) return encrypted({ code: "0", data: { list: [{
|
||||
billId: "2026081400000001", eventType: "COMMON_REIM", title: "目标费用单",
|
||||
}] } });
|
||||
throw new Error(`unexpected ${path}`);
|
||||
});
|
||||
const result = await client.listMyTodos({ userId: "ding-u1" }, 1);
|
||||
assert.equal(result.items[0]?.billId, "2026081400000001");
|
||||
assert.deepEqual(pages, [1, 2]);
|
||||
});
|
||||
|
||||
test("Xft write transport failures are uncertain while deterministic rejections stay failed", async () => {
|
||||
for (const [upstream, expected] of [
|
||||
[{ code: "XFTSYS9999", message: "temporary unavailable", status: 503 }, "outcome_uncertain"],
|
||||
[{ code: "BIZ001", message: "not allowed", status: 400 }, "failed"],
|
||||
] as const) {
|
||||
const client = new XftOpenApiClient(config(), async (input) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
|
||||
if (path.endsWith("todo-task/page/by-user")) return encrypted({ code: "0", data: { records: [{
|
||||
todoTaskId: "TD-1", groupId: "2026081400000001", appCode: "xft-itrip",
|
||||
}] } });
|
||||
if (path.endsWith("todo-task/detail/by-ids")) return encrypted({ code: "0", data: [] });
|
||||
if (path.endsWith("bill-list")) return encrypted({ code: "0", data: { list: [{ billId: "2026081400000001", eventType: "COMMON_REIM" }] } });
|
||||
if (path.endsWith("approve/pass")) {
|
||||
return new Response(String(sm4.encrypt(JSON.stringify({ code: upstream.code, message: upstream.message }), privateKey.slice(0, 32))), {
|
||||
status: upstream.status,
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected ${path}`);
|
||||
});
|
||||
const result = await client.processApprovalBatch({ userId: "ding-u1" }, "agree", "同意", [{ billId: "2026081400000001" }]);
|
||||
assert.equal(result.results[0]?.status, expected);
|
||||
}
|
||||
});
|
||||
|
||||
test("Xft OpenAPI fails closed when the DingTalk user has no enterprise mapping", async () => {
|
||||
let calls = 0;
|
||||
const client = new XftOpenApiClient(config(), async () => {
|
||||
calls += 1;
|
||||
return encrypted({ code: "0", data: { records: [] } });
|
||||
});
|
||||
await assert.rejects(client.listMyTodos({ userId: "unknown" }, 50), /未映射到薪福通企业成员/);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test("Xft OpenAPI rejects non-exact, missing-identity, and ambiguous user mappings", async () => {
|
||||
for (const records of [
|
||||
[{ thirdStaffId: "victim-user", enterpriseUserId: "EMP-VICTIM" }],
|
||||
[{ enterpriseUserId: "EMP-WITHOUT-DING-ID" }],
|
||||
[
|
||||
{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" },
|
||||
{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-2" },
|
||||
],
|
||||
]) {
|
||||
let mappingCalls = 0;
|
||||
let downstreamCalls = 0;
|
||||
const client = new XftOpenApiClient(config(), async (input) => {
|
||||
if (new URL(String(input)).pathname.includes("user-mapping")) {
|
||||
mappingCalls += 1;
|
||||
return encrypted({ code: "0", data: { records } });
|
||||
}
|
||||
downstreamCalls += 1;
|
||||
return encrypted({ code: "0", data: {} });
|
||||
});
|
||||
await assert.rejects(client.listMyTodos({ userId: "ding-u1" }, 50), /未映射|多个薪福通成员映射/);
|
||||
assert.equal(mappingCalls, 1);
|
||||
assert.equal(downstreamCalls, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("Xft OpenAPI surfaces XFT framework business errors", async () => {
|
||||
const client = new XftOpenApiClient(config(), async () => encrypted({
|
||||
SYCOMRETZ: [{ ERRCOD: "SYOPA16", ERRMSG: "应用状态错误" }],
|
||||
}));
|
||||
await assert.rejects(client.authStatus({ userId: "ding-u1" }), /应用状态错误/);
|
||||
});
|
||||
|
||||
test("Xft OpenAPI preserves official GET endpoint query parameters in the signed target", async () => {
|
||||
let capturedUrl: URL | undefined;
|
||||
let capturedInit: RequestInit | undefined;
|
||||
const client = new XftOpenApiClient(config(), async (input, init) => {
|
||||
capturedUrl = new URL(String(input));
|
||||
capturedInit = init;
|
||||
return encrypted({ code: "0", data: { billId: "2099010200000001" } });
|
||||
});
|
||||
const request = client as unknown as {
|
||||
request(path: string, body: undefined, method: "GET", identity: { enterpriseUserId: string }): Promise<unknown>;
|
||||
};
|
||||
await request.request(
|
||||
"/itrip/xft-api/v1/reimburse/bill/common/detail?billId=2099010200000001",
|
||||
undefined,
|
||||
"GET",
|
||||
{ enterpriseUserId: "EMP-1" },
|
||||
);
|
||||
|
||||
assert.equal(capturedUrl?.pathname, "/itrip/xft-api/v1/reimburse/bill/common/detail");
|
||||
assert.equal(capturedUrl?.searchParams.get("billId"), "2099010200000001");
|
||||
assert.equal(capturedUrl?.searchParams.get("CSCUSRNBR"), "EMP-1");
|
||||
assert.equal(capturedInit?.method, "GET");
|
||||
assert.equal(capturedInit?.body, undefined);
|
||||
assert.equal(new Headers(capturedInit?.headers).has("x-alb-digest"), false);
|
||||
});
|
||||
|
||||
test("Xft OpenAPI derives empId server-side and validates ownership before and after a write", async () => {
|
||||
let todoQueries = 0;
|
||||
const actionBodies: Record<string, unknown>[] = [];
|
||||
const fakeFetch: typeof fetch = async (input, init) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
const body = decodedBody(init);
|
||||
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { list: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
|
||||
if (path.endsWith("todo-task/page/by-user")) {
|
||||
todoQueries += 1;
|
||||
return encrypted({ code: "0", data: todoQueries === 1 ? { records: [{ todoTaskId: "TD-1", groupId: "10000000001", appCode: "xft-itrip" }] } : { records: [] } });
|
||||
}
|
||||
if (path.endsWith("todo-task/detail/by-ids")) return encrypted({ code: "0", data: [{ todoTaskId: "TD-1", businessId: "10000000001", appCode: "xft-itrip" }] });
|
||||
if (path.endsWith("bill-list")) return encrypted({ code: "0", data: { list: [{ billId: "10000000001", eventType: "COMMON_REIM" }] } });
|
||||
if (path.endsWith("approve/pass")) { actionBodies.push(body); return encrypted({ code: "0", data: {} }); }
|
||||
throw new Error(`unexpected ${path}`);
|
||||
};
|
||||
const client = new XftOpenApiClient(config(), fakeFetch);
|
||||
const result = await client.processApprovalBatch({ userId: "ding-u1" }, "agree", "同意", [{ billId: "10000000001" }]);
|
||||
|
||||
assert.equal(result.succeeded, 1);
|
||||
assert.equal(todoQueries, 2);
|
||||
assert.equal(actionBodies[0]?.empId, "EMP-1");
|
||||
});
|
||||
|
||||
test("Xft submission discovers live templates and derives protected create fields server-side", async () => {
|
||||
const createBodies: Record<string, unknown>[] = [];
|
||||
const fakeFetch: typeof fetch = async (input, init) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
const body = init?.body ? decodedBody(init) : {};
|
||||
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{
|
||||
thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1",
|
||||
}] } });
|
||||
if (path.endsWith("member/get/by-id")) {
|
||||
assert.deepEqual(body, { id: "EMP-1", extFields: ["historyId", "org"] });
|
||||
return encrypted({ returnCode: "SUC0000", body: {
|
||||
memberId: "EMP-1", number: "E001", idRelation: { enterpriseUserId: "EMP-1", staffId: "0000000001" },
|
||||
} });
|
||||
}
|
||||
if (path.endsWith("form/query-event-list")) return encrypted({ code: "0", data: { total: 1, list: [{
|
||||
eventSeqId: 12345, eventNumber: "FORM-1", eventName: "费用报销单", eventStatus: "1", eventType: "COMMON_REIM",
|
||||
}] } });
|
||||
if (path.includes("form/customFields/eventSeqId")) return encrypted({ code: "0", data: [{
|
||||
fieldNumber: "C1", name: "说明", type: "INPUT", required: true, options: [],
|
||||
}, {
|
||||
fieldNumber: "C1", name: "说明", type: "INPUT", required: true, options: [],
|
||||
}, {
|
||||
fieldNumber: "billDepartmentCode", name: "承担部门", type: "ADV_DEPARTMENT", required: true, options: [],
|
||||
}, {
|
||||
fieldNumber: "C-DYNAMIC-DEPT", name: "承担部门", type: "ADV_DEPARTMENT", required: true, options: [],
|
||||
}] });
|
||||
if (path.endsWith("reimburse/bill/create")) {
|
||||
createBodies.push(body);
|
||||
return encrypted({ code: "SUC0000", body: 2026081200000001, success: true });
|
||||
}
|
||||
throw new Error(`unexpected ${path}`);
|
||||
};
|
||||
const client = new XftOpenApiClient(config(), fakeFetch);
|
||||
const templates = await client.listSubmissionTemplates({ userId: "ding-u1" }, 20);
|
||||
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);
|
||||
const standardFields = (schema.inputContract.values as Record<string, unknown>).standardFields as Array<Record<string, unknown>>;
|
||||
const expenseList = standardFields.find((field) => field.key === "expenseList");
|
||||
assert.deepEqual((expenseList?.itemFields as Array<Record<string, unknown>>).filter((field) => field.required).map((field) => field.key), [
|
||||
"expTypeCode", "currencyCode", "originalCurrencyReimAmt",
|
||||
]);
|
||||
const identityCheck = await client.validateSubmissionIdentity({ userId: "ding-u1" });
|
||||
assert.equal(identityCheck.validated, true);
|
||||
assert.doesNotMatch(JSON.stringify(identityCheck), /E001|EMP-1/);
|
||||
const result = await client.submitApproval({ userId: "ding-u1" }, templates[0]!, {
|
||||
reimAmount: 10, reimAmountCurrencyCode: "CNY", expenseList: [{ amount: 10 }],
|
||||
});
|
||||
assert.equal(result.billId, "2026081200000001");
|
||||
assert.equal(createBodies[0]?.eventNumber, "FORM-1");
|
||||
assert.equal(createBodies[0]?.empNumber, "E001");
|
||||
assert.equal(createBodies[0]?.billStatus, "APPRV_PEND");
|
||||
assert.equal(createBodies[0]?.billApplyAuthCheckType, "Y");
|
||||
});
|
||||
|
||||
test("Xft submission options expose only the current user's departments and searchable city codes", async () => {
|
||||
const templateRecord = {
|
||||
eventSeqId: 1, eventNumber: "TRAVEL-1", eventName: "出差费用申请单", eventStatus: "1", eventType: "TRAVEL_APPLY",
|
||||
};
|
||||
let cityRequests = 0;
|
||||
const client = new XftOpenApiClient(config(), async (input, init) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
|
||||
if (path.endsWith("form/query-event-list")) return encrypted({ code: "0", data: { list: [templateRecord] } });
|
||||
if (path.endsWith("member/get/by-id")) {
|
||||
assert.deepEqual(decodedBody(init), { id: "EMP-1", extFields: ["historyId", "org"] });
|
||||
return encrypted({ returnCode: "SUC0000", body: {
|
||||
number: "SECRET-EMPLOYEE-NUMBER",
|
||||
idRelation: { enterpriseUserId: "EMP-1" },
|
||||
organizationRelations: [
|
||||
{ organizationCode: "ORG-SZ", organizationName: "深圳总部" },
|
||||
{ organization: { code: "ORG-RD", name: "研发部" } },
|
||||
],
|
||||
} });
|
||||
}
|
||||
if (path.endsWith("common/city/getAllCity")) {
|
||||
cityRequests += 1;
|
||||
return encrypted({ code: "0", data: {
|
||||
regions: [{ cityCode: "440300", cityName: "深圳市" }, { cityCode: "441900", cityName: "东莞市" }],
|
||||
} });
|
||||
}
|
||||
throw new Error(`unexpected ${path}`);
|
||||
});
|
||||
const template = (await client.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!;
|
||||
const departments = await client.getSubmissionOptions({ userId: "ding-u1" }, template, "department");
|
||||
assert.deepEqual(departments.items, [
|
||||
{ code: "ORG-SZ", label: "深圳总部" },
|
||||
{ code: "ORG-RD", label: "研发部" },
|
||||
]);
|
||||
assert.doesNotMatch(JSON.stringify(departments), /SECRET-EMPLOYEE-NUMBER|EMP-1/);
|
||||
const cities = await client.getSubmissionOptions({ userId: "ding-u1" }, template, "city", "深圳", 10);
|
||||
assert.deepEqual(cities.items, [{ code: "440300", label: "深圳市" }]);
|
||||
const cachedCities = await client.getSubmissionOptions({ userId: "ding-u1" }, template, "city", "东莞", 10);
|
||||
assert.deepEqual(cachedCities.items, [{ code: "441900", label: "东莞市" }]);
|
||||
assert.equal(cityRequests, 1);
|
||||
});
|
||||
|
||||
test("Xft recall derives empId from the authenticated user and validates the upstream success key", async () => {
|
||||
const recallBodies: Record<string, unknown>[] = [];
|
||||
const client = new XftOpenApiClient(config(), async (input, init) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
|
||||
if (path.endsWith("member/get/by-id")) return encrypted({ returnCode: "SUC0000", body: {
|
||||
number: "E001", idRelation: { enterpriseUserId: "EMP-1" },
|
||||
} });
|
||||
if (path.endsWith("bills/detail/2026081200000001")) return encrypted({ code: "0", data: {
|
||||
billId: 2026081200000001, empNumber: "E001", billSts: "1",
|
||||
} });
|
||||
if (path.endsWith("approve/recall")) {
|
||||
recallBodies.push(decodedBody(init));
|
||||
return encrypted({ code: "0", data: { success: true, error: "", successKey: [2026081200000001] } });
|
||||
}
|
||||
throw new Error(`unexpected ${path}`);
|
||||
});
|
||||
const result = await client.recallSubmission({ userId: "ding-u1" }, "2026081200000001", "测试撤回");
|
||||
assert.equal(result.status, "revoked");
|
||||
assert.deepEqual(recallBodies[0], {
|
||||
empId: "E001", businessId: 2026081200000001, approveCategory: "BILL", withdrawReason: "测试撤回",
|
||||
});
|
||||
});
|
||||
|
||||
test("Xft recall reports deterministic failure without retrying", async () => {
|
||||
let recallCalls = 0;
|
||||
const client = new XftOpenApiClient(config(), async (input) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
|
||||
if (path.endsWith("member/get/by-id")) return encrypted({ returnCode: "SUC0000", body: {
|
||||
number: "E001", idRelation: { enterpriseUserId: "EMP-1" },
|
||||
} });
|
||||
if (path.endsWith("bills/detail/2026081200000001")) return encrypted({ code: "0", data: {
|
||||
billId: 2026081200000001, empNumber: "E001", billSts: "1",
|
||||
} });
|
||||
if (path.endsWith("approve/recall")) {
|
||||
recallCalls += 1;
|
||||
return encrypted({ code: "0", data: { success: false, error: {
|
||||
"2026081200000001": { errorMessage: "单据状态不允许撤回", traceId: "must-not-leak" },
|
||||
}, successKey: [] } });
|
||||
}
|
||||
throw new Error(`unexpected ${path}`);
|
||||
});
|
||||
const result = await client.recallSubmission({ userId: "ding-u1" }, "2026081200000001");
|
||||
assert.equal(result.status, "failed");
|
||||
assert.match(result.message ?? "", /状态不允许/);
|
||||
assert.doesNotMatch(result.message ?? "", /trace|must-not-leak/i);
|
||||
assert.equal(recallCalls, 1);
|
||||
});
|
||||
|
||||
test("Xft recall reconciles a framework service error against live bill status", async () => {
|
||||
let recallCalls = 0;
|
||||
const client = new XftOpenApiClient(config(), async (input) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
|
||||
if (path.endsWith("member/get/by-id")) return encrypted({ returnCode: "SUC0000", body: {
|
||||
number: "E001", idRelation: { enterpriseUserId: "EMP-1" },
|
||||
} });
|
||||
if (path.endsWith("approve/recall")) {
|
||||
recallCalls += 1;
|
||||
return encrypted({ code: "XFTSYS9999", message: "服务异常" });
|
||||
}
|
||||
if (path.endsWith("bills/detail/2026081200000001")) return encrypted({ code: "0", data: {
|
||||
billId: 2026081200000001, empNumber: "E001", billSts: "1",
|
||||
} });
|
||||
throw new Error(`unexpected ${path}`);
|
||||
});
|
||||
const result = await client.recallSubmission({ userId: "ding-u1" }, "2026081200000001");
|
||||
assert.equal(result.status, "failed");
|
||||
assert.match(result.message ?? "", /服务异常.*待审批/);
|
||||
assert.equal((result.diagnostics as Record<string, unknown>).postWriteVerified, true);
|
||||
assert.equal(recallCalls, 1);
|
||||
});
|
||||
|
||||
test("Xft submission status revalidates current-user ownership and returns only compact state", async () => {
|
||||
const client = new XftOpenApiClient(config(), async (input, init) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
|
||||
if (path.endsWith("member/get/by-id")) return encrypted({ returnCode: "SUC0000", body: {
|
||||
number: "E001", idRelation: { enterpriseUserId: "EMP-1" },
|
||||
} });
|
||||
if (path.endsWith("bills/detail/2099010300000001")) return encrypted({ code: "0", data: {
|
||||
billId: 2099010300000001, empNumber: "E001", billSts: "11", billApprSts: "WITHDRAWN", privateFormData: "not returned",
|
||||
aprHisList: [{ nodeName: "发起", actions: [{ action: "start", empNumber: "must-not-leak" }] }],
|
||||
} });
|
||||
throw new Error(`unexpected ${path}`);
|
||||
});
|
||||
const result = await client.getMySubmissionStatus({ userId: "ding-u1" }, "2099010300000001");
|
||||
assert.deepEqual(result, {
|
||||
billId: "2099010300000001", status: "11", statusName: "已撤回", approvalStatus: "WITHDRAWN",
|
||||
revoked: true, revokeAllowed: false,
|
||||
flowHistory: [{ nodeName: "发起", actions: ["start"] }],
|
||||
diagnostics: {
|
||||
source: "xft_official_openapi", realtime: true, ownershipRevalidated: true,
|
||||
applicantIdentity: "server_derived", persistedBusinessData: false,
|
||||
},
|
||||
});
|
||||
assert.doesNotMatch(JSON.stringify(result), /privateFormData|must-not-leak|E001|EMP-1/);
|
||||
});
|
||||
|
||||
test("Xft submission ownership ignores employee numbers from approval history", async () => {
|
||||
const client = new XftOpenApiClient(config(), async (input) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
|
||||
if (path.endsWith("member/get/by-id")) return encrypted({ returnCode: "SUC0000", body: {
|
||||
number: "E001", idRelation: { enterpriseUserId: "EMP-1" },
|
||||
} });
|
||||
if (path.endsWith("bills/detail/2026081400000001")) return encrypted({ code: "0", data: {
|
||||
billId: "2026081400000001", empNumber: "E999", billSts: "1",
|
||||
aprHisList: [{ actions: [{ empNumber: "E001" }] }],
|
||||
} });
|
||||
throw new Error(`unexpected ${path}`);
|
||||
});
|
||||
await assert.rejects(client.getMySubmissionStatus({ userId: "ding-u1" }, "2026081400000001"), /不属于当前登录用户/);
|
||||
});
|
||||
|
||||
test("Xft submission ownership accepts object rows from the condition-query fallback", async () => {
|
||||
const client = new XftOpenApiClient(config(), async (input) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
|
||||
if (path.endsWith("member/get/by-id")) return encrypted({ returnCode: "SUC0000", body: {
|
||||
number: "E001", idRelation: { enterpriseUserId: "EMP-1" },
|
||||
} });
|
||||
if (path.endsWith("bills/detail/2026081400000001")) return encrypted({ code: "0", data: {
|
||||
billId: "2026081400000001", billSts: "1",
|
||||
} });
|
||||
if (path.endsWith("bills/queryBillByCondition")) return encrypted({ code: "0", data: {
|
||||
list: [{ billId: "2026081400000001" }],
|
||||
} });
|
||||
throw new Error(`unexpected ${path}`);
|
||||
});
|
||||
const result = await client.getMySubmissionStatus({ userId: "ding-u1" }, "2026081400000001");
|
||||
assert.equal((result.diagnostics as Record<string, unknown>).ownershipRevalidated, true);
|
||||
assert.equal(result.revokeAllowed, true);
|
||||
});
|
||||
|
||||
test("Xft submission routes every supported template kind to its documented create endpoint", async () => {
|
||||
const templateRecords = [
|
||||
{ eventSeqId: 1, eventNumber: "T1", eventName: "差旅申请", eventStatus: "1", eventType: "TRAVEL_APPLY" },
|
||||
{ eventSeqId: 2, eventNumber: "T2", eventName: "费用申请", eventStatus: "1", eventType: "EXPENSE_APPLY" },
|
||||
{ eventSeqId: 3, eventNumber: "T3", eventName: "招待申请", eventStatus: "1", eventType: "ENTERTAINMENT_APPLY" },
|
||||
{ eventSeqId: 8, eventNumber: "T8", eventName: "招待费用申请单", eventStatus: "1", eventType: "EXPENSE_APPLY" },
|
||||
{ eventSeqId: 4, eventNumber: "T4", eventName: "通用申请", eventStatus: "1", eventType: "COMMON_APPLY" },
|
||||
{ eventSeqId: 5, eventNumber: "T5", eventName: "用车申请", eventStatus: "1", eventType: "USE_CAR_APPLY" },
|
||||
{ eventSeqId: 6, eventNumber: "T6", eventName: "日常报销", eventStatus: "1", eventType: "COMMON_REIM" },
|
||||
{ eventSeqId: 7, eventNumber: "T7", eventName: "差旅报销", eventStatus: "1", eventType: "TRAVEL_REIM" },
|
||||
];
|
||||
const createPaths: string[] = [];
|
||||
const client = new XftOpenApiClient(config(), async (input) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
|
||||
if (path.endsWith("form/query-event-list")) return encrypted({ code: "0", data: { list: templateRecords } });
|
||||
if (path.endsWith("member/get/by-id")) return encrypted({ returnCode: "SUC0000", body: {
|
||||
number: "E001", idRelation: { enterpriseUserId: "EMP-1" },
|
||||
} });
|
||||
if (path.endsWith("/create")) {
|
||||
createPaths.push(path);
|
||||
return encrypted({ code: "0", data: { billId: String(1000 + createPaths.length) } });
|
||||
}
|
||||
throw new Error(`unexpected ${path}`);
|
||||
});
|
||||
const templates = await client.listSubmissionTemplates({ userId: "ding-u1" }, 20);
|
||||
assert.equal(templates.length, 8);
|
||||
assert.equal(templates.find((item) => item.eventNumber === "T8")?.kind, "expense_apply");
|
||||
for (const template of templates) await client.submitApproval({ userId: "ding-u1" }, template, {});
|
||||
assert.deepEqual(createPaths.sort(), [
|
||||
"/itrip/xft-api/v1/bills/apply/common/create",
|
||||
"/itrip/xft-api/v1/bills/apply/entertainment/create",
|
||||
"/itrip/xft-api/v1/bills/apply/expense/create",
|
||||
"/itrip/xft-api/v1/bills/apply/expense/create",
|
||||
"/itrip/xft-api/v1/bills/apply/travel/create",
|
||||
"/itrip/xft-api/v1/bills/apply/use-car/create",
|
||||
"/itrip/xft-api/v1/reimburse/bill/create",
|
||||
"/itrip/xft-api/v1/reimburse/bill/travel/create",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Xft submission fails closed when the authenticated member has no employee number", async () => {
|
||||
let createCalls = 0;
|
||||
const client = new XftOpenApiClient(config(), async (input) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
|
||||
if (path.endsWith("form/query-event-list")) return encrypted({ code: "0", data: { list: [{
|
||||
eventSeqId: 1, eventNumber: "FORM-1", eventName: "通用申请", eventStatus: "1", eventType: "COMMON_APPLY",
|
||||
}] } });
|
||||
if (path.endsWith("member/get/by-id")) return encrypted({ returnCode: "SUC0000", body: {
|
||||
memberId: "EMP-1", number: null, idRelation: { enterpriseUserId: "EMP-1", staffId: "0000000001" },
|
||||
} });
|
||||
if (path.endsWith("bills/apply/common/create")) createCalls += 1;
|
||||
return encrypted({ code: "0", data: {} });
|
||||
});
|
||||
const template = (await client.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!;
|
||||
await assert.rejects(client.submitApproval({ userId: "ding-u1" }, template, {}), /未维护员工号/);
|
||||
assert.equal(createCalls, 0);
|
||||
});
|
||||
|
||||
test("Xft submission rejects a member lookup that does not match the authenticated identity", async () => {
|
||||
let createCalls = 0;
|
||||
const client = new XftOpenApiClient(config(), async (input) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
|
||||
if (path.endsWith("form/query-event-list")) return encrypted({ code: "0", data: { list: [{
|
||||
eventSeqId: 1, eventNumber: "FORM-1", eventName: "通用申请", eventStatus: "1", eventType: "COMMON_APPLY",
|
||||
}] } });
|
||||
if (path.endsWith("member/get/by-id")) return encrypted({ returnCode: "SUC0000", body: {
|
||||
memberId: "EMP-OTHER", number: "VICTIM-001", idRelation: { enterpriseUserId: "EMP-OTHER" },
|
||||
} });
|
||||
if (path.endsWith("bills/apply/common/create")) createCalls += 1;
|
||||
return encrypted({ code: "0", data: {} });
|
||||
});
|
||||
const template = (await client.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!;
|
||||
await assert.rejects(client.submitApproval({ userId: "ding-u1" }, template, {}), /登录身份不一致/);
|
||||
assert.equal(createCalls, 0);
|
||||
});
|
||||
|
||||
test("Xft submission rejects caller-controlled applicant and status fields before create", async () => {
|
||||
const client = new XftOpenApiClient(config(), async (input, init) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
|
||||
if (path.endsWith("form/query-event-list")) return encrypted({ code: "0", data: { list: [{
|
||||
eventSeqId: 1, eventNumber: "FORM-1", eventName: "通用申请", eventStatus: "1", eventType: "COMMON_APPLY",
|
||||
}] } });
|
||||
throw new Error(`unexpected create call ${path} ${String(init?.body)}`);
|
||||
});
|
||||
const template = (await client.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!;
|
||||
await assert.rejects(client.submitApproval({ userId: "ding-u1" }, template, { empNumber: "victim" }), /Agent 不得传入/);
|
||||
});
|
||||
|
||||
test("Xft OpenAPI tolerates eventual consistency after an accepted write", async () => {
|
||||
let todoQueries = 0;
|
||||
const client = new XftOpenApiClient(config(), async (input, init) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
|
||||
if (path.endsWith("todo-task/page/by-user")) {
|
||||
todoQueries += 1;
|
||||
return encrypted({ code: "0", data: { records: todoQueries < 4 ? [{ todoTaskId: "TD-1", groupId: "10000000001", appCode: "xft-itrip" }] : [] } });
|
||||
}
|
||||
if (path.endsWith("todo-task/detail/by-ids")) return encrypted({ code: "0", data: [] });
|
||||
if (path.endsWith("bill-list")) return encrypted({ code: "0", data: { list: [{ billId: "10000000001", eventType: "COMMON_REIM" }] } });
|
||||
if (path.endsWith("approve/pass")) { void decodedBody(init); return encrypted({ code: "0", data: {} }); }
|
||||
throw new Error(`unexpected ${path}`);
|
||||
});
|
||||
const result = await client.processApprovalBatch({ userId: "ding-u1" }, "agree", "同意", [{ billId: "10000000001" }]);
|
||||
assert.equal(result.succeeded, 1);
|
||||
assert.equal(todoQueries, 4);
|
||||
});
|
||||
|
||||
test("Xft detail falls back to generic bill detail when a specialized endpoint is unavailable", async () => {
|
||||
const targetBillId = "2099010400000001";
|
||||
const client = new XftOpenApiClient(config(), async (input) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
|
||||
if (path.endsWith("todo-task/page/by-user")) return encrypted({ code: "0", data: { records: [{ todoTaskId: "TD-1", groupId: targetBillId, appCode: "xft-itrip" }] } });
|
||||
if (path.endsWith("todo-task/detail/by-ids")) return encrypted({ code: "0", data: [] });
|
||||
if (path.endsWith("bill-list")) return encrypted({ code: "0", data: { list: [{ billId: targetBillId, eventType: "EXPENSE_APPLY" }] } });
|
||||
if (path.endsWith("bills/apply/expense/detail")) return new Response("Not Found", { status: 404 });
|
||||
if (path.endsWith(`bills/detail/${targetBillId}`)) return encrypted({ code: "0", data: { billId: targetBillId, applyExpenses: [{ expenseNumber: "MEAL" }] } });
|
||||
throw new Error(`unexpected ${path}`);
|
||||
});
|
||||
const detail = await client.getTodoDetail({ userId: "ding-u1" }, targetBillId);
|
||||
assert.deepEqual(detail.form, { billId: targetBillId, applyExpenses: [{ expenseNumber: "MEAL" }] });
|
||||
assert.equal((detail.diagnostics as Record<string, unknown>).detailFallback, "generic_bill_detail_after_specialized_404");
|
||||
});
|
||||
|
||||
test("Xft ownership verification scans beyond the first 1000 pending tasks", async () => {
|
||||
const targetBillId = "99999999999";
|
||||
const requestedPages: number[] = [];
|
||||
const firstPage = Array.from({ length: 1000 }, (_, index) => ({
|
||||
todoTaskId: `TD-${index}`, groupId: String(10_000_000_000 + index), appCode: "xft-itrip",
|
||||
}));
|
||||
const client = new XftOpenApiClient(config(), async (input, init) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
|
||||
if (path.endsWith("todo-task/page/by-user")) {
|
||||
const body = decodedBody(init);
|
||||
requestedPages.push(Number(body.currentPage));
|
||||
return encrypted({ code: "0", data: { records: body.currentPage === 1 ? firstPage : [{ todoTaskId: "TD-TARGET", groupId: targetBillId, appCode: "xft-itrip" }] } });
|
||||
}
|
||||
if (path.endsWith("todo-task/detail/by-ids")) return encrypted({ code: "0", data: [] });
|
||||
if (path.endsWith("bill-list")) return encrypted({ code: "0", data: { list: [{ billId: targetBillId, eventType: "COMMON_REIM" }] } });
|
||||
if (path.endsWith("reimburse/bill/common/detail")) return encrypted({ code: "0", data: { billId: targetBillId } });
|
||||
throw new Error(`unexpected ${path}`);
|
||||
});
|
||||
const detail = await client.getTodoDetail({ userId: "ding-u1" }, targetBillId);
|
||||
assert.equal(detail.todo.billId, targetBillId);
|
||||
assert.deepEqual(requestedPages, [1, 2]);
|
||||
});
|
||||
|
||||
test("Xft OpenAPI never calls an action endpoint for a bill outside the user's live todos", async () => {
|
||||
let actionCalls = 0;
|
||||
const client = new XftOpenApiClient(config(), async (input, init) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { list: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
|
||||
if (path.endsWith("todo-task/page/by-user")) return encrypted({ code: "0", data: { records: [] } });
|
||||
if (path.includes("/approve/")) actionCalls += 1;
|
||||
void init;
|
||||
return encrypted({ code: "0", data: {} });
|
||||
});
|
||||
const result = await client.processApprovalBatch({ userId: "ding-u1" }, "agree", "", [{ billId: "other-user-bill" }]);
|
||||
assert.equal(result.failed, 1);
|
||||
assert.equal(actionCalls, 0);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"rootDir": ".",
|
||||
"outDir": "dist",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": false,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user