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
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:
+208
-5
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -1 +1 @@
|
||||
export const APPLICATION_VERSION = "1.1.0-rc.18";
|
||||
export const APPLICATION_VERSION = "1.1.0-rc.19";
|
||||
|
||||
+222
-4
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user