fix: list unadapted XFT approval tasks safely

This commit is contained in:
2026-08-29 18:09:29 +00:00
parent b00c22d74e
commit f301cef3e8
9 changed files with 283 additions and 45 deletions
+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,
},
}),
};
}
+84 -20
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,
},
@@ -818,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;
}
@@ -836,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"));
@@ -982,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) {
@@ -1026,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("薪福通待办超过安全分页上限,无法确认单据归属");
@@ -1258,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,
@@ -1273,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() ?? "";
@@ -1317,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 } : {}),
+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;