Author SHA1 Message Date
gitea-admin ba2cfa48cb chore: release 1.1.0-rc.18
CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s
2026-08-29 18:10:54 +00:00
gitea-admin f301cef3e8 fix: list unadapted XFT approval tasks safely 2026-08-29 18:09:29 +00:00
gitea-admin b00c22d74e chore: release 1.1.0-rc.17
CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s
2026-08-26 07:10:04 +00:00
gitea-admin 76a4e0592c fix: repair XFT expense type diagnostics 2026-08-26 05:05:59 +00:00
13 changed files with 626 additions and 101 deletions
+5 -1
View File
@@ -13,7 +13,7 @@ Agent → 远程 Streamable HTTP MCP / OAuth 统一网关
## 工具
- `approval_login`:检查登录或返回唯一授权链接。
- `approval_list_todos`:实时列出本人待办,签发短期 `approvalId`,并为可安全整组处理的结果签发 `selectionId`。
- `approval_list_todos`:实时列出本人待办;薪福通不按单据类型过滤。签发短期 `approvalId`,并仅为可安全整组处理的结果签发 `selectionId`。
- `approval_list_history`:实时列出本人已经处理过或本人发起的审批;默认最近 30 天,可用 `startDate/endDate` 指定最长 120 天的时间窗口,并签发只能读取详情的短期 `approvalId`。
- `approval_get_detail`:返回摘要、结构化费用明细或受限 raw 分页。
- `approval_get_actions`:检查同意、拒绝与退回能力。
@@ -28,6 +28,8 @@ Agent → 远程 Streamable HTTP MCP / OAuth 统一网关
待办、历史和处理结果使用统一、精简的领域字段,不向 Agent 暴露钉钉任务 ID、薪福通员工号或上游原始响应。历史查询分为 `handled`(本人已经处理过)和 `submitted`(本人发起),结果没有 `selectionId` 和可执行动作。常见 `approvalId` 使用紧凑的 v4 无状态加密格式:薪福通通常约 60 字符、钉钉 OA 通常约 100 字符,并绑定当前用户和 30 分钟有效期;历史引用还绑定查询语义,只能读取详情。旧版未过期的 v2/v3 引用仍可读取。
薪福通消息中心返回且带稳定任务标识的本人待办都会进入列表。原有费用单据以及能被正式单据接口实时识别的新类型继续支持详情、同意、拒绝和退回;尚未确认接口兼容性的项目返回空 `availableActions` 与 `manualAction.reason=unsupported_xft_task_type`,只允许引导到薪福通客户端查看和处理。此类引用读取详情不会请求未验证的上游接口,动作查询全部返回禁用,批处理在预检阶段以 `provider_not_supported`、`writesStarted=false` 整批零写入;列表包含此类项目时不签发 `selectionId`。新增的能力诊断只给出数量,不额外携带任务 ID、单号、人员或正文。
一次写调用最多处理 20 条。整组同意/拒绝可直接使用 `selectionId`;超过 20 条时,Agent 可从同一次原始列表中按 `approvalId` 分组,不需要 `batchPlan`,也不能重新拉取后把新待办混入原授权。服务器在首个写入前校验整批 `approvalId` 和 `targetId`;任一引用无效时返回 `preflight_failed`、`writesStarted=false`,整批零写入且不自动补偿或重试。普通文本与结构化结果保持同一份精简数据,以兼容不渲染 `structuredContent` 的宿主。
Agent 不传 `userId`、员工号或单据所有者。网关从 OAuth Token 确定用户,再分别映射钉钉 OA 与薪福通身份。钉钉提单中的申请人和部门也由网关通过当前登录用户的通讯录档案实时填写;即使用户修改本地桥,也不能冒用他人身份或部门。
@@ -48,6 +50,8 @@ Agent 不传 `userId`、员工号或单据所有者。网关从 OAuth Token 确
用户不需要理解薪福通的任何内部选择值。实时 Schema 会把部门、城市、费用类型、出行方式、往返、时间范围、币种和酒店星级等字段标记为名称优先的选择项;Agent 只展示“深圳市”“交通费”“飞机”“往返”“人民币”等业务名称,选定后仅在内部写入正式接口值。动态下拉框也会标准化成 `label/code`,表格子字段同样递归处理。准备阶段的最终确认预览会再次按实时选项反向解析,只返回业务字段名和业务名称,不回显城市码、费用编码、字段号或表格列号;任何值无法可靠反向解析时,不生成 `confirmationId`。无法取得可靠名称映射时安全停止或允许跳过可选项,不退化为向用户索取工号、城市码、组织码、档案编号、数字舱位值或其他内部 ID。
薪福通正式 OpenAPI 当前没有独立的费用类型目录查询。费用类型会先从模板实时字段读取,再检查企业自定义多级数据源;两处都没有可用选项时,MCP 返回具体的实时检查结果并引导在费用管理页面确认或使用薪福通客户端,不会把空目录误报为网络 `fetch` 故障。
人员、企业自定义档案、关联前置单据、分摊及文档未给出可靠名称映射的舱位字段目前标记为 `client_only`。必填时不生成提交预览并引导薪福通客户端;可选时不会作为普通问题打扰用户。单据公司、部门、字段引用和行程序号等服务器管理或内部派生值也不会作为用户输入项展示。
服务端临时保留不可逆的用户/发票 HMAC 指纹。首次使用时只为最近 30 天尚未索引的审批读取详情;以后准备提单会刷新精简历史状态,并只为新增审批补建指纹,不会重复逐张下载已索引发票。指纹和最小审批索引最多保留 35 天,过期后自动级联删除;10 分钟提单预留到期即删除。
+2
View File
@@ -24,6 +24,8 @@
Agent 面向统一审批语义,不直接理解钉钉 `processInstanceId/taskId`、薪福通员工号、上游响应或完整 Schema。网关在内部完成来源路由、字段抽取、动作调用和写后复核,只返回审批判断所需的精简字段与统一状态。
薪福通当前待办以消息中心的本人任务为可见性边界,不再用应用码或单据类型白名单过滤。对旧费用类型以及能被正式 `bill-list` 实时识别的未知应用任务,网关标记为 `bill_api` 并沿用详情和审批链路;其他带稳定任务标识的项目标记为 `list_only`,只返回安全列表字段、空动作集合和客户端处理提示。`list_only` 的上游任务标识只进入用户绑定、来源绑定且短期有效的 v4 加密引用,不作为业务编号展示;详情和动作查询不会触发未经确认的上游接口,写请求在整批预检阶段返回 `provider_not_supported` 且零写入。包含此类项目的结果不签发 `selectionId`,诊断仅汇总 `billApiCount/clientOnlyCount`。本次能力分级只作用于当前待办,已处理历史仍只读取正式单据接口可验证的记录。
薪福通所有选择字段按领域选项抽象。部门、城市和费用类型由选项工具返回精简 `label/code` 映射;模板内嵌下拉框被标准化成同样结构,固定枚举通过 `enumLabels` 提供中文名称,表格子字段递归应用相同规则。Agent 只把业务名称展示给用户,正式值仅用于内部构造请求。准备确认时,网关再用实时 Schema 把正式值反向解析为业务名称;预览不包含城市码、费用编码、自定义字段号或表格列号,任一选择无法反向解析时会在签发确认引用前安全失败。无法取得可靠名称映射的人员、档案、关联单据、分摊和部分舱位字段标记为 `client_only`,必填时安全停止、可选时允许省略,绝不转而索取工号或编码。用车申请已从 MCP 发起范围移除,实时模板列表会过滤,旧模板引用也会被网关拒绝。响应不包含完整数据源、内部身份或其他企业档案,选项目录也不持久化。
所有工具以普通 `content.text` 中的精简 JSON 作为跨宿主可见结果,同时保留相同的精简 `structuredContent` 供支持结构化结果的宿主使用;两处都不携带诊断字段或上游原始响应。`raw` 仍是受过滤、分页和硬限额保护的按需诊断视图,不是默认数据通道。
+1 -1
View File
@@ -17,7 +17,7 @@
薪福通自定义字段在工具内部统一使用 `customFieldValues[].fieldNumber/fieldValue`,但用户不应看到或提供这些引用。日期字段使用 `yyyy-MM-dd HH:mm`;部门由可读名称选择后内部映射;下拉框与表格子字段使用实时名称映射。人员、附件及其他没有安全名称查询或上传能力的字段标记为 `client_only`,不会要求用户填写花名册编号或附件编号。`approval_get_submission_schema` 会返回实时 `valueFormat`、`selectionCapability` 和该模板的精简 `minimalExample`;后者只说明 `values` 形状,所有尖括号占位符都必须由用户业务回答或实时选项替换,不能原样提交。Agent 不得按字段名称猜编码或结构。
薪福通模板要求承担部门、城市或费用类型时,使用同一 `templateId` 调用 `approval_get_submission_options`。`kind=department` 只会返回当前登录用户本人档案中的可选组织;`kind=city` 可用 `query` 按城市名称搜索;`kind=expense_type` 先读取模板内嵌选项,再按需读取企业多级费用类型数据源。费用申请、差旅预计费用及报销明细均标记对应选项源。固定枚举提供 `enumLabels`,例如出行方式、单程/往返、上午/下午、人民币和酒店星级;动态 `SELECT` 的 `options` 统一为 `{label, code}`,表格列也递归标准化。Agent 只向用户显示名称并在内部使用正式值,不得向用户索取内部编码。`approval_prepare_submission` 会对标准字段、自定义下拉和表格列做第二次反向解析,确认预览只保留业务名称;无法映射时不签发确认引用。城市全量接口较慢,网关给予独立的 45 秒超时并在进程内缓存字典 24 小时,不写入磁盘;费用类型保持实时且不落盘。响应不会暴露员工号、完整数据源或原始成员档案。
薪福通模板要求承担部门、城市或费用类型时,使用同一 `templateId` 调用 `approval_get_submission_options`。`kind=department` 只会返回当前登录用户本人档案中的可选组织;`kind=city` 可用 `query` 按城市名称搜索;`kind=expense_type` 先读取模板内嵌选项,再按需读取企业多级费用类型数据源。多级数据源分页接口按正式契约把 `start/limit` 放在 query,并用列表的 `seqId` 读取详情中的 `optionSaveDtoList`;该接口只覆盖企业自定义多级数据源,薪福通正式 OpenAPI 当前没有独立的费用类型目录查询。模板没有内嵌选项且企业多级数据源为空或不含费用类型时,工具会明确返回这两项实时检查结果并引导在费用管理页面确认或使用客户端,不再包装成笼统的获取失败。费用申请、差旅预计费用及报销明细均标记对应选项源。固定枚举提供 `enumLabels`,例如出行方式、单程/往返、上午/下午、人民币和酒店星级;动态 `SELECT` 的 `options` 统一为 `{label, code}`,表格列也递归标准化。Agent 只向用户显示名称并在内部使用正式值,不得向用户索取内部编码。`approval_prepare_submission` 会对标准字段、自定义下拉和表格列做第二次反向解析,确认预览只保留业务名称;无法映射时不签发确认引用。城市全量接口较慢,网关给予独立的 45 秒超时并在进程内缓存字典 24 小时,不写入磁盘;费用类型保持实时且不落盘。响应不会暴露员工号、完整数据源或原始成员档案。
薪福通的自定义字段接口可能同时列出标准单据抬头控件,也可能用动态字段号返回名为“承担部门/单据部门”的控件,但创建接口禁止把这些标准控件放进 `customFieldValues`。MCP 会剔除服务器管理控件并拒绝调用方显式指定公司或部门;两者省略时由薪福通根据当前登录申请人的档案默认填写。真正的业务部门选择字段则只展示当前用户可用的部门名称并在内部映射。
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "dingtalk-approval-mcp",
"version": "1.1.0-rc.16",
"version": "1.1.0-rc.18",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dingtalk-approval-mcp",
"version": "1.1.0-rc.16",
"version": "1.1.0-rc.18",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0",
"express": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dingtalk-approval-mcp",
"version": "1.1.0-rc.16",
"version": "1.1.0-rc.18",
"description": "A lightweight Agent bridge for a unified DingTalk OA and XFT approval gateway.",
"private": true,
"type": "module",
+2 -2
View File
@@ -433,7 +433,7 @@ function parseEndpoint(argv: string[]): string {
export function createBridgeMcpServer(bridge: RemoteBridge): McpServer {
const server = new McpServer({ name: "dingtalk-approval-stdio-bridge", version: APPLICATION_VERSION }, {
instructions: "首次使用或登录链接过期时调用approval_login;每次调用会检查登录并在需要时生成有效的新链接,绝不因链接过期而重新安装。工具不会通过其他消息通道自动发送链接,必须把content.text中的登录URL原样写入当前面向用户的回复,不得只声称已生成或已推送。审批引用、调用容量和内部分批不得向用户解释;查看时只回答业务信息,用户明确授权后在授权范围内自动处理。写前校验失败时零写入,任何不确定结果都不得自动重试。提单草稿仅可保存在 Agent 所在机器的当前用户私有目录,不得上传网关;恢复草稿后必须重新读取实时模板 Schema,短期提交引用不得写入草稿。薪福通所有选择型字段只向用户展示业务名称:optionSource 选项展示label,内嵌枚举展示enumLabels;用户选定label后,values中必须填同一项的code而不能填label,code只存在于工具调用内部。不得向用户索取或展示城市码、组织码、工号、档案编号、数字舱位值或其他内部ID;selectionCapability=client_only时停止构造该必填项或允许跳过该可选项。",
instructions: "首次使用或登录链接过期时调用approval_login;每次调用会检查登录并在需要时生成有效的新链接,绝不因链接过期而重新安装。工具不会通过其他消息通道自动发送链接,必须把content.text中的登录URL原样写入当前面向用户的回复,不得只声称已生成或已推送。审批引用、调用容量和内部分批不得向用户解释;查看时只回答业务信息,用户明确授权后在授权范围内自动处理。薪福通待办不按单据类型过滤;availableActions=[]且manualAction.reason=unsupported_xft_task_type的项目仍要展示,但只能引导薪福通客户端查看处理,禁止尝试MCP写入。写前校验失败时零写入,任何不确定结果都不得自动重试。提单草稿仅可保存在 Agent 所在机器的当前用户私有目录,不得上传网关;恢复草稿后必须重新读取实时模板 Schema,短期提交引用不得写入草稿。薪福通所有选择型字段只向用户展示业务名称:optionSource 选项展示label,内嵌枚举展示enumLabels;用户选定label后,values中必须填同一项的code而不能填label,code只存在于工具调用内部。不得向用户索取或展示城市码、组织码、工号、档案编号、数字舱位值或其他内部ID;selectionCapability=client_only时停止构造该必填项或允许跳过该可选项。",
});
server.registerTool("approval_login", {
title: "登录审批服务", description: "检查登录;未登录或旧链接已过期时生成新的钉钉授权链接。同一身份同时用于钉钉OA和薪福通,无需重新安装。", inputSchema: {},
@@ -443,7 +443,7 @@ export function createBridgeMcpServer(bridge: RemoteBridge): McpServer {
return bridgeAuthToolResult(result);
});
server.registerTool("approval_list_todos", {
title: "列出我的审批待办", description: "实时列出当前登录用户本人的钉钉OA和薪福通待办。", inputSchema: {
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 },
+2 -2
View File
@@ -33,7 +33,7 @@ const MCP_SESSION_MAX_PER_USER = 8;
const MCP_SESSION_MAX_TOTAL = 1000;
export const APPROVAL_MCP_INSTRUCTIONS = [
"先调用 approval_list_todos 获取当前待办。用户只是在查看时,仅回答标题、申请人、金额、时间等业务信息,不主动讨论审批接口、批处理方式或索取写操作授权。用户明确要求处理后,在其授权范围内内部选择整组引用或自动分批;技术分批不得造成重复确认。",
"先调用 approval_list_todos 获取当前待办。薪福通待办不按单据类型过滤;availableActions=[] 且 manualAction.reason=unsupported_xft_task_type 的项目仍要展示给用户,但只能引导到薪福通客户端查看和处理,禁止尝试 MCP 写入。用户只是在查看时,仅回答标题、申请人、金额、时间等业务信息,不主动讨论审批接口、批处理方式或索取写操作授权。用户明确要求处理后,在其授权范围内内部选择整组引用或自动分批;技术分批不得造成重复确认。",
"approvalId、selectionId、targetId、单次条数限制、分批方式以及内部引用是否返回均属于 Agent 与 MCP 的内部调用细节,绝不向用户展示、解释或要求用户操作。面向用户只说明目标审批、业务动作、成功结果和需要用户决策的业务失败;不要说引用是否签发、是否失效、是否存在一键全选或如何分组。",
"查询本人处理过或本人发起的审批时调用 approval_list_history;默认最近30天,可用 startDate/endDate 指定最长120天的时间窗口。用其只读 approvalId 获取详情;历史引用不能用于 approval_get_actions 或 approval_process_batch,也不会返回 selectionId。",
"费用复核优先调用 approval_get_detail(view=expense);summary 用于一般详情,raw 仅用于分页补充字段。",
@@ -67,7 +67,7 @@ export function createApprovalMcpServer(
server.registerTool("approval_list_todos", {
title: "列出我的审批待办",
description: "审批操作的入口。通过同一登录身份实时列出当前用户在钉钉OA和薪福通中的待办,并取得后续接口必须使用的approvalId;列表尽可能直接包含金额和带时区的提交时间。",
description: "审批操作的入口。通过同一登录身份实时列出当前用户在钉钉OA和薪福通中的待办;薪福通不按单据类型静默过滤。已确认正式接口兼容的项目取得可用approvalId,未适配项目也会显示但以availableActions=[]和manualAction明确要求到薪福通客户端查看处理。列表尽可能直接包含金额和带时区的提交时间。",
inputSchema: {
source: z.enum(["all", "dingtalk", "xft"]).default("all"),
query: z.string().max(200).default(""),
+62 -7
View File
@@ -7,6 +7,7 @@ import { ApprovalService, type ApprovalHistoryItem, type ProcessApprovalItem, ty
import type { ApprovalHistoryRange, ApprovalHistoryScope, AuthenticatedDingTalkUser, XftApprovalProvider, XftHistoryItem, XftSubmissionField, XftSubmissionKind, XftSubmissionOptionKind, XftSubmissionTemplate, XftTodoItem } from "./xft.js";
import { browseRawDetail, compactApprovalDetail, type ApprovalDetailOptions, type ApprovalDetailView } from "./approval-detail.js";
import { sensitiveLabel } from "./approval-metadata.js";
import { isRecord } from "./json.js";
import type { InvoiceDeduplicationService } from "./invoice-dedup.js";
export type UnifiedSource = "dingtalk" | "xft";
@@ -235,7 +236,12 @@ export class UnifiedApprovalService {
if (!this.xft) sourceStatus.xft = { status: "disabled" };
else tasks.push(this.xft.listMyTodos(user, limit, query).then((result) => {
xftItems = result.items.map((item) => this.normalizeXftTodo(user, item));
sourceStatus.xft = { status: "ok", count: result.items.length };
sourceStatus.xft = {
status: "ok",
count: result.items.length,
...(typeof result.diagnostics?.billApiCount === "number" ? { billApiCount: result.diagnostics.billApiCount } : {}),
...(typeof result.diagnostics?.clientOnlyCount === "number" ? { clientOnlyCount: result.diagnostics.clientOnlyCount } : {}),
};
}).catch(async (error) => {
if (error instanceof XftSessionExpiredError && this.xftChallenges) {
try { sourceStatus.xft = { status: "auth_required", authChallenge: await this.xftChallenges.getOrStart(user) }; }
@@ -251,10 +257,15 @@ export class UnifiedApprovalService {
: interleave(dingTalkItems, xftItems);
const returnedItems = items.slice(0, limit);
const sourcesHealthy = requestedSourcesHealthy(source, sourceStatus);
const containsClientOnly = returnedItems.some((item) => isRecord(item)
&& item.source === "xft"
&& isRecord(item.manualAction)
&& item.manualAction.reason === "unsupported_xft_task_type");
const selectionUnavailableReason = !issueSelection ? "selection_not_requested"
: returnedItems.length === 0 ? "empty_result"
: !sourcesHealthy ? "requested_source_unhealthy"
: returnedItems.length > APPROVAL_BATCH_LIMIT ? "result_exceeds_batch_limit"
: containsClientOnly ? "contains_client_only_item"
: undefined;
const selectionAvailable = selectionUnavailableReason === undefined;
return {
@@ -710,6 +721,20 @@ export class UnifiedApprovalService {
const ref = this.decodeApproval(user, approvalId);
const view = options.view ?? "summary";
if (ref.provider === "xft") {
if (ref.clientOnly) {
return {
approvalId,
source: "xft" as const,
sourceType: "unadapted_xft_task",
view,
status: "client_required",
detailCapability: {
status: "client_only",
message: "该薪福通待办已在列表中显示,但尚未确认正式详情接口兼容性,请在薪福通客户端查看详情",
},
diagnostics: { realtime: true, persistedBusinessData: false, upstreamDetailRequested: false },
};
}
if (!this.xft || !ref.billId) throw new ApprovalSafetyError("薪福通审批源未启用");
try {
const raw = redactSensitive(ref.historyScope
@@ -772,6 +797,18 @@ export class UnifiedApprovalService {
};
}
if (ref.provider === "official_oa") return this.getOfficialOaActions(user, approvalId, ref);
if (ref.clientOnly) {
const message = "该薪福通待办已列出,但尚未确认正式审批接口兼容性,请在薪福通客户端处理";
return {
approvalId,
actions: [
{ action: "approve" as const, allowed: false, clientRequired: true, unavailableReason: "provider_not_supported", message },
{ action: "reject" as const, allowed: false, clientRequired: true, unavailableReason: "provider_not_supported", message },
{ action: "return" as const, allowed: false, clientRequired: true, unavailableReason: "provider_not_supported", message },
],
manualAction: { required: true, reason: "unsupported_xft_task_type", message },
};
}
if (!this.xft || !ref.billId) throw new ApprovalSafetyError("薪福通审批源未启用");
let returned;
try { returned = await this.xft.listReturnNodes(user, ref.billId); }
@@ -1060,6 +1097,12 @@ export class UnifiedApprovalService {
if (ref.historyScope) {
throw new ApprovalReferenceValidationError("history_reference_read_only", "历史审批引用仅用于读取,不能提交审批动作");
}
if (ref.provider === "xft" && ref.clientOnly) {
throw new ApprovalReferenceValidationError(
"provider_not_supported",
"该薪福通待办尚未确认正式审批接口兼容性,不能通过 MCP 处理,请在薪福通客户端完成",
);
}
if (ref.provider === "xft" && action === "approve" && ref.allowApprove === false) {
throw new ApprovalReferenceValidationError("action_not_allowed", "该薪福通待办当前不允许同意");
}
@@ -1194,19 +1237,24 @@ export class UnifiedApprovalService {
}
private normalizeXftTodo(user: AuthenticatedDingTalkUser, item: XftTodoItem) {
const billApiSupported = item.capability !== "list_only" && Boolean(item.billId);
const referenceId = billApiSupported ? item.billId : item.taskId;
if (!referenceId) throw new ApprovalSafetyError("薪福通待办缺少安全引用标识");
const ref: ApprovalReference = {
kind: "approval",
userId: user.userId,
provider: "xft",
billId: item.billId,
...(item.allowPass !== undefined ? { allowApprove: item.allowPass } : {}),
...(item.allowReject !== undefined ? { allowReject: item.allowReject } : {}),
billId: billApiSupported ? item.billId : `task:${referenceId}`,
...(billApiSupported ? {} : { clientOnly: true }),
...(billApiSupported && item.allowPass !== undefined ? { allowApprove: item.allowPass } : { allowApprove: false }),
...(billApiSupported && item.allowReject !== undefined ? { allowReject: item.allowReject } : { allowReject: false }),
exp: expiresInMinutes(30),
};
const clientOnlyMessage = "该薪福通待办已列出,但尚未确认正式详情和审批接口兼容性,请在薪福通客户端查看和处理";
return {
approvalId: this.signPortableApprovalReference(ref),
source: "xft",
businessNo: item.billId,
...(billApiSupported ? { businessNo: item.billId } : {}),
title: item.title ?? "薪福通审批",
applicant: {
...(item.applicantName ? { name: item.applicantName } : {}),
@@ -1215,11 +1263,18 @@ export class UnifiedApprovalService {
...(item.applyTime ? { submittedAt: localTime(item.applyTime) } : {}),
...(item.amount !== undefined ? { amount: { value: item.amount, currency: item.currency ?? "CNY" } } : {}),
...(item.reason ? { summary: item.reason } : {}),
availableActions: [
availableActions: billApiSupported ? [
...(item.allowPass === false ? [] : ["approve"]),
...(item.allowReject === false ? [] : ["reject"]),
"return",
],
] : [],
...(billApiSupported ? {} : {
manualAction: {
required: true,
reason: "unsupported_xft_task_type",
message: clientOnlyMessage,
},
}),
};
}
+1 -1
View File
@@ -1 +1 @@
export const APPLICATION_VERSION = "1.1.0-rc.16";
export const APPLICATION_VERSION = "1.1.0-rc.18";
+233 -45
View File
@@ -124,8 +124,9 @@ export class XftOpenApiClient implements XftApprovalProvider {
const scanLimit = hasQuery ? 200 : Math.min(200, Math.max(limit, 1));
const { items, totalCount, truncated } = await this.loadCurrentTodos(identity, scanLimit);
const filtered = items.filter((item) => matchesXftTodoQuery(item, search));
const returnedItems = filtered.slice(0, limit);
return {
items: filtered.slice(0, limit).map(stripRaw),
items: returnedItems.map(stripRaw),
totalCount: hasQuery ? filtered.length : totalCount,
identity: {
enterpriseUserId: identity.enterpriseUserId,
@@ -135,6 +136,8 @@ export class XftOpenApiClient implements XftApprovalProvider {
source: "xft_official_openapi",
realtime: true,
identityMapping: this.api.dingTalkAppCode,
billApiCount: returnedItems.filter((item) => item.capability !== "list_only").length,
clientOnlyCount: returnedItems.filter((item) => item.capability === "list_only").length,
...(truncated ? { truncated: true, scanLimit } : {}),
persistedBusinessData: false,
},
@@ -456,6 +459,7 @@ export class XftOpenApiClient implements XftApprovalProvider {
identity: XftIdentity,
template: XftSubmissionTemplate,
): Promise<XftSubmissionOption[]> {
let customFieldError: unknown;
try {
const rawFields = await this.request(
`/itrip/xft-api/v1/form/customFields/eventSeqId/${encodeURIComponent(template.eventSeqId)}`,
@@ -465,34 +469,46 @@ export class XftOpenApiClient implements XftApprovalProvider {
);
const embedded = extractExpenseTypeOptions(rawFields);
if (embedded.length) return embedded;
} catch {
} catch (error) {
customFieldError = error;
// Some templates do not expose standard expense dictionaries in custom fields.
// Fall through to the enterprise multilevel data-source APIs.
}
const candidates: Array<{ id: string; raw: JsonObject }> = [];
let sourceCount = 0;
for (let page = 0; page < EXPENSE_TYPE_SOURCE_MAX_PAGES; page += 1) {
let response: unknown;
try {
response = await this.request(
"/itrip/xft-api/v1/dataSource/findMultilevelSourcePage",
{ start: page * EXPENSE_TYPE_SOURCE_PAGE_SIZE, limit: EXPENSE_TYPE_SOURCE_PAGE_SIZE },
"POST",
identity,
);
} catch {
return [];
}
const start = page * EXPENSE_TYPE_SOURCE_PAGE_SIZE;
const response = await this.request(
`/itrip/xft-api/v1/dataSource/findMultilevelSourcePage?start=${start}&limit=${EXPENSE_TYPE_SOURCE_PAGE_SIZE}`,
{},
"POST",
identity,
);
const records = pagedRecords(response);
sourceCount += records.length;
for (const record of records) {
const name = stringAt(record, ["sourceName", "dataSourceName", "multilevelSourceName", "displayName", "name", "title"]);
const id = stringAt(record, ["sourceSeqId", "dataSourceSeqId", "multilevelSourceSeqId", "seqId", "id"]);
const name = stringAt(record, [
"selectSourceName", "sourceName", "dataSourceName", "multilevelSourceName", "displayName", "name", "title",
]);
const id = stringAt(record, ["seqId", "sourceSeqId", "dataSourceSeqId", "multilevelSourceSeqId"]);
if (id && name && EXPENSE_TYPE_HINT.test(name)) candidates.push({ id, raw: record });
}
if (records.length < EXPENSE_TYPE_SOURCE_PAGE_SIZE) break;
}
if (!candidates.length) {
if (customFieldError) throw customFieldError;
const sourceResult = sourceCount === 0
? "企业多级数据源列表为空"
: `企业多级数据源共返回 ${sourceCount} 项,但没有费用类型数据源`;
throw new ApprovalSafetyError(
`薪福通费用类型查询没有返回可用选项:提单实时字段未提供内嵌选项,且${sourceResult}。`
+ "薪福通正式 OpenAPI 未提供独立的费用类型目录查询,请在费用管理页面确认配置或使用薪福通客户端完成",
);
}
const output: XftSubmissionOption[] = [];
const detailErrors: unknown[] = [];
for (const candidate of candidates.slice(0, 10)) {
output.push(...extractExpenseTypeOptions(candidate.raw, true));
try {
@@ -503,10 +519,17 @@ export class XftOpenApiClient implements XftApprovalProvider {
identity,
);
output.push(...extractExpenseTypeOptions(detail, true));
} catch {
} catch (error) {
detailErrors.push(error);
// Continue with other matching sources. Empty output fails closed below.
}
}
if (!output.length && detailErrors.length) throw detailErrors[0];
if (!output.length) {
throw new ApprovalSafetyError(
`薪福通费用类型查询没有返回可用选项:已找到 ${candidates.length} 个费用类型数据源,但详情中没有可选择的名称与编码`,
);
}
return uniqueOptions(output);
}
@@ -798,8 +821,9 @@ export class XftOpenApiClient implements XftApprovalProvider {
for (let currentPage = 1; currentPage <= 100 && items.length < limit; currentPage += 1) {
const page = await this.loadTodoPage(identity, currentPage, pageSize, billId);
for (const item of page.items) {
if (seen.has(item.billId)) continue;
seen.add(item.billId);
const identity = item.billId ? `bill:${item.billId}` : item.taskId ? `task:${item.taskId}` : undefined;
if (!identity || seen.has(identity)) continue;
seen.add(identity);
items.push(item);
if (items.length >= limit) break;
}
@@ -816,6 +840,7 @@ export class XftOpenApiClient implements XftApprovalProvider {
for (let currentPage = 1; currentPage <= 100 && items.length < limit; currentPage += 1) {
const page = await this.loadTaskPage(identity, currentPage, pageSize, "done");
for (const item of page.items) {
if (!item.billId || item.capability === "list_only") continue;
if (seen.has(item.billId)) continue;
seen.add(item.billId);
items.push(toHistoryItem(item, "handled"));
@@ -962,28 +987,51 @@ export class XftOpenApiClient implements XftApprovalProvider {
const detailed = objectRecords(detailResult);
const byTaskId = new Map(detailed.map((record) => [stringAt(record, ["todoTaskId"]) ?? "", record]));
const todoRecords = pageRecords.map((record) => ({ ...record, ...(byTaskId.get(stringAt(record, ["todoTaskId"]) ?? "") ?? {}) }));
const relevant = todoRecords.filter((record) => isSmartExpenseTodo(record));
const byBillTodo = new Map<string, JsonObject>();
for (const record of relevant) {
const id = billIdFromTodo(record);
if (id) byBillTodo.set(id, record);
const selectedRecords = billId
? todoRecords.filter((record) => billIdFromTodo(record) === billId)
: todoRecords;
const knownBillIds = uniqueStrings(selectedRecords
.filter((record) => isSmartExpenseTodo(record))
.flatMap((record) => billIdFromTodo(record) ?? []));
const probeBillIds = uniqueStrings(selectedRecords
.filter((record) => !isSmartExpenseTodo(record))
.flatMap((record) => billIdFromTodo(record) ?? []));
const knownSummaries = knownBillIds.length ? await this.loadBillSummaries(identity, knownBillIds) : [];
let probedSummaries: JsonObject[] = [];
let billProbeFailed = false;
if (probeBillIds.length) {
try { probedSummaries = await this.loadBillSummaries(identity, probeBillIds); }
catch { billProbeFailed = true; }
}
const selectedIds = billId ? [...byBillTodo.keys()].filter((id) => id === billId) : [...byBillTodo.keys()];
if (selectedIds.length === 0) return { items: [] as CurrentTodo[], pageRecordCount: pageRecords.length };
const summaries = await this.request(
"/itrip/xft-api/v1/bills/bill-list",
{ billIds: selectedIds.map(businessIdValue), start: 0, limit: selectedIds.length },
"POST",
identity,
);
const byId = new Map(findObjectArrays(summaries).flat().flatMap((item) => {
const byId = new Map([...knownSummaries, ...probedSummaries].flatMap((item) => {
const id = stringAt(item, ["billId", "businessId", "id"]);
return id ? [[id, item] as const] : [];
}));
const items = selectedIds.map((id) => normalizeTodo(id, { ...(byBillTodo.get(id) ?? {}), ...(byId.get(id) ?? {}) }));
const items = selectedRecords.flatMap((record) => {
const id = billIdFromTodo(record);
const summary = id ? byId.get(id) : undefined;
if (id && (isSmartExpenseTodo(record) || summary)) {
return [normalizeTodo(id, { ...record, ...(summary ?? {}) })];
}
if (billId) return [];
const unsupported = normalizeListOnlyTodo(record, id
? billProbeFailed ? "bill_lookup_unavailable" : "unsupported_task_type"
: "missing_bill_identifier");
return unsupported ? [unsupported] : [];
});
return { items, pageRecordCount: pageRecords.length };
}
private async loadBillSummaries(identity: XftIdentity, billIds: string[]): Promise<JsonObject[]> {
const summaries = await this.request(
"/itrip/xft-api/v1/bills/bill-list",
{ billIds: billIds.map(businessIdValue), start: 0, limit: billIds.length },
"POST",
identity,
);
return findObjectArrays(summaries).flat();
}
private async findCurrentTodo(identity: XftIdentity, billId: string): Promise<CurrentTodo | undefined> {
const pageSize = 1000;
for (let currentPage = 1; currentPage <= 100; currentPage += 1) {
@@ -1006,7 +1054,9 @@ export class XftOpenApiClient implements XftApprovalProvider {
const pageSize = 1000;
for (let currentPage = 1; currentPage <= 100; currentPage += 1) {
const page = await this.loadTodoPage(identity, currentPage, pageSize);
for (const item of page.items) if (billIds.has(item.billId)) found.add(item.billId);
for (const item of page.items) {
if (item.billId && billIds.has(item.billId)) found.add(item.billId);
}
if (found.size === billIds.size || page.pageRecordCount < pageSize) return found;
}
throw new ApprovalSafetyError("薪福通待办超过安全分页上限,无法确认单据归属");
@@ -1078,30 +1128,31 @@ export class XftOpenApiClient implements XftApprovalProvider {
signal: AbortSignal.timeout(timeoutMs),
});
} catch (error) {
throw new XftApiError(`请求薪福通正式接口失败:${errorMessage(error)}`, 0, "TRANSPORT_ERROR");
throw xftTransportError(error, pathname, "request", timeoutMs);
}
let text: string;
try { text = await response.text(); }
catch (error) {
throw new XftApiError(`读取薪福通响应失败:${errorMessage(error)}`, 0, "RESPONSE_STREAM_ERROR");
throw xftTransportError(error, pathname, "response", timeoutMs);
}
const operation = xftOperationLabel(pathname);
let parsed: unknown;
try {
const plaintext = this.api.encrypted && /^[0-9a-fA-F]+$/.test(text) && text.length % 2 === 0
? String(sm4.decrypt(text, this.api.authoritySecret.slice(0, 32)))
: text;
parsed = plaintext ? JSON.parse(plaintext) : {};
} catch { throw new XftApiError(`薪福通正式接口返回解密或 JSON 解析失败(HTTP ${response.status})`, response.status); }
parsed = this.decryptEnvelope(parsed);
if (!response.ok) throw new XftApiError(apiMessage(parsed) ?? `薪福通接口 HTTP ${response.status}`, response.status, apiCode(parsed));
} catch { throw new XftApiError(`薪福通${operation}返回解密或 JSON 解析失败(HTTP ${response.status})`, response.status); }
parsed = this.decryptEnvelope(parsed, operation);
if (!response.ok) throw xftResponseError(parsed, response.status, operation);
const code = apiCode(parsed);
if (!isXftSuccessCode(code)) {
throw new XftApiError(apiMessage(parsed) ?? `薪福通接口错误 ${code}`, response.status, code);
throw xftResponseError(parsed, response.status, operation);
}
return unwrapData(parsed);
}
private decryptEnvelope(value: unknown): unknown {
private decryptEnvelope(value: unknown, operation: string): unknown {
if (!this.api.encrypted || !isObject(value)) return value;
const encrypted = typeof value.secretMsg === "string" ? value.secretMsg
: isObject(value.data) && typeof value.data.secretMsg === "string" ? value.data.secretMsg
@@ -1111,11 +1162,114 @@ export class XftOpenApiClient implements XftApprovalProvider {
const plaintext = sm4.decrypt(encrypted, this.api.authoritySecret.slice(0, 32));
return JSON.parse(String(plaintext));
} catch {
throw new XftApiError("薪福通响应解密失败,请核对应用加密配置和 AuthoritySecret", 502, "DECRYPT_FAILED");
throw new XftApiError(`薪福通${operation}响应解密失败,请核对应用加密配置和 AuthoritySecret`, 502, "DECRYPT_FAILED");
}
}
}
type XftTransportPhase = "request" | "response";
interface XftTransportDiagnostic {
operation: string;
phase: XftTransportPhase;
category: string;
causeName?: string;
causeCode?: string;
timeoutMs: number;
}
function xftTransportError(error: unknown, path: string, phase: XftTransportPhase, timeoutMs: number): XftApiError {
const operation = xftOperationLabel(path);
const diagnostic = xftTransportDiagnostic(error, operation, phase, timeoutMs);
// Keep logs useful without recording URLs, query parameters, identities, request bodies, or upstream payloads.
console.error("[dingtalk-approval-mcp] XFT transport failure", diagnostic);
const technical = [
diagnostic.causeCode ? `底层错误码 ${diagnostic.causeCode}` : undefined,
diagnostic.causeName && diagnostic.causeName !== "TypeError" ? `错误类型 ${diagnostic.causeName}` : undefined,
`超时上限 ${timeoutMs}ms`,
].filter(Boolean).join(";");
const prefix = phase === "request" ? "请求" : "读取响应";
const code = phase === "request" ? "TRANSPORT_ERROR" : "RESPONSE_STREAM_ERROR";
return new XftApiError(`薪福通${operation}${prefix}失败:${diagnostic.category}(${technical})`, 0, code);
}
function xftTransportDiagnostic(
error: unknown,
operation: string,
phase: XftTransportPhase,
timeoutMs: number,
): XftTransportDiagnostic {
const chain = errorDiagnosticChain(error);
const causeCode = chain.map((item) => safeDiagnosticCode(item.code)).find(Boolean);
const names = chain.map((item) => safeDiagnosticName(item.name)).filter((name): name is string => Boolean(name));
const causeName = names.find((name) => name !== "Error" && name !== "TypeError")
?? names.find((name) => name !== "Error");
const markers = `${causeCode ?? ""} ${causeName ?? ""}`.toUpperCase();
let category = phase === "response" ? "响应流在读取过程中中断" : "网络传输失败";
if (/TIMEOUT|ETIMEDOUT/.test(markers)) category = phase === "response" ? "读取响应超时" : "连接或等待响应超时";
else if (/ENOTFOUND|EAI_AGAIN|DNS/.test(markers)) category = "DNS 解析失败";
else if (/ECONNREFUSED/.test(markers)) category = "连接被拒绝";
else if (/ECONNRESET|UND_ERR_SOCKET|EPIPE/.test(markers)) category = "网络连接被重置或中断";
else if (/ENETUNREACH|EHOSTUNREACH/.test(markers)) category = "目标网络或主机不可达";
else if (/CERT|TLS|SSL|UNABLE_TO_VERIFY|SELF_SIGNED/.test(markers)) category = "TLS 或证书校验失败";
else if (/ABORT/.test(markers)) category = "请求被取消或超过等待时间";
return { operation, phase, category, ...(causeName ? { causeName } : {}), ...(causeCode ? { causeCode } : {}), timeoutMs };
}
function errorDiagnosticChain(error: unknown): Array<Record<string, unknown>> {
const output: Array<Record<string, unknown>> = [];
const pending: unknown[] = [error];
const seen = new Set<object>();
while (pending.length && output.length < 8) {
const current = pending.shift();
if ((!current || typeof current !== "object") && typeof current !== "function") continue;
if (seen.has(current as object)) continue;
seen.add(current as object);
const record = current as Record<string, unknown>;
output.push(record);
if (record.cause) pending.push(record.cause);
if (Array.isArray(record.errors)) pending.push(...record.errors);
}
return output;
}
function safeDiagnosticCode(value: unknown): string | undefined {
return typeof value === "string" && /^[A-Z][A-Z0-9_]{1,63}$/.test(value) ? value : undefined;
}
function safeDiagnosticName(value: unknown): string | undefined {
return typeof value === "string" && /^[A-Za-z][A-Za-z0-9]{0,63}(?:Error|Exception)$/.test(value) ? value : undefined;
}
function xftResponseError(value: unknown, status: number, operation: string): XftApiError {
const code = apiCode(value);
const visibleCode = safeXftApiCode(code);
const metadata = [`操作:${operation}`, `HTTP ${status}`, ...(visibleCode ? [`薪福通错误码 ${visibleCode}`] : [])].join(";");
return new XftApiError(`${apiMessage(value) ?? "薪福通接口返回业务错误"}(${metadata})`, status, code);
}
function safeXftApiCode(value: string | undefined): string | undefined {
return value && /^[A-Za-z0-9_.:-]{1,64}$/.test(value) ? value : undefined;
}
function xftOperationLabel(path: string): string {
if (path.includes("user-mapping")) return "用户身份映射";
if (path.includes("member/get/by-id")) return "当前用户成员档案";
if (path.includes("todo-task/page/by-user")) return "待办或已处理列表";
if (path.includes("todo-task/detail/by-ids")) return "待办任务详情";
if (path.endsWith("/bill-list")) return "单据摘要";
if (path.includes("/form/query-event-list")) return "提单模板列表";
if (path.includes("/form/customFields/eventSeqId/")) return "提单实时字段";
if (path.includes("/dataSource/findMultilevelSourcePage")) return "费用类型数据源列表";
if (path.includes("/dataSource/getMultiLevelDetail/")) return "费用类型数据源详情";
if (path.includes("/common/city/getAllCity")) return "城市选项";
if (path.includes("/approve/")) return "审批写操作";
if (path.includes("/create")) return "审批发起";
if (path.includes("/detail") || path.includes("/taskHistory/")) return "审批详情";
if (path.includes("/queryBillByCondition")) return "本人发起记录";
return "业务接口";
}
function isUncertainXftWriteError(error: unknown): boolean {
return !(error instanceof ApprovalSafetyError)
&& (!(error instanceof XftApiError)
@@ -1134,6 +1288,7 @@ function normalizeTodo(billId: string, raw: JsonObject): CurrentTodo {
return {
source: "xft",
billId,
capability: "bill_api",
taskId: stringAt(raw, ["processTaskId", "todoTaskId"]),
eventType: stringAt(raw, ["eventType", "billType", "eventCode"]),
title: stringAt(raw, ["billName", "title", "eventName", "eventNam", "billTypeName"]) ?? parsedContent.title,
@@ -1149,11 +1304,35 @@ function normalizeTodo(billId: string, raw: JsonObject): CurrentTodo {
};
}
function normalizeListOnlyTodo(
raw: JsonObject,
unavailableReason: NonNullable<XftTodoItem["unavailableReason"]>,
): CurrentTodo | undefined {
const taskId = stringAt(raw, ["todoTaskId", "businessId", "groupId", "REFNBR", "referenceNumber"]);
if (!taskId) return undefined;
const parsedContent = parseTodoContent(stringAt(raw, ["content"]));
return {
source: "xft",
taskId,
capability: "list_only",
unavailableReason,
title: stringAt(raw, ["billName", "title", "eventName", "eventNam", "billTypeName"]) ?? parsedContent.title ?? "薪福通待办",
applicantName: stringAt(raw, ["applyUserName", "applicantName", "aplNam", "empName", "applyEmpName"]) ?? parsedContent.applicantName,
applicantDepartment: stringAt(raw, ["departmentName", "deptName", "aplDepNam", "applyDeptName"]) ?? parsedContent.department,
applyTime: stringAt(raw, ["applyTime", "aplTime", "submitTime", "createTime"]),
allowPass: false,
allowReject: false,
raw,
};
}
function objectRecords(value: unknown): JsonObject[] {
if (Array.isArray(value)) return value.filter(isObject);
return findObjectArrays(value).flat();
}
function uniqueStrings(values: string[]): string[] { return [...new Set(values)]; }
function isSmartExpenseTodo(record: JsonObject): boolean {
const appCode = stringAt(record, ["appCode", "APPCOD"])?.toLowerCase() ?? "";
const businessCode = stringAt(record, ["businessCode", "BUSTYP"])?.toUpperCase() ?? "";
@@ -1193,12 +1372,21 @@ function stripRaw(item: CurrentTodo): XftTodoItem {
}
function toHistoryItem(item: CurrentTodo, historyScope: ApprovalHistoryScope): CurrentHistory {
const { allowPass: _allowPass, allowReject: _allowReject, ...rest } = item;
const {
allowPass: _allowPass,
allowReject: _allowReject,
capability: _capability,
unavailableReason: _unavailableReason,
billId,
...rest
} = item;
if (!billId) throw new ApprovalSafetyError("薪福通历史任务缺少正式单据标识");
const statuses = collectStatusValues(item.raw);
const status = statuses.billStatus ?? statuses.billSts ?? statuses.status;
const completedAt = normalizedHistoryTime(item.raw, ["finishTime", "finishedAt", "completedAt", "dealTime", "approveTime", "endTime"]);
return {
...rest,
billId,
historyScope,
...(status ? { status, statusName: xftBillStatusName(status) } : {}),
...(completedAt ? { completedAt } : {}),
@@ -1394,11 +1582,11 @@ function extractExpenseTypeOptions(value: unknown, trustedExpenseSource = false)
]);
const code = explicitCode ?? (path.length > 0 ? stringAt(record, [
"optionCode", "optionNumber", "itemCode", "itemNumber", "nodeCode", "nodeNumber", "dataCode", "dataNumber",
"sourceOptionCode", "sourceOptionNumber", "code", "number", "value", "key",
"sourceOptionCode", "sourceOptionNumber", "code", "number", "value", "key", "seqId",
]) : undefined);
const label = stringAt(record, [
"expenseName", "expenseTypeName", "expTypeName", "optionName", "itemName", "nodeName", "dataName",
"sourceOptionName", "label", "text", "displayName", "name", "title",
"sourceOptionName", "selectOptionName", "label", "text", "displayName", "name", "title",
]);
if (code && label) candidates.push({ code, label });
}
+5 -2
View File
@@ -6,8 +6,10 @@ export interface AuthenticatedDingTalkUser {
export interface XftTodoItem {
source: "xft";
billId: string;
billId?: string;
taskId?: string;
capability?: "bill_api" | "list_only";
unavailableReason?: "unsupported_task_type" | "missing_bill_identifier" | "bill_lookup_unavailable";
eventSeqId?: string;
title?: string;
applicantName?: string;
@@ -28,7 +30,8 @@ export interface ApprovalHistoryRange {
endTime: number;
}
export interface XftHistoryItem extends Omit<XftTodoItem, "allowPass" | "allowReject"> {
export interface XftHistoryItem extends Omit<XftTodoItem, "allowPass" | "allowReject" | "billId" | "capability" | "unavailableReason"> {
billId: string;
historyScope: ApprovalHistoryScope;
status?: string;
statusName?: string;
+59
View File
@@ -388,6 +388,65 @@ test("XFT action restrictions are enforced during zero-write preflight", async (
assert.equal(writes, 0);
});
test("unadapted XFT todos stay visible but detail and every write action remain client-only", async () => {
let detailCalls = 0;
let writes = 0;
const ding = {
async listMyTodos() {
return { items: [{
source: "official_oa", processInstanceId: "mixed-instance", taskId: "mixed-task", title: "钉钉审批", raw: {},
}] };
},
} as unknown as ApprovalService;
const xft = {
async listMyTodos() {
return {
items: [{
source: "xft", taskId: "provider-task-secret", capability: "list_only", unavailableReason: "unsupported_task_type",
title: "薪福通其他审批", allowPass: false, allowReject: false,
}],
diagnostics: { billApiCount: 0, clientOnlyCount: 1 },
};
},
async getTodoDetail() { detailCalls += 1; return {}; },
async listReturnNodes() { detailCalls += 1; return []; },
async processApprovalBatch() { writes += 1; return { results: [{ success: true }] }; },
};
const service = new UnifiedApprovalService(config(), ding, xft as never);
const user = { userId: "user-1" };
const listed = await service.listTodos(user, "all", 10);
assert.equal(listed.returned, 2);
assert.equal("selectionId" in listed, false, "a mixed result containing client-only XFT tasks cannot become a batch selection");
assert.equal(listed.sourceStatus.xft?.billApiCount, 0);
assert.equal(listed.sourceStatus.xft?.clientOnlyCount, 1);
const item = listed.items.find((candidate) => candidate.title === "薪福通其他审批")!;
assert.equal("businessNo" in item, false);
assert.deepEqual(item.availableActions, []);
assert.equal((item.manualAction as Record<string, unknown>).reason, "unsupported_xft_task_type");
const approvalId = String(item.approvalId);
assert.match(approvalId, /^v4\./);
assert.doesNotMatch(approvalId, /provider-task-secret/);
const detail = await service.getDetail(user, approvalId) as unknown as Record<string, unknown>;
assert.equal(detail.status, "client_required");
assert.deepEqual(detail.diagnostics, { realtime: true, persistedBusinessData: false, upstreamDetailRequested: false });
assert.equal(detailCalls, 0, "client-only detail must not call an unverified upstream detail API");
const actions = await service.getActions(user, approvalId);
assert.deepEqual(actions.actions.map((action) => [action.action, action.allowed]), [
["approve", false], ["reject", false], ["return", false],
]);
assert.equal((actions.manualAction as Record<string, unknown>).reason, "unsupported_xft_task_type");
assert.equal(detailCalls, 0, "client-only action discovery must not call an unverified upstream action API");
const processed = await service.processBatch(user, "approve", "同意", [{ approvalId }]);
assert.equal(processed.status, "preflight_failed");
assert.equal(processed.writesStarted, false);
assert.equal(processed.invalidItems?.[0]?.code, "provider_not_supported");
assert.equal(writes, 0);
});
test("approval reference expiry is checked once before the batch starts", async () => {
const originalNow = Date.now;
let now = originalNow();
+251 -37
View File
@@ -186,30 +186,83 @@ test("Xft OpenAPI does not turn missing or blank amounts into zero", async () =>
}
});
test("Xft OpenAPI continues past an irrelevant full first todo page", async () => {
test("Xft OpenAPI keeps unadapted personal todos visible instead of filtering them", 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",
}] } });
return encrypted({ code: "0", data: { records: [{
todoTaskId: "OTHER-1", groupId: "OTHER-1", appCode: "other-app", title: "其他审批",
}], totalSize: 1 } });
}
if (path.endsWith("todo-task/detail/by-ids")) return encrypted({ code: "0", data: [] });
throw new Error(`unexpected ${path}`);
});
const result = await client.listMyTodos({ userId: "ding-u1" }, 10);
assert.equal(result.items[0]?.title, "其他审批");
assert.equal(result.items[0]?.capability, "list_only");
assert.equal(result.items[0]?.unavailableReason, "missing_bill_identifier");
assert.equal(result.diagnostics.billApiCount, 0);
assert.equal(result.diagnostics.clientOnlyCount, 1);
assert.deepEqual(pages, [1]);
});
test("Xft OpenAPI upgrades an unknown app todo when the official bill API recognizes it", async () => {
const targetBillId = "2026081400000001";
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-INVITATION", groupId: targetBillId, appCode: "new-approval-app",
}], totalSize: 1 } });
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: "目标费用单",
billId: targetBillId, eventType: "INVITATION_APPLY", 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]);
const result = await client.listMyTodos({ userId: "ding-u1" }, 10);
assert.equal(result.items[0]?.billId, targetBillId);
assert.equal(result.items[0]?.title, "招待费用申请单");
assert.equal(result.items[0]?.capability, "bill_api");
assert.equal(result.diagnostics.billApiCount, 1);
assert.equal(result.diagnostics.clientOnlyCount, 0);
});
test("Xft OpenAPI degrades a failed unknown bill probe without hiding known expense todos", async () => {
const knownBillId = "2026081400000001";
const unknownBillId = "2026081400000002";
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")) return encrypted({ code: "0", data: { records: [{
todoTaskId: "TD-KNOWN", groupId: knownBillId, appCode: "xft-itrip",
}, {
todoTaskId: "TD-UNKNOWN", groupId: unknownBillId, appCode: "new-approval-app", title: "新类型审批",
}], totalSize: 2 } });
if (path.endsWith("todo-task/detail/by-ids")) return encrypted({ code: "0", data: [] });
if (path.endsWith("bill-list")) {
const ids = (decodedBody(init).billIds as Array<string | number>).map(String);
if (ids.includes(unknownBillId)) return encrypted({ code: "BIZ_LOOKUP_FAILED", message: "lookup unavailable" });
return encrypted({ code: "0", data: { list: [{ billId: knownBillId, eventType: "COMMON_REIM", title: "费用报销单" }] } });
}
throw new Error(`unexpected ${path}`);
});
const result = await client.listMyTodos({ userId: "ding-u1" }, 10);
assert.equal(result.items.length, 2);
assert.equal(result.items[0]?.billId, knownBillId);
assert.equal(result.items[0]?.capability, "bill_api");
assert.equal(result.items[1]?.title, "新类型审批");
assert.equal(result.items[1]?.billId, undefined);
assert.equal(result.items[1]?.capability, "list_only");
assert.equal(result.items[1]?.unavailableReason, "bill_lookup_unavailable");
assert.equal(result.diagnostics.billApiCount, 1);
assert.equal(result.diagnostics.clientOnlyCount, 1);
});
test("Xft write transport failures are uncertain while deterministic rejections stay failed", async () => {
@@ -273,10 +326,37 @@ test("Xft OpenAPI rejects non-exact, missing-identity, and ambiguous user mappin
});
test("Xft OpenAPI surfaces XFT framework business errors", async () => {
const client = new XftOpenApiClient(config(), async () => encrypted({
SYCOMRETZ: [{ ERRCOD: "SYOPA16", ERRMSG: "应用状态错误" }],
}));
await assert.rejects(client.authStatus({ userId: "ding-u1" }), /应用状态错误/);
const client = new XftOpenApiClient(config(), async () => new Response(
String(sm4.encrypt(JSON.stringify({
SYCOMRETZ: [{ ERRCOD: "SYOPA16", ERRMSG: "应用状态错误" }],
}), privateKey.slice(0, 32))),
{ status: 403 },
));
await assert.rejects(
client.authStatus({ userId: "ding-u1" }),
/应用状态错误(操作:用户身份映射;HTTP 403;薪福通错误码 SYOPA16)/,
);
});
test("Xft OpenAPI exposes safe transport diagnostics without leaking the native error message", async () => {
for (const [cause, expected] of [
[Object.assign(new Error("connect ECONNRESET https://user:password@private.invalid/?token=secret"), { code: "ECONNRESET" }),
/用户身份映射请求失败:网络连接被重置或中断(底层错误码 ECONNRESET;超时上限 2000ms)/],
[Object.assign(new Error("getaddrinfo ENOTFOUND private.invalid"), { code: "ENOTFOUND" }),
/用户身份映射请求失败:DNS 解析失败(底层错误码 ENOTFOUND;超时上限 2000ms)/],
[Object.assign(new Error("connect timed out"), { code: "UND_ERR_CONNECT_TIMEOUT", name: "ConnectTimeoutError" }),
/用户身份映射请求失败:连接或等待响应超时(底层错误码 UND_ERR_CONNECT_TIMEOUT;错误类型 ConnectTimeoutError;超时上限 2000ms)/],
] as const) {
const client = new XftOpenApiClient(config(), async () => {
throw new TypeError("fetch failed", { cause });
});
await assert.rejects(client.authStatus({ userId: "ding-u1" }), (error: unknown) => {
assert.ok(error instanceof Error);
assert.match(error.message, expected);
assert.doesNotMatch(error.message, /password|private\.invalid|token=secret|fetch failed/);
return true;
});
}
});
test("Xft OpenAPI preserves official GET endpoint query parameters in the signed target", async () => {
@@ -454,7 +534,8 @@ test("Xft submission options expose only departments, cities, and the matching e
let expenseSourceRequests = 0;
let unrelatedSourceRequests = 0;
const client = new XftOpenApiClient(config(), async (input, init) => {
const path = new URL(String(input)).pathname;
const url = new URL(String(input));
const path = url.pathname;
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
if (path.endsWith("form/query-event-list")) return encrypted({ code: "0", data: { list: [templateRecord] } });
if (path.endsWith("member/get/by-id")) {
@@ -476,27 +557,32 @@ test("Xft submission options expose only departments, cities, and the matching e
}
if (path.includes("form/customFields/eventSeqId")) return encrypted({ code: "0", data: [] });
if (path.endsWith("dataSource/findMultilevelSourcePage")) {
assert.deepEqual(decodedBody(init), { start: 0, limit: 100 });
return encrypted({ code: "0", data: { records: [
{ sourceSeqId: "DS-EXP", sourceName: "企业费用类型" },
{ sourceSeqId: "DS-PROJECT", sourceName: "项目档案" },
] } });
assert.equal(init?.method, "POST");
assert.equal(url.searchParams.get("start"), "0");
assert.equal(url.searchParams.get("limit"), "100");
assert.deepEqual(decodedBody(init), {});
return encrypted({ success: true, message: "调用成功!", totalCount: 2, list: [
{ seqId: 150, id: "DS-EXP", selectSourceName: "企业费用类型" },
{ seqId: 151, id: "DS-PROJECT", selectSourceName: "项目档案" },
] });
}
if (path.endsWith("dataSource/getMultiLevelDetail/DS-EXP")) {
if (path.endsWith("dataSource/getMultiLevelDetail/150")) {
expenseSourceRequests += 1;
return encrypted({ code: "0", data: {
sourceSeqId: "DS-EXP",
sourceName: "企业费用类型",
levels: [{ nodes: [
{ optionNumber: "TRAFFIC", optionName: "交通费" },
{ optionNumber: "HOTEL", optionName: "住宿费" },
{ optionNumber: "HIDDEN", optionName: "停用费用", selectable: false },
] }],
return encrypted({ success: true, message: "调用成功!", data: {
id: "DS-EXP",
selectSourceName: "企业费用类型",
optionSaveDtoList: [
{ seqId: 601, selectOptionName: "交通费", optionSaveDtoList: null },
{ seqId: 602, selectOptionName: "住宿费", optionSaveDtoList: null },
{ seqId: 603, selectOptionName: "停用费用", optionSaveDtoList: null, selectable: false },
],
} });
}
if (path.endsWith("dataSource/getMultiLevelDetail/DS-PROJECT")) {
if (path.endsWith("dataSource/getMultiLevelDetail/151")) {
unrelatedSourceRequests += 1;
return encrypted({ code: "0", data: { nodes: [{ optionNumber: "P1", optionName: "项目一" }] } });
return encrypted({ success: true, message: "调用成功!", data: {
optionSaveDtoList: [{ seqId: 701, selectOptionName: "项目一", optionSaveDtoList: null }],
} });
}
throw new Error(`unexpected ${path}`);
});
@@ -513,7 +599,7 @@ test("Xft submission options expose only departments, cities, and the matching e
assert.deepEqual(cachedCities.items, [{ code: "441900", label: "东莞市" }]);
assert.equal(cityRequests, 1);
const expenseTypes = await client.getSubmissionOptions({ userId: "ding-u1" }, template, "expense_type", "住宿", 10);
assert.deepEqual(expenseTypes.items, [{ code: "HOTEL", label: "住宿费" }]);
assert.deepEqual(expenseTypes.items, [{ code: "602", label: "住宿费" }]);
assert.equal(expenseTypes.total, 1);
assert.equal(expenseTypes.hasMore, false);
assert.equal(expenseSourceRequests, 1);
@@ -669,15 +755,143 @@ test("Xft expense types prefer user-readable live template options and fail clos
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
if (path.endsWith("form/query-event-list")) return encrypted({ code: "0", data: { list: [templateRecord] } });
if (path.includes("form/customFields/eventSeqId")) return encrypted({ code: "0", data: [] });
if (path.endsWith("dataSource/findMultilevelSourcePage")) return encrypted({ code: "0", data: { records: [
{ sourceSeqId: "DS-PROJECT", sourceName: "项目档案" },
] } });
if (path.endsWith("dataSource/findMultilevelSourcePage")) return encrypted({
success: true, message: "调用成功!", totalCount: 1, list: [
{ seqId: 100, id: "DS-PROJECT", selectSourceName: "项目档案" },
],
});
throw new Error(`unexpected ${path}`);
});
const unavailableTemplate = (await unavailable.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!;
await assert.rejects(
unavailable.getSubmissionOptions({ userId: "ding-u1" }, unavailableTemplate, "expense_type"),
/暂时无法实时获取可选费用类型/,
/提单实时字段未提供内嵌选项,且企业多级数据源共返回 1 项,但没有费用类型数据源.*正式 OpenAPI 未提供独立的费用类型目录查询/,
);
});
test("Xft expense type lookup explains a successful empty official data-source response", async () => {
const templateRecord = {
eventSeqId: 16, eventNumber: "EXP-16", eventName: "费用报销单", eventStatus: "1", eventType: "COMMON_REIM",
};
const client = new XftOpenApiClient(config(), async (input) => {
const path = new URL(String(input)).pathname;
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
if (path.endsWith("form/query-event-list")) return encrypted({ code: "0", data: { list: [templateRecord] } });
if (path.includes("form/customFields/eventSeqId")) return encrypted({ code: "0", data: [] });
if (path.endsWith("dataSource/findMultilevelSourcePage")) return encrypted({
success: true, message: "调用成功!", totalCount: 0, list: [],
});
throw new Error(`unexpected ${path}`);
});
const template = (await client.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!;
await assert.rejects(
client.getSubmissionOptions({ userId: "ding-u1" }, template, "expense_type"),
/提单实时字段未提供内嵌选项,且企业多级数据源列表为空.*请在费用管理页面确认配置或使用薪福通客户端完成/,
);
});
test("Xft expense type lookup follows the official data-source paging and detail contract", async () => {
const templateRecord = {
eventSeqId: 17, eventNumber: "EXP-17", eventName: "费用报销单", eventStatus: "1", eventType: "COMMON_REIM",
};
let listRequests = 0;
let detailRequests = 0;
const client = new XftOpenApiClient(config(), async (input, init) => {
const url = new URL(String(input));
const path = url.pathname;
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
if (path.endsWith("form/query-event-list")) return encrypted({ code: "0", data: { list: [templateRecord] } });
if (path.includes("form/customFields/eventSeqId")) return encrypted({ code: "0", data: [] });
if (path.endsWith("dataSource/findMultilevelSourcePage")) {
listRequests += 1;
assert.equal(init?.method, "POST");
assert.equal(url.searchParams.get("start"), "0");
assert.equal(url.searchParams.get("limit"), "100");
assert.deepEqual(decodedBody(init), {});
return encrypted({
success: true, message: "调用成功!", totalCount: 1, list: [
{ seqId: 150, id: "SOUR01", selectSourceName: "企业费用类型" },
],
});
}
if (path.endsWith("dataSource/getMultiLevelDetail/150")) {
detailRequests += 1;
assert.equal(init?.method, "GET");
assert.equal(init?.body, undefined);
return encrypted({
success: true,
message: "调用成功!",
data: {
id: "SOUR01",
selectSourceName: "企业费用类型",
optionSaveDtoList: [{
seqId: 1214876,
selectOptionName: "交通费",
optionSaveDtoList: [{ seqId: 1214877, selectOptionName: "出租车费", optionSaveDtoList: null }],
}],
},
});
}
throw new Error(`unexpected ${path}`);
});
const template = (await client.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!;
const result = await client.getSubmissionOptions({ userId: "ding-u1" }, template, "expense_type");
assert.equal(listRequests, 1);
assert.equal(detailRequests, 1);
assert.deepEqual(result.items, [
{ code: "1214876", label: "交通费" },
{ code: "1214877", label: "出租车费" },
]);
});
test("Xft expense type lookup preserves data-source API errors instead of replacing them with an empty result", async () => {
const templateRecord = {
eventSeqId: 18, eventNumber: "EXP-18", eventName: "费用报销单", eventStatus: "1", eventType: "COMMON_REIM",
};
const client = new XftOpenApiClient(config(), async (input) => {
const path = new URL(String(input)).pathname;
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
if (path.endsWith("form/query-event-list")) return encrypted({ code: "0", data: { list: [templateRecord] } });
if (path.includes("form/customFields/eventSeqId")) return encrypted({ code: "0", data: [] });
if (path.endsWith("dataSource/findMultilevelSourcePage")) {
return new Response(String(sm4.encrypt(JSON.stringify({
code: "NO_PERMISSION", message: "未开通多级数据源查询权限",
}), privateKey.slice(0, 32))), { status: 403 });
}
throw new Error(`unexpected ${path}`);
});
const template = (await client.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!;
await assert.rejects(
client.getSubmissionOptions({ userId: "ding-u1" }, template, "expense_type"),
/未开通多级数据源查询权限(操作:费用类型数据源列表;HTTP 403;薪福通错误码 NO_PERMISSION)/,
);
});
test("Xft expense type lookup preserves a failed matching data-source detail", async () => {
const templateRecord = {
eventSeqId: 19, eventNumber: "EXP-19", eventName: "费用报销单", eventStatus: "1", eventType: "COMMON_REIM",
};
const client = new XftOpenApiClient(config(), async (input) => {
const path = new URL(String(input)).pathname;
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
if (path.endsWith("form/query-event-list")) return encrypted({ code: "0", data: { list: [templateRecord] } });
if (path.includes("form/customFields/eventSeqId")) return encrypted({ code: "0", data: [] });
if (path.endsWith("dataSource/findMultilevelSourcePage")) return encrypted({
success: true, message: "调用成功!", totalCount: 1, list: [
{ seqId: 151, id: "SOUR02", selectSourceName: "企业费用类型" },
],
});
if (path.endsWith("dataSource/getMultiLevelDetail/151")) {
throw new TypeError("fetch failed", {
cause: Object.assign(new Error("socket closed with internal address"), { code: "UND_ERR_SOCKET" }),
});
}
throw new Error(`unexpected ${path}`);
});
const template = (await client.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!;
await assert.rejects(
client.getSubmissionOptions({ userId: "ding-u1" }, template, "expense_type"),
/费用类型数据源详情请求失败:网络连接被重置或中断(底层错误码 UND_ERR_SOCKET;超时上限 2000ms)/,
);
});