Files
dingMCP/docs/architecture.md
T
gitea-admin 2aa78aebd9
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
fix: route cross-device OAuth through device flow
2026-08-19 02:05:07 +00:00

98 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 架构
## 数据路径
```text
用户 ─ Messenger ─ Agent ─ 远程 Streamable HTTP MCP/OAuth ─ 统一审批网关
├─ 钉钉 OA
└─ 薪福通
```
支持远程 MCP 和标准 OAuth,且用户能完成宿主所注册回调的场景直接连接网关,不安装客户端运行时。若宿主使用 `127.0.0.1`/`localhost` 回调,授权页必须在 Agent 所在设备打开。宿主缺少远程传输/OAuth 能力,或用户通过 SSH、无头终端、Messenger 等方式跨设备交互而无法完成本机回调时,使用本地 Bridge 把 stdio 转换为远程 Streamable HTTP,并通过可跨设备的 device-code 登录。Bridge 只保存用户自己的短期 access/refresh token,不包含企业密钥、不读取审批上游,也不监听端口。
## 客户端安装边界
同机回调可达时,默认安装仅在宿主中登记远程 HTTPS MCP URL。OAuth 客户端注册、PKCE、Token 保存和刷新均由宿主的标准 MCP 实现负责,终端不需要 Node/npm、Bridge 或项目安装器。安装 Agent 必须按实际回调拓扑选择路径:能够显示 URL 不等于能够完成回调;回调是 loopback 且用户会在另一台设备打开时直接选择兼容 Bridge。网关的 MCP `instructions` 与工具描述提供业务工作流,终端修改本地提示不能绕过服务器的身份和写前校验。
兼容发行物只为不支持远程 MCP/OAuth 的宿主保留。它不使用全局 npm prefix:首次安装器以固定 Ed25519 公钥验证 schema v3 清单和 artifact SHA-256,并把内置依赖展开到稳定的用户目录。Windows/Linux/macOS 引导可复用系统 Node 或安装经官方哈希校验的固定私有 Node,不改 PATH、不要求管理员权限。发布物优先使用国内 Gitea 和 Node 固定版本源,自建 HTTPS/官方源作为受同一签名或哈希保护的备用。
兼容 Windows 引导以 UTF-8 BOM 发布,确保 Windows PowerShell 5.1 正确解析中文,并显式把控制台和原生程序管道固定为 UTF-8,避免 Node 返回的 JSON/诊断被按系统代码页解码。传给 `node -e` 的脚本使用 PowerShell 不会吞掉的引号组合。Windows ZIP 目录名只去掉一次 `.zip`,避免把 Node 语义版本中的 `.2-win-x64` 误当作第二层扩展名。下载优先调用 `curl.exe` 复用代理,并设置 10 秒连接、20 秒低速和 90 秒总时限;`Invoke-WebRequest` 只在没有 curl 时兜底。安装器仍输出稳定 `bridgePath`、`skillPath`、`mcpConfig` 和 `postInstall`,保留原子安装、跨进程锁、明确诊断、更新与卸载能力。
## Agent 领域接口
Agent 面向统一审批语义,不直接理解钉钉 `processInstanceId/taskId`、薪福通员工号、上游响应或完整 Schema。网关在内部完成来源路由、字段抽取、动作调用和写后复核,只返回审批判断所需的精简字段与统一状态。
所有工具以普通 `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 个远程工具调用同时在途,避免宿主瞬间并发压垮上游。
## 一次登录
远程模式由宿主按 MCP 标准读取网关的 Protected Resource / Authorization Server Metadata,通过动态客户端注册、授权码 + PKCE 和 Token 刷新完成登录。宿主负责呈现授权入口;用户本人完成 consent 和钉钉登录后,网关从已验证 Token 得到当前企业 `userId`:
- 钉钉 OA 直接按该 `userId` 查询本人审批任务;
- 薪福通先把该 `userId` 映射为企业成员,再从本人企业成员档案读取花名册员工号。
两条数据路径共用同一个网关 Token,因此不会出现本地 DWS 与网关分别登录的情况。
首次认证后的首个成功业务响应会在普通 `content.text` 和 `structuredContent.onboarding` 中同时返回简短用法示例;错误响应不会自动执行示例。兼容 Bridge 另外在本地受保护会话中记录 `pending/delivered` 状态,避免重复展示,但不保存审批正文。
远程 `https://approval.justconn.top/mcp` 已按 MCP 授权规范提供 HTTP 401 challenge、OAuth Protected Resource Metadata、Authorization Server Metadata、动态客户端注册、PKCE、Token 刷新和撤销。面向只支持 stdio 或不能承接 OAuth 回调的宿主,轻量桥使用 device-code 兼容流程;桥会在普通文本、标准 `resource_link` 和 `authChallenge.directUrl` 中返回同一个短期登录链接,不会调用不存在的消息通道自动推送。
开放动态客户端注册不等于静默授权。授权码和设备码流程都会先进入网关 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.14`,薪福通撤回仍是实验能力:当前企业真实调用 `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`,而不是误报异常。