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([ "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 { 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; 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>; revertTask(userId: string, request: { processInstanceId: string; taskId: string; targetActivityId: string; revertAction: "REVERT_FOR_RESUBMIT" | "REVERT_FOR_APPROVAL"; remark?: string; }): Promise; } export interface AuthChallenge { provider: "xft"; type: "qr"; challengeId: string; qrImageUrl: string; directUrl?: string; expiresAt: string; pollAfterSeconds: number; } export interface XftChallengeProvider { getOrStart(user: AuthenticatedDingTalkUser): Promise; 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(); 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> = []; let xftItems: Array> = []; const sourceStatus: Record> = {}; const tasks: Promise[] = []; 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> = []; let xftItems: Array> = []; const sourceStatus: Record> = {}; const tasks: Promise[] = []; 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> = []; let xftItems: Array> = []; const sourceStatus: Record> = {}; const tasks: Promise[] = []; 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; [key: string]: unknown; }> { const template = this.decodeSubmissionTemplate(user, input.templateId); assertPlainSubmissionValues(input.values); let preview: Record; 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(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(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 { 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> = []; let officialClientTodos: Promise | undefined; const batchClientUrl = async (ref: ApprovalReference): Promise => { 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>(); 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> = 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> } { const decoded: PreflightApprovalItem[] = []; const invalidItems: Array> = []; const seen = new Set(); 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(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>): 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(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(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(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 { 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>(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): 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, 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 : {}; const amount = item.amount && typeof item.amount === "object" && !Array.isArray(item.amount) ? item.amount as Record : {}; 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>, ): 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 { 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): Array> { let content: unknown = schema.schemaContent ?? schema.content; if (typeof content === "string") { try { content = JSON.parse(content); } catch { throw new ApprovalSafetyError("钉钉审批模板 Schema 内容无法解析"); } } const fields: Array> = []; 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; const props = record.props && typeof record.props === "object" && !Array.isArray(record.props) ? record.props as Record : {}; 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; 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; return typeof record.value === "string" ? record.value : typeof record.label === "string" ? record.label : undefined; } function dingTalkFieldConstraints(type: string, props: Record): Record { const formats: Record = { 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>, values: Record): 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>, 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, fields: Array>, 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, customFields: XftSubmissionField[], attachments: XftResolvedSubmissionAttachment[] = [], ): void { const required: Record = { 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 = { 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): void { const reject = (record: Record, 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(); 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(); 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 { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } function dingTalkSubmissionRequest( processCode: string, input: SubmissionInput, fields: Array>, 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, fields: Array>, 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) { 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 : {}; 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): Record { 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 : {}; 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).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): Array> { 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] : []); } } return findRecordsByKey(forecast, "activityId") .filter((record) => "activityType" in record || "workflowActor" in record); } function forecastTargetSelect(rule: Record): boolean { return rule.targetSelect === true || rule.isTargetSelect === true; } function findRecordsByKey(value: unknown, key: string, depth = 0): Array> { 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; return [...(key in record ? [record] : []), ...Object.values(record).flatMap((item) => findRecordsByKey(item, key, depth + 1))]; } function assertPlainSubmissionValues(values: Record): 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 { 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 { if (!(input.attachments ?? []).length) return input.values; return { ...input.values, invoiceAttachments: input.attachments!.map((attachment) => ({ sha256: attachment.sha256 })), }; } function attachmentPreview(attachments: XftResolvedSubmissionAttachment[]): Array> { return attachments.map((attachment) => ({ field: attachment.fieldName, fileName: attachment.fileName, size: attachment.size, sha256: `…${attachment.sha256.slice(-8)}`, uploadStatus: "将在最终确认后上传", })); } function summarizeValues(values: Record): Record { 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) }; return String(value); } interface ReadableXftSummaryContext { provider: XftApprovalProvider; user: AuthenticatedDingTalkUser; template: XftSubmissionTemplate; customFields: XftSubmissionField[]; optionLabels: Map; } async function readableXftSubmissionSummary( provider: XftApprovalProvider, user: AuthenticatedDingTalkUser, template: XftSubmissionTemplate, customFields: XftSubmissionField[], inputContract: Record, values: Record, ): Promise> { 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 = { "预览说明": "当前实时模板未提供可读字段定义;已完成结构校验,但内部表单值不会在预览中展示", "已填写业务区块数": 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, descriptors: Array>, context: ReadableXftSummaryContext, path: string, allowUnknownKeys = false, ): Promise> { const output: Record = {}; const knownKeys = new Set(); 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, context: ReadableXftSummaryContext, path: string, ): Promise { 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, enumLabels: Record | undefined, inlineOptions: Array>, optionSource: Record | undefined, context: ReadableXftSummaryContext, path: string, ): Promise { 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>> { 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 = { ...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, context: ReadableXftSummaryContext, path: string, ): Promise>> { 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 = {}; 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; return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(",")}}`; } return JSON.stringify(value); } function uniqueJson(values: T[]): T[] { const seen = new Set(); return values.filter((value) => { const key = stableJson(value); if (seen.has(key)) return false; seen.add(key); return true; }); } function interleave(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 : { value: raw }; const existingTodo = record.todo && typeof record.todo === "object" && !Array.isArray(record.todo) ? record.todo as Record : {}; 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(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; 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)}`; }