feat: support secure XFT submission attachments
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:
2026-09-01 03:44:36 +00:00
parent ba2cfa48cb
commit b7313ab74d
21 changed files with 1078 additions and 98 deletions
+208 -5
View File
@@ -1,7 +1,11 @@
#!/usr/bin/env node
import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import {
chmodSync, closeSync, constants as fsConstants, cpSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync,
readFileSync, renameSync, rmSync, writeFileSync,
} from "node:fs";
import { homedir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
@@ -11,6 +15,11 @@ import { z } from "zod";
import { APPROVAL_BATCH_LIMIT } from "./approval-contract.js";
import { isDirectEntrypoint } from "./entrypoint.js";
import { APPLICATION_VERSION } from "./version.js";
import {
XFT_ATTACHMENT_MAX_FILE_BYTES,
XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION,
XFT_ATTACHMENT_MAX_TOTAL_BYTES,
} from "./xft.js";
interface BridgeState {
client?: { client_id: string; client_secret?: string };
@@ -39,6 +48,23 @@ export const APPROVAL_ONBOARDING_EXAMPLES = [
const REMOTE_WRITE_TIMEOUT_MS = 45_000;
interface LocalSubmissionAttachment {
fieldName: string;
localPath: string;
displayName?: string;
size?: number;
modifiedAt?: string;
sha256?: string;
}
interface InspectedLocalAttachment {
fieldName: string;
fileName: string;
size: number;
sha256: string;
content: Buffer;
}
class AuthRequired extends Error {
constructor(readonly challenge: NonNullable<BridgeState["device"]>) { super("需要钉钉登录"); }
}
@@ -100,6 +126,32 @@ export class RemoteBridge {
});
}
async prepareSubmission(args: Record<string, unknown>): Promise<any> {
const attachments = inspectLocalSubmissionAttachments(args.attachments);
return this.call("approval_prepare_submission", {
...args,
attachments: attachments.map(localAttachmentMetadata),
});
}
async submitApproval(args: Record<string, unknown>): Promise<any> {
const attachments = inspectLocalSubmissionAttachments(args.attachments);
if (!attachments.length) return this.call("approval_submit", { ...args, attachments: [] });
const confirmationId = typeof args.confirmationId === "string" ? args.confirmationId : "";
if (!confirmationId) throw new Error("附件提交缺少最终确认,请重新预览并取得用户确认");
try {
const uploaded = [];
for (const attachment of attachments) {
const result = await this.uploadSubmissionAttachment(attachment, confirmationId);
uploaded.push({ ...localAttachmentMetadata(attachment), attachmentRef: result.attachmentRef });
}
return this.call("approval_submit", { ...args, attachments: uploaded });
} catch (error) {
if (error instanceof AuthRequired) return this.authResult(error.challenge);
throw error;
}
}
private async withCallSlot<T>(work: () => Promise<T>): Promise<T> {
if (this.activeCalls < 3) this.activeCalls += 1;
else await new Promise<void>((resolve) => this.callWaiters.push(resolve));
@@ -111,6 +163,53 @@ export class RemoteBridge {
}
}
private async uploadSubmissionAttachment(
attachment: InspectedLocalAttachment,
confirmationId: string,
): Promise<{ attachmentRef: string }> {
await this.tryCompleteDeviceLogin();
if (!this.usableAccessToken()) await this.refreshAccessToken();
if (!this.usableAccessToken()) throw new AuthRequired(await this.startDeviceLogin());
let response: Response;
try {
response = await this.fetchFn(new URL("/xft/submission-attachments", this.origin), {
method: "POST",
headers: {
authorization: `Bearer ${this.state.tokens!.access_token}`,
"content-type": "application/octet-stream",
"content-length": String(attachment.size),
"x-dingmcp-field-name": encodeURIComponent(attachment.fieldName),
"x-dingmcp-file-name": encodeURIComponent(attachment.fileName),
"x-dingmcp-file-sha256": attachment.sha256,
"x-dingmcp-confirmation-id": confirmationId,
},
body: attachment.content.buffer.slice(
attachment.content.byteOffset,
attachment.content.byteOffset + attachment.content.byteLength,
) as ArrayBuffer,
signal: AbortSignal.timeout(Math.max(REMOTE_WRITE_TIMEOUT_MS, 120_000)),
});
} catch (error) {
throw new Error(`附件“${attachment.fileName}”上传到审批网关时连接失败;审批尚未创建,请勿自动重试`, { cause: error });
}
let value: Record<string, unknown>;
try { value = await response.json() as Record<string, unknown>; }
catch { throw new Error(`附件“${attachment.fileName}”上传失败:审批网关返回了无法解析的响应`); }
if (!response.ok || typeof value.attachmentRef !== "string") {
const detail = typeof value.error === "string" ? value.error : `HTTP ${response.status}`;
throw new Error(`附件“${attachment.fileName}”上传失败:${detail}`);
}
if (
value.fieldName !== attachment.fieldName
|| value.fileName !== attachment.fileName
|| value.size !== attachment.size
|| value.sha256 !== attachment.sha256
) {
throw new Error(`附件“${attachment.fileName}”上传响应与本地文件不一致,审批尚未创建`);
}
return { attachmentRef: value.attachmentRef };
}
private async remoteClient(): Promise<Client> {
if (this.client && this.usableAccessToken()) return this.client;
if (this.client) {
@@ -420,6 +519,101 @@ function isExplicitOAuthInvalidation(status: number, value: Record<string, unkno
&& ["invalid_grant", "invalid_client", "unauthorized_client", "access_denied", "expired_token"].includes(code);
}
function inspectLocalSubmissionAttachments(value: unknown): InspectedLocalAttachment[] {
if (value === undefined) return [];
if (!Array.isArray(value)) throw new Error("attachments 必须是本机附件数组");
if (value.length > XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION) {
throw new Error(`一次薪福通提单最多选择 ${XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION} 个附件`);
}
const attachments = value.map((item) => inspectLocalSubmissionAttachment(item));
const total = attachments.reduce((sum, attachment) => sum + attachment.size, 0);
if (total > XFT_ATTACHMENT_MAX_TOTAL_BYTES) throw new Error("一次薪福通提单的附件总大小不能超过 50MB");
return attachments;
}
function inspectLocalSubmissionAttachment(value: unknown): InspectedLocalAttachment {
const attachment = isObject(value) ? value as unknown as LocalSubmissionAttachment : undefined;
if (!attachment || typeof attachment.fieldName !== "string" || !attachment.fieldName.trim()) {
throw new Error("附件缺少实时表单中的字段名称");
}
if (typeof attachment.localPath !== "string" || !attachment.localPath.trim()) {
throw new Error("附件缺少用户明确选择的本机文件");
}
if (!isAbsolute(attachment.localPath)) throw new Error("附件必须使用本机绝对路径,请重新选择文件");
const resolvedPath = resolve(attachment.localPath);
const fileName = basename(resolvedPath);
if (attachment.displayName !== undefined && attachment.displayName !== fileName) {
throw new Error(`附件“${attachment.displayName}”与当前选择的文件名不一致,请重新选择`);
}
if (!fileName || fileName.length > 240 || /[\r\n\0"]/u.test(fileName)) throw new Error("附件文件名无效,请重新命名后选择");
let before;
let fd: number | undefined;
try {
before = lstatSync(resolvedPath);
if (!before.isFile() || before.isSymbolicLink()) throw new Error("not a regular file");
fd = openSync(resolvedPath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
const opened = fstatSync(fd);
if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino) throw new Error("file changed while opening");
if (opened.size <= 0 || opened.size > XFT_ATTACHMENT_MAX_FILE_BYTES) throw new Error("invalid file size");
const content = readFileSync(fd);
const after = fstatSync(fd);
if (after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || content.length !== opened.size) {
throw new Error("file changed while reading");
}
validateLocalInvoiceAttachment(fileName, content);
const sha256 = createHash("sha256").update(content).digest("hex");
if (attachment.size !== undefined && attachment.size !== content.length) {
throw new Error("stored size changed");
}
if (attachment.sha256 !== undefined && attachment.sha256 !== sha256) {
throw new Error("stored hash changed");
}
return {
fieldName: attachment.fieldName.trim(),
fileName,
size: content.length,
sha256,
content,
};
} catch (error) {
const reason = error instanceof Error ? error.message : "file validation failed";
if (reason === "invalid file type") throw new Error(`附件“${fileName}”不是支持的 PDF、OFD、JPG、PNG、XML 或 ZIP 文件`);
if (reason === "invalid file size") throw new Error(`附件“${fileName}”必须大于 0 字节且单个不超过 50MB`);
if (reason === "stored hash changed" || reason === "stored size changed" || reason === "file changed while reading") {
throw new Error(`附件“${fileName}”自保存草稿或预览后发生变化,请重新选择并确认`);
}
throw new Error(`无法安全读取附件“${fileName}”;请选择未经过符号链接的普通文件`);
} finally {
if (fd !== undefined) closeSync(fd);
}
}
function validateLocalInvoiceAttachment(fileName: string, content: Buffer): void {
const extension = extname(fileName).toLowerCase();
const zip = content.length >= 4 && content[0] === 0x50 && content[1] === 0x4b
&& [[0x03, 0x04], [0x05, 0x06], [0x07, 0x08]].some(([a, b]) => content[2] === a && content[3] === b);
const valid = extension === ".pdf" ? content.subarray(0, 5).toString("ascii") === "%PDF-"
: extension === ".png" ? content.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
: extension === ".jpg" || extension === ".jpeg" ? content[0] === 0xff && content[1] === 0xd8 && content[2] === 0xff
: extension === ".xml" ? content.subarray(0, 1024).toString("utf8").replace(/^\uFEFF?\s*/u, "").startsWith("<")
: extension === ".ofd" || extension === ".zip" ? zip
: false;
if (!valid) throw new Error("invalid file type");
}
function localAttachmentMetadata(attachment: InspectedLocalAttachment) {
return {
fieldName: attachment.fieldName,
fileName: attachment.fileName,
size: attachment.size,
sha256: attachment.sha256,
};
}
function isObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function normalizeEndpoint(value: string): URL {
const url = new URL(value);
if (url.protocol !== "https:" || url.pathname !== "/mcp") throw new Error("远程 MCP 必须是 HTTPS /mcp 地址");
@@ -433,7 +627,7 @@ function parseEndpoint(argv: string[]): string {
export function createBridgeMcpServer(bridge: RemoteBridge): McpServer {
const server = new McpServer({ name: "dingtalk-approval-stdio-bridge", version: APPLICATION_VERSION }, {
instructions: "首次使用或登录链接过期时调用approval_login;每次调用会检查登录并在需要时生成有效的新链接,绝不因链接过期而重新安装。工具不会通过其他消息通道自动发送链接,必须把content.text中的登录URL原样写入当前面向用户的回复,不得只声称已生成或已推送。审批引用、调用容量和内部分批不得向用户解释;查看时只回答业务信息,用户明确授权后在授权范围内自动处理。薪福通待办不按单据类型过滤;availableActions=[]且manualAction.reason=unsupported_xft_task_type的项目仍要展示,但只能引导薪福通客户端查看处理,禁止尝试MCP写入。写前校验失败时零写入,任何不确定结果都不得自动重试。提单草稿仅可保存在 Agent 所在机器的当前用户私有目录,不得上传网关;恢复草稿后必须重新读取实时模板 Schema,短期提交引用不得写入草稿。薪福通所有选择型字段只向用户展示业务名称:optionSource 选项展示label,内嵌枚举展示enumLabels;用户选定label后,values中必须填同一项的code而不能填label,code只存在于工具调用内部。不得向用户索取或展示城市码、组织码、工号、档案编号、数字舱位值或其他内部ID;selectionCapability=client_only时停止构造该必填项或允许跳过该可选项。",
instructions: "首次使用或登录链接过期时调用approval_login;每次调用会检查登录并在需要时生成有效的新链接,绝不因链接过期而重新安装。工具不会通过其他消息通道自动发送链接,必须把content.text中的登录URL原样写入当前面向用户的回复,不得只声称已生成或已推送。审批引用、调用容量和内部分批不得向用户解释;查看时只回答业务信息,用户明确授权后在授权范围内自动处理。薪福通待办不按单据类型过滤;availableActions=[]且manualAction.reason=unsupported_xft_task_type的项目仍要展示,但只能引导薪福通客户端查看处理,禁止尝试MCP写入。写前校验失败时零写入,任何不确定结果都不得自动重试。提单草稿仅可保存在 Agent 所在机器的当前用户私有目录,不得上传网关;恢复草稿后必须重新读取实时模板 Schema,短期提交引用不得写入草稿。草稿附件保存字段名称、本地路径、大小、修改时间和SHA-256;prepare只读取并复核本地文件、把不含路径的元数据用于预览,最终确认后的submit才上传,网关不保存文件。薪福通所有选择型字段只向用户展示业务名称:optionSource 选项展示label,内嵌枚举展示enumLabels;用户选定label后,values中必须填同一项的code而不能填label,code只存在于工具调用内部。不得向用户索取或展示城市码、组织码、工号、档案编号、数字舱位值或其他内部ID;selectionCapability=client_only时停止构造该必填项或允许跳过该可选项。",
});
server.registerTool("approval_login", {
title: "登录审批服务", description: "检查登录;未登录或旧链接已过期时生成新的钉钉授权链接。同一身份同时用于钉钉OA和薪福通,无需重新安装。", inputSchema: {},
@@ -503,16 +697,25 @@ export function createBridgeMcpServer(bridge: RemoteBridge): McpServer {
actionerStaffIds: z.array(z.string().min(1).max(500)).min(1).max(50),
})).max(20).default([]),
allowDuplicateInvoices: z.boolean().default(false).describe("仅在网关已提示重复风险或查重不完整、且用户明确确认仍要提交后设为 true"),
attachments: z.array(z.object({
fieldName: z.string().min(1).max(200).describe("实时 Schema 中的附件字段业务名称"),
localPath: z.string().min(1).max(4096).describe("用户明确选择的本机绝对文件路径;只由签名 Bridge 读取,不发送给网关"),
displayName: z.string().min(1).max(240).optional(),
size: z.number().int().min(1).max(XFT_ATTACHMENT_MAX_FILE_BYTES).optional(),
modifiedAt: z.string().datetime().optional(),
sha256: z.string().regex(/^[0-9a-f]{64}$/).optional(),
})).max(XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION).default([])
.describe("本机草稿附件引用;prepare复核文件且不上传,用户最终确认后的submit才上传"),
};
server.registerTool("approval_prepare_submission", {
title: "校验并预览新审批", description: "无副作用校验全部字段并自动查重本人近30天钉钉/薪福通发票;选择项在values中填所选label对应的code,成功预览只显示业务名称;有重复风险或查重不完整时要求用户明确确认,不会直接生成提交引用。", inputSchema: submissionInputSchema,
annotations: { readOnlyHint: true, destructiveHint: false },
}, async (args) => bridge.call("approval_prepare_submission", args));
}, async (args) => bridge.prepareSubmission(args));
server.registerTool("approval_submit", {
title: "正式发起审批", description: "仅在用户看过最终预览并明确确认后,使用完全相同内容和重复风险决定发起审批;禁止自动重试。", inputSchema: {
confirmationId: z.string().min(20).max(4096), ...submissionInputSchema,
}, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
}, async (args) => bridge.call("approval_submit", args));
}, async (args) => bridge.submitApproval(args));
server.registerTool("approval_revoke_submission", {
title: "撤销本人发起的审批", description: "使用提单成功后返回的 revokeId 撤销本人发起的审批;禁止自动重试。薪福通 recall 当前为实验能力,必须以写后状态复核为准。", inputSchema: {
revokeId: z.string().min(20).max(4096), remark: z.string().max(1000).default(""),
+107 -8
View File
@@ -11,7 +11,7 @@ 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 { ApprovalSafetyError, XftApiError, errorMessage } from "./errors.js";
import type { AppConfig } from "./config.js";
import { DingTalkClient } from "./dingtalk.js";
import type { XftApprovalProvider } from "./xft.js";
@@ -22,6 +22,8 @@ import { DINGTALK_OAUTH_AUTHORIZE_ORIGIN, DingTalkMcpOAuthProvider } from "./oau
import type { AuthenticatedDingTalkUser } from "./xft.js";
import { InvoiceDeduplicationService, InvoiceDedupStore, invoiceDedupDatabasePath } from "./invoice-dedup.js";
import { APPLICATION_VERSION } from "./version.js";
import { SignedReferenceCodec } from "./signed-reference.js";
import { XFT_ATTACHMENT_MAX_FILE_BYTES, XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION } from "./xft.js";
type TransportEntry =
| { kind: "streamable"; transport: StreamableHTTPServerTransport; userId: string; createdAt: number; lastSeenAt: number }
@@ -42,7 +44,7 @@ export const APPROVAL_MCP_INSTRUCTIONS = [
"钉钉开放接口无法预检部分模板的签字等业务规则;要求手写签名时返回客户端处理链接。处理结果中 failed 表示操作被拒绝且未生效;除非用户看过原因后再次明确要求,否则不要重试。succeeded 表示节点提交成功;completed 才表示无需当前用户继续审批。uncertain/outcome_uncertain 表示已经提交但未确认,禁止自动重试,应稍后重新列出待办。followUpRequired 大于 0 时应重新列出待办并告知用户。",
"出现 authChallenge 时,工具不会通过其他消息通道自动发送内容。必须把 directUrl 原样写入当前面向用户的回复;有二维码图片时直接作为当前回复附件,禁止转成 ASCII,也不得只声称已生成或已推送。",
"发起审批范围仅包括薪福通已支持模板,以及 6 个钉钉候选模板:北极大的出差申请、外出、业务招待申请,捷康的差旅费用报销、业务招待费用报销、业务招待申请。禁止绕过范围限制。固定使用 approval_list_submission_templates → approval_get_submission_schema → approval_prepare_submission。所有选择型字段只向用户展示业务名称:optionSource 指向的部门、城市或费用类型必须用 approval_get_submission_options 实时查询;内嵌 options/enumLabels 只展示 label 或中文名称。用户选择 label 后,Agent 必须在 values 对应字段中填入同一选项的 code,不能把 label 当作正式值;code 只存在于工具调用内部,绝不出现在用户对话。绝不向用户展示、解释或索取字段编码、城市码、组织码、工号、档案编号、数字舱位值或其他内部 ID。selectionCapability.status=client_only 的字段不得猜测或构造;必填时停止并引导薪福通客户端,可选时只说明客户端可补充且允许用户跳过。必须先检查 submissionCapability;client_only 时停止 API 构造并引导原生客户端。读取 Schema 后,逐项核对用户已提供的信息;必填字段缺失或含糊时必须继续询问且不得猜测,直到全部完整。只列出 userVisible!=false 且非 client_only 的尚未填写非必填字段,使用业务名称说明并询问用户是否补充;用户可以明确跳过。prepare 若返回缺失、无效或需自选处理人的字段,继续询问后重新 prepare。prepare 成功后必须向用户展示完整预览并另行取得明确确认;确认后才用完全相同的内容调用 approval_submit。不要让用户或 Agent 提供发起人 userId、薪福通员工号或审批状态。",
"用户明确要求起草、保存、继续、列出或删除提单草稿时,草稿只能由 Agent 保存在运行机器的当前 OS 用户私有数据目录:Windows %LOCALAPPDATA%\\DingTalkApproval\\drafts、macOS ~/Library/Application Support/DingTalkApproval/drafts、Linux ${XDG_DATA_HOME:-~/.local/share}/dingtalk-approval/drafts;POSIX 目录/文件权限为 0700/0600并用同目录临时文件原子替换。不得上传网关、写入项目仓库或声称已在钉钉/薪福通创建草稿。宿主没有本地文件能力时明确说明无法持久保存。每个 UUID.json 使用 schemaVersion=1、kind=dingtalk-approval-local-draft、draftId、title、source、templateName、status(editing或submit_uncertain)、values、optionalFieldDecision、attachments、createdAt、updatedAt;只保存用户填写值和附件本地路径/大小/mtime/SHA-256,不得保存 Token、身份 ID、短期 templateId/confirmationId/revokeId 或完整上游 Schema。恢复时必须重新列模板并实时读取 Schema,附件必须在正式准备前重新校验;本地路径不能当作上游附件值。每次草稿操作自动清理超过 90 天未更新的普通草稿。正式提交成功或即时完成后删除;明确失败时保留;outcome_uncertain 时冻结且禁止自动重试,先实时核验本人发起记录。",
"用户明确要求起草、保存、继续、列出或删除提单草稿时,草稿只能由 Agent 保存在运行机器的当前 OS 用户私有数据目录:Windows %LOCALAPPDATA%\\DingTalkApproval\\drafts、macOS ~/Library/Application Support/DingTalkApproval/drafts、Linux ${XDG_DATA_HOME:-~/.local/share}/dingtalk-approval/drafts;POSIX 目录/文件权限为 0700/0600并用同目录临时文件原子替换。不得上传网关、写入项目仓库或声称已在钉钉/薪福通创建草稿。宿主没有本地文件能力时明确说明无法持久保存。每个 UUID.json 使用 schemaVersion=1、kind=dingtalk-approval-local-draft、draftId、title、source、templateName、status(editing或submit_uncertain)、values、optionalFieldDecision、attachments、createdAt、updatedAt;只保存用户填写值和附件本地路径/大小/mtime/SHA-256,不得保存 Token、身份 ID、短期 templateId/confirmationId/revokeId 或完整上游 Schema。恢复时必须重新列模板并实时读取 Schema,附件必须在正式准备前重新校验。签名本地 Bridge 会把路径转换为不含本地路径的附件元数据参与预览,并只在最终确认后上传;网关不保存文件。每次草稿操作自动清理超过 90 天未更新的普通草稿。正式提交成功或即时完成后删除;明确失败时保留;outcome_uncertain 时冻结且禁止自动重试,先实时核验本人发起记录。",
"approval_prepare_submission 会自动检查当前用户近30天本人发起的钉钉与薪福通审批是否使用了相同发票。若返回 duplicate_confirmation_required 或 duplicate_check_unavailable,本次没有生成 confirmationId;必须展示匹配单号、状态和查重覆盖情况并询问用户。只有用户明确表示知晓重复或查重不完整仍要提交后,才可将 allowDuplicateInvoices=true 原样重新 prepare。不得自行代替用户确认。",
"approval_submit 发起后会返回实时核验结果;仍在审批中的单据会带 revokeId,按企业规则即时完成的薪福通流程返回 completed 且不可撤回。只有用户明确要求撤销该本人审批时才调用 approval_revoke_submission;禁止用撤销代替拒绝别人的待办,禁止自动重试 completed、failed 或 outcome_uncertain。薪福通创建或撤回需要复核时使用只读 approval_get_submission_status,它会实时校验本人已发起单据归属,但不会再次执行写操作。",
].join("\n");
@@ -207,14 +209,32 @@ export function createApprovalMcpServer(
})).max(20).default([]),
allowDuplicateInvoices: z.boolean().default(false).describe("仅当 prepare 已展示重复/疑似重复或查重覆盖不完整,且用户明确确认仍要提交时设为 true;不得由 Agent 自行开启"),
};
const attachmentMetadataSchema = z.object({
fieldName: z.string().min(1).max(200).describe("实时 Schema 中的附件字段业务名称"),
fileName: z.string().min(1).max(240),
size: z.number().int().min(1).max(XFT_ATTACHMENT_MAX_FILE_BYTES),
sha256: z.string().regex(/^[0-9a-f]{64}$/),
});
const prepareSubmissionInputSchema = {
...submissionInputSchema,
attachments: z.array(attachmentMetadataSchema).max(XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION).default([])
.describe("薪福通本机附件元数据;不含本地路径、文件内容或预上传引用,prepare 不上传"),
};
const submitSubmissionInputSchema = {
...submissionInputSchema,
attachments: z.array(attachmentMetadataSchema.extend({
attachmentRef: z.string().min(20).max(4096).describe("仅最终提交时由签名本地 Bridge 上传后填写"),
})).max(XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION).default([])
.describe("薪福通附件元数据和绑定本次确认的短期上传引用;不含本地路径和文件内容"),
};
server.registerTool("approval_prepare_submission", {
title: "校验并预览新审批",
description: "仅在用户已补齐所有必填字段并选择是否填写可选字段后调用。选择项在values中使用所选label对应的code,不能直接填label;code不得展示给用户。实时复核模板和字段构造能力,并自动查重本人近30天钉钉/薪福通发票。若返回缺失或无效字段,Agent必须继续询问;若为 client_only 则停止 API 构造。重复风险或查重不完整时不生成confirmationId,必须展示结果并询问用户;只有用户明确接受后才以allowDuplicateInvoices=true重试准备。成功预览会反向显示业务名称,仍需另行获得最终提交确认。",
inputSchema: submissionInputSchema,
inputSchema: prepareSubmissionInputSchema,
annotations: { readOnlyHint: true, destructiveHint: false },
}, async ({ templateId, values, deptId, targetSelectActioners, allowDuplicateInvoices }) => {
const result = await unified.prepareSubmission(user, { templateId, values, deptId, targetSelectActioners, allowDuplicateInvoices });
}, async ({ templateId, values, deptId, targetSelectActioners, allowDuplicateInvoices, attachments }) => {
const result = await unified.prepareSubmission(user, { templateId, values, deptId, targetSelectActioners, allowDuplicateInvoices, attachments });
const summary = result.confirmationId
? "审批尚未提交;已生成待用户确认的完整预览。"
: "审批尚未提交;发票查重需要用户决定,本次未生成提交确认。";
@@ -226,12 +246,12 @@ export function createApprovalMcpServer(
description: "仅在用户看过approval_prepare_submission最终预览并明确确认后调用。必须原样重传相同表单内容、allowDuplicateInvoices决定和一次性confirmationId;服务端再次校验当前登录身份、模板和发票预留。薪福通流程可能进入待审批,也可能按企业规则即时完成并返回completed;两者都禁止自动重试。",
inputSchema: {
confirmationId: z.string().min(20).max(4096).describe("approval_prepare_submission返回的一次性确认引用"),
...submissionInputSchema,
...submitSubmissionInputSchema,
},
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
}, async ({ confirmationId, templateId, values, deptId, targetSelectActioners, allowDuplicateInvoices }) => {
}, async ({ confirmationId, templateId, values, deptId, targetSelectActioners, allowDuplicateInvoices, attachments }) => {
try {
const result = await unified.submitApproval(user, confirmationId, { templateId, values, deptId, targetSelectActioners, allowDuplicateInvoices });
const result = await unified.submitApproval(user, confirmationId, { templateId, values, deptId, targetSelectActioners, allowDuplicateInvoices, attachments });
audit?.(user.userId, "submit_approval", confirmationId, "submitted");
return compactResult(
result.status === "completed" ? "审批已发起并按企业流程规则即时完成。"
@@ -400,6 +420,52 @@ export async function startHttpServer(config: AppConfig): Promise<void> {
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpUrl),
}) : undefined;
const attachmentSigner = new SignedReferenceCodec(config.approvalRefSecret);
const attachmentUploadHandler = async (req: Request, res: Response) => {
try {
if (!xft?.uploadSubmissionAttachment) throw new ApprovalSafetyError("服务器未启用薪福通附件上传");
const user = requestUser(config, oauth, req.auth);
const fieldName = encodedHeader(req, "x-dingmcp-field-name");
const fileName = encodedHeader(req, "x-dingmcp-file-name");
const sha256 = headerString(req.headers["x-dingmcp-file-sha256"]);
const confirmationId = headerString(req.headers["x-dingmcp-confirmation-id"]);
if (!fieldName || !fileName || !sha256 || !confirmationId || !Buffer.isBuffer(req.body)) {
throw new ApprovalSafetyError("附件上传请求缺少字段名称、文件名、SHA-256、提单确认或二进制内容");
}
assertAttachmentConfirmation(attachmentSigner, user, confirmationId, {
fieldName, fileName, size: req.body.length, sha256,
});
const uploaded = await xft.uploadSubmissionAttachment(user, {
fieldName,
fileName,
size: req.body.length,
sha256,
content: req.body,
confirmationId,
});
res.setHeader("Cache-Control", "no-store");
res.json({
fieldName: uploaded.fieldName,
fileName: uploaded.fileName,
size: uploaded.size,
sha256: uploaded.sha256,
attachmentRef: uploaded.attachmentRef,
});
} catch (error) {
const status = error instanceof ApprovalSafetyError ? 400 : error instanceof XftApiError ? 502 : 500;
res.setHeader("Cache-Control", "no-store");
res.status(status).json({
error: errorMessage(error),
...(error instanceof XftApiError && error.code ? { code: error.code } : {}),
});
}
};
const attachmentBody = express.raw({ type: "application/octet-stream", limit: `${XFT_ATTACHMENT_MAX_FILE_BYTES}b` });
app.post(
"/xft/submission-attachments",
...(authMiddleware ? [authMiddleware, attachmentBody, attachmentUploadHandler] : [attachmentBody, attachmentUploadHandler]),
);
const transports = new Map<string, TransportEntry>();
let pendingTransportSessions = 0;
const pendingTransportSessionsByUser = new Map<string, number>();
@@ -587,6 +653,39 @@ export function isLoopbackAddress(value: string | undefined): boolean {
}
function headerString(value: string | string[] | undefined): string | undefined { return Array.isArray(value) ? value[0] : value; }
function encodedHeader(req: Request, name: string): string | undefined {
const value = headerString(req.headers[name]);
if (!value || value.length > 1000) return undefined;
try { return decodeURIComponent(value); } catch { return undefined; }
}
export function assertAttachmentConfirmation(
signer: SignedReferenceCodec,
user: AuthenticatedDingTalkUser,
confirmationId: string,
attachment: { fieldName: string; fileName: string; size: number; sha256: string },
): void {
let confirmation: Record<string, unknown>;
try { confirmation = signer.verify<Record<string, unknown>>(confirmationId); }
catch { throw new ApprovalSafetyError("提单确认无效或已过期,请重新预览后再上传附件"); }
if (
confirmation.kind !== "submission_confirmation"
|| confirmation.source !== "xft"
|| confirmation.userId !== user.userId
|| typeof confirmation.exp !== "number"
|| confirmation.exp < Date.now()
) {
throw new ApprovalSafetyError("提单确认无效、已过期或不属于当前用户,请重新预览后再上传附件");
}
const manifest = Array.isArray(confirmation.attachmentManifest) ? confirmation.attachmentManifest : [];
if (!manifest.some((item) => item && typeof item === "object" && !Array.isArray(item)
&& (item as Record<string, unknown>).fieldName === attachment.fieldName
&& (item as Record<string, unknown>).fileName === attachment.fileName
&& (item as Record<string, unknown>).size === attachment.size
&& (item as Record<string, unknown>).sha256 === attachment.sha256)) {
throw new ApprovalSafetyError(`附件“${attachment.fileName}”不在用户刚刚确认的提单预览中,请重新预览`);
}
}
export function compactResult(summary: string, value: unknown, images?: XftChallengeProvider) {
const visibleValue = agentVisibleResult(value);
const serialized = JSON.stringify(visibleValue);
+62
View File
@@ -0,0 +1,62 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
import { deflateRawSync, inflateRawSync } from "node:zlib";
export class SignedReferenceCodec {
private readonly key: Buffer;
constructor(secret: string) {
this.key = createHash("sha256").update(secret).digest();
}
sign(value: object): string {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", this.key, iv);
const compressed = deflateRawSync(Buffer.from(JSON.stringify(value), "utf8"), { level: 9 });
const payload = Buffer.concat([cipher.update(compressed), cipher.final()]);
return `v3.${iv.toString("base64url")}.${payload.toString("base64url")}.${cipher.getAuthTag().toString("base64url")}`;
}
verify<T>(value: string): T {
const parts = value.split(".");
if (parts.length !== 4) throw new SignedReferenceFormatError();
const [version, encodedIv, encodedPayload, encodedTag] = parts;
if (!version || !["v2", "v3"].includes(version) || !encodedIv || !encodedPayload || !encodedTag) {
throw new SignedReferenceFormatError();
}
let iv: Buffer;
let payload: Buffer;
let tag: Buffer;
try {
iv = decodeCanonicalBase64Url(encodedIv);
payload = decodeCanonicalBase64Url(encodedPayload);
tag = decodeCanonicalBase64Url(encodedTag);
if (iv.length !== 12 || tag.length !== 16 || payload.length === 0) throw new Error("invalid encrypted reference lengths");
} catch {
throw new SignedReferenceFormatError();
}
try {
const decipher = createDecipheriv("aes-256-gcm", this.key, iv);
decipher.setAuthTag(tag);
const decrypted = Buffer.concat([decipher.update(payload), decipher.final()]);
const plaintext = version === "v3" ? inflateRawSync(decrypted, { maxOutputLength: 64 * 1024 }) : decrypted;
return JSON.parse(plaintext.toString("utf8")) as T;
} catch {
throw new SignedReferenceIntegrityError();
}
}
}
export class SignedReferenceFormatError extends Error {
constructor() { super("引用格式不完整,可能在传输中被截断,请重新获取最新引用"); }
}
export class SignedReferenceIntegrityError extends Error {
constructor() { super("引用无效或已损坏,请重新获取最新引用"); }
}
function decodeCanonicalBase64Url(value: string): Buffer {
if (!/^[A-Za-z0-9_-]+$/.test(value)) throw new Error("invalid base64url");
const decoded = Buffer.from(value, "base64url");
if (decoded.toString("base64url") !== value) throw new Error("non-canonical base64url");
return decoded;
}
+134 -60
View File
@@ -1,14 +1,30 @@
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "node:crypto";
import { deflateRawSync, inflateRawSync } from "node:zlib";
import { APPROVAL_BATCH_LIMIT } from "./approval-contract.js";
import type { AppConfig } from "./config.js";
import { ApprovalSafetyError, XftSessionExpiredError, errorMessage } from "./errors.js";
import { ApprovalService, type ApprovalHistoryItem, type ProcessApprovalItem, type TodoItem } from "./approval-service.js";
import type { ApprovalHistoryRange, ApprovalHistoryScope, AuthenticatedDingTalkUser, XftApprovalProvider, XftHistoryItem, XftSubmissionField, XftSubmissionKind, XftSubmissionOptionKind, XftSubmissionTemplate, XftTodoItem } from "./xft.js";
import {
XFT_ATTACHMENT_MAX_FILE_BYTES,
XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION,
XFT_ATTACHMENT_MAX_TOTAL_BYTES,
type ApprovalHistoryRange,
type ApprovalHistoryScope,
type AuthenticatedDingTalkUser,
type XftApprovalProvider,
type XftHistoryItem,
type XftResolvedSubmissionAttachment,
type XftSubmissionAttachment,
type XftSubmissionField,
type XftSubmissionKind,
type XftSubmissionOptionKind,
type XftSubmissionTemplate,
type XftTodoItem,
} from "./xft.js";
import { browseRawDetail, compactApprovalDetail, type ApprovalDetailOptions, type ApprovalDetailView } from "./approval-detail.js";
import { sensitiveLabel } from "./approval-metadata.js";
import { isRecord } from "./json.js";
import type { InvoiceDeduplicationService } from "./invoice-dedup.js";
import { SignedReferenceCodec } from "./signed-reference.js";
export type UnifiedSource = "dingtalk" | "xft";
export type UnifiedAction = "approve" | "reject" | "return";
@@ -56,6 +72,7 @@ interface SubmissionConfirmationReference {
nonce: string;
serviceEpoch: string;
invoiceReservationId?: string;
attachmentManifest?: Array<{ fieldName: string; fileName: string; size: number; sha256: string }>;
exp: number;
processCode?: string;
xftTemplate?: XftSubmissionTemplate;
@@ -155,6 +172,7 @@ export interface SubmissionInput {
deptId?: number;
targetSelectActioners?: Array<{ actionerKey: string; actionerStaffIds: string[] }>;
allowDuplicateInvoices?: boolean;
attachments?: XftSubmissionAttachment[];
}
export interface OfficialOaReturnClient {
@@ -486,7 +504,8 @@ export class UnifiedApprovalService {
if (!this.xft || !template.xftTemplate) throw new ApprovalSafetyError("薪福通提单源未启用");
assertXftSubmissionTemplateAllowed(template.xftTemplate);
const schema = await this.xft.getSubmissionSchema(user, template.xftTemplate);
validateXftSubmissionValues(template.xftTemplate.kind, input.values, schema.fields);
const attachments = validateXftSubmissionAttachments(schema.fields, input.attachments ?? [], false);
validateXftSubmissionValues(template.xftTemplate.kind, input.values, schema.fields, attachments);
const identityValidation = await this.xft.validateSubmissionIdentity(user);
preview = {
source: "xft", template: schema.template,
@@ -494,10 +513,12 @@ export class UnifiedApprovalService {
identityValidation,
submissionStatus: "APPRV_PEND",
formSummary: await readableXftSubmissionSummary(this.xft, user, schema.template, schema.fields, schema.inputContract, input.values),
...(attachments.length ? { attachments: attachmentPreview(attachments) } : {}),
processForecast: { supported: false, message: "薪福通正式接口不提供无副作用的流程预测;提交时由上游完成最终业务校验" },
};
fingerprint = hashJson({ template: schema.template, fields: schema.fields });
} else {
if ((input.attachments ?? []).length) throw new ApprovalSafetyError("本地附件上传当前仅支持薪福通模板");
if (!template.processCode) throw new ApprovalSafetyError("钉钉模板引用缺少 processCode");
const current = await this.dingTalk.getSubmissionSchema(template.processCode);
assertDingTalkSubmissionTemplateAllowed(current.template);
@@ -523,9 +544,10 @@ export class UnifiedApprovalService {
const expiresAt = Date.now() + 10 * 60_000;
const invoiceEvidenceExpected = template.source === "xft"
&& (template.xftTemplate?.kind === "common_reimburse" || template.xftTemplate?.kind === "travel_reimburse");
const deduplicationValues = submissionValuesForInvoiceDeduplication(input);
const deduplication = this.invoiceDedup
? await this.invoiceDedup.prepare(
user, input.values, input.allowDuplicateInvoices === true, expiresAt, invoiceEvidenceExpected,
user, deduplicationValues, input.allowDuplicateInvoices === true, expiresAt, invoiceEvidenceExpected,
)
: undefined;
if (deduplication?.duplicateCheck.requiresUserConfirmation) {
@@ -545,6 +567,7 @@ export class UnifiedApprovalService {
templateFingerprint: fingerprint, requestHash: hashJson(normalized), nonce,
serviceEpoch: this.submissionServiceEpoch,
...(deduplication?.reservationId ? { invoiceReservationId: deduplication.reservationId } : {}),
...((input.attachments ?? []).length ? { attachmentManifest: submissionAttachmentManifest(input) } : {}),
...(template.processCode ? { processCode: template.processCode } : {}),
...(template.xftTemplate ? { xftTemplate: template.xftTemplate } : {}),
exp: expiresAt,
@@ -574,6 +597,7 @@ export class UnifiedApprovalService {
if (hashJson(normalizedSubmissionInput(input)) !== confirmation.requestHash) {
throw new ApprovalSafetyError("提单内容与用户确认时不一致,请重新准备并确认");
}
const deduplicationValues = submissionValuesForInvoiceDeduplication(input);
if (confirmation.invoiceReservationId) this.invoiceDedup?.assertReservation(user, confirmation.invoiceReservationId);
this.consumedSubmissionNonces.set(confirmation.nonce, confirmation.exp);
try {
@@ -584,14 +608,21 @@ export class UnifiedApprovalService {
if (hashJson({ template: schema.template, fields: schema.fields }) !== confirmation.templateFingerprint) {
throw new ApprovalSafetyError("薪福通表单在确认后发生变化,请重新准备并确认");
}
validateXftSubmissionValues(confirmation.xftTemplate.kind, input.values, schema.fields);
const submitted = await this.xft.submitApproval(user, confirmation.xftTemplate, input.values);
const attachments = validateXftSubmissionAttachments(schema.fields, input.attachments ?? [], true);
validateXftSubmissionValues(confirmation.xftTemplate.kind, input.values, schema.fields, attachments);
const submitted = await this.xft.submitApproval(
user,
confirmation.xftTemplate,
input.values,
attachments,
confirmationId,
);
let liveStatus;
try {
liveStatus = await this.xft.getMySubmissionStatus(user, submitted.billId);
} catch {
const duplicateIndexUpdate = await this.invoiceDedup?.recordSubmission(
user, confirmation.invoiceReservationId, input.values, "xft", submitted.billId, "outcome_uncertain",
user, confirmation.invoiceReservationId, deduplicationValues, "xft", submitted.billId, "outcome_uncertain",
);
return {
source: "xft" as const,
@@ -604,7 +635,7 @@ export class UnifiedApprovalService {
const completedImmediately = /^(?:2|APPRV)$/i.test(liveStatus.status);
if (completedImmediately) {
const duplicateIndexUpdate = await this.invoiceDedup?.recordSubmission(
user, confirmation.invoiceReservationId, input.values, "xft", submitted.billId, "approved",
user, confirmation.invoiceReservationId, deduplicationValues, "xft", submitted.billId, "approved",
);
return {
source: "xft" as const,
@@ -619,7 +650,7 @@ export class UnifiedApprovalService {
}
if (!liveStatus.revokeAllowed) {
const duplicateIndexUpdate = await this.invoiceDedup?.recordSubmission(
user, confirmation.invoiceReservationId, input.values, "xft", submitted.billId, liveStatus.status,
user, confirmation.invoiceReservationId, deduplicationValues, "xft", submitted.billId, liveStatus.status,
);
return {
source: "xft" as const,
@@ -636,7 +667,7 @@ export class UnifiedApprovalService {
exp: expiresInMinutes(7 * 24 * 60),
} satisfies SubmissionRevokeReference);
const duplicateIndexUpdate = await this.invoiceDedup?.recordSubmission(
user, confirmation.invoiceReservationId, input.values, "xft", submitted.billId, liveStatus.status,
user, confirmation.invoiceReservationId, deduplicationValues, "xft", submitted.billId, liveStatus.status,
);
return {
source: "xft", ...submitted, revokeId,
@@ -664,7 +695,7 @@ export class UnifiedApprovalService {
exp: expiresInMinutes(7 * 24 * 60),
} satisfies SubmissionRevokeReference);
const duplicateIndexUpdate = await this.invoiceDedup?.recordSubmission(
user, confirmation.invoiceReservationId, input.values, "dingtalk", processInstanceId, "pending",
user, confirmation.invoiceReservationId, deduplicationValues, "dingtalk", processInstanceId, "pending",
);
return {
source: "dingtalk", status: "submitted", processInstanceId, revokeId,
@@ -1506,49 +1537,6 @@ export class UnifiedApprovalService {
}
}
class SignedReferenceCodec {
private readonly key: Buffer;
constructor(secret: string) {
this.key = createHash("sha256").update(secret).digest();
}
sign(value: object): string {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", this.key, iv);
const compressed = deflateRawSync(Buffer.from(JSON.stringify(value), "utf8"), { level: 9 });
const payload = Buffer.concat([cipher.update(compressed), cipher.final()]);
return `v3.${iv.toString("base64url")}.${payload.toString("base64url")}.${cipher.getAuthTag().toString("base64url")}`;
}
verify<T>(value: string): T {
const parts = value.split(".");
if (parts.length !== 4) throw new ReferenceFormatError();
const [version, encodedIv, encodedPayload, encodedTag] = parts;
if (!version || !["v2", "v3"].includes(version) || !encodedIv || !encodedPayload || !encodedTag) throw new ReferenceFormatError();
let iv: Buffer;
let payload: Buffer;
let tag: Buffer;
try {
iv = decodeCanonicalBase64Url(encodedIv);
payload = decodeCanonicalBase64Url(encodedPayload);
tag = decodeCanonicalBase64Url(encodedTag);
if (iv.length !== 12 || tag.length !== 16 || payload.length === 0) throw new Error("invalid encrypted reference lengths");
} catch {
throw new ReferenceFormatError();
}
try {
const decipher = createDecipheriv("aes-256-gcm", this.key, iv);
decipher.setAuthTag(tag);
const decrypted = Buffer.concat([decipher.update(payload), decipher.final()]);
const plaintext = version === "v3" ? inflateRawSync(decrypted, { maxOutputLength: 64 * 1024 }) : decrypted;
return JSON.parse(plaintext.toString("utf8")) as T;
} catch {
throw new ReferenceIntegrityError();
}
}
}
const COMPACT_APPROVAL_AAD = Buffer.from("dingmcp:approval:v4", "utf8");
const MAX_COMPACT_FIELD_BYTES = 8 * 1024;
@@ -2091,6 +2079,7 @@ function validateXftSubmissionValues(
kind: XftSubmissionKind,
values: Record<string, unknown>,
customFields: XftSubmissionField[],
attachments: XftResolvedSubmissionAttachment[] = [],
): void {
const required: Record<XftSubmissionKind, string[]> = {
travel_apply: ["businessTrip"], expense_apply: ["applyExpenses"],
@@ -2104,7 +2093,7 @@ function validateXftSubmissionValues(
throw new ApprovalSafetyError(`该薪福通模板包含当前 MCP 无法安全填写的必填项:${requiredUnsupported.map((field) => field.name).join("、")},请在薪福通客户端发起`);
}
const missingBusiness = required[kind].filter((key) => isBlankSubmissionValue(values[key]));
const missingCustom = missingRequiredXftCustomFields(customFields, values.customFieldValues);
const missingCustom = missingRequiredXftCustomFields(customFields, values.customFieldValues, attachments);
if (missingBusiness.length || missingCustom.length) {
const businessLabels: Record<string, string> = {
businessTrip: "差旅行程", applyExpenses: "事前申请费用", entertainmentApplyApiCreateDTO: "招待信息",
@@ -2121,7 +2110,7 @@ function validateXftSubmissionValues(
if (hasBillCompany || hasBillDepartment) {
throw new ApprovalSafetyError("单据公司和部门由薪福通按当前登录用户填写,不应向用户索取或由 Agent 指定");
}
validateRequiredXftCustomFields(customFields, values.customFieldValues);
validateRequiredXftCustomFields(customFields, values.customFieldValues, attachments);
validateXftCustomFieldValues(customFields, values.customFieldValues);
if (kind === "common_reimburse" || kind === "travel_reimburse") {
requirePositiveDecimal(values.reimAmount, "报销总金额 reimAmount");
@@ -2196,8 +2185,9 @@ function validateXftEntertainment(value: unknown): void {
function validateRequiredXftCustomFields(
fields: XftSubmissionField[],
value: unknown,
attachments: XftResolvedSubmissionAttachment[] = [],
): void {
const missing = missingRequiredXftCustomFields(fields, value);
const missing = missingRequiredXftCustomFields(fields, value, attachments);
if (missing.length) {
throw new ApprovalSafetyError(`薪福通提单缺少必填自定义字段:${missing.map((field) => field.name).join("、")}`);
}
@@ -2206,11 +2196,15 @@ function validateRequiredXftCustomFields(
function missingRequiredXftCustomFields(
fields: XftSubmissionField[],
value: unknown,
attachments: XftResolvedSubmissionAttachment[] = [],
): XftSubmissionField[] {
const entries = Array.isArray(value) ? value.filter(isSubmissionRecord) : [];
return fields.filter((field) => field.required).filter((field) => !entries.some((entry) =>
String(entry.fieldNumber ?? "") === field.fieldNumber && !isBlankSubmissionValue(entry.fieldValue),
));
const attachmentFields = new Set(attachments.map((attachment) => attachment.fieldNumber));
return fields.filter((field) => field.required).filter((field) => {
if (isXftAttachmentField(field) && attachmentFields.has(field.fieldNumber)) return false;
return !entries.some((entry) => String(entry.fieldNumber ?? "") === field.fieldNumber
&& !isBlankSubmissionValue(entry.fieldValue));
});
}
function validateXftCustomFieldValues(fields: XftSubmissionField[], value: unknown): void {
@@ -2228,6 +2222,9 @@ function validateXftCustomFieldValues(fields: XftSubmissionField[], value: unkno
if (field.selectionCapability?.status === "client_only") {
throw new ApprovalSafetyError(`“${field.name}”当前无法通过 MCP 按业务名称安全填写,请在薪福通客户端完成`);
}
if (isXftAttachmentField(field)) {
throw new ApprovalSafetyError(`附件字段“${field.name}”必须通过 attachments 使用本机文件,不能直接提供附件编号`);
}
if (typeof raw.fieldValue !== "string") throw new ApprovalSafetyError(`薪福通自定义字段“${field.name}”的 fieldValue 必须是字符串;数组和表格需传 JSON 字符串`);
const type = field.type.toUpperCase();
if (type.includes("TABLE")) validateXftTableField(field, raw.fieldValue);
@@ -2244,6 +2241,60 @@ function validateXftCustomFieldValues(fields: XftSubmissionField[], value: unkno
});
}
function validateXftSubmissionAttachments(
fields: XftSubmissionField[],
attachments: XftSubmissionAttachment[],
requireUploadReference: boolean,
): XftResolvedSubmissionAttachment[] {
if (!Array.isArray(attachments)) throw new ApprovalSafetyError("attachments 必须是数组");
if (attachments.length > XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION) {
throw new ApprovalSafetyError(`一次薪福通提单最多上传 ${XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION} 个附件`);
}
const seen = new Set<string>();
let totalBytes = 0;
return attachments.map((attachment) => {
if (!attachment || typeof attachment !== "object") throw new ApprovalSafetyError("附件信息格式无效");
if (!attachment.fieldName || attachment.fieldName.length > 200) throw new ApprovalSafetyError("附件缺少有效的表单字段名称");
if (!attachment.fileName || attachment.fileName.length > 240 || /[\\/\r\n\0"]/u.test(attachment.fileName)) {
throw new ApprovalSafetyError("附件文件名无效,请重新选择本机普通文件");
}
if (!Number.isSafeInteger(attachment.size) || attachment.size <= 0 || attachment.size > XFT_ATTACHMENT_MAX_FILE_BYTES) {
throw new ApprovalSafetyError(`附件“${attachment.fileName}”必须大于 0 字节且单个不超过 50MB`);
}
totalBytes += attachment.size;
if (!Number.isSafeInteger(totalBytes) || totalBytes > XFT_ATTACHMENT_MAX_TOTAL_BYTES) {
throw new ApprovalSafetyError("一次薪福通提单的附件总大小不能超过 50MB");
}
if (!/^[0-9a-f]{64}$/.test(attachment.sha256)) throw new ApprovalSafetyError(`附件“${attachment.fileName}”的 SHA-256 格式无效`);
const matches = fields.filter((field) => field.name === attachment.fieldName && isXftAttachmentField(field));
if (matches.length !== 1) {
throw new ApprovalSafetyError(matches.length === 0
? `实时薪福通表单中没有名为“${attachment.fieldName}”的附件字段,请重新读取表单`
: `实时薪福通表单中“${attachment.fieldName}”附件字段不唯一,请重新选择`);
}
if (requireUploadReference && !attachment.attachmentRef) {
throw new ApprovalSafetyError(`附件“${attachment.fileName}”尚未由本机 Bridge 上传,请重新执行最终提交`);
}
if (!requireUploadReference && attachment.attachmentRef) {
throw new ApprovalSafetyError("预览阶段不得预上传附件,请移除上传引用后重新准备");
}
const field = matches[0]!;
const duplicateKey = `${field.fieldNumber}\0${attachment.sha256}`;
if (seen.has(duplicateKey)) throw new ApprovalSafetyError(`附件“${attachment.fileName}”在同一字段中重复`);
seen.add(duplicateKey);
return {
...attachment,
fieldNumber: field.fieldNumber,
...(requireUploadReference ? { attachmentRef: attachment.attachmentRef! } : { attachmentRef: "" }),
};
});
}
function isXftAttachmentField(field: XftSubmissionField): boolean {
const type = field.type.toUpperCase();
return type.includes("ATTACH") || type.includes("ACCESSOR");
}
function validateXftTableField(field: XftSubmissionField, value: string): void {
let rows: unknown;
try { rows = JSON.parse(value); } catch { throw new ApprovalSafetyError(`薪福通表格字段“${field.name}”必须是 JSON 数组字符串`); }
@@ -2470,9 +2521,32 @@ function normalizedSubmissionInput(input: SubmissionInput): Record<string, unkno
deptId: input.deptId ?? -1,
targetSelectActioners: input.targetSelectActioners ?? [],
allowDuplicateInvoices: input.allowDuplicateInvoices === true,
attachments: submissionAttachmentManifest(input),
};
}
function submissionAttachmentManifest(input: SubmissionInput) {
return (input.attachments ?? []).map(({ fieldName, fileName, size, sha256 }) => ({ fieldName, fileName, size, sha256 }));
}
function submissionValuesForInvoiceDeduplication(input: SubmissionInput): Record<string, unknown> {
if (!(input.attachments ?? []).length) return input.values;
return {
...input.values,
invoiceAttachments: input.attachments!.map((attachment) => ({ sha256: attachment.sha256 })),
};
}
function attachmentPreview(attachments: XftResolvedSubmissionAttachment[]): Array<Record<string, unknown>> {
return attachments.map((attachment) => ({
field: attachment.fieldName,
fileName: attachment.fileName,
size: attachment.size,
sha256: `…${attachment.sha256.slice(-8)}`,
uploadStatus: "将在最终确认后上传",
}));
}
function summarizeValues(values: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.entries(values).slice(0, 200).map(([key, value]) => [key, summarizeValue(value)]));
}
+1 -1
View File
@@ -1 +1 @@
export const APPLICATION_VERSION = "1.1.0-rc.18";
export const APPLICATION_VERSION = "1.1.0-rc.19";
+222 -4
View File
@@ -1,3 +1,4 @@
import { createHash, randomBytes } from "node:crypto";
import smCrypto from "sm-crypto";
import type { AppConfig } from "./config.js";
import { ApprovalSafetyError, XftApiError, errorMessage } from "./errors.js";
@@ -13,12 +14,17 @@ import {
type XftApprovalProvider,
type XftHistoryItem,
type XftSubmissionField,
XFT_ATTACHMENT_MAX_FILE_BYTES,
XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION,
type XftResolvedSubmissionAttachment,
type XftSubmissionAttachmentUpload,
type XftSubmissionKind,
type XftSubmissionOption,
type XftSubmissionOptionKind,
type XftSubmissionTemplate,
type XftTodoItem,
} from "./xft.js";
import { SignedReferenceCodec } from "./signed-reference.js";
type FetchLike = typeof fetch;
type JsonObject = Record<string, unknown>;
@@ -30,6 +36,18 @@ interface XftIdentity {
employeeNumber?: string;
}
interface XftUploadedAttachmentReference {
kind: "xft_uploaded_attachment";
userId: string;
fileId: string;
fieldName: string;
fileName: string;
size: number;
sha256: string;
confirmationHash: string;
exp: number;
}
interface CurrentTodo extends XftTodoItem {
raw: JsonObject;
}
@@ -87,6 +105,7 @@ const EXPENSE_TYPE_HINT = /费用\s*(?:类型|类别|项目)|(?:类型|类别|
/** Official XFT OpenAPI adapter. It never accepts an empId from an Agent. */
export class XftOpenApiClient implements XftApprovalProvider {
private readonly api: NonNullable<AppConfig["xftOpenApi"]>;
private readonly attachmentSigner: SignedReferenceCodec;
private cityOptionsCache?: { fetchedAt: number; items: XftSubmissionOption[] };
constructor(
@@ -95,6 +114,7 @@ export class XftOpenApiClient implements XftApprovalProvider {
) {
if (!config.xftOpenApi) throw new Error("服务器未启用薪福通正式 API");
this.api = config.xftOpenApi;
this.attachmentSigner = new SignedReferenceCodec(config.approvalRefSecret);
if (!/^(?:[0-9a-fA-F]{32}|[0-9a-fA-F]{64})$/.test(this.api.authoritySecret)) {
throw new Error("薪福通 AuthoritySecret 必须是 32 或 64 位十六进制密钥");
}
@@ -533,17 +553,63 @@ export class XftOpenApiClient implements XftApprovalProvider {
return uniqueOptions(output);
}
async uploadSubmissionAttachment(
user: AuthenticatedDingTalkUser,
attachment: XftSubmissionAttachmentUpload,
) {
assertAttachmentUpload(attachment);
const identity = await this.resolveIdentity(user);
const employeeNumber = await this.requireEmployeeNumber(identity);
const result = await this.requestFileUpload(
`/itrip/xft-api/v1/accessory/custom-field/upload?uploaderNumber=${encodeURIComponent(employeeNumber)}`,
attachment.fileName,
attachment.content,
identity,
);
const fileId = typeof result === "string" || typeof result === "number"
? String(result)
: isObject(result)
? stringAt(result, ["fileId", "id", "body"])
: undefined;
if (!fileId || fileId.length > 500) {
throw new XftApiError("薪福通附件上传已返回成功,但响应缺少附件编号", 502, "MISSING_FILE_ID");
}
const exp = Date.now() + 15 * 60_000;
const attachmentRef = this.attachmentSigner.sign({
kind: "xft_uploaded_attachment",
userId: user.userId,
fileId,
fieldName: attachment.fieldName,
fileName: attachment.fileName,
size: attachment.size,
sha256: attachment.sha256,
confirmationHash: attachmentConfirmationHash(attachment.confirmationId),
exp,
} satisfies XftUploadedAttachmentReference);
return {
fieldName: attachment.fieldName,
fileName: attachment.fileName,
size: attachment.size,
sha256: attachment.sha256,
attachmentRef,
expiresAt: new Date(exp).toISOString(),
};
}
async submitApproval(
user: AuthenticatedDingTalkUser,
template: XftSubmissionTemplate,
values: Record<string, unknown>,
attachments: XftResolvedSubmissionAttachment[] = [],
confirmationId = "",
) {
const identity = await this.resolveIdentity(user);
const current = await this.requireSubmissionTemplate(identity, template);
assertSubmissionValues(values);
const employeeNumber = await this.requireEmployeeNumber(identity);
const attachmentFields = this.resolveUploadedAttachments(user, attachments, confirmationId);
const payload = {
...structuredClone(values),
...submissionValuesWithAttachments(values, attachmentFields),
eventNumber: current.eventNumber,
empNumber: employeeNumber,
billStatus: "APPRV_PEND",
@@ -564,11 +630,41 @@ export class XftOpenApiClient implements XftApprovalProvider {
applicantIdentity: "server_derived",
billStatus: "APPRV_PEND",
permissionCheck: "enabled",
attachmentCount: attachments.length,
attachmentsUploadedAfterConfirmation: attachments.length > 0,
persistedBusinessData: false,
},
};
}
private resolveUploadedAttachments(
user: AuthenticatedDingTalkUser,
attachments: XftResolvedSubmissionAttachment[],
confirmationId: string,
): Array<XftResolvedSubmissionAttachment & { fileId: string }> {
if (!attachments.length) return [];
if (!confirmationId) throw new ApprovalSafetyError("附件缺少本次提单确认上下文,请重新预览并确认");
const confirmationHash = attachmentConfirmationHash(confirmationId);
return attachments.map((attachment) => {
let reference: XftUploadedAttachmentReference;
try { reference = this.attachmentSigner.verify<XftUploadedAttachmentReference>(attachment.attachmentRef); }
catch { throw new ApprovalSafetyError(`附件“${attachment.fileName}”的上传引用无效,请重新选择文件并预览`); }
if (
reference.kind !== "xft_uploaded_attachment"
|| reference.userId !== user.userId
|| reference.exp < Date.now()
|| reference.confirmationHash !== confirmationHash
|| reference.fieldName !== attachment.fieldName
|| reference.fileName !== attachment.fileName
|| reference.size !== attachment.size
|| reference.sha256 !== attachment.sha256
) {
throw new ApprovalSafetyError(`附件“${attachment.fileName}”与本次用户确认或本地文件不一致,请重新预览并确认`);
}
return { ...attachment, fileId: reference.fileId };
});
}
async recallSubmission(user: AuthenticatedDingTalkUser, billId: string, reason = "") {
const currentStatus = await this.getMySubmissionStatus(user, billId);
if (!currentStatus.revokeAllowed) {
@@ -1130,6 +1226,63 @@ export class XftOpenApiClient implements XftApprovalProvider {
} catch (error) {
throw xftTransportError(error, pathname, "request", timeoutMs);
}
return this.parseResponse(response, pathname, timeoutMs);
}
private async requestFileUpload(
path: string,
fileName: string,
content: Buffer,
identity: XftIdentity,
timeoutMs = Math.max(this.config.requestTimeoutMs, 90_000),
) {
const requestTimeMs = Date.now();
const timestamp = Math.floor(requestTimeMs / 1000).toString();
const queryStart = path.indexOf("?");
const pathname = queryStart === -1 ? path : path.slice(0, queryStart);
const endpointQuery = new URLSearchParams(queryStart === -1 ? "" : path.slice(queryStart + 1));
const query = new URLSearchParams();
query.set("CSCAPPUID", this.api.appId);
query.set("CSCPRJCOD", this.api.enterpriseId);
query.set("CSCUSRNBR", identity.enterpriseUserId);
query.set("CSCREQTIM", String(requestTimeMs));
query.set("OPAUID", this.api.enterpriseId);
for (const [key, value] of endpointQuery) query.append(key, value);
const requestTarget = `${pathname}?${query.toString()}`;
// The official SDK intentionally omits x-alb-digest for multipart file
// uploads and signs only method/path plus timestamp. File-upload request
// bodies are also explicitly excluded from SM4 encryption.
const signingText = `POST ${requestTarget}\nx-alb-timestamp: ${timestamp}`;
const signature = sm2.doSignature(signingText, this.api.authoritySecret, {
hash: true,
userId: "1234567812345678",
});
const multipart = multipartFileBody(fileName, content);
let response: Response;
try {
response = await this.fetchFn(`${this.api.apiBaseUrl}${requestTarget}`, {
method: "POST",
headers: {
appid: this.api.appId,
"x-alb-timestamp": timestamp,
"x-alb-verify": "sm3withsm2",
apisign: signature,
"content-type": `multipart/form-data; charset=UTF-8; boundary=${multipart.boundary}`,
"content-length": String(multipart.body.length),
},
body: multipart.body.buffer.slice(
multipart.body.byteOffset,
multipart.body.byteOffset + multipart.body.byteLength,
) as ArrayBuffer,
signal: AbortSignal.timeout(timeoutMs),
});
} catch (error) {
throw xftTransportError(error, pathname, "request", timeoutMs);
}
return this.parseResponse(response, pathname, timeoutMs);
}
private async parseResponse(response: Response, pathname: string, timeoutMs: number) {
let text: string;
try { text = await response.text(); }
catch (error) {
@@ -1800,7 +1953,7 @@ function submissionFieldShape(
type: string,
options: XftSubmissionOption[],
optionSource?: ReturnType<typeof expenseTypeOptionSource> | ReturnType<typeof cityOptionSource> | ReturnType<typeof departmentOptionSource>,
): Pick<XftSubmissionField, "valueFormat" | "itemFields" | "selectionCapability"> {
): Pick<XftSubmissionField, "valueFormat" | "itemFields" | "selectionCapability" | "attachmentCapability"> {
const normalized = type.toUpperCase();
if (normalized.includes("TABLE")) {
const candidates = [value.columns, value.children, value.fieldList, value.tableColumns]
@@ -1812,8 +1965,14 @@ function submissionFieldShape(
};
}
if (normalized.includes("ATTACH") || normalized.includes("ACCESSOR")) return {
valueFormat: "当前 MCP 无法安全上传附件,请在薪福通客户端补充",
selectionCapability: clientOnlySelection("当前 MCP 没有薪福通附件上传能力"),
valueFormat: "通过 attachments 传入本机文件引用;本地草稿保存路径和 SHA-256,最终确认后由签名 Bridge 上传",
attachmentCapability: {
status: "supported",
input: "local_file",
uploadWhen: "after_final_confirmation",
maxFileBytes: XFT_ATTACHMENT_MAX_FILE_BYTES,
maxFilesPerSubmission: XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION,
},
};
if (normalized.includes("PERSON") || normalized.includes("EMP")) return {
valueFormat: "当前 MCP 无法按姓名安全选择人员,请在薪福通客户端补充",
@@ -2188,3 +2347,62 @@ function assertSubmissionValues(values: Record<string, unknown>): void {
}
}
}
function assertAttachmentUpload(attachment: XftSubmissionAttachmentUpload): void {
if (!Buffer.isBuffer(attachment.content)) throw new ApprovalSafetyError("附件内容必须由签名本地 Bridge 读取");
if (!Number.isSafeInteger(attachment.size) || attachment.size <= 0 || attachment.size > XFT_ATTACHMENT_MAX_FILE_BYTES) {
throw new ApprovalSafetyError("薪福通附件必须大于 0 字节且单个不超过 50MB");
}
if (attachment.content.length !== attachment.size) throw new ApprovalSafetyError("附件大小在上传前发生变化,请重新选择文件");
if (!attachment.fieldName || attachment.fieldName.length > 200) throw new ApprovalSafetyError("附件缺少有效的表单字段名称");
if (!/^[0-9a-f]{64}$/.test(attachment.sha256)) throw new ApprovalSafetyError("附件 SHA-256 格式无效");
const actualHash = createHash("sha256").update(attachment.content).digest("hex");
if (actualHash !== attachment.sha256) throw new ApprovalSafetyError("附件内容与预览时的 SHA-256 不一致,请重新预览");
if (!attachment.fileName || attachment.fileName.length > 240 || /[\\/\r\n\0"]/u.test(attachment.fileName)) {
throw new ApprovalSafetyError("附件文件名无效,请使用普通文件名后重试");
}
if (!attachment.confirmationId || attachment.confirmationId.length > 4096) {
throw new ApprovalSafetyError("附件缺少有效的提单确认,请重新预览");
}
}
function attachmentConfirmationHash(confirmationId: string): string {
return createHash("sha256").update(confirmationId, "utf8").digest("base64url");
}
function multipartFileBody(fileName: string, content: Buffer): { boundary: string; body: Buffer } {
const boundary = `----------------dingmcp${randomBytes(12).toString("hex")}`;
const prefix = Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${fileName}"\r\n\r\n`,
"utf8",
);
const suffix = Buffer.from(`\r\n--${boundary}--\r\n`, "utf8");
return { boundary, body: Buffer.concat([prefix, content, suffix]) };
}
function submissionValuesWithAttachments(
values: Record<string, unknown>,
attachments: Array<XftResolvedSubmissionAttachment & { fileId: string }>,
): Record<string, unknown> {
const output = structuredClone(values);
if (!attachments.length) return output;
const current = output.customFieldValues;
if (current !== undefined && !Array.isArray(current)) {
throw new ApprovalSafetyError("薪福通 customFieldValues 必须是数组");
}
const customFieldValues = Array.isArray(current) ? structuredClone(current) : [];
const grouped = new Map<string, string[]>();
for (const attachment of attachments) {
const fileIds = grouped.get(attachment.fieldNumber) ?? [];
fileIds.push(attachment.fileId);
grouped.set(attachment.fieldNumber, fileIds);
}
for (const [fieldNumber, fileIds] of grouped) {
if (customFieldValues.some((item) => isObject(item) && String(item.fieldNumber ?? "") === fieldNumber)) {
throw new ApprovalSafetyError("附件字段必须由本地 Bridge 上传,不能同时在 values 中提供附件编号");
}
customFieldValues.push({ fieldNumber, fieldValue: JSON.stringify(fileIds) });
}
output.customFieldValues = customFieldValues;
return output;
}
+39
View File
@@ -65,6 +65,10 @@ export type XftSubmissionKind =
| "common_reimburse"
| "travel_reimburse";
export const XFT_ATTACHMENT_MAX_FILE_BYTES = 50 * 1024 * 1024;
export const XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION = 10;
export const XFT_ATTACHMENT_MAX_TOTAL_BYTES = 50 * 1024 * 1024;
export interface XftSubmissionTemplate {
eventSeqId: string;
eventNumber: string;
@@ -90,6 +94,13 @@ export interface XftSubmissionField {
kind: XftSubmissionOptionKind;
};
selectionCapability?: XftSubmissionSelectionCapability;
attachmentCapability?: {
status: "supported";
input: "local_file";
uploadWhen: "after_final_confirmation";
maxFileBytes: number;
maxFilesPerSubmission: number;
};
}
export type XftSubmissionOptionKind = "department" | "city" | "expense_type";
@@ -99,6 +110,28 @@ export interface XftSubmissionOption {
label: string;
}
export interface XftSubmissionAttachment {
fieldName: string;
fileName: string;
size: number;
sha256: string;
attachmentRef?: string;
}
export interface XftResolvedSubmissionAttachment extends XftSubmissionAttachment {
fieldNumber: string;
attachmentRef: string;
}
export interface XftSubmissionAttachmentUpload {
fieldName: string;
fileName: string;
size: number;
sha256: string;
content: Buffer;
confirmationId: string;
}
export type XftSubmissionSelectionCapability =
| {
status: "supported";
@@ -188,10 +221,16 @@ export interface XftApprovalProvider {
validated: true;
diagnostics: Record<string, unknown>;
}>;
uploadSubmissionAttachment?(
user: AuthenticatedDingTalkUser,
attachment: XftSubmissionAttachmentUpload,
): Promise<XftSubmissionAttachment & { attachmentRef: string }>;
submitApproval(
user: AuthenticatedDingTalkUser,
template: XftSubmissionTemplate,
values: Record<string, unknown>,
attachments?: XftResolvedSubmissionAttachment[],
confirmationId?: string,
): Promise<{ billId: string; status: "submitted"; diagnostics: Record<string, unknown> }>;
recallSubmission(
user: AuthenticatedDingTalkUser,