Files
dingMCP/src/unified-approval.ts
T
gitea-admin b7313ab74d
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
feat: support secure XFT submission attachments
2026-09-01 03:44:36 +00:00

2877 lines
139 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "node:crypto";
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 {
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";
export type SubmissionSource = "dingtalk" | "xft";
const DINGTALK_SUBMISSION_TEMPLATES = {
"北极大_人事假勤": ["出差申请单_北极大", "外出_北极大", "业务招待申请_北极大"],
"捷康_费用报销": ["差旅费用报销_捷康", "业务招待费用报销_捷康", "业务招待申请_捷康"],
} as const;
const DINGTALK_SUBMISSION_CATEGORIES = Object.keys(DINGTALK_SUBMISSION_TEMPLATES);
const ALLOWED_XFT_SUBMISSION_KINDS = new Set<XftSubmissionKind>([
"travel_apply", "expense_apply", "entertainment_apply", "common_apply",
"common_reimburse", "travel_reimburse",
]);
function assertXftSubmissionTemplateAllowed(template: XftSubmissionTemplate): void {
if (!ALLOWED_XFT_SUBMISSION_KINDS.has(template.kind)) {
throw new ApprovalSafetyError("该薪福通模板不在 MCP 支持的发起范围内,请重新列出可发起模板");
}
}
function xftSubmissionCapability(kind: XftSubmissionKind): Record<string, unknown> {
return {
status: "api_supported_with_constraints",
finalValidation: "薪福通创建接口会在正式提交时完成预算、权限和流程业务校验;创建后网关实时核验结果,流程可能进入待审批,也可能按企业规则即时完成",
kind,
};
}
interface SubmissionTemplateReference {
kind: "submission_template";
userId: string;
source: SubmissionSource;
exp: number;
processCode?: string;
xftTemplate?: XftSubmissionTemplate;
}
interface SubmissionConfirmationReference {
kind: "submission_confirmation";
userId: string;
source: SubmissionSource;
templateFingerprint: string;
requestHash: string;
nonce: string;
serviceEpoch: string;
invoiceReservationId?: string;
attachmentManifest?: Array<{ fieldName: string; fileName: string; size: number; sha256: string }>;
exp: number;
processCode?: string;
xftTemplate?: XftSubmissionTemplate;
}
interface SubmissionRevokeReference {
kind: "submission_revoke";
userId: string;
source: SubmissionSource;
processInstanceId?: string;
billId?: string;
exp: number;
}
interface ApprovalReference {
kind: "approval";
userId: string;
provider: "official_oa" | "yida" | "xft";
historyScope?: ApprovalHistoryScope;
exp: number;
processInstanceId?: string;
taskId?: string;
appType?: string;
processCode?: string;
clientOnly?: boolean;
clientUrl?: string;
billId?: string;
allowApprove?: boolean;
allowReject?: boolean;
title?: string;
applicantName?: string;
applicantDepartment?: string;
submittedAt?: string;
amount?: number;
currency?: string;
summary?: string;
}
interface ApprovalSelectionReference {
kind: "approval_selection";
userId: string;
source: UnifiedSource | "all";
query: string;
limit: number;
count: number;
digest: string;
exp: number;
}
interface PreflightApprovalItem {
approvalId: string;
targetId?: string;
ref: ApprovalReference;
target?: TargetReference;
}
interface XftTargetReference {
kind: "target";
userId: string;
provider: "xft";
billId: string;
nodeId: string;
exp: number;
}
interface OfficialOaTargetReference {
kind: "target";
userId: string;
provider: "official_oa";
processInstanceId: string;
taskId: string;
activityId: string;
revertAction: "REVERT_FOR_RESUBMIT" | "REVERT_FOR_APPROVAL";
exp: number;
}
type TargetReference = XftTargetReference | OfficialOaTargetReference;
interface UnifiedActionCapability {
action: UnifiedAction;
allowed: boolean;
submissionSupported?: boolean;
guaranteed?: boolean;
remarkRequired?: boolean;
[key: string]: unknown;
}
interface UnifiedActionsResult {
approvalId: string;
actions: UnifiedActionCapability[];
[key: string]: unknown;
}
export interface SubmissionInput {
templateId: string;
values: Record<string, unknown>;
deptId?: number;
targetSelectActioners?: Array<{ actionerKey: string; actionerStaffIds: string[] }>;
allowDuplicateInvoices?: boolean;
attachments?: XftSubmissionAttachment[];
}
export interface OfficialOaReturnClient {
supportsUser(userId: string): boolean;
listRevertActivities(userId: string, taskId: string): Promise<Array<{
activityId: string;
activityName?: string;
revertAction: "REVERT_FOR_RESUBMIT" | "REVERT_FOR_APPROVAL";
approverNames: string[];
}>>;
revertTask(userId: string, request: {
processInstanceId: string;
taskId: string;
targetActivityId: string;
revertAction: "REVERT_FOR_RESUBMIT" | "REVERT_FOR_APPROVAL";
remark?: string;
}): Promise<void>;
}
export interface AuthChallenge {
provider: "xft";
type: "qr";
challengeId: string;
qrImageUrl: string;
directUrl?: string;
expiresAt: string;
pollAfterSeconds: number;
}
export interface XftChallengeProvider {
getOrStart(user: AuthenticatedDingTalkUser): Promise<AuthChallenge>;
getImage?(challengeId: string): Buffer | undefined;
}
export class UnifiedApprovalService {
private readonly signer: SignedReferenceCodec;
private readonly approvalSigner: CompactApprovalReferenceCodec;
private readonly submissionServiceEpoch = randomBytes(18).toString("base64url");
private readonly consumedSubmissionNonces = new Map<string, number>();
constructor(
private readonly config: AppConfig,
private readonly dingTalk: ApprovalService,
private readonly xft?: XftApprovalProvider,
private readonly xftChallenges?: XftChallengeProvider,
private readonly officialOaReturns?: OfficialOaReturnClient,
private readonly invoiceDedup?: InvoiceDeduplicationService,
) {
this.signer = new SignedReferenceCodec(config.approvalRefSecret);
this.approvalSigner = new CompactApprovalReferenceCodec(config.approvalRefSecret);
}
async listTodos(
user: AuthenticatedDingTalkUser,
source: UnifiedSource | "all",
limit: number,
query = "",
issueSelection = true,
) {
let dingTalkItems: Array<Record<string, unknown>> = [];
let xftItems: Array<Record<string, unknown>> = [];
const sourceStatus: Record<string, Record<string, unknown>> = {};
const tasks: Promise<void>[] = [];
if (source !== "xft") {
const dingTalkScanLimit = query.trim() ? 200 : limit;
tasks.push(this.dingTalk.listMyTodos(user.userId, "all", dingTalkScanLimit).then((result) => {
const normalized = result.items.map((item) => this.normalizeDingTalkTodo(user, item));
dingTalkItems = normalized.filter((item) => matchesTodoQuery(item, query));
sourceStatus.dingtalk = {
status: "ok",
count: normalized.length,
enrichmentFailures: result.diagnostics?.officialEnrichmentFailures ?? 0,
};
}).catch((error) => {
sourceStatus.dingtalk = { status: "unavailable", message: errorMessage(error) };
}));
}
if (source !== "dingtalk") {
if (!this.xft) sourceStatus.xft = { status: "disabled" };
else tasks.push(this.xft.listMyTodos(user, limit, query).then((result) => {
xftItems = result.items.map((item) => this.normalizeXftTodo(user, item));
sourceStatus.xft = {
status: "ok",
count: result.items.length,
...(typeof result.diagnostics?.billApiCount === "number" ? { billApiCount: result.diagnostics.billApiCount } : {}),
...(typeof result.diagnostics?.clientOnlyCount === "number" ? { clientOnlyCount: result.diagnostics.clientOnlyCount } : {}),
};
}).catch(async (error) => {
if (error instanceof XftSessionExpiredError && this.xftChallenges) {
try { sourceStatus.xft = { status: "auth_required", authChallenge: await this.xftChallenges.getOrStart(user) }; }
catch (challengeError) { sourceStatus.xft = { status: "unavailable", message: errorMessage(challengeError) }; }
} else {
sourceStatus.xft = { status: "unavailable", message: errorMessage(error) };
}
}));
}
await Promise.all(tasks);
const items = source === "dingtalk" ? dingTalkItems
: source === "xft" ? xftItems
: interleave(dingTalkItems, xftItems);
const returnedItems = items.slice(0, limit);
const sourcesHealthy = requestedSourcesHealthy(source, sourceStatus);
const containsClientOnly = returnedItems.some((item) => isRecord(item)
&& item.source === "xft"
&& isRecord(item.manualAction)
&& item.manualAction.reason === "unsupported_xft_task_type");
const selectionUnavailableReason = !issueSelection ? "selection_not_requested"
: returnedItems.length === 0 ? "empty_result"
: !sourcesHealthy ? "requested_source_unhealthy"
: returnedItems.length > APPROVAL_BATCH_LIMIT ? "result_exceeds_batch_limit"
: containsClientOnly ? "contains_client_only_item"
: undefined;
const selectionAvailable = selectionUnavailableReason === undefined;
return {
items: returnedItems,
returned: returnedItems.length,
...(selectionAvailable ? {
selectionId: this.signer.sign({
kind: "approval_selection",
userId: user.userId,
source,
query,
limit,
count: returnedItems.length,
digest: this.approvalSelectionDigest(user, returnedItems),
exp: expiresInMinutes(15),
} satisfies ApprovalSelectionReference),
} : {}),
sourceStatus,
diagnostics: { realtime: true, persistedBusinessData: false },
};
}
async listHistory(
user: AuthenticatedDingTalkUser,
scope: ApprovalHistoryScope,
source: UnifiedSource | "all",
limit: number,
query = "",
startDate = "",
endDate = "",
) {
const range = resolveHistoryRange(startDate, endDate, this.config.officialLookbackDays);
let dingTalkItems: Array<Record<string, unknown>> = [];
let xftItems: Array<Record<string, unknown>> = [];
const sourceStatus: Record<string, Record<string, unknown>> = {};
const tasks: Promise<void>[] = [];
const scanLimit = query.trim() ? 200 : limit;
if (source !== "xft") {
tasks.push(this.dingTalk.listMyHistory(user.userId, scope, scanLimit, range).then((result) => {
const normalized = result.items.map((item) => this.normalizeDingTalkHistory(user, item));
dingTalkItems = normalized.filter((item) => matchesTodoQuery(item, query));
sourceStatus.dingtalk = {
status: "ok", count: normalized.length,
...(result.diagnostics?.truncated === true ? {
truncated: true,
...(typeof result.diagnostics.scanLimit === "number" ? { scanLimit: result.diagnostics.scanLimit } : {}),
} : {}),
};
}).catch((error) => {
sourceStatus.dingtalk = { status: "unavailable", message: errorMessage(error) };
}));
}
if (source !== "dingtalk") {
if (!this.xft) sourceStatus.xft = { status: "disabled" };
else tasks.push(this.xft.listMyHistory(user, scope, scanLimit, query, range).then((result) => {
xftItems = result.items.map((item) => this.normalizeXftHistory(user, item));
sourceStatus.xft = {
status: "ok", count: result.items.length,
...(result.diagnostics?.truncated === true ? {
truncated: true,
...(typeof result.diagnostics.scanLimit === "number" ? { scanLimit: result.diagnostics.scanLimit } : {}),
} : {}),
};
}).catch(async (error) => {
if (error instanceof XftSessionExpiredError && this.xftChallenges) {
try { sourceStatus.xft = { status: "auth_required", authChallenge: await this.xftChallenges.getOrStart(user) }; }
catch (challengeError) { sourceStatus.xft = { status: "unavailable", message: errorMessage(challengeError) }; }
} else {
sourceStatus.xft = { status: "unavailable", message: errorMessage(error) };
}
}));
}
await Promise.all(tasks);
const items = source === "dingtalk" ? dingTalkItems
: source === "xft" ? xftItems
: sortUnifiedHistory([...dingTalkItems, ...xftItems]);
return {
scope,
timeRange: {
startDate: new Date(range.startTime).toISOString(),
endDate: new Date(range.endTime).toISOString(),
timeZone: "Asia/Shanghai",
defaultedToRecent30Days: !startDate.trim() && !endDate.trim(),
},
items: items.slice(0, limit),
returned: Math.min(items.length, limit),
sourceStatus,
diagnostics: { realtime: true, persistedBusinessData: false, readOnlyHistory: true },
};
}
async listSubmissionTemplates(
user: AuthenticatedDingTalkUser,
source: SubmissionSource | "all",
limit: number,
query = "",
) {
let dingTalkItems: Array<Record<string, unknown>> = [];
let xftItems: Array<Record<string, unknown>> = [];
const sourceStatus: Record<string, Record<string, unknown>> = {};
const tasks: Promise<void>[] = [];
if (source !== "xft") tasks.push(this.dingTalk.listSubmissionTemplates(limit, query, DINGTALK_SUBMISSION_CATEGORIES).then((templates) => {
const supported = templates.filter(dingTalkSubmissionTemplateAllowed);
dingTalkItems = supported.map((template) => ({
templateId: this.signer.sign({
kind: "submission_template", userId: user.userId, source: "dingtalk",
processCode: template.processCode, exp: expiresInMinutes(30),
} satisfies SubmissionTemplateReference),
source: "dingtalk",
name: template.name ?? template.processCode,
category: template.dirName,
}));
sourceStatus.dingtalk = {
status: "ok",
count: supported.length,
allowedTemplates: Object.values(DINGTALK_SUBMISSION_TEMPLATES).flat(),
};
}).catch((error) => { sourceStatus.dingtalk = { status: "unavailable", message: errorMessage(error) }; }));
if (source !== "dingtalk") {
if (!this.xft) sourceStatus.xft = { status: "disabled" };
else tasks.push(this.xft.listSubmissionTemplates(user, limit, query).then((templates) => {
const supported = templates.filter((template) => ALLOWED_XFT_SUBMISSION_KINDS.has(template.kind));
xftItems = supported.map((template) => ({
templateId: this.signer.sign({
kind: "submission_template", userId: user.userId, source: "xft", xftTemplate: template,
exp: expiresInMinutes(30),
} satisfies SubmissionTemplateReference),
source: "xft",
name: template.eventName,
kind: template.kind,
eventType: template.eventType,
}));
sourceStatus.xft = { status: "ok", count: supported.length };
}).catch((error) => { sourceStatus.xft = { status: "unavailable", message: errorMessage(error) }; }));
}
await Promise.all(tasks);
const items = source === "dingtalk" ? dingTalkItems
: source === "xft" ? xftItems
: interleave(dingTalkItems, xftItems);
return { items: items.slice(0, limit), sourceStatus, diagnostics: { realtime: true, persistedBusinessData: false } };
}
async getSubmissionSchema(user: AuthenticatedDingTalkUser, templateId: string) {
const template = this.decodeSubmissionTemplate(user, templateId);
if (template.source === "xft") {
if (!this.xft || !template.xftTemplate) throw new ApprovalSafetyError("薪福通提单源未启用");
assertXftSubmissionTemplateAllowed(template.xftTemplate);
const schema = await this.xft.getSubmissionSchema(user, template.xftTemplate);
const requiredClientOnly = schema.fields.filter(requiredXftFieldNeedsNativeClient);
const capability = requiredClientOnly.length ? {
status: "client_only",
reason: `包含当前 MCP 无法按业务名称安全填写的必填项:${requiredClientOnly.map((field) => field.name).join("、")}`,
} : xftSubmissionCapability(template.xftTemplate.kind);
return {
templateId,
source: "xft",
...schema,
submissionCapability: capability,
};
}
if (!template.processCode) throw new ApprovalSafetyError("钉钉模板引用缺少 processCode");
const current = await this.dingTalk.getSubmissionSchema(template.processCode);
assertDingTalkSubmissionTemplateAllowed(current.template);
const fields = compactDingTalkSchema(current.schema);
const unsupportedSuites = fields.filter((field) => field.type === "DDBizSuite").map((field) => String(field.name));
const apiIncompatibility = dingTalkSubmissionApiIncompatibility(current.template);
return {
templateId,
source: "dingtalk",
template: { name: current.template.name, processCode: current.template.processCode },
fields,
submissionCapability: apiIncompatibility ? {
status: "client_only",
reason: apiIncompatibility,
} : unsupportedSuites.length ? {
status: "client_only",
reason: `包含官方发起 API 无法可靠构造的钉钉业务套件:${unsupportedSuites.join("、")}`,
} : { status: "api_supported_with_schema_constraints" },
inputContract: {
values: "对象;key 必须与 fields[].name 完全一致,value 按 fields[].type/constraints 填写",
deptId: "默认使用 -1;服务器会实时解析当前登录用户的所属部门,显式部门也必须属于本人",
targetSelectActioners: "只在准备结果要求自选审批人时填写,userId 必须来自可信通讯录查询",
},
diagnostics: { realtime: true, persistedBusinessData: false },
};
}
async getSubmissionOptions(
user: AuthenticatedDingTalkUser,
templateId: string,
kind: XftSubmissionOptionKind,
query = "",
limit = 50,
) {
const template = this.decodeSubmissionTemplate(user, templateId);
if (template.source !== "xft" || !this.xft || !template.xftTemplate) {
throw new ApprovalSafetyError("实时提单选项查询当前仅用于薪福通模板");
}
assertXftSubmissionTemplateAllowed(template.xftTemplate);
return {
templateId,
source: "xft" as const,
...(await this.xft.getSubmissionOptions(user, template.xftTemplate, kind, query, limit)),
};
}
async prepareSubmission(user: AuthenticatedDingTalkUser, input: SubmissionInput): Promise<{
status: string;
confirmationId?: string;
preview: Record<string, any>;
[key: string]: unknown;
}> {
const template = this.decodeSubmissionTemplate(user, input.templateId);
assertPlainSubmissionValues(input.values);
let preview: Record<string, unknown>;
let fingerprint: string;
if (template.source === "xft") {
if (!this.xft || !template.xftTemplate) throw new ApprovalSafetyError("薪福通提单源未启用");
assertXftSubmissionTemplateAllowed(template.xftTemplate);
const schema = await this.xft.getSubmissionSchema(user, template.xftTemplate);
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,
applicant: "当前登录用户(由服务器映射,不能由 Agent 指定)",
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);
const fields = compactDingTalkSchema(current.schema);
assertDingTalkApiConstructible(fields, current.template);
validateDingTalkValues(fields, input.values);
const departmentId = await this.dingTalk.resolveSubmissionDepartment(user.userId, input.deptId);
const request = dingTalkSubmissionRequest(template.processCode, input, fields, departmentId);
const forecast = await this.dingTalk.forecastSubmission(user.userId, request);
const selectionRequirements = forecastSelectionRequirements(forecast);
validateTargetSelections(selectionRequirements, input.targetSelectActioners ?? []);
preview = {
source: "dingtalk",
template: { name: current.template.name, processCode: current.template.processCode },
applicant: "当前登录用户(由服务器填写,不能由 Agent 指定)",
department: "当前登录用户的已验证所属部门(由服务器填写)",
formSummary: summarizeValues(input.values),
processForecast: compactForecast(forecast),
selectionRequirements,
};
fingerprint = hashJson({ template: current.template.processCode, fields });
}
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, deduplicationValues, input.allowDuplicateInvoices === true, expiresAt, invoiceEvidenceExpected,
)
: undefined;
if (deduplication?.duplicateCheck.requiresUserConfirmation) {
return {
status: deduplication.duplicateCheck.status === "check_unavailable"
? "duplicate_check_unavailable"
: "duplicate_confirmation_required",
preview: { ...preview, duplicateCheck: deduplication.duplicateCheck },
message: "发票查重发现历史重复、疑似重复或覆盖不完整。本次没有生成提交确认;请向用户展示查重结果。只有用户明确表示仍要提交后,才能以 allowDuplicateInvoices=true 原样重新准备。",
diagnostics: { persistedBusinessData: false, confirmationCreated: false },
};
}
const nonce = randomBytes(18).toString("base64url");
const normalized = normalizedSubmissionInput(input);
const confirmationId = this.signer.sign({
kind: "submission_confirmation", userId: user.userId, source: template.source,
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,
} satisfies SubmissionConfirmationReference);
return {
status: "confirmation_required",
confirmationId,
expiresAt: new Date(expiresAt).toISOString(),
preview: { ...preview, ...(deduplication ? { duplicateCheck: deduplication.duplicateCheck } : {}) },
message: "请向用户展示完整预览;只有用户明确确认后,才能使用相同内容调用 approval_submit。",
diagnostics: { persistedBusinessData: false, confirmationBoundToContent: true, oneTimeConfirmation: true },
};
}
async submitApproval(user: AuthenticatedDingTalkUser, confirmationId: string, input: SubmissionInput) {
this.cleanupSubmissionNonces();
const confirmation = this.signer.verify<SubmissionConfirmationReference>(confirmationId);
if (confirmation.kind !== "submission_confirmation" || confirmation.userId !== user.userId || confirmation.exp < Date.now()) {
throw new ApprovalSafetyError("confirmationId 无效、已过期或不属于当前用户,请重新准备提单");
}
if (confirmation.serviceEpoch !== this.submissionServiceEpoch) {
throw new ApprovalSafetyError("confirmationId 来自先前的服务进程,为避免重启后重复提单,请重新准备并确认");
}
if (this.consumedSubmissionNonces.has(confirmation.nonce)) {
throw new ApprovalSafetyError("该 confirmationId 已使用;为避免重复提单,禁止再次提交");
}
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 {
if (confirmation.source === "xft") {
if (!this.xft || !confirmation.xftTemplate) throw new ApprovalSafetyError("薪福通提单源未启用");
assertXftSubmissionTemplateAllowed(confirmation.xftTemplate);
const schema = await this.xft.getSubmissionSchema(user, confirmation.xftTemplate);
if (hashJson({ template: schema.template, fields: schema.fields }) !== confirmation.templateFingerprint) {
throw new ApprovalSafetyError("薪福通表单在确认后发生变化,请重新准备并确认");
}
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, deduplicationValues, "xft", submitted.billId, "outcome_uncertain",
);
return {
source: "xft" as const,
status: "outcome_uncertain" as const,
billId: submitted.billId,
message: "薪福通已创建单据,但暂时无法确认其已进入待审批状态;禁止重试,请稍后用 approval_get_submission_status 核验",
diagnostics: { ...submitted.diagnostics, automaticRetryAllowed: false, postWriteVerified: false, duplicateIndexUpdate },
};
}
const completedImmediately = /^(?:2|APPRV)$/i.test(liveStatus.status);
if (completedImmediately) {
const duplicateIndexUpdate = await this.invoiceDedup?.recordSubmission(
user, confirmation.invoiceReservationId, deduplicationValues, "xft", submitted.billId, "approved",
);
return {
source: "xft" as const,
status: "completed" as const,
billId: submitted.billId,
observedStatus: liveStatus.status,
observedStatusName: liveStatus.statusName,
completionMode: "workflow_completed_immediately" as const,
message: `薪福通单据已创建,审批流程已即时完成并处于“${liveStatus.statusName}”;这通常由企业流程规则(例如没有其他有效审批人)导致,当前状态不可撤回`,
diagnostics: { ...submitted.diagnostics, automaticRetryAllowed: false, postWriteVerified: true, duplicateIndexUpdate },
};
}
if (!liveStatus.revokeAllowed) {
const duplicateIndexUpdate = await this.invoiceDedup?.recordSubmission(
user, confirmation.invoiceReservationId, deduplicationValues, "xft", submitted.billId, liveStatus.status,
);
return {
source: "xft" as const,
status: "upstream_state_mismatch" as const,
billId: submitted.billId,
observedStatus: liveStatus.status,
observedStatusName: liveStatus.statusName,
message: `薪福通已创建单据,但上游实际状态为“${liveStatus.statusName}”,并未进入可撤回的待审批流程;禁止重试,请在薪福通核对`,
diagnostics: { ...submitted.diagnostics, automaticRetryAllowed: false, postWriteVerified: true, duplicateIndexUpdate },
};
}
const revokeId = this.signer.sign({
kind: "submission_revoke", userId: user.userId, source: "xft", billId: submitted.billId,
exp: expiresInMinutes(7 * 24 * 60),
} satisfies SubmissionRevokeReference);
const duplicateIndexUpdate = await this.invoiceDedup?.recordSubmission(
user, confirmation.invoiceReservationId, deduplicationValues, "xft", submitted.billId, liveStatus.status,
);
return {
source: "xft", ...submitted, revokeId,
observedStatus: liveStatus.status,
observedStatusName: liveStatus.statusName,
diagnostics: { ...submitted.diagnostics, postWriteVerified: true, duplicateIndexUpdate },
};
}
if (!confirmation.processCode) throw new ApprovalSafetyError("钉钉提单确认缺少 processCode");
const current = await this.dingTalk.getSubmissionSchema(confirmation.processCode);
assertDingTalkSubmissionTemplateAllowed(current.template);
const fields = compactDingTalkSchema(current.schema);
assertDingTalkApiConstructible(fields, current.template);
if (hashJson({ template: current.template.processCode, fields }) !== confirmation.templateFingerprint) {
throw new ApprovalSafetyError("钉钉审批模板在确认后发生变化,请重新准备并确认");
}
validateDingTalkValues(fields, input.values);
const departmentId = await this.dingTalk.resolveSubmissionDepartment(user.userId, input.deptId);
const processInstanceId = await this.dingTalk.submitApproval(
user.userId,
dingTalkSubmissionRequest(confirmation.processCode, input, fields, departmentId),
);
const revokeId = this.signer.sign({
kind: "submission_revoke", userId: user.userId, source: "dingtalk", processInstanceId,
exp: expiresInMinutes(7 * 24 * 60),
} satisfies SubmissionRevokeReference);
const duplicateIndexUpdate = await this.invoiceDedup?.recordSubmission(
user, confirmation.invoiceReservationId, deduplicationValues, "dingtalk", processInstanceId, "pending",
);
return {
source: "dingtalk", status: "submitted", processInstanceId, revokeId,
diagnostics: { applicantIdentity: "server_derived", persistedBusinessData: false, duplicateIndexUpdate },
};
} catch (error) {
// A request may have reached the upstream. Never permit an automatic replay with the same confirmation.
throw error;
}
}
async revokeSubmission(user: AuthenticatedDingTalkUser, revokeId: string, remark = "") {
let reference: SubmissionRevokeReference;
try { reference = this.signer.verify<SubmissionRevokeReference>(revokeId); }
catch { throw new ApprovalSafetyError("revokeId 无效、已过期或来自先前的服务会话,请使用 approval_submit 最新返回的值"); }
if (
reference.kind !== "submission_revoke" || !["dingtalk", "xft"].includes(reference.source) ||
reference.userId !== user.userId || reference.exp < Date.now()
) {
throw new ApprovalSafetyError("revokeId 无效、已过期或不属于当前登录用户");
}
if (reference.source === "xft") {
if (!this.xft || !reference.billId) throw new ApprovalSafetyError("薪福通撤回引用缺少单据编号或服务未启用");
const recalled = await this.xft.recallSubmission(user, reference.billId, remark);
return {
source: "xft", ...recalled,
diagnostics: { ...recalled.diagnostics, referenceUserBound: true, automaticRetryAllowed: false },
};
}
if (!reference.processInstanceId) throw new ApprovalSafetyError("钉钉撤销引用缺少审批实例");
return {
source: "dingtalk", ...(await this.dingTalk.revokeSubmission(user.userId, reference.processInstanceId, remark)),
diagnostics: { ownershipRevalidated: true, persistedBusinessData: false, automaticRetryAllowed: false },
};
}
async getSubmissionStatus(user: AuthenticatedDingTalkUser, source: SubmissionSource, businessId: string) {
if (source !== "xft") throw new ApprovalSafetyError("已发起单据状态核验当前仅支持薪福通");
if (!this.xft) throw new ApprovalSafetyError("薪福通提单源未启用");
if (!/^\d{10,20}$/.test(businessId)) throw new ApprovalSafetyError("薪福通单据编号格式无效");
const status = await this.xft.getMySubmissionStatus(user, businessId);
const revokeId = status.revokeAllowed ? this.signer.sign({
kind: "submission_revoke", userId: user.userId, source: "xft", billId: status.billId,
exp: expiresInMinutes(30),
} satisfies SubmissionRevokeReference) : undefined;
return {
source: "xft" as const,
...status,
...(revokeId ? { revokeId, revokeIdExpiresAt: new Date(Date.now() + 30 * 60_000).toISOString() } : {}),
};
}
async getDetail(user: AuthenticatedDingTalkUser, approvalId: string, options: ApprovalDetailOptions = {}) {
const ref = this.decodeApproval(user, approvalId);
const view = options.view ?? "summary";
if (ref.provider === "xft") {
if (ref.clientOnly) {
return {
approvalId,
source: "xft" as const,
sourceType: "unadapted_xft_task",
view,
status: "client_required",
detailCapability: {
status: "client_only",
message: "该薪福通待办已在列表中显示,但尚未确认正式详情接口兼容性,请在薪福通客户端查看详情",
},
diagnostics: { realtime: true, persistedBusinessData: false, upstreamDetailRequested: false },
};
}
if (!this.xft || !ref.billId) throw new ApprovalSafetyError("薪福通审批源未启用");
try {
const raw = redactSensitive(ref.historyScope
? await this.xft.getHistoryDetail(user, ref.historyScope, ref.billId)
: await this.xft.getTodoDetail(user, ref.billId));
return this.presentDetail({
approvalId,
source: "xft",
sourceType: "smart_expense",
}, raw, view, options);
} catch (error) {
if (error instanceof XftSessionExpiredError && this.xftChallenges) {
return { approvalId, source: "xft", status: "auth_required", authChallenge: await this.xftChallenges.getOrStart(user) };
}
throw error;
}
}
if (!ref.processInstanceId) throw new ApprovalSafetyError("审批引用缺少流程实例");
const raw = redactSensitive(ref.historyScope
? await this.dingTalk.getHistoryDetail(user.userId, ref.historyScope, ref.processInstanceId, ref.taskId)
: await this.dingTalk.getDetail(user.userId, {
source: ref.provider,
processInstanceId: ref.processInstanceId,
...(ref.appType ? { appType: ref.appType } : {}),
}));
return this.presentDetail({
approvalId,
source: "dingtalk",
sourceType: ref.provider,
}, view === "raw" ? raw : withReferenceMetadata(raw, ref), view, options);
}
private presentDetail(
identity: { approvalId: string; source: UnifiedSource; sourceType: string },
raw: unknown,
view: ApprovalDetailView,
options: ApprovalDetailOptions,
) {
if (view === "raw") {
return {
...identity,
view,
detail: browseRawDetail(raw, options.rawPath, options.rawCursor, options.rawLimit, options.rawQuery),
};
}
return { ...identity, view, ...compactApprovalDetail(raw, view) };
}
async getActions(user: AuthenticatedDingTalkUser, approvalId: string): Promise<UnifiedActionsResult> {
const ref = this.decodeApproval(user, approvalId);
if (ref.historyScope) throw new ApprovalSafetyError("历史审批引用仅用于读取详情,不能执行审批动作");
if (ref.provider === "yida") {
if (!ref.processInstanceId || !ref.appType) throw new ApprovalSafetyError("宜搭审批引用缺少任务信息");
await this.dingTalk.getDetail(user.userId, {
source: "yida", processInstanceId: ref.processInstanceId, appType: ref.appType,
});
return {
approvalId,
actions: [...APPROVE_REJECT_ACTIONS, { action: "return" as const, allowed: false, unavailableReason: "provider_not_supported", message: "宜搭审批当前不支持退回" }],
};
}
if (ref.provider === "official_oa") return this.getOfficialOaActions(user, approvalId, ref);
if (ref.clientOnly) {
const message = "该薪福通待办已列出,但尚未确认正式审批接口兼容性,请在薪福通客户端处理";
return {
approvalId,
actions: [
{ action: "approve" as const, allowed: false, clientRequired: true, unavailableReason: "provider_not_supported", message },
{ action: "reject" as const, allowed: false, clientRequired: true, unavailableReason: "provider_not_supported", message },
{ action: "return" as const, allowed: false, clientRequired: true, unavailableReason: "provider_not_supported", message },
],
manualAction: { required: true, reason: "unsupported_xft_task_type", message },
};
}
if (!this.xft || !ref.billId) throw new ApprovalSafetyError("薪福通审批源未启用");
let returned;
try { returned = await this.xft.listReturnNodes(user, ref.billId); }
catch (error) {
if (error instanceof XftSessionExpiredError && this.xftChallenges) {
return { approvalId, status: "auth_required", authChallenge: await this.xftChallenges.getOrStart(user), actions: [] };
}
throw error;
}
return {
approvalId,
actions: [
...APPROVE_REJECT_ACTIONS.map((capability) => capability.action === "approve"
? { ...capability, allowed: ref.allowApprove !== false }
: { ...capability, allowed: ref.allowReject !== false }),
{
action: "return" as const,
allowed: returned.nodes.length > 0,
...(returned.nodes.length === 0 ? { unavailableReason: "no_revert_target", message: "当前节点没有可退回目标" } : {}),
remarkRequired: returned.requirements.commentRequired,
signatureRequired: returned.requirements.signatureRequired,
targets: returned.nodes.map((node) => ({
targetId: this.signer.sign({
kind: "target",
userId: user.userId,
provider: "xft",
billId: ref.billId!,
nodeId: node.nodeId,
exp: expiresInMinutes(30),
}),
label: node.nodeName ?? node.nodeId,
approverNames: node.approverNames,
})),
},
],
};
}
async processBatch(
user: AuthenticatedDingTalkUser,
action: UnifiedAction,
remark: string,
requestedItems: Array<{ approvalId: string; targetId?: string }>,
selectionId?: string,
) {
if (selectionId && requestedItems.length > 0) throw new ApprovalSafetyError("selectionId 与 items 只能选择一种目标方式");
if (selectionId && action === "return") throw new ApprovalSafetyError("整组 selectionId 不支持退回;退回必须逐张取得并提供 targetId");
let items = requestedItems;
if (selectionId) {
const selection = this.decodeApprovalSelection(user, selectionId);
const live = await this.listTodos(user, selection.source, selection.limit, selection.query, false);
if (
live.returned !== selection.count
|| !requestedSourcesHealthy(selection.source, live.sourceStatus)
|| this.approvalSelectionDigest(user, live.items) !== selection.digest
) {
throw new ApprovalSafetyError("待办列表在确认后发生变化;为避免处理错单,请重新列出并让用户重新确认");
}
items = live.items.map((item) => ({ approvalId: String(item.approvalId) }));
}
if (items.length === 0) throw new ApprovalSafetyError("审批列表不能为空");
if (items.length > APPROVAL_BATCH_LIMIT) throw new ApprovalSafetyError(`一次最多处理 ${APPROVAL_BATCH_LIMIT} 张审批单`);
const preflight = this.preflightBatchReferences(user, action, items);
if (preflight.invalidItems.length > 0) {
return {
action,
status: "preflight_failed",
writesStarted: false,
total: items.length,
succeeded: 0,
uncertain: 0,
failed: 0,
notAttempted: items.length,
results: [],
invalidItems: preflight.invalidItems,
requiresRelist: true,
retryable: false,
diagnostics: { transactional: false, realtimeValidation: false, persistedBusinessData: false },
};
}
const results: Array<Record<string, unknown>> = [];
let officialClientTodos: Promise<TodoItem[]> | undefined;
const batchClientUrl = async (ref: ApprovalReference): Promise<string | undefined> => {
if (!ref.processInstanceId || !ref.taskId) return undefined;
officialClientTodos ??= this.dingTalk.listMyTodos(user.userId, "official_oa", 200)
.then((listed) => listed.items)
.catch(() => []);
return (await officialClientTodos).find((todo) => todo.source === "official_oa"
&& todo.processInstanceId === ref.processInstanceId
&& todo.taskId === ref.taskId)?.clientUrl;
};
const xftOutcomes = new Map<string, Record<string, unknown>>();
const xftItems = preflight.items.filter((item) => item.ref.provider === "xft");
if (xftItems.length > 0) {
if (!this.xft) {
for (const item of xftItems) xftOutcomes.set(item.ref.billId ?? "", {
source: "xft", status: "failed", success: false, failureConfirmed: true,
retryable: false, message: "薪福通审批源未启用",
});
} else {
try {
const processed = await this.xft.processApprovalBatch(
user,
action === "approve" ? "agree" : action,
remark,
xftItems.map((item) => ({
billId: item.ref.billId!,
...(item.target?.provider === "xft" ? { targetNodeId: item.target.nodeId } : {}),
})),
);
for (const result of processed.results) {
xftOutcomes.set(result.billId, result.status === "succeeded" ? {
source: "xft", status: "processed", success: true,
} : result.status === "outcome_uncertain" ? {
source: "xft", status: "outcome_uncertain", outcomeUncertain: true,
success: false, message: result.error,
} : {
source: "xft", status: "failed", success: false, failureConfirmed: true,
retryable: false, message: result.error,
});
}
} catch (error) {
if (error instanceof XftSessionExpiredError && this.xftChallenges) {
const authChallenge = await this.xftChallenges.getOrStart(user);
for (const item of xftItems) xftOutcomes.set(item.ref.billId ?? "", {
source: "xft", status: "auth_required", success: false, authChallenge,
});
} else {
for (const item of xftItems) xftOutcomes.set(item.ref.billId ?? "", {
source: "xft", status: "failed", success: false, failureConfirmed: true,
retryable: false, message: errorMessage(error),
});
}
}
}
}
for (const item of preflight.items) {
const ref = item.ref;
try {
if (ref.provider === "official_oa" && ref.clientOnly && action !== "return") {
const clientUrl = await batchClientUrl(ref);
results.push({
approvalId: item.approvalId,
source: "dingtalk",
status: "manual_action_required",
success: false,
message: "该审批模板要求在钉钉客户端完成手写签名,不能通过开放接口处理",
manualAction: {
required: true,
reason: "signature_required",
...(clientUrl ? { url: clientUrl } : {}),
},
});
continue;
}
if (ref.provider === "xft") {
if (!ref.billId) throw new ApprovalSafetyError("薪福通审批引用缺少单据编号");
results.push({ approvalId: item.approvalId, ...(xftOutcomes.get(ref.billId) ?? {
source: "xft", status: "failed", success: false, failureConfirmed: true,
retryable: false, message: "薪福通未返回该单据的处理结果",
}) });
continue;
}
if (action === "return") {
if (ref.provider !== "official_oa") throw new ApprovalSafetyError("宜搭审批当前不支持退回");
if (item.target?.provider !== "official_oa") throw new ApprovalSafetyError("钉钉 OA 退回目标无效");
const returned = await this.processOfficialOaReturn(user, ref, item.target, remark);
if (returned.status === "outcome_uncertain") {
results.push({
approvalId: item.approvalId,
source: "dingtalk",
status: "outcome_uncertain",
outcomeUncertain: true,
success: false,
message: returned.message,
});
continue;
}
results.push({ approvalId: item.approvalId, source: "dingtalk", status: "processed", success: true });
continue;
}
const processItem = dingTalkProcessItem(ref);
const processed = await this.dingTalk.processBatch(
user.userId,
action === "approve" ? "agree" : "refuse",
remark,
[processItem],
);
const result = processed.results[0];
if (result?.outcomeUncertain === true || result?.status === "outcome_uncertain") {
results.push({
approvalId: item.approvalId,
source: "dingtalk",
status: "outcome_uncertain",
outcomeUncertain: true,
success: false,
message: String(result.message ?? "审批操作已经提交,但最终状态暂时无法确认;禁止自动重试,请稍后重新列出待办。"),
});
continue;
}
if (!result?.success) {
const failureMessage = String(result?.error ?? "钉钉审批处理失败");
if (ref.provider === "official_oa" && /签字|签名|signature|handwrite/i.test(failureMessage)) {
const clientUrl = await batchClientUrl(ref);
results.push({
approvalId: item.approvalId, source: "dingtalk", status: "manual_action_required", success: false,
message: "该审批在提交时被确认需要钉钉客户端签字,请在客户端完成",
manualAction: { required: true, reason: "signature_required", ...(clientUrl ? { url: clientUrl } : {}) },
});
continue;
}
results.push({
approvalId: item.approvalId,
source: "dingtalk",
status: "failed",
success: false,
failureConfirmed: result?.failureConfirmed !== false,
retryable: false,
message: failureMessage,
...(result?.supportReference ? { supportReference: result.supportReference } : {}),
});
continue;
}
if (result.followUpRequired === true) {
results.push({
approvalId: item.approvalId,
source: "dingtalk",
status: "follow_up_required",
success: true,
completed: false,
message: String(result.message ?? "当前节点已处理,但同一审批实例仍有新的待审批节点"),
});
} else {
results.push({ approvalId: item.approvalId, source: "dingtalk", status: "processed", success: true, completed: true });
}
} catch (error) {
const message = errorMessage(error);
if (ref.provider === "official_oa" && /签字|签名|signature|handwrite/i.test(message)) {
const clientUrl = await batchClientUrl(ref);
results.push({
approvalId: item.approvalId, source: "dingtalk", status: "manual_action_required", success: false,
message: "该审批在提交时被确认需要钉钉客户端签字,请在客户端完成",
manualAction: { required: true, reason: "signature_required", ...(clientUrl ? { url: clientUrl } : {}) },
});
} else {
results.push({ approvalId: item.approvalId, status: "failed", success: false, message });
}
}
}
const visibleResults: Array<Record<string, any>> = results.map((result, index) => {
const ref = preflight.items[index]?.ref;
return {
index: index + 1,
...(ref?.provider === "xft" && ref.billId ? { businessNo: ref.billId } : {}),
...(ref?.provider !== "xft" && ref?.processInstanceId ? { businessNo: ref.processInstanceId } : {}),
...(ref?.title ? { title: ref.title } : {}),
...(ref?.applicantName ? { applicant: ref.applicantName } : {}),
...(ref?.amount !== undefined ? { amount: { value: ref.amount, currency: ref.currency ?? "CNY" } } : {}),
...result,
};
});
return {
action,
total: visibleResults.length,
succeeded: visibleResults.filter((item) => item.success).length,
uncertain: visibleResults.filter((item) => item.outcomeUncertain === true).length,
failed: visibleResults.filter((item) => !item.success && item.outcomeUncertain !== true).length,
completed: visibleResults.filter((item) => item.success && item.completed !== false).length,
followUpRequired: visibleResults.filter((item) => item.status === "follow_up_required").length,
results: visibleResults,
diagnostics: { transactional: false, realtimeValidation: true, persistedBusinessData: false },
};
}
private preflightBatchReferences(
user: AuthenticatedDingTalkUser,
action: UnifiedAction,
items: Array<{ approvalId: string; targetId?: string }>,
): { items: PreflightApprovalItem[]; invalidItems: Array<Record<string, unknown>> } {
const decoded: PreflightApprovalItem[] = [];
const invalidItems: Array<Record<string, unknown>> = [];
const seen = new Set<string>();
for (const [index, item] of items.entries()) {
try {
const ref = this.decodeApproval(user, item.approvalId);
if (ref.historyScope) {
throw new ApprovalReferenceValidationError("history_reference_read_only", "历史审批引用仅用于读取,不能提交审批动作");
}
if (ref.provider === "xft" && ref.clientOnly) {
throw new ApprovalReferenceValidationError(
"provider_not_supported",
"该薪福通待办尚未确认正式审批接口兼容性,不能通过 MCP 处理,请在薪福通客户端完成",
);
}
if (ref.provider === "xft" && action === "approve" && ref.allowApprove === false) {
throw new ApprovalReferenceValidationError("action_not_allowed", "该薪福通待办当前不允许同意");
}
if (ref.provider === "xft" && action === "reject" && ref.allowReject === false) {
throw new ApprovalReferenceValidationError("action_not_allowed", "该薪福通待办当前不允许拒绝");
}
const identity = stableApprovalIdentity(ref);
if (seen.has(identity)) {
throw new ApprovalReferenceValidationError("duplicate_reference", "同一审批任务在批次中重复出现");
}
let target: TargetReference | undefined;
if (action === "return") target = this.decodeReturnTarget(user, ref, item.targetId);
else if (item.targetId) {
throw new ApprovalReferenceValidationError("unexpected_target", "同意或拒绝不应提供 targetId");
}
seen.add(identity);
decoded.push({ ...item, ref, ...(target ? { target } : {}) });
} catch (error) {
invalidItems.push({
index: index + 1,
code: error instanceof ApprovalReferenceValidationError ? error.code : "preflight_invalid",
message: errorMessage(error),
});
}
}
return { items: invalidItems.length === 0 ? decoded : [], invalidItems };
}
private decodeReturnTarget(
user: AuthenticatedDingTalkUser,
ref: ApprovalReference,
targetId: string | undefined,
): TargetReference {
if (ref.provider === "yida") {
throw new ApprovalReferenceValidationError("provider_not_supported", "宜搭审批当前不支持退回");
}
if (!targetId) throw new ApprovalReferenceValidationError("target_required", "退回必须提供 targetId");
let target: TargetReference;
try { target = this.signer.verify<TargetReference>(targetId); }
catch (error) {
if (error instanceof ReferenceFormatError) {
throw new ApprovalReferenceValidationError("target_truncated", "targetId 格式不完整,可能被宿主截断;请重新获取可执行动作");
}
throw new ApprovalReferenceValidationError("target_invalid", "targetId 已损坏或来自不同密钥的服务;请重新获取可执行动作");
}
if (target.kind !== "target") throw new ApprovalReferenceValidationError("target_type_invalid", "targetId 类型无效");
if (target.exp < Date.now()) throw new ApprovalReferenceValidationError("target_expired", "targetId 已过期;请重新获取可执行动作");
if (target.userId !== user.userId) throw new ApprovalReferenceValidationError("target_wrong_user", "targetId 不属于当前用户");
if (ref.provider === "xft") {
if (target.provider !== "xft" || !ref.billId || target.billId !== ref.billId) {
throw new ApprovalReferenceValidationError("target_task_mismatch", "targetId 不属于当前薪福通审批任务");
}
return target;
}
if (
target.provider !== "official_oa" || !ref.processInstanceId || !ref.taskId
|| target.processInstanceId !== ref.processInstanceId || target.taskId !== ref.taskId
) {
throw new ApprovalReferenceValidationError("target_task_mismatch", "targetId 不属于当前钉钉审批任务");
}
return target;
}
private approvalSelectionDigest(user: AuthenticatedDingTalkUser, items: Array<Record<string, unknown>>): string {
const identities = items.map((item) => {
if (typeof item.approvalId !== "string") throw new ApprovalSafetyError("待办缺少安全引用,不能生成批量选择");
const ref = this.decodeApproval(user, item.approvalId);
return stableApprovalIdentity(ref);
}).sort();
return hashJson(identities);
}
private decodeApprovalSelection(user: AuthenticatedDingTalkUser, selectionId: string): ApprovalSelectionReference {
let ref: ApprovalSelectionReference;
try { ref = this.signer.verify<ApprovalSelectionReference>(selectionId); }
catch (error) {
if (error instanceof ReferenceFormatError) throw new ApprovalSafetyError("selectionId 格式不完整,可能被宿主截断;请重新列出待办");
throw new ApprovalSafetyError("selectionId 已损坏或来自先前的服务会话;请重新列出待办");
}
if (ref.kind !== "approval_selection") throw new ApprovalSafetyError("selectionId 类型无效,请重新列出待办");
if (ref.exp < Date.now()) throw new ApprovalSafetyError("selectionId 已过期,请重新列出待办");
if (ref.userId !== user.userId) throw new ApprovalSafetyError("selectionId 不属于当前登录用户");
return ref;
}
private normalizeDingTalkTodo(user: AuthenticatedDingTalkUser, item: TodoItem) {
const clientOnly = item.source === "official_oa"
&& item.processCode !== undefined
&& this.config.officialClientOnlyProcessCodes.includes(item.processCode);
const ref: ApprovalReference = {
kind: "approval",
userId: user.userId,
provider: item.source,
processInstanceId: item.processInstanceId,
taskId: item.taskId,
...(item.processCode ? { processCode: item.processCode } : {}),
...(clientOnly ? { clientOnly: true } : {}),
...(item.clientUrl ? { clientUrl: item.clientUrl } : {}),
...(item.appType ? { appType: item.appType } : {}),
...(item.title ? { title: item.title } : {}),
...(item.applicantName ? { applicantName: item.applicantName } : {}),
...(item.applicantDepartment ? { applicantDepartment: item.applicantDepartment } : {}),
...(item.submittedAt ? { submittedAt: item.submittedAt } : {}),
...(item.amount !== undefined ? { amount: item.amount } : {}),
...(item.currency ? { currency: item.currency } : {}),
exp: expiresInMinutes(30),
};
return {
approvalId: this.signPortableApprovalReference(ref),
source: "dingtalk",
businessNo: item.processInstanceId,
title: item.title ?? item.processName ?? "钉钉审批",
applicant: {
...(item.applicantName ? { name: item.applicantName } : {}),
...(item.applicantDepartment ? { department: item.applicantDepartment } : {}),
},
...(item.submittedAt ? { submittedAt: localTime(item.submittedAt) } : {}),
...(item.amount !== undefined ? { amount: { value: item.amount, currency: item.currency ?? "CNY" } } : {}),
...(item.summary ? { summary: item.summary } : {}),
availableActions: [
...(clientOnly ? [] : ["approve", "reject"]),
],
...(clientOnly ? {
manualAction: {
required: true,
reason: "signature_required",
message: "该审批模板要求在钉钉客户端完成手写签名",
...(item.clientUrl ? { url: item.clientUrl } : {}),
},
} : {}),
};
}
private normalizeXftTodo(user: AuthenticatedDingTalkUser, item: XftTodoItem) {
const billApiSupported = item.capability !== "list_only" && Boolean(item.billId);
const referenceId = billApiSupported ? item.billId : item.taskId;
if (!referenceId) throw new ApprovalSafetyError("薪福通待办缺少安全引用标识");
const ref: ApprovalReference = {
kind: "approval",
userId: user.userId,
provider: "xft",
billId: billApiSupported ? item.billId : `task:${referenceId}`,
...(billApiSupported ? {} : { clientOnly: true }),
...(billApiSupported && item.allowPass !== undefined ? { allowApprove: item.allowPass } : { allowApprove: false }),
...(billApiSupported && item.allowReject !== undefined ? { allowReject: item.allowReject } : { allowReject: false }),
exp: expiresInMinutes(30),
};
const clientOnlyMessage = "该薪福通待办已列出,但尚未确认正式详情和审批接口兼容性,请在薪福通客户端查看和处理";
return {
approvalId: this.signPortableApprovalReference(ref),
source: "xft",
...(billApiSupported ? { businessNo: item.billId } : {}),
title: item.title ?? "薪福通审批",
applicant: {
...(item.applicantName ? { name: item.applicantName } : {}),
...(item.applicantDepartment ? { department: item.applicantDepartment } : {}),
},
...(item.applyTime ? { submittedAt: localTime(item.applyTime) } : {}),
...(item.amount !== undefined ? { amount: { value: item.amount, currency: item.currency ?? "CNY" } } : {}),
...(item.reason ? { summary: item.reason } : {}),
availableActions: billApiSupported ? [
...(item.allowPass === false ? [] : ["approve"]),
...(item.allowReject === false ? [] : ["reject"]),
"return",
] : [],
...(billApiSupported ? {} : {
manualAction: {
required: true,
reason: "unsupported_xft_task_type",
message: clientOnlyMessage,
},
}),
};
}
private normalizeDingTalkHistory(user: AuthenticatedDingTalkUser, item: ApprovalHistoryItem) {
const ref: ApprovalReference = {
kind: "approval",
userId: user.userId,
provider: item.source,
historyScope: item.historyScope,
processInstanceId: item.processInstanceId,
...(item.taskId ? { taskId: item.taskId } : {}),
...(item.appType ? { appType: item.appType } : {}),
...(item.title ? { title: item.title } : {}),
...(item.applicantName ? { applicantName: item.applicantName } : {}),
...(item.applicantDepartment ? { applicantDepartment: item.applicantDepartment } : {}),
...(item.submittedAt ? { submittedAt: item.submittedAt } : {}),
...(item.amount !== undefined ? { amount: item.amount } : {}),
...(item.currency ? { currency: item.currency } : {}),
exp: expiresInMinutes(30),
};
return historyListItem(this.signPortableApprovalReference(ref), "dingtalk", item.historyScope, {
businessNo: item.processInstanceId,
title: item.title ?? item.processName ?? "钉钉审批",
applicantName: item.applicantName,
applicantDepartment: item.applicantDepartment,
submittedAt: item.submittedAt,
completedAt: item.completedAt,
amount: item.amount,
currency: item.currency,
summary: item.summary,
status: item.status,
result: item.result,
});
}
private normalizeXftHistory(user: AuthenticatedDingTalkUser, item: XftHistoryItem) {
const ref: ApprovalReference = {
kind: "approval",
userId: user.userId,
provider: "xft",
historyScope: item.historyScope,
billId: item.billId,
exp: expiresInMinutes(30),
};
return historyListItem(this.signPortableApprovalReference(ref), "xft", item.historyScope, {
businessNo: item.billId,
title: item.title ?? "薪福通审批",
applicantName: item.applicantName,
applicantDepartment: item.applicantDepartment,
submittedAt: item.applyTime,
completedAt: item.completedAt,
amount: item.amount,
currency: item.currency,
summary: item.reason,
status: item.status,
statusName: item.statusName,
});
}
private decodeApproval(user: AuthenticatedDingTalkUser, approvalId: string): ApprovalReference {
let ref: ApprovalReference;
try {
ref = approvalId.startsWith("v4.")
? this.approvalSigner.verify(approvalId, user.userId)
: this.signer.verify<ApprovalReference>(approvalId);
}
catch (error) {
if (error instanceof ReferenceFormatError) {
throw new ApprovalReferenceValidationError("reference_truncated", "approvalId 格式不完整,可能被宿主截断;请重新列出待办并使用完整引用");
}
throw new ApprovalReferenceValidationError("reference_invalid", "approvalId 已损坏、不属于当前用户或来自不同密钥的服务;请重新列出待办");
}
if (ref.kind !== "approval") throw new ApprovalReferenceValidationError("reference_type_invalid", "approvalId 类型无效,请重新列出待办");
if (ref.exp < Date.now()) throw new ApprovalReferenceValidationError("reference_expired", "approvalId 已过期,请重新列出待办");
if (ref.userId !== user.userId) throw new ApprovalReferenceValidationError("reference_wrong_user", "approvalId 不属于当前用户(当前登录身份)");
return ref;
}
private signPortableApprovalReference(ref: ApprovalReference): string {
const signed = this.approvalSigner.sign(ref);
if (signed.length > 512) throw new ApprovalSafetyError("上游审批标识过长,无法生成可安全传输的审批引用");
return signed;
}
private decodeSubmissionTemplate(user: AuthenticatedDingTalkUser, templateId: string): SubmissionTemplateReference {
const ref = this.signer.verify<SubmissionTemplateReference>(templateId);
if (ref.kind !== "submission_template" || ref.userId !== user.userId || ref.exp < Date.now()) {
throw new ApprovalSafetyError("templateId 无效、已过期或不属于当前用户,请重新列出可发起模板");
}
return ref;
}
private cleanupSubmissionNonces(): void {
for (const [nonce, expiresAt] of this.consumedSubmissionNonces) {
if (expiresAt < Date.now()) this.consumedSubmissionNonces.delete(nonce);
}
}
private async getOfficialOaActions(
user: AuthenticatedDingTalkUser,
approvalId: string,
ref: ApprovalReference,
) {
if (ref.clientOnly) {
const clientUrl = await this.findOfficialClientUrl(user, ref);
return {
approvalId,
actions: [
{ action: "approve" as const, allowed: false, signatureRequired: true, clientRequired: true },
{ action: "reject" as const, allowed: false, clientRequired: true },
{ action: "return" as const, allowed: false, clientRequired: true, unavailableReason: "signature_required", message: "该模板要求在钉钉客户端完成操作" },
],
manualAction: {
required: true,
reason: "signature_required",
message: "该审批模板要求在钉钉客户端完成手写签名",
...(clientUrl ? { url: clientUrl } : {}),
},
};
}
const unavailable = (message: string) => ({
approvalId,
actions: [...unknownSignatureActions(), { action: "return" as const, allowed: false, unavailable: true, unavailableReason: "capability_unavailable", message }],
signatureRequirement: "unknown",
preflight: "task_pending_confirmed",
preflightMessage: "网关已确认任务仍属于当前用户且处于待审批状态;签字等钉钉业务规则只能在执行时最终确认,因此 guaranteed=false。",
signatureMessage: "钉钉开放接口不提供模板签字要求预检;若模板要求手写签名,执行时会提示改用钉钉客户端。",
});
if (!ref.processInstanceId || !ref.taskId) throw new ApprovalSafetyError("审批引用缺少任务信息");
await this.dingTalk.assertOfficialTaskPending(user.userId, ref.processInstanceId, ref.taskId);
if (!this.officialOaReturns) return unavailable("钉钉 OA 官方接口当前未提供可靠的退回目标");
if (!this.officialOaReturns.supportsUser(user.userId)) return unavailable("当前登录用户不支持钉钉 OA 退回");
try {
const activities = await this.officialOaReturns.listRevertActivities(user.userId, ref.taskId);
return {
approvalId,
actions: [
...unknownSignatureActions(),
{
action: "return" as const,
allowed: activities.length > 0,
...(activities.length === 0 ? { unavailableReason: "no_revert_target", message: "当前节点没有可退回目标" } : {}),
remarkRequired: false,
targets: activities.map((activity) => ({
targetId: this.signer.sign({
kind: "target",
userId: user.userId,
provider: "official_oa",
processInstanceId: ref.processInstanceId!,
taskId: ref.taskId!,
activityId: activity.activityId,
revertAction: activity.revertAction,
exp: expiresInMinutes(30),
}),
label: activity.activityName ?? (activity.revertAction === "REVERT_FOR_RESUBMIT" ? "退回发起人" : activity.activityId),
approverNames: activity.approverNames,
revertMode: activity.revertAction,
})),
},
],
signatureRequirement: "unknown",
preflight: "task_pending_confirmed",
preflightMessage: "网关已确认任务仍属于当前用户且处于待审批状态;签字等钉钉业务规则只能在执行时最终确认,因此 guaranteed=false。",
signatureMessage: "钉钉开放接口不提供模板签字要求预检;若模板要求手写签名,执行时会提示改用钉钉客户端。",
};
} catch (error) {
return unavailable(errorMessage(error));
}
}
private async processOfficialOaReturn(
user: AuthenticatedDingTalkUser,
ref: ApprovalReference,
target: OfficialOaTargetReference,
remark: string,
): Promise<{ status: "succeeded" } | { status: "outcome_uncertain"; message: string }> {
if (!ref.processInstanceId || !ref.taskId) throw new ApprovalSafetyError("审批引用缺少任务信息");
if (!this.officialOaReturns || !this.officialOaReturns.supportsUser(user.userId)) {
throw new ApprovalSafetyError("钉钉 OA 退回组件未配置或未绑定当前用户");
}
await this.dingTalk.assertOfficialTaskPending(user.userId, ref.processInstanceId, ref.taskId);
const current = await this.officialOaReturns.listRevertActivities(user.userId, ref.taskId);
if (!current.some((activity) => activity.activityId === target.activityId && activity.revertAction === target.revertAction)) {
throw new ApprovalSafetyError("指定的退回节点已不在当前可退回节点列表中");
}
try {
await this.officialOaReturns.revertTask(user.userId, {
processInstanceId: ref.processInstanceId,
taskId: ref.taskId,
targetActivityId: target.activityId,
revertAction: target.revertAction,
...(remark ? { remark } : {}),
});
} catch {
return {
status: "outcome_uncertain",
message: "钉钉 OA 退回请求可能已经到达上游,但响应无法确认;禁止自动重试,请稍后重新查询待办",
};
}
try {
if (await this.dingTalk.isOfficialTaskPending(user.userId, ref.processInstanceId, ref.taskId)) {
return {
status: "outcome_uncertain",
message: "钉钉 OA 已接收退回提交,但原任务仍在实时待办中;禁止自动重试,请稍后重新查询待办",
};
}
} catch {
return {
status: "outcome_uncertain",
message: "钉钉 OA 已接收退回提交,但实时状态复核失败;禁止自动重试,请稍后重新查询待办",
};
}
return { status: "succeeded" };
}
private async findOfficialClientUrl(
user: AuthenticatedDingTalkUser,
ref: ApprovalReference,
): Promise<string | undefined> {
if (!ref.processInstanceId || !ref.taskId) return undefined;
try {
const live = await this.dingTalk.listMyTodos(user.userId, "official_oa", 200);
return live.items.find((item) => item.source === "official_oa"
&& item.processInstanceId === ref.processInstanceId
&& item.taskId === ref.taskId)?.clientUrl;
} catch {
return undefined;
}
}
}
const COMPACT_APPROVAL_AAD = Buffer.from("dingmcp:approval:v4", "utf8");
const MAX_COMPACT_FIELD_BYTES = 8 * 1024;
/**
* Stateless, user-bound approval references. The authenticated user is part of
* the derived key instead of the ciphertext, and provider identifiers use a
* compact binary representation rather than encrypted JSON.
*/
class CompactApprovalReferenceCodec {
private readonly masterKey: Buffer;
constructor(secret: string) {
this.masterKey = createHash("sha256").update(secret).digest();
}
sign(ref: ApprovalReference): string {
const plaintext = encodeCompactApproval(ref);
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", this.userKey(ref.userId), iv);
cipher.setAAD(COMPACT_APPROVAL_AAD, { plaintextLength: plaintext.length });
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const protectedBody = Buffer.concat([iv, encrypted, cipher.getAuthTag()]);
if (protectedBody.length > 0xffff) throw new ApprovalSafetyError("审批引用长度超出安全范围");
const length = Buffer.allocUnsafe(2);
length.writeUInt16BE(protectedBody.length, 0);
const body = Buffer.concat([length, protectedBody]);
return `v4.${body.toString("base64url")}`;
}
verify(value: string, userId: string): ApprovalReference {
const parts = value.split(".");
if (parts.length !== 2 || parts[0] !== "v4" || !parts[1]) throw new ReferenceFormatError();
let body: Buffer;
try {
body = decodeCanonicalBase64Url(parts[1]);
if (body.length < 2 + 12 + 16 + 6) throw new Error("compact reference too short");
if (body.readUInt16BE(0) !== body.length - 2) throw new ReferenceFormatError();
} catch {
throw new ReferenceFormatError();
}
const iv = body.subarray(2, 14);
const tag = body.subarray(body.length - 16);
const encrypted = body.subarray(14, body.length - 16);
try {
const decipher = createDecipheriv("aes-256-gcm", this.userKey(userId), iv);
decipher.setAAD(COMPACT_APPROVAL_AAD, { plaintextLength: encrypted.length });
decipher.setAuthTag(tag);
const plaintext = Buffer.concat([decipher.update(encrypted), decipher.final()]);
return decodeCompactApproval(plaintext, userId);
} catch {
throw new ReferenceIntegrityError();
}
}
private userKey(userId: string): Buffer {
return createHmac("sha256", this.masterKey)
.update("dingmcp:approval-user-key:v4\0", "utf8")
.update(userId, "utf8")
.digest();
}
}
function encodeCompactApproval(ref: ApprovalReference): Buffer {
const provider = ref.provider === "official_oa" ? 0 : ref.provider === "yida" ? 1 : 2;
let flags = provider;
if (ref.clientOnly) flags |= 1 << 2;
if (ref.allowApprove === false) flags |= 1 << 3;
if (ref.allowReject === false) flags |= 1 << 4;
if (ref.historyScope === "handled") flags |= 1 << 5;
if (ref.historyScope === "submitted") flags |= 1 << 6;
const expiresAtSeconds = Math.floor(ref.exp / 1000);
if (!Number.isSafeInteger(expiresAtSeconds) || expiresAtSeconds <= 0 || expiresAtSeconds > 0xffff_ffff) {
throw new ApprovalSafetyError("审批引用有效期超出可编码范围");
}
const header = Buffer.allocUnsafe(5);
header[0] = flags;
header.writeUInt32BE(expiresAtSeconds, 1);
if (ref.provider === "xft") {
if (!ref.billId) throw new ApprovalSafetyError("薪福通审批引用缺少单据标识");
return Buffer.concat([header, encodeCompactIdentifier(ref.billId)]);
}
if (!ref.processInstanceId || (ref.historyScope !== "submitted" && !ref.taskId)) {
throw new ApprovalSafetyError("钉钉审批引用缺少实例或任务标识");
}
const fields = [header, encodeCompactIdentifier(ref.processInstanceId)];
if (ref.historyScope !== "submitted") fields.push(encodeCompactIdentifier(ref.taskId!));
if (ref.provider === "yida") {
if (!ref.appType) throw new ApprovalSafetyError("宜搭审批引用缺少应用标识");
fields.push(encodeCompactIdentifier(ref.appType));
}
return Buffer.concat(fields);
}
function decodeCompactApproval(value: Buffer, userId: string): ApprovalReference {
if (value.length < 6) throw new Error("compact approval payload too short");
const flags = value[0]!;
if ((flags & 0b1000_0000) !== 0 || (flags & (1 << 5)) !== 0 && (flags & (1 << 6)) !== 0) {
throw new Error("unknown compact approval flags");
}
const providerCode = flags & 0b11;
const provider = providerCode === 0 ? "official_oa" : providerCode === 1 ? "yida" : providerCode === 2 ? "xft" : undefined;
if (!provider) throw new Error("unknown compact approval provider");
const ref: ApprovalReference = {
kind: "approval",
userId,
provider,
exp: value.readUInt32BE(1) * 1000,
...(flags & (1 << 5) ? { historyScope: "handled" as const } : {}),
...(flags & (1 << 6) ? { historyScope: "submitted" as const } : {}),
...(flags & (1 << 2) ? { clientOnly: true } : {}),
...(flags & (1 << 3) ? { allowApprove: false } : {}),
...(flags & (1 << 4) ? { allowReject: false } : {}),
};
let offset = 5;
if (provider === "xft") {
const decoded = decodeCompactIdentifier(value, offset);
ref.billId = decoded.value;
offset = decoded.offset;
} else {
const instance = decodeCompactIdentifier(value, offset);
ref.processInstanceId = instance.value;
offset = instance.offset;
if (ref.historyScope !== "submitted") {
const task = decodeCompactIdentifier(value, offset);
ref.taskId = task.value;
offset = task.offset;
}
if (provider === "yida") {
const app = decodeCompactIdentifier(value, offset);
ref.appType = app.value;
offset = app.offset;
}
}
if (offset !== value.length) throw new Error("trailing compact approval bytes");
return ref;
}
function encodeCompactIdentifier(value: string): Buffer {
const bytes = Buffer.from(value, "utf8");
if (!value || bytes.length > MAX_COMPACT_FIELD_BYTES) throw new ApprovalSafetyError("上游审批标识长度超出安全范围");
if (/^(?:0|[1-9]\d*)$/.test(value)) {
const numeric = BigInt(value);
if (numeric <= 0xffff_ffff_ffff_ffffn) return Buffer.concat([Buffer.from([0]), encodeVarUint(numeric)]);
}
if (/^[A-Za-z0-9_-]+$/.test(value)) {
const decoded = Buffer.from(value, "base64url");
if (decoded.toString("base64url") === value && decoded.length < bytes.length) {
return Buffer.concat([Buffer.from([1]), encodeVarUint(BigInt(decoded.length)), decoded]);
}
}
return Buffer.concat([Buffer.from([2]), encodeVarUint(BigInt(bytes.length)), bytes]);
}
function decodeCompactIdentifier(value: Buffer, start: number): { value: string; offset: number } {
if (start >= value.length) throw new Error("missing compact identifier");
const type = value[start]!;
if (type === 0) {
const decoded = decodeVarUint(value, start + 1);
return { value: decoded.value.toString(10), offset: decoded.offset };
}
if (type !== 1 && type !== 2) throw new Error("unknown compact identifier encoding");
const length = decodeVarUint(value, start + 1);
if (length.value > BigInt(MAX_COMPACT_FIELD_BYTES)) throw new Error("compact identifier too large");
const size = Number(length.value);
const end = length.offset + size;
if (end > value.length) throw new Error("truncated compact identifier");
const bytes = value.subarray(length.offset, end);
const decoded = type === 1 ? bytes.toString("base64url") : bytes.toString("utf8");
if (!decoded || (type === 2 && Buffer.from(decoded, "utf8").compare(bytes) !== 0)) throw new Error("invalid compact identifier");
return { value: decoded, offset: end };
}
function encodeVarUint(input: bigint): Buffer {
if (input < 0n) throw new Error("negative varuint");
const bytes: number[] = [];
let value = input;
do {
let byte = Number(value & 0x7fn);
value >>= 7n;
if (value > 0n) byte |= 0x80;
bytes.push(byte);
} while (value > 0n);
return Buffer.from(bytes);
}
function decodeVarUint(value: Buffer, start: number): { value: bigint; offset: number } {
let decoded = 0n;
let shift = 0n;
for (let offset = start; offset < value.length && offset < start + 10; offset += 1) {
const byte = value[offset]!;
decoded |= BigInt(byte & 0x7f) << shift;
if ((byte & 0x80) === 0) return { value: decoded, offset: offset + 1 };
shift += 7n;
}
throw new Error("invalid compact varuint");
}
class ReferenceFormatError extends Error {
constructor() { super("引用格式不完整,可能在传输中被截断,请重新列出并使用完整引用"); }
}
class ReferenceIntegrityError extends Error {
constructor() { super("引用无效或已损坏,请重新列出并使用最新引用"); }
}
class ApprovalReferenceValidationError extends ApprovalSafetyError {
constructor(readonly code: string, message: string) {
super(message);
this.name = "ApprovalReferenceValidationError";
}
}
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;
}
function dingTalkProcessItem(ref: ApprovalReference): ProcessApprovalItem {
if (ref.historyScope) throw new ApprovalSafetyError("历史审批引用不能执行审批动作");
if (!ref.processInstanceId || !ref.taskId || ref.provider === "xft") throw new ApprovalSafetyError("审批引用缺少任务信息");
return {
source: ref.provider,
processInstanceId: ref.processInstanceId,
taskId: ref.taskId,
...(ref.appType ? { appType: ref.appType } : {}),
};
}
function expiresInMinutes(minutes: number): number { return Date.now() + minutes * 60_000; }
const APPROVE_REJECT_ACTIONS: UnifiedActionCapability[] = [
{ action: "approve", allowed: true, submissionSupported: true, guaranteed: false, remarkRequired: false },
{ action: "reject", allowed: true, submissionSupported: true, guaranteed: false, remarkRequired: false },
];
function unknownSignatureActions() {
return APPROVE_REJECT_ACTIONS.map((action) => ({
...action,
preflight: "task_pending_confirmed",
executionValidation: "deferred",
signatureRequirement: "unknown",
message: "已确认任务仍属于当前用户且处于待处理状态,可以提交;签字等钉钉业务规则仍在执行时最终确认。",
}));
}
function stableApprovalIdentity(ref: ApprovalReference): string {
return [ref.provider, ref.historyScope ?? "todo", ref.processInstanceId ?? "", ref.taskId ?? "", ref.appType ?? "", ref.billId ?? ""].join("\u0000");
}
function historyListItem(
approvalId: string,
source: UnifiedSource,
scope: ApprovalHistoryScope,
value: {
businessNo: string;
title: string;
applicantName?: string;
applicantDepartment?: string;
submittedAt?: string;
completedAt?: string;
amount?: number;
currency?: string;
summary?: string;
status?: string;
statusName?: string;
result?: string;
},
) {
const completedTime = value.completedAt ? Date.parse(value.completedAt) : NaN;
return {
approvalId,
source,
sourceType: source === "xft" ? "smart_expense" : "official_oa",
historyScope: scope,
businessNo: value.businessNo,
title: value.title,
applicant: {
...(value.applicantName ? { name: value.applicantName } : {}),
...(value.applicantDepartment ? { department: value.applicantDepartment } : {}),
},
...(value.submittedAt ? { submittedAt: value.submittedAt, ...localTimeFields(value.submittedAt) } : {}),
...(value.completedAt ? {
completedAt: value.completedAt,
...(Number.isFinite(completedTime) ? {
completedAtLocal: new Date(completedTime + 8 * 3_600_000).toISOString().replace("Z", "+08:00"),
completedTimeZone: "Asia/Shanghai",
} : {}),
} : {}),
...(value.amount !== undefined ? { amount: { value: value.amount, currency: value.currency ?? "CNY" } } : {}),
...(value.summary ? { summary: value.summary } : {}),
...(value.status ? { status: value.status } : {}),
...(value.statusName ? { statusName: value.statusName } : {}),
...(value.result ? { result: value.result } : {}),
availableActions: [],
readOnly: true,
};
}
function sortUnifiedHistory<T extends Record<string, unknown>>(items: T[]): T[] {
return items.sort((left, right) => historyItemTime(right) - historyItemTime(left));
}
function resolveHistoryRange(
startDate: string,
endDate: string,
maxDays: number,
now = Date.now(),
): ApprovalHistoryRange {
const maximumMilliseconds = Math.max(1, maxDays) * 86_400_000;
const endTime = endDate.trim() ? parseHistoryBoundary(endDate, "end") : now;
const startTime = startDate.trim() ? parseHistoryBoundary(startDate, "start") : endTime - 30 * 86_400_000;
if (startTime > endTime) throw new ApprovalSafetyError("审批历史开始时间不能晚于结束时间");
if (endTime - startTime > maximumMilliseconds) {
throw new ApprovalSafetyError(`审批历史单次查询跨度不能超过 ${Math.max(1, maxDays)} 天`);
}
return { startTime, endTime };
}
function parseHistoryBoundary(value: string, boundary: "start" | "end"): number {
const trimmed = value.trim();
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
const start = Date.parse(`${trimmed}T00:00:00+08:00`);
const roundTrip = new Date(start + 8 * 3_600_000).toISOString().slice(0, 10);
if (!Number.isFinite(start) || roundTrip !== trimmed) throw new ApprovalSafetyError(`${boundary === "start" ? "startDate" : "endDate"} 不是有效日期`);
return boundary === "end" ? start + 86_400_000 - 1 : start;
}
if (!/^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/i.test(trimmed)) {
throw new ApprovalSafetyError(`${boundary === "start" ? "startDate" : "endDate"} 必须是 YYYY-MM-DD 或带时区的 ISO 8601 时间`);
}
const parsed = Date.parse(trimmed);
if (!Number.isFinite(parsed)) throw new ApprovalSafetyError(`${boundary === "start" ? "startDate" : "endDate"} 不是有效时间`);
return parsed;
}
function historyItemTime(item: Record<string, unknown>): number {
const raw = typeof item.completedAt === "string" ? item.completedAt
: typeof item.submittedAt === "string" ? item.submittedAt : "";
const parsed = Date.parse(raw);
return Number.isFinite(parsed) ? parsed : 0;
}
function matchesTodoQuery(item: Record<string, unknown>, query: string): boolean {
const normalized = query.trim().toLowerCase();
if (!normalized) return true;
const applicant = item.applicant && typeof item.applicant === "object" && !Array.isArray(item.applicant)
? item.applicant as Record<string, unknown> : {};
const amount = item.amount && typeof item.amount === "object" && !Array.isArray(item.amount)
? item.amount as Record<string, unknown> : {};
return [
item.source, item.sourceType, item.businessNo, item.title, item.summary,
applicant.name, applicant.department, amount.value, amount.currency,
].some((value) => String(value ?? "").toLowerCase().includes(normalized));
}
function requestedSourcesHealthy(
source: UnifiedSource | "all",
status: Record<string, Record<string, unknown>>,
): boolean {
return (source === "xft" || source === "all" ? status.xft?.status === "ok" : true)
&& (source === "dingtalk" || source === "all" ? status.dingtalk?.status === "ok" : true);
}
function localTimeFields(value: string): Record<string, string> {
const time = Date.parse(value);
if (!Number.isFinite(time)) return {};
const local = new Date(time + 8 * 3_600_000).toISOString().replace("Z", "+08:00");
return { submittedAtLocal: local, submittedTimeZone: "Asia/Shanghai" };
}
function localTime(value: string): string {
return localTimeFields(value).submittedAtLocal ?? value;
}
function dingTalkSubmissionTemplateAllowed(template: { dirName?: string; name?: string }): boolean {
if (!template.dirName || !template.name) return false;
const names = DINGTALK_SUBMISSION_TEMPLATES[template.dirName as keyof typeof DINGTALK_SUBMISSION_TEMPLATES];
return names?.includes(template.name as never) ?? false;
}
function assertDingTalkSubmissionTemplateAllowed(template: { dirName?: string; name?: string }): void {
if (!dingTalkSubmissionTemplateAllowed(template)) {
throw new ApprovalSafetyError("该钉钉审批模板不在允许发起范围;仅支持北极大的出差申请、外出、业务招待申请,以及捷康的差旅费用报销、业务招待费用报销、业务招待申请");
}
}
function dingTalkSubmissionApiIncompatibility(template: { name?: string }): string | undefined {
if (template.name === "业务招待申请_北极大") {
return "该模板含 TableField 明细;真实测试中钉钉企业应用创建接口返回系统异常,旧企业接口也拒绝组件格式。当前只能在钉钉客户端或个人 DWS 通道发起";
}
return undefined;
}
function compactDingTalkSchema(schema: Record<string, unknown>): Array<Record<string, unknown>> {
let content: unknown = schema.schemaContent ?? schema.content;
if (typeof content === "string") {
try { content = JSON.parse(content); } catch { throw new ApprovalSafetyError("钉钉审批模板 Schema 内容无法解析"); }
}
const fields: Array<Record<string, unknown>> = [];
const walk = (value: unknown, parent?: string): void => {
if (Array.isArray(value)) { for (const item of value) walk(item, parent); return; }
if (!value || typeof value !== "object") return;
const record = value as Record<string, unknown>;
const props = record.props && typeof record.props === "object" && !Array.isArray(record.props)
? record.props as Record<string, unknown> : {};
const type = typeof record.componentName === "string" ? record.componentName : undefined;
const rawLabel = props.label;
let name = typeof rawLabel === "string" ? rawLabel
: Array.isArray(rawLabel) && typeof rawLabel[0] === "string" ? rawLabel[0]
: undefined;
if (!name && type === "DDBizSuite") {
name = typeof props.bizType === "string" && props.bizType.includes("businessFinance") ? "费控业务套件" : "钉钉业务套件";
}
if (type && name && type !== "TextNote") {
const unsupported = [
"DDAttachment", "SignatureField", "InvoiceField", "RecipientAccountField",
"OcrTextField", "OcrIdCardField", "CalculateField", "SeqNumberField",
"DDBizSuite", "OpenDataField",
].includes(type);
const serverManaged = parent === undefined && (
(name === "申请人" && type === "InnerContactField") || type === "DepartmentField"
);
fields.push({
name,
key: typeof props.id === "string" && props.id.trim() ? props.id.trim() : name,
type, required: props.required === true, supported: !unsupported, serverManaged,
...(parent ? { parent } : {}),
...(Array.isArray(props.options) ? { options: props.options.map(sanitizeDingTalkOption).filter((item) => item !== undefined) } : {}),
constraints: dingTalkFieldConstraints(type, props),
});
}
const children = Array.isArray(record.children) ? record.children : [];
for (const child of children) walk(child, ["TableField", "DDBizSuite"].includes(type ?? "") ? name : parent);
if (Array.isArray(record.items)) walk(record.items, parent);
};
walk(content);
return fields;
}
function sanitizeDingTalkOption(value: unknown): unknown {
if (typeof value === "string") {
try {
const parsed = JSON.parse(value) as unknown;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
const record = parsed as Record<string, unknown>;
return typeof record.value === "string" ? record.value : typeof record.label === "string" ? record.label : value;
}
} catch { /* ordinary string option */ }
return value;
}
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const record = value as Record<string, unknown>;
return typeof record.value === "string" ? record.value : typeof record.label === "string" ? record.label : undefined;
}
function dingTalkFieldConstraints(type: string, props: Record<string, unknown>): Record<string, unknown> {
const formats: Record<string, string> = {
TextField: "text", TextareaField: "text", NumberField: "numeric_string", MoneyField: "numeric_string",
DDDateField: "yyyy-MM-dd", DDDateRangeField: "JSON string array [start,end]",
DDMultiSelectField: "JSON string array", InnerContactField: "DingTalk userId or JSON string array",
DepartmentField: "department ID or JSON string array", AddressField: "JSON string array",
TableField: "JSON string array of row objects", DDPhotoField: "JSON string array of HTTPS URLs",
RelateField: "existing DingTalk processInstanceId",
};
return {
valueFormat: formats[type] ?? "string",
...(typeof props.multiple === "boolean" ? { multiple: props.multiple } : {}),
...(typeof props.choice === "string" ? { choice: props.choice } : {}),
};
}
function validateDingTalkValues(fields: Array<Record<string, unknown>>, values: Record<string, unknown>): void {
const topLevel = fields.filter((field) => field.parent === undefined);
const allowed = new Map(topLevel.map((field) => [String(field.name), field]));
for (const key of Object.keys(values)) {
if (!allowed.has(key)) throw new ApprovalSafetyError(`字段“${key}”不在当前钉钉审批模板中`);
}
for (const field of topLevel) {
if (field.serverManaged === true && !isBlankSubmissionValue(values[String(field.name)])) {
throw new ApprovalSafetyError(`字段“${String(field.name)}”由服务器根据当前登录用户填写,Agent 不得传入`);
}
if (field.required === true && field.supported === false) {
throw new ApprovalSafetyError(`必填字段“${String(field.name)}”类型为 ${String(field.type)},必须在钉钉客户端完成`);
}
if (field.required === true && field.serverManaged !== true && isBlankSubmissionValue(values[String(field.name)])) {
throw new ApprovalSafetyError(`缺少必填字段“${String(field.name)}”`);
}
if (field.type === "TableField" && !isBlankSubmissionValue(values[String(field.name)])) {
validateDingTalkTable(field, fields, values[String(field.name)]);
}
}
}
function assertDingTalkApiConstructible(
fields: Array<Record<string, unknown>>,
template?: { name?: string },
): void {
const incompatible = template ? dingTalkSubmissionApiIncompatibility(template) : undefined;
if (incompatible) throw new ApprovalSafetyError(incompatible);
const unsupported = fields.filter((field) => field.supported === false && field.type === "DDBizSuite");
if (unsupported.length) {
throw new ApprovalSafetyError(`该模板包含钉钉业务套件 ${unsupported.map((field) => `“${String(field.name)}”`).join("、")};官方发起 API 无法可靠构造套件内部字段,请在钉钉客户端发起`);
}
const requiredUnsupported = fields.filter((field) => field.required === true && field.supported === false);
if (requiredUnsupported.length) {
throw new ApprovalSafetyError(`该模板包含 API 无法构造的必填字段:${requiredUnsupported.map((field) => String(field.name)).join("、")},请在钉钉客户端发起`);
}
}
function validateDingTalkTable(
field: Record<string, unknown>,
fields: Array<Record<string, unknown>>,
value: unknown,
): void {
let rows = value;
if (typeof rows === "string") {
try { rows = JSON.parse(rows); } catch { throw new ApprovalSafetyError(`明细字段“${String(field.name)}”必须是 JSON 行数组`); }
}
if (!Array.isArray(rows) || rows.length === 0) throw new ApprovalSafetyError(`明细字段“${String(field.name)}”至少需要一行`);
if (rows.length > 100) throw new ApprovalSafetyError(`明细字段“${String(field.name)}”最多支持 100 行`);
const children = fields.filter((candidate) => candidate.parent === field.name);
rows.forEach((row, index) => {
if (!isSubmissionRecord(row)) throw new ApprovalSafetyError(`明细字段“${String(field.name)}”第 ${index + 1} 行必须是对象`);
const allowed = new Set(children.map((child) => String(child.name)));
for (const key of Object.keys(row)) if (!allowed.has(key)) {
throw new ApprovalSafetyError(`明细字段“${String(field.name)}”第 ${index + 1} 行含未知子字段“${key}”`);
}
for (const child of children) {
if (child.required === true && isBlankSubmissionValue(row[String(child.name)])) {
throw new ApprovalSafetyError(`明细字段“${String(field.name)}”第 ${index + 1} 行缺少“${String(child.name)}”`);
}
if (child.supported === false && !isBlankSubmissionValue(row[String(child.name)])) {
throw new ApprovalSafetyError(`明细字段“${String(field.name)}”的子字段“${String(child.name)}”不能通过 API 提交`);
}
}
});
}
function validateXftSubmissionValues(
kind: XftSubmissionKind,
values: Record<string, unknown>,
customFields: XftSubmissionField[],
attachments: XftResolvedSubmissionAttachment[] = [],
): void {
const required: Record<XftSubmissionKind, string[]> = {
travel_apply: ["businessTrip"], expense_apply: ["applyExpenses"],
entertainment_apply: ["entertainmentApplyApiCreateDTO"], common_apply: [], use_car_apply: ["useCarInfo"],
common_reimburse: ["reimAmount", "reimAmountCurrencyCode", "expenseList"],
travel_reimburse: ["reimAmount", "reimAmountCurrencyCode", "businessTrip", "expenseList"],
};
assertNoClientOnlyStandardValues(values);
const requiredUnsupported = customFields.filter(requiredXftFieldNeedsNativeClient);
if (requiredUnsupported.length) {
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, attachments);
if (missingBusiness.length || missingCustom.length) {
const businessLabels: Record<string, string> = {
businessTrip: "差旅行程", applyExpenses: "事前申请费用", entertainmentApplyApiCreateDTO: "招待信息",
useCarInfo: "用车信息", reimAmount: "报销总金额", reimAmountCurrencyCode: "报销币种", expenseList: "费用明细",
};
const parts = [
...(missingBusiness.length ? [`业务字段:${missingBusiness.map((key) => businessLabels[key] ?? key).join("、")}`] : []),
...(missingCustom.length ? [`自定义字段:${missingCustom.map((field) => field.name).join("、")}`] : []),
];
throw new ApprovalSafetyError(`薪福通提单缺少必填${parts.join(";")}`);
}
const hasBillCompany = !isBlankSubmissionValue(values.billCompanyCode);
const hasBillDepartment = !isBlankSubmissionValue(values.billDepartmentCode);
if (hasBillCompany || hasBillDepartment) {
throw new ApprovalSafetyError("单据公司和部门由薪福通按当前登录用户填写,不应向用户索取或由 Agent 指定");
}
validateRequiredXftCustomFields(customFields, values.customFieldValues, attachments);
validateXftCustomFieldValues(customFields, values.customFieldValues);
if (kind === "common_reimburse" || kind === "travel_reimburse") {
requirePositiveDecimal(values.reimAmount, "报销总金额 reimAmount");
if (values.reimAmountCurrencyCode !== "CNY") throw new ApprovalSafetyError("报销币种目前只支持人民币");
validateXftExpenses(values.expenseList);
}
if (kind === "travel_apply" || kind === "travel_reimburse") validateXftBusinessTrip(values.businessTrip);
if (kind === "expense_apply") validateXftExpenseApplication(values.applyExpenses);
if (kind === "entertainment_apply") validateXftEntertainment(values.entertainmentApplyApiCreateDTO);
}
function requiredXftFieldNeedsNativeClient(field: XftSubmissionField): boolean {
if (!field.required) return false;
if (field.selectionCapability?.status === "client_only") return true;
return (field.itemFields ?? []).some((item) => item.required === true
&& isSubmissionRecord(item.selectionCapability)
&& item.selectionCapability.status === "client_only");
}
function assertNoClientOnlyStandardValues(values: Record<string, unknown>): void {
const reject = (record: Record<string, unknown>, key: string, label: string) => {
if (!isBlankSubmissionValue(record[key])) {
throw new ApprovalSafetyError(`${label}当前无法通过 MCP 按业务名称安全填写;不要提供内部编码或 ID,请省略该可选项或改用薪福通客户端`);
}
};
reject(values, "relatedBillId", "关联申请单");
reject(values, "peerEmpNumbers", "同行人员");
reject(values, "billCollectionInfo", "收款信息");
reject(values, "billLendPayRelList", "核销借款");
if (isSubmissionRecord(values.businessTrip) && Array.isArray(values.businessTrip.businessTripDetails)) {
values.businessTrip.businessTripDetails.filter(isSubmissionRecord).forEach((trip) => {
reject(trip, "customArchiveOptionNumbers", "行程自定义档案");
if (Array.isArray(trip.estimateExpenses)) trip.estimateExpenses.filter(isSubmissionRecord)
.forEach((expense) => reject(expense, "spreadInfo", "预计费用分摊"));
});
}
if (Array.isArray(values.expenseList)) values.expenseList.filter(isSubmissionRecord).forEach((expense) => {
reject(expense, "participants", "费用参与人员");
reject(expense, "aircraftCabin", "飞机舱位");
reject(expense, "trainCabin", "火车席别");
reject(expense, "shipCabin", "轮船舱位");
reject(expense, "spreadInfo", "费用分摊");
});
}
function validateXftExpenseApplication(value: unknown): void {
if (!Array.isArray(value) || !value.length) throw new ApprovalSafetyError("事前申请费用 applyExpenses 至少需要 1 条");
value.forEach((item, index) => {
if (!isSubmissionRecord(item)) throw new ApprovalSafetyError(`事前申请费用第 ${index + 1} 条必须是对象`);
if (isBlankSubmissionValue(item.expenseNumber)) throw new ApprovalSafetyError(`事前申请费用第 ${index + 1} 条缺少费用类型,请先查询并选择可用名称`);
requirePositiveDecimal(item.amount, `事前申请费用第 ${index + 1} 条申请金额 amount`);
if (!isBlankSubmissionValue(item.participantNum)
&& (!Number.isSafeInteger(Number(item.participantNum)) || Number(item.participantNum) <= 0)) {
throw new ApprovalSafetyError(`事前申请费用第 ${index + 1} 条参与人数 participantNum 必须是大于 0 的整数`);
}
});
}
function validateXftEntertainment(value: unknown): void {
if (!isSubmissionRecord(value)) throw new ApprovalSafetyError("招待信息 entertainmentApplyApiCreateDTO 必须是对象");
for (const key of ["entertainmentCityCode", "entertainmentPersonCount", "entertainmentStartTime", "entertainmentEndTime"] as const) {
if (isBlankSubmissionValue(value[key])) throw new ApprovalSafetyError(`招待信息缺少 ${key}`);
}
if (!Number.isSafeInteger(Number(value.entertainmentPersonCount)) || Number(value.entertainmentPersonCount) <= 0) {
throw new ApprovalSafetyError("招待人数 entertainmentPersonCount 必须是大于 0 的整数");
}
requireDateString(value.entertainmentStartTime, "招待开始日期", /^\d{4}-\d{2}-\d{2}$/);
requireDateString(value.entertainmentEndTime, "招待结束日期", /^\d{4}-\d{2}-\d{2}$/);
if (String(value.entertainmentStartTime) > String(value.entertainmentEndTime)) throw new ApprovalSafetyError("招待结束日期不能早于开始日期");
}
function validateRequiredXftCustomFields(
fields: XftSubmissionField[],
value: unknown,
attachments: XftResolvedSubmissionAttachment[] = [],
): void {
const missing = missingRequiredXftCustomFields(fields, value, attachments);
if (missing.length) {
throw new ApprovalSafetyError(`薪福通提单缺少必填自定义字段:${missing.map((field) => field.name).join("、")}`);
}
}
function missingRequiredXftCustomFields(
fields: XftSubmissionField[],
value: unknown,
attachments: XftResolvedSubmissionAttachment[] = [],
): XftSubmissionField[] {
const entries = Array.isArray(value) ? value.filter(isSubmissionRecord) : [];
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 {
if (value === undefined) return;
if (!Array.isArray(value)) throw new ApprovalSafetyError("薪福通 customFieldValues 必须是数组");
const known = new Map(fields.map((field) => [field.fieldNumber, field]));
const seen = new Set<string>();
value.forEach((raw, index) => {
if (!isSubmissionRecord(raw)) throw new ApprovalSafetyError(`第 ${index + 1} 个薪福通自定义字段必须是对象`);
const fieldNumber = String(raw.fieldNumber ?? "");
const field = known.get(fieldNumber);
if (!field) throw new ApprovalSafetyError(`薪福通自定义字段编码“${fieldNumber}”不在当前实时表单中`);
if (seen.has(fieldNumber)) throw new ApprovalSafetyError(`薪福通自定义字段“${field.name}(${fieldNumber})”重复`);
seen.add(fieldNumber);
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);
if (type === "DATE" && !/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(raw.fieldValue)) {
throw new ApprovalSafetyError(`薪福通自定义字段“${field.name}”必须使用 yyyy-MM-dd HH:mm 日期时间格式`);
}
if (type.includes("DEPARTMENT") || type.includes("ORG")) {
let parsed: unknown;
try { parsed = JSON.parse(raw.fieldValue); } catch { throw new ApprovalSafetyError(`薪福通字段“${field.name}”的部门选择格式无效,请重新查询并选择部门名称`); }
if (!Array.isArray(parsed) || parsed.some((item) => typeof item !== "string" || !item.trim())) {
throw new ApprovalSafetyError(`薪福通字段“${field.name}”的部门选择格式无效,请重新查询并选择部门名称`);
}
}
});
}
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 数组字符串`); }
if (!Array.isArray(rows)) throw new ApprovalSafetyError(`薪福通表格字段“${field.name}”必须是 JSON 数组`);
rows.forEach((row, index) => {
if (!isSubmissionRecord(row) || !Number.isSafeInteger(Number(row.sequence)) || !Array.isArray(row.columnValues)) {
throw new ApprovalSafetyError(`薪福通表格字段“${field.name}”第 ${index + 1} 行必须包含 sequence 和 columnValues`);
}
for (const column of row.columnValues) {
if (!isSubmissionRecord(column) || isBlankSubmissionValue(column.titleCode) || isBlankSubmissionValue(column.value)) {
throw new ApprovalSafetyError(`薪福通表格字段“${field.name}”第 ${index + 1} 行的列必须包含 titleCode 和 value`);
}
const itemField = (field.itemFields ?? []).find((item) => String(item.titleCode ?? "") === String(column.titleCode ?? ""));
if (itemField && isSubmissionRecord(itemField.selectionCapability) && itemField.selectionCapability.status === "client_only") {
throw new ApprovalSafetyError(`薪福通表格字段“${field.name}”中的“${String(itemField.label ?? "选项")}”当前无法通过 MCP 安全填写,请在薪福通客户端完成`);
}
}
});
}
function validateXftExpenses(value: unknown): void {
if (!Array.isArray(value) || value.length === 0) throw new ApprovalSafetyError("费用明细 expenseList 至少需要一笔");
value.forEach((item, index) => {
if (!isSubmissionRecord(item)) throw new ApprovalSafetyError(`第 ${index + 1} 笔费用必须是对象`);
if (isBlankSubmissionValue(item.expTypeCode)) throw new ApprovalSafetyError(`第 ${index + 1} 笔费用缺少费用类型,请先查询并选择可用名称`);
if (isBlankSubmissionValue(item.currencyCode)) throw new ApprovalSafetyError(`第 ${index + 1} 笔费用缺少费用币种`);
if (isBlankSubmissionValue(item.originalCurrencyReimAmt)) throw new ApprovalSafetyError(`第 ${index + 1} 笔费用缺少费用金额`);
if (item.currencyCode !== "CNY") throw new ApprovalSafetyError(`第 ${index + 1} 笔费用币种目前只支持人民币`);
requirePositiveDecimal(item.originalCurrencyReimAmt, `第 ${index + 1} 笔费用金额 originalCurrencyReimAmt`);
if (item.wrTim !== undefined) requireDateString(item.wrTim, `第 ${index + 1} 笔消费日期 wrTim`, /^\d{4}-\d{2}-\d{2}$/);
});
}
function validateXftBusinessTrip(value: unknown): void {
if (!isSubmissionRecord(value) || !Array.isArray(value.businessTripDetails) || value.businessTripDetails.length === 0) {
throw new ApprovalSafetyError("差旅行程 businessTrip.businessTripDetails 至少需要一段行程");
}
value.businessTripDetails.forEach((item, index) => {
if (!isSubmissionRecord(item)) throw new ApprovalSafetyError(`第 ${index + 1} 段行程必须是对象`);
if (isBlankSubmissionValue(item.departCityCode)) throw new ApprovalSafetyError(`第 ${index + 1} 段行程缺少出发城市`);
if (isBlankSubmissionValue(item.destinationCityCode)) throw new ApprovalSafetyError(`第 ${index + 1} 段行程缺少目的城市`);
if (isBlankSubmissionValue(item.endTime)) throw new ApprovalSafetyError(`第 ${index + 1} 段行程缺少结束时间`);
const beginTime = item.beginTime ?? item.startTime;
if (isBlankSubmissionValue(beginTime)) throw new ApprovalSafetyError(`第 ${index + 1} 段行程缺少开始时间`);
requireDateString(beginTime, `第 ${index + 1} 段出发时间`, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/);
requireDateString(item.endTime, `第 ${index + 1} 段结束时间`, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/);
if (String(beginTime) > String(item.endTime)) throw new ApprovalSafetyError(`第 ${index + 1} 段行程结束时间不能早于出发时间`);
if (item.roundTrip !== undefined && !["N", "Y"].includes(String(item.roundTrip))) {
throw new ApprovalSafetyError(`第 ${index + 1} 段行程的往返选择无效,请重新选择“单程”或“往返”`);
}
if (item.travelMode !== undefined) {
const modes = String(item.travelMode).split(",").map((mode) => mode.trim()).filter(Boolean);
const allowed = new Set(["Train", "Bus", "Ship", "Hotel", "RideHailing", "AirPlane", "Other"]);
if (!modes.length || modes.some((mode) => !allowed.has(mode))) throw new ApprovalSafetyError(`第 ${index + 1} 段行程含不支持的出行方式,请从 Schema 的中文名称中重新选择`);
}
if (item.estimateExpenses !== undefined) {
if (!Array.isArray(item.estimateExpenses)) throw new ApprovalSafetyError(`第 ${index + 1} 段行程 estimateExpenses 必须是数组`);
item.estimateExpenses.forEach((expense, expenseIndex) => {
if (!isSubmissionRecord(expense) || isBlankSubmissionValue(expense.expenseTypeNumber) || isBlankSubmissionValue(expense.applyAmount)) {
throw new ApprovalSafetyError(`第 ${index + 1} 段行程第 ${expenseIndex + 1} 笔预计费用缺少费用类型或申请金额`);
}
requirePositiveDecimal(expense.applyAmount, `第 ${index + 1} 段行程第 ${expenseIndex + 1} 笔预计费用 applyAmount`);
});
}
});
}
function requirePositiveDecimal(value: unknown, label: string): void {
const text = typeof value === "number" ? String(value) : typeof value === "string" ? value.trim() : "";
if (!/^\d{1,10}(?:\.\d{1,2})?$/.test(text) || Number(text) <= 0) {
throw new ApprovalSafetyError(`${label} 必须是大于 0、最多两位小数的金额`);
}
}
function requireDateString(value: unknown, label: string, pattern: RegExp): void {
if (typeof value !== "string" || !pattern.test(value)) throw new ApprovalSafetyError(`${label} 格式不正确`);
}
function isSubmissionRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function dingTalkSubmissionRequest(
processCode: string,
input: SubmissionInput,
fields: Array<Record<string, unknown>>,
departmentId: number,
) {
const values = { ...input.values };
return {
processCode,
deptId: departmentId,
formComponentValues: fields
.filter((field) => field.parent === undefined && field.serverManaged !== true && Object.hasOwn(values, String(field.name)))
.map((field) => ({
// The legacy create API resolves top-level controls by their display
// label, while TableField row objects resolve columns by component ID.
name: String(field.name),
value: serializeDingTalkSubmissionField(field, fields, values[String(field.name)]),
})),
...(input.targetSelectActioners?.length ? { targetSelectActioners: input.targetSelectActioners } : {}),
};
}
function serializeDingTalkSubmissionField(
field: Record<string, unknown>,
fields: Array<Record<string, unknown>>,
value: unknown,
): string {
if (field.type !== "TableField") return submissionStringValue(value);
let rows: unknown = value;
if (typeof value === "string") {
try { rows = JSON.parse(value) as unknown; }
catch { throw new ApprovalSafetyError(`明细字段“${String(field.name)}”必须是有效的 JSON 行数组`); }
}
if (!Array.isArray(rows)) throw new ApprovalSafetyError(`明细字段“${String(field.name)}”必须是 JSON 行数组`);
const children = fields.filter((candidate) => candidate.parent === field.name);
return JSON.stringify(rows.map((row) => {
if (!isSubmissionRecord(row)) throw new ApprovalSafetyError(`明细字段“${String(field.name)}”的行必须是对象`);
return Object.fromEntries(children.flatMap((child) => {
const raw = row[String(child.name)];
if (isBlankSubmissionValue(raw)) return [];
return [[String(child.name), submissionStringValue(raw)]];
}));
}));
}
function forecastSelectionRequirements(forecast: Record<string, unknown>) {
const rules = forecastRuleRecords(forecast).filter((record) => forecastTargetSelect(record));
return rules.map((rule) => {
const actor = rule.workflowActor && typeof rule.workflowActor === "object" && !Array.isArray(rule.workflowActor)
? rule.workflowActor as Record<string, unknown> : {};
return {
actionerKey: typeof actor.actorKey === "string" ? actor.actorKey : "",
activityName: typeof rule.activityName === "string" ? rule.activityName : "自选处理人",
actorType: typeof actor.actorType === "string" ? actor.actorType : "approver",
required: true,
};
}).filter((item) => item.actionerKey);
}
function validateTargetSelections(
required: Array<{ actionerKey: string }>,
selections: Array<{ actionerKey: string; actionerStaffIds: string[] }>,
): void {
const allowed = new Set(required.map((item) => item.actionerKey));
for (const item of required) {
const selection = selections.find((candidate) => candidate.actionerKey === item.actionerKey);
if (!selection?.actionerStaffIds.length) throw new ApprovalSafetyError(`流程节点 ${item.actionerKey} 需要选择处理人`);
}
for (const item of selections) {
if (!allowed.has(item.actionerKey)) throw new ApprovalSafetyError(`自选节点 ${item.actionerKey} 不在当前流程预测结果中`);
if (item.actionerStaffIds.some((id) => !id.trim())) throw new ApprovalSafetyError("自选处理人 userId 不能为空");
}
}
function compactForecast(forecast: Record<string, unknown>): Record<string, unknown> {
const nodes = forecastRuleRecords(forecast).map((node) => {
const actioners = Array.isArray(node.activityActioners) ? node.activityActioners : [];
const actor = node.workflowActor && typeof node.workflowActor === "object" && !Array.isArray(node.workflowActor)
? node.workflowActor as Record<string, unknown> : {};
return {
activityId: node.activityId,
activityName: node.activityName,
activityType: node.activityType,
targetSelect: forecastTargetSelect(node),
...(typeof actor.actorType === "string" ? { actorType: actor.actorType } : {}),
...(typeof actor.approvalMethod === "string" ? { approvalMethod: actor.approvalMethod } : {}),
actioners: actioners.flatMap((item) => item && typeof item === "object" && !Array.isArray(item)
? [String((item as Record<string, unknown>).name ?? "")].filter(Boolean) : []),
};
});
const forecastSuccess = typeof forecast.forecastSuccess === "boolean" ? forecast.forecastSuccess
: typeof forecast.isForecastSuccess === "boolean" ? forecast.isForecastSuccess : undefined;
const staticWorkflow = typeof forecast.staticWorkflow === "boolean" ? forecast.staticWorkflow
: typeof forecast.isStaticWorkflow === "boolean" ? forecast.isStaticWorkflow : undefined;
return {
supported: forecast.supported !== false,
...(forecastSuccess !== undefined ? { forecastSuccess } : {}),
...(staticWorkflow !== undefined ? { staticWorkflow } : {}),
...(typeof forecast.message === "string" ? { message: forecast.message } : {}),
nodes: uniqueJson(nodes).slice(0, 100),
};
}
function forecastRuleRecords(forecast: Record<string, unknown>): Array<Record<string, unknown>> {
for (const key of ["workflowActivityRuleVOs", "workflowActivityRules"]) {
const value = forecast[key];
if (Array.isArray(value)) {
return value.flatMap((item) => item && typeof item === "object" && !Array.isArray(item)
? [item as Record<string, unknown>] : []);
}
}
return findRecordsByKey(forecast, "activityId")
.filter((record) => "activityType" in record || "workflowActor" in record);
}
function forecastTargetSelect(rule: Record<string, unknown>): boolean {
return rule.targetSelect === true || rule.isTargetSelect === true;
}
function findRecordsByKey(value: unknown, key: string, depth = 0): Array<Record<string, unknown>> {
if (depth > 8) return [];
if (Array.isArray(value)) return value.flatMap((item) => findRecordsByKey(item, key, depth + 1));
if (!value || typeof value !== "object") return [];
const record = value as Record<string, unknown>;
return [...(key in record ? [record] : []), ...Object.values(record).flatMap((item) => findRecordsByKey(item, key, depth + 1))];
}
function assertPlainSubmissionValues(values: Record<string, unknown>): void {
if (!values || typeof values !== "object" || Array.isArray(values)) throw new ApprovalSafetyError("values 必须是 JSON 对象");
const json = JSON.stringify(values);
if (json.length > 200_000) throw new ApprovalSafetyError("提单内容不能超过 200KB");
const managed = ["userId", "originatorUserId", "empNumber", "wrEmpNumber", "billStatus", "billApplyAuthCheckType", "billApplyAuthCheckTypes"];
if (managed.some((key) => Object.hasOwn(values, key))) {
throw new ApprovalSafetyError("提单内容不得包含发起人身份、员工号或审批状态字段,这些字段由服务器填写");
}
}
function normalizedSubmissionInput(input: SubmissionInput): Record<string, unknown> {
return {
templateId: input.templateId,
values: input.values,
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)]));
}
function summarizeValue(value: unknown): unknown {
if (typeof value === "string") return value.length > 500 ? `${value.slice(0, 500)}…` : value;
if (typeof value === "number" || typeof value === "boolean" || value === null) return value;
if (Array.isArray(value)) return { itemCount: value.length, preview: value.slice(0, 5).map(summarizeValue) };
if (value && typeof value === "object") return { fieldCount: Object.keys(value).length, preview: summarizeValues(value as Record<string, unknown>) };
return String(value);
}
interface ReadableXftSummaryContext {
provider: XftApprovalProvider;
user: AuthenticatedDingTalkUser;
template: XftSubmissionTemplate;
customFields: XftSubmissionField[];
optionLabels: Map<string, string>;
}
async function readableXftSubmissionSummary(
provider: XftApprovalProvider,
user: AuthenticatedDingTalkUser,
template: XftSubmissionTemplate,
customFields: XftSubmissionField[],
inputContract: Record<string, unknown>,
values: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const valueContract = isSubmissionRecord(inputContract.values) ? inputContract.values : undefined;
const standardFields = Array.isArray(valueContract?.standardFields)
? valueContract.standardFields.filter(isSubmissionRecord)
: [];
const context: ReadableXftSummaryContext = {
provider, user, template, customFields, optionLabels: new Map(),
};
if (!standardFields.length) {
const compatibilitySummary: Record<string, unknown> = {
"预览说明": "当前实时模板未提供可读字段定义;已完成结构校验,但内部表单值不会在预览中展示",
"已填写业务区块数": Object.keys(values).length,
};
if (customFields.length && Object.hasOwn(values, "customFieldValues")) {
const custom = await readableXftCustomFields(values.customFieldValues, context, "表单自定义字段");
if (custom.length) compatibilitySummary["表单自定义字段"] = custom;
}
// Some third-party/test providers still implement the older contract. Never
// fall back to echoing their opaque values, because those may be codes or IDs.
return compatibilitySummary;
}
const output = await readableXftRecord(values, standardFields, context, "表单", true);
const known = new Set(standardFields.flatMap((field) => [
String(field.key ?? ""),
...(Array.isArray(field.aliases) ? field.aliases.map(String) : []),
]).filter(Boolean));
const standardHasCustomFields = standardFields.some((field) => String(field.key ?? "") === "customFieldValues");
if (!standardHasCustomFields && Object.hasOwn(values, "customFieldValues")) {
const custom = await readableXftCustomFields(values.customFieldValues, context, "表单自定义字段");
if (custom.length) output["表单自定义字段"] = custom;
}
known.add("customFieldValues");
const supplementary = Object.keys(values).filter((key) => !known.has(key));
if (supplementary.length) {
const invoiceOnly = supplementary.every((key) => /invoice|发票|票据/i.test(key));
if (!invoiceOnly) {
throw new ApprovalSafetyError("当前实时模板无法为部分已填写内容生成不含内部值的完整预览,请刷新模板后重新填写");
}
output["发票查重资料"] = "已提供;唯一标识仅用于查重,并在确认预览中脱敏展示";
}
return output;
}
async function readableXftRecord(
record: Record<string, unknown>,
descriptors: Array<Record<string, unknown>>,
context: ReadableXftSummaryContext,
path: string,
allowUnknownKeys = false,
): Promise<Record<string, unknown>> {
const output: Record<string, unknown> = {};
const knownKeys = new Set<string>();
for (const descriptor of descriptors) {
const key = String(descriptor.key ?? descriptor.fieldNumber ?? descriptor.titleCode ?? "");
if (!key) continue;
const aliases = Array.isArray(descriptor.aliases) ? descriptor.aliases.map(String) : [];
[key, ...aliases].forEach((candidate) => knownKeys.add(candidate));
const matchedKey = [key, ...aliases].find((candidate) => Object.hasOwn(record, candidate));
if (!matchedKey || isBlankSubmissionValue(record[matchedKey])) continue;
if (key === "customFieldValues") {
const custom = await readableXftCustomFields(record[matchedKey], context, `${path}自定义字段`);
if (custom.length) output["自定义字段"] = custom;
continue;
}
if (descriptor.userVisible === false || descriptor.serverManaged === true || descriptor.internallyDerived === true) continue;
const label = String(descriptor.label ?? descriptor.name ?? "业务字段");
output[label] = await readableXftValue(record[matchedKey], descriptor, context, `${path}“${label}”`);
}
if (!allowUnknownKeys && Object.keys(record).some((key) => !knownKeys.has(key))) {
throw new ApprovalSafetyError(`${path}包含当前实时模板未定义的内容,无法生成不含内部值的完整预览`);
}
return output;
}
async function readableXftValue(
value: unknown,
descriptor: Record<string, unknown>,
context: ReadableXftSummaryContext,
path: string,
): Promise<unknown> {
const selection = isSubmissionRecord(descriptor.selectionCapability) ? descriptor.selectionCapability : undefined;
if (selection?.status === "client_only") {
throw new ApprovalSafetyError(`${path}当前无法通过 MCP 按业务名称安全预览,请省略该可选项或在薪福通客户端完成`);
}
if (String(descriptor.key ?? "") === "customFieldValues") {
return readableXftCustomFields(value, context, path);
}
const type = String(descriptor.type ?? "").toUpperCase();
if (type.includes("TABLE")) return readableXftTable(value, descriptor, context, path);
const enumLabels = isSubmissionRecord(descriptor.enumLabels) ? descriptor.enumLabels : undefined;
const inlineOptions = Array.isArray(descriptor.options) ? descriptor.options.filter(isSubmissionRecord) : [];
const optionSource = isSubmissionRecord(descriptor.optionSource) ? descriptor.optionSource : undefined;
if (enumLabels || inlineOptions.length || optionSource) {
const selected = type === "COMMA_SEPARATED_ENUM" && typeof value === "string"
? value.split(",").map((item) => item.trim()).filter(Boolean)
: normalizeXftSelectedValues(value, type);
const labels = await Promise.all(selected.map((item) => readableXftOptionLabel(
item, descriptor, enumLabels, inlineOptions, optionSource, context, path,
)));
return labels.length === 1 && descriptor.multiple !== true && type !== "COMMA_SEPARATED_ENUM" ? labels[0] : labels;
}
const objectFields = Array.isArray(descriptor.fields) ? descriptor.fields.filter(isSubmissionRecord) : [];
if (objectFields.length) {
if (!isSubmissionRecord(value)) throw new ApprovalSafetyError(`${path}无法生成安全的业务预览,请重新填写`);
return readableXftRecord(value, objectFields, context, path);
}
const itemFields = Array.isArray(descriptor.itemFields) ? descriptor.itemFields.filter(isSubmissionRecord) : [];
if (type === "ARRAY" || Array.isArray(value)) {
if (!Array.isArray(value)) throw new ApprovalSafetyError(`${path}无法生成安全的业务预览,请重新填写`);
if (!itemFields.length) {
if (value.some((item) => item !== null && typeof item === "object")) {
throw new ApprovalSafetyError(`${path}缺少可读字段定义,无法安全展示`);
}
return value.map((item) => summarizeValue(item));
}
return Promise.all(value.map(async (item, index) => {
if (!isSubmissionRecord(item)) throw new ApprovalSafetyError(`${path}第 ${index + 1} 项无法生成安全的业务预览`);
return readableXftRecord(item, itemFields, context, `${path}第 ${index + 1} 项`);
}));
}
if (isSubmissionRecord(value)) {
throw new ApprovalSafetyError(`${path}缺少可读字段定义,无法安全展示`);
}
return summarizeValue(value);
}
function normalizeXftSelectedValues(value: unknown, type: string): string[] {
if (Array.isArray(value)) return value.map(String);
if (typeof value !== "string") return [String(value)];
if (type.includes("DEPARTMENT") || type.includes("ORG") || value.trim().startsWith("[")) {
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) return parsed.map(String);
} catch {
// A non-JSON option is handled as one selected value below.
}
}
return [value];
}
async function readableXftOptionLabel(
code: string,
descriptor: Record<string, unknown>,
enumLabels: Record<string, unknown> | undefined,
inlineOptions: Array<Record<string, unknown>>,
optionSource: Record<string, unknown> | undefined,
context: ReadableXftSummaryContext,
path: string,
): Promise<string> {
const fixed = typeof enumLabels?.[code] === "string" ? String(enumLabels[code]) : undefined;
if (fixed) return fixed;
const inline = inlineOptions.find((item) => String(item.code ?? "") === code);
if (inline && typeof inline.label === "string" && inline.label.trim()) return inline.label.trim();
const kind = optionSource?.kind;
if (kind === "department" || kind === "city" || kind === "expense_type") {
const cacheKey = `${kind}\u0000${code}`;
const cached = context.optionLabels.get(cacheKey);
if (cached) return cached;
const queried = await context.provider.getSubmissionOptions(context.user, context.template, kind, code, 200);
const matched = queried.items.find((item) => item.code === code && item.label.trim());
if (matched) {
context.optionLabels.set(cacheKey, matched.label.trim());
return matched.label.trim();
}
}
throw new ApprovalSafetyError(`${path}的当前选择已失效或无法映射为业务名称,请重新查询可用名称后选择`);
}
async function readableXftCustomFields(
value: unknown,
context: ReadableXftSummaryContext,
path: string,
): Promise<Array<Record<string, unknown>>> {
if (value === undefined) return [];
if (!Array.isArray(value)) throw new ApprovalSafetyError(`${path}无法生成安全的业务预览`);
const known = new Map(context.customFields.map((field) => [field.fieldNumber, field]));
return Promise.all(value.map(async (item, index) => {
if (!isSubmissionRecord(item)) throw new ApprovalSafetyError(`${path}第 ${index + 1} 项无法生成安全的业务预览`);
const field = known.get(String(item.fieldNumber ?? ""));
if (!field) throw new ApprovalSafetyError(`${path}包含当前实时模板无法识别的字段,请刷新模板后重试`);
const descriptor: Record<string, unknown> = {
...field,
key: field.fieldNumber,
label: field.name,
};
return { [field.name]: await readableXftValue(item.fieldValue, descriptor, context, `${path}“${field.name}”`) };
}));
}
async function readableXftTable(
value: unknown,
descriptor: Record<string, unknown>,
context: ReadableXftSummaryContext,
path: string,
): Promise<Array<Record<string, unknown>>> {
let rows = value;
if (typeof rows === "string") {
try { rows = JSON.parse(rows); } catch { throw new ApprovalSafetyError(`${path}无法生成安全的业务预览`); }
}
if (!Array.isArray(rows)) throw new ApprovalSafetyError(`${path}无法生成安全的业务预览`);
const itemFields = Array.isArray(descriptor.itemFields) ? descriptor.itemFields.filter(isSubmissionRecord) : [];
return Promise.all(rows.map(async (row, index) => {
if (!isSubmissionRecord(row) || !Array.isArray(row.columnValues)) {
throw new ApprovalSafetyError(`${path}第 ${index + 1} 行无法生成安全的业务预览`);
}
const output: Record<string, unknown> = {};
for (const column of row.columnValues) {
if (!isSubmissionRecord(column)) throw new ApprovalSafetyError(`${path}第 ${index + 1} 行无法生成安全的业务预览`);
const columnDescriptor = itemFields.find((item) => String(item.titleCode ?? "") === String(column.titleCode ?? ""));
if (!columnDescriptor) throw new ApprovalSafetyError(`${path}包含当前实时模板无法识别的列,请刷新模板后重试`);
const label = String(columnDescriptor.label ?? "业务字段");
output[label] = await readableXftValue(column.value, columnDescriptor, context, `${path}第 ${index + 1} 行“${label}”`);
}
return output;
}));
}
function submissionStringValue(value: unknown): string {
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return JSON.stringify(value);
}
function isBlankSubmissionValue(value: unknown): boolean {
return value === undefined || value === null || value === "" || (Array.isArray(value) && value.length === 0);
}
function hashJson(value: unknown): string {
return createHash("sha256").update(stableJson(value)).digest("base64url");
}
function stableJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
if (value && typeof value === "object") {
const record = value as Record<string, unknown>;
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(",")}}`;
}
return JSON.stringify(value);
}
function uniqueJson<T>(values: T[]): T[] {
const seen = new Set<string>();
return values.filter((value) => { const key = stableJson(value); if (seen.has(key)) return false; seen.add(key); return true; });
}
function interleave<T>(left: T[], right: T[]): T[] {
const output: T[] = [];
const length = Math.max(left.length, right.length);
for (let index = 0; index < length; index += 1) {
if (index < left.length) output.push(left[index]!);
if (index < right.length) output.push(right[index]!);
}
return output;
}
function withReferenceMetadata(raw: unknown, ref: ApprovalReference): unknown {
const record = raw && typeof raw === "object" && !Array.isArray(raw) ? raw as Record<string, unknown> : { value: raw };
const existingTodo = record.todo && typeof record.todo === "object" && !Array.isArray(record.todo)
? record.todo as Record<string, unknown> : {};
return {
...record,
todo: {
...existingTodo,
...(ref.title ? { title: ref.title } : {}),
...(ref.applicantName ? { applicantName: ref.applicantName } : {}),
...(ref.applicantDepartment ? { applicantDepartment: ref.applicantDepartment } : {}),
...(ref.submittedAt ? { submittedAt: ref.submittedAt } : {}),
...(ref.amount !== undefined ? { amount: ref.amount } : {}),
...(ref.currency ? { currency: ref.currency } : {}),
...(ref.summary ? { summary: ref.summary } : {}),
},
};
}
function redactSensitive<T>(value: T): T {
return redactValue(value, "", false) as T;
}
function redactValue(value: unknown, key: string, forceMask: boolean): unknown {
if ((forceMask || sensitiveLabel(key)) && (typeof value === "string" || typeof value === "number")) return maskValue(String(value));
if (typeof value === "string" && /^[\s]*[\[{]/.test(value) && value.length < 2_000_000) {
try { return JSON.stringify(redactValue(JSON.parse(value), key, forceMask)); } catch {}
}
if (Array.isArray(value)) return value.map((item) => redactValue(item, key, forceMask));
if (value && typeof value === "object") {
const record = value as Record<string, unknown>;
const dynamicSensitive = [record.name, record.title, record.label, record.fieldName, record.bizAlias, record.componentType]
.some((candidate) => typeof candidate === "string" && sensitiveLabel(candidate));
return Object.fromEntries(Object.entries(record).map(([childKey, child]) => [
childKey,
redactValue(child, childKey, forceMask || (dynamicSensitive && /^(value|content|displayValue|extValue)$/i.test(childKey))),
]));
}
return value;
}
function maskValue(value: string): string {
if (value.length <= 4) return "****";
return `${value.slice(0, 2)}${"*".repeat(Math.min(12, value.length - 4))}${value.slice(-2)}`;
}