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
+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)]));
}