feat: support secure XFT submission attachments
CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s
CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s
This commit is contained in:
+134
-60
@@ -1,14 +1,30 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "node:crypto";
|
||||
import { deflateRawSync, inflateRawSync } from "node:zlib";
|
||||
import { APPROVAL_BATCH_LIMIT } from "./approval-contract.js";
|
||||
import type { AppConfig } from "./config.js";
|
||||
import { ApprovalSafetyError, XftSessionExpiredError, errorMessage } from "./errors.js";
|
||||
import { ApprovalService, type ApprovalHistoryItem, type ProcessApprovalItem, type TodoItem } from "./approval-service.js";
|
||||
import type { ApprovalHistoryRange, ApprovalHistoryScope, AuthenticatedDingTalkUser, XftApprovalProvider, XftHistoryItem, XftSubmissionField, XftSubmissionKind, XftSubmissionOptionKind, XftSubmissionTemplate, XftTodoItem } from "./xft.js";
|
||||
import {
|
||||
XFT_ATTACHMENT_MAX_FILE_BYTES,
|
||||
XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION,
|
||||
XFT_ATTACHMENT_MAX_TOTAL_BYTES,
|
||||
type ApprovalHistoryRange,
|
||||
type ApprovalHistoryScope,
|
||||
type AuthenticatedDingTalkUser,
|
||||
type XftApprovalProvider,
|
||||
type XftHistoryItem,
|
||||
type XftResolvedSubmissionAttachment,
|
||||
type XftSubmissionAttachment,
|
||||
type XftSubmissionField,
|
||||
type XftSubmissionKind,
|
||||
type XftSubmissionOptionKind,
|
||||
type XftSubmissionTemplate,
|
||||
type XftTodoItem,
|
||||
} from "./xft.js";
|
||||
import { browseRawDetail, compactApprovalDetail, type ApprovalDetailOptions, type ApprovalDetailView } from "./approval-detail.js";
|
||||
import { sensitiveLabel } from "./approval-metadata.js";
|
||||
import { isRecord } from "./json.js";
|
||||
import type { InvoiceDeduplicationService } from "./invoice-dedup.js";
|
||||
import { SignedReferenceCodec } from "./signed-reference.js";
|
||||
|
||||
export type UnifiedSource = "dingtalk" | "xft";
|
||||
export type UnifiedAction = "approve" | "reject" | "return";
|
||||
@@ -56,6 +72,7 @@ interface SubmissionConfirmationReference {
|
||||
nonce: string;
|
||||
serviceEpoch: string;
|
||||
invoiceReservationId?: string;
|
||||
attachmentManifest?: Array<{ fieldName: string; fileName: string; size: number; sha256: string }>;
|
||||
exp: number;
|
||||
processCode?: string;
|
||||
xftTemplate?: XftSubmissionTemplate;
|
||||
@@ -155,6 +172,7 @@ export interface SubmissionInput {
|
||||
deptId?: number;
|
||||
targetSelectActioners?: Array<{ actionerKey: string; actionerStaffIds: string[] }>;
|
||||
allowDuplicateInvoices?: boolean;
|
||||
attachments?: XftSubmissionAttachment[];
|
||||
}
|
||||
|
||||
export interface OfficialOaReturnClient {
|
||||
@@ -486,7 +504,8 @@ export class UnifiedApprovalService {
|
||||
if (!this.xft || !template.xftTemplate) throw new ApprovalSafetyError("薪福通提单源未启用");
|
||||
assertXftSubmissionTemplateAllowed(template.xftTemplate);
|
||||
const schema = await this.xft.getSubmissionSchema(user, template.xftTemplate);
|
||||
validateXftSubmissionValues(template.xftTemplate.kind, input.values, schema.fields);
|
||||
const attachments = validateXftSubmissionAttachments(schema.fields, input.attachments ?? [], false);
|
||||
validateXftSubmissionValues(template.xftTemplate.kind, input.values, schema.fields, attachments);
|
||||
const identityValidation = await this.xft.validateSubmissionIdentity(user);
|
||||
preview = {
|
||||
source: "xft", template: schema.template,
|
||||
@@ -494,10 +513,12 @@ export class UnifiedApprovalService {
|
||||
identityValidation,
|
||||
submissionStatus: "APPRV_PEND",
|
||||
formSummary: await readableXftSubmissionSummary(this.xft, user, schema.template, schema.fields, schema.inputContract, input.values),
|
||||
...(attachments.length ? { attachments: attachmentPreview(attachments) } : {}),
|
||||
processForecast: { supported: false, message: "薪福通正式接口不提供无副作用的流程预测;提交时由上游完成最终业务校验" },
|
||||
};
|
||||
fingerprint = hashJson({ template: schema.template, fields: schema.fields });
|
||||
} else {
|
||||
if ((input.attachments ?? []).length) throw new ApprovalSafetyError("本地附件上传当前仅支持薪福通模板");
|
||||
if (!template.processCode) throw new ApprovalSafetyError("钉钉模板引用缺少 processCode");
|
||||
const current = await this.dingTalk.getSubmissionSchema(template.processCode);
|
||||
assertDingTalkSubmissionTemplateAllowed(current.template);
|
||||
@@ -523,9 +544,10 @@ export class UnifiedApprovalService {
|
||||
const expiresAt = Date.now() + 10 * 60_000;
|
||||
const invoiceEvidenceExpected = template.source === "xft"
|
||||
&& (template.xftTemplate?.kind === "common_reimburse" || template.xftTemplate?.kind === "travel_reimburse");
|
||||
const deduplicationValues = submissionValuesForInvoiceDeduplication(input);
|
||||
const deduplication = this.invoiceDedup
|
||||
? await this.invoiceDedup.prepare(
|
||||
user, input.values, input.allowDuplicateInvoices === true, expiresAt, invoiceEvidenceExpected,
|
||||
user, deduplicationValues, input.allowDuplicateInvoices === true, expiresAt, invoiceEvidenceExpected,
|
||||
)
|
||||
: undefined;
|
||||
if (deduplication?.duplicateCheck.requiresUserConfirmation) {
|
||||
@@ -545,6 +567,7 @@ export class UnifiedApprovalService {
|
||||
templateFingerprint: fingerprint, requestHash: hashJson(normalized), nonce,
|
||||
serviceEpoch: this.submissionServiceEpoch,
|
||||
...(deduplication?.reservationId ? { invoiceReservationId: deduplication.reservationId } : {}),
|
||||
...((input.attachments ?? []).length ? { attachmentManifest: submissionAttachmentManifest(input) } : {}),
|
||||
...(template.processCode ? { processCode: template.processCode } : {}),
|
||||
...(template.xftTemplate ? { xftTemplate: template.xftTemplate } : {}),
|
||||
exp: expiresAt,
|
||||
@@ -574,6 +597,7 @@ export class UnifiedApprovalService {
|
||||
if (hashJson(normalizedSubmissionInput(input)) !== confirmation.requestHash) {
|
||||
throw new ApprovalSafetyError("提单内容与用户确认时不一致,请重新准备并确认");
|
||||
}
|
||||
const deduplicationValues = submissionValuesForInvoiceDeduplication(input);
|
||||
if (confirmation.invoiceReservationId) this.invoiceDedup?.assertReservation(user, confirmation.invoiceReservationId);
|
||||
this.consumedSubmissionNonces.set(confirmation.nonce, confirmation.exp);
|
||||
try {
|
||||
@@ -584,14 +608,21 @@ export class UnifiedApprovalService {
|
||||
if (hashJson({ template: schema.template, fields: schema.fields }) !== confirmation.templateFingerprint) {
|
||||
throw new ApprovalSafetyError("薪福通表单在确认后发生变化,请重新准备并确认");
|
||||
}
|
||||
validateXftSubmissionValues(confirmation.xftTemplate.kind, input.values, schema.fields);
|
||||
const submitted = await this.xft.submitApproval(user, confirmation.xftTemplate, input.values);
|
||||
const attachments = validateXftSubmissionAttachments(schema.fields, input.attachments ?? [], true);
|
||||
validateXftSubmissionValues(confirmation.xftTemplate.kind, input.values, schema.fields, attachments);
|
||||
const submitted = await this.xft.submitApproval(
|
||||
user,
|
||||
confirmation.xftTemplate,
|
||||
input.values,
|
||||
attachments,
|
||||
confirmationId,
|
||||
);
|
||||
let liveStatus;
|
||||
try {
|
||||
liveStatus = await this.xft.getMySubmissionStatus(user, submitted.billId);
|
||||
} catch {
|
||||
const duplicateIndexUpdate = await this.invoiceDedup?.recordSubmission(
|
||||
user, confirmation.invoiceReservationId, input.values, "xft", submitted.billId, "outcome_uncertain",
|
||||
user, confirmation.invoiceReservationId, deduplicationValues, "xft", submitted.billId, "outcome_uncertain",
|
||||
);
|
||||
return {
|
||||
source: "xft" as const,
|
||||
@@ -604,7 +635,7 @@ export class UnifiedApprovalService {
|
||||
const completedImmediately = /^(?:2|APPRV)$/i.test(liveStatus.status);
|
||||
if (completedImmediately) {
|
||||
const duplicateIndexUpdate = await this.invoiceDedup?.recordSubmission(
|
||||
user, confirmation.invoiceReservationId, input.values, "xft", submitted.billId, "approved",
|
||||
user, confirmation.invoiceReservationId, deduplicationValues, "xft", submitted.billId, "approved",
|
||||
);
|
||||
return {
|
||||
source: "xft" as const,
|
||||
@@ -619,7 +650,7 @@ export class UnifiedApprovalService {
|
||||
}
|
||||
if (!liveStatus.revokeAllowed) {
|
||||
const duplicateIndexUpdate = await this.invoiceDedup?.recordSubmission(
|
||||
user, confirmation.invoiceReservationId, input.values, "xft", submitted.billId, liveStatus.status,
|
||||
user, confirmation.invoiceReservationId, deduplicationValues, "xft", submitted.billId, liveStatus.status,
|
||||
);
|
||||
return {
|
||||
source: "xft" as const,
|
||||
@@ -636,7 +667,7 @@ export class UnifiedApprovalService {
|
||||
exp: expiresInMinutes(7 * 24 * 60),
|
||||
} satisfies SubmissionRevokeReference);
|
||||
const duplicateIndexUpdate = await this.invoiceDedup?.recordSubmission(
|
||||
user, confirmation.invoiceReservationId, input.values, "xft", submitted.billId, liveStatus.status,
|
||||
user, confirmation.invoiceReservationId, deduplicationValues, "xft", submitted.billId, liveStatus.status,
|
||||
);
|
||||
return {
|
||||
source: "xft", ...submitted, revokeId,
|
||||
@@ -664,7 +695,7 @@ export class UnifiedApprovalService {
|
||||
exp: expiresInMinutes(7 * 24 * 60),
|
||||
} satisfies SubmissionRevokeReference);
|
||||
const duplicateIndexUpdate = await this.invoiceDedup?.recordSubmission(
|
||||
user, confirmation.invoiceReservationId, input.values, "dingtalk", processInstanceId, "pending",
|
||||
user, confirmation.invoiceReservationId, deduplicationValues, "dingtalk", processInstanceId, "pending",
|
||||
);
|
||||
return {
|
||||
source: "dingtalk", status: "submitted", processInstanceId, revokeId,
|
||||
@@ -1506,49 +1537,6 @@ export class UnifiedApprovalService {
|
||||
}
|
||||
}
|
||||
|
||||
class SignedReferenceCodec {
|
||||
private readonly key: Buffer;
|
||||
|
||||
constructor(secret: string) {
|
||||
this.key = createHash("sha256").update(secret).digest();
|
||||
}
|
||||
|
||||
sign(value: object): string {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv("aes-256-gcm", this.key, iv);
|
||||
const compressed = deflateRawSync(Buffer.from(JSON.stringify(value), "utf8"), { level: 9 });
|
||||
const payload = Buffer.concat([cipher.update(compressed), cipher.final()]);
|
||||
return `v3.${iv.toString("base64url")}.${payload.toString("base64url")}.${cipher.getAuthTag().toString("base64url")}`;
|
||||
}
|
||||
|
||||
verify<T>(value: string): T {
|
||||
const parts = value.split(".");
|
||||
if (parts.length !== 4) throw new ReferenceFormatError();
|
||||
const [version, encodedIv, encodedPayload, encodedTag] = parts;
|
||||
if (!version || !["v2", "v3"].includes(version) || !encodedIv || !encodedPayload || !encodedTag) throw new ReferenceFormatError();
|
||||
let iv: Buffer;
|
||||
let payload: Buffer;
|
||||
let tag: Buffer;
|
||||
try {
|
||||
iv = decodeCanonicalBase64Url(encodedIv);
|
||||
payload = decodeCanonicalBase64Url(encodedPayload);
|
||||
tag = decodeCanonicalBase64Url(encodedTag);
|
||||
if (iv.length !== 12 || tag.length !== 16 || payload.length === 0) throw new Error("invalid encrypted reference lengths");
|
||||
} catch {
|
||||
throw new ReferenceFormatError();
|
||||
}
|
||||
try {
|
||||
const decipher = createDecipheriv("aes-256-gcm", this.key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
const decrypted = Buffer.concat([decipher.update(payload), decipher.final()]);
|
||||
const plaintext = version === "v3" ? inflateRawSync(decrypted, { maxOutputLength: 64 * 1024 }) : decrypted;
|
||||
return JSON.parse(plaintext.toString("utf8")) as T;
|
||||
} catch {
|
||||
throw new ReferenceIntegrityError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const COMPACT_APPROVAL_AAD = Buffer.from("dingmcp:approval:v4", "utf8");
|
||||
const MAX_COMPACT_FIELD_BYTES = 8 * 1024;
|
||||
|
||||
@@ -2091,6 +2079,7 @@ function validateXftSubmissionValues(
|
||||
kind: XftSubmissionKind,
|
||||
values: Record<string, unknown>,
|
||||
customFields: XftSubmissionField[],
|
||||
attachments: XftResolvedSubmissionAttachment[] = [],
|
||||
): void {
|
||||
const required: Record<XftSubmissionKind, string[]> = {
|
||||
travel_apply: ["businessTrip"], expense_apply: ["applyExpenses"],
|
||||
@@ -2104,7 +2093,7 @@ function validateXftSubmissionValues(
|
||||
throw new ApprovalSafetyError(`该薪福通模板包含当前 MCP 无法安全填写的必填项:${requiredUnsupported.map((field) => field.name).join("、")},请在薪福通客户端发起`);
|
||||
}
|
||||
const missingBusiness = required[kind].filter((key) => isBlankSubmissionValue(values[key]));
|
||||
const missingCustom = missingRequiredXftCustomFields(customFields, values.customFieldValues);
|
||||
const missingCustom = missingRequiredXftCustomFields(customFields, values.customFieldValues, attachments);
|
||||
if (missingBusiness.length || missingCustom.length) {
|
||||
const businessLabels: Record<string, string> = {
|
||||
businessTrip: "差旅行程", applyExpenses: "事前申请费用", entertainmentApplyApiCreateDTO: "招待信息",
|
||||
@@ -2121,7 +2110,7 @@ function validateXftSubmissionValues(
|
||||
if (hasBillCompany || hasBillDepartment) {
|
||||
throw new ApprovalSafetyError("单据公司和部门由薪福通按当前登录用户填写,不应向用户索取或由 Agent 指定");
|
||||
}
|
||||
validateRequiredXftCustomFields(customFields, values.customFieldValues);
|
||||
validateRequiredXftCustomFields(customFields, values.customFieldValues, attachments);
|
||||
validateXftCustomFieldValues(customFields, values.customFieldValues);
|
||||
if (kind === "common_reimburse" || kind === "travel_reimburse") {
|
||||
requirePositiveDecimal(values.reimAmount, "报销总金额 reimAmount");
|
||||
@@ -2196,8 +2185,9 @@ function validateXftEntertainment(value: unknown): void {
|
||||
function validateRequiredXftCustomFields(
|
||||
fields: XftSubmissionField[],
|
||||
value: unknown,
|
||||
attachments: XftResolvedSubmissionAttachment[] = [],
|
||||
): void {
|
||||
const missing = missingRequiredXftCustomFields(fields, value);
|
||||
const missing = missingRequiredXftCustomFields(fields, value, attachments);
|
||||
if (missing.length) {
|
||||
throw new ApprovalSafetyError(`薪福通提单缺少必填自定义字段:${missing.map((field) => field.name).join("、")}`);
|
||||
}
|
||||
@@ -2206,11 +2196,15 @@ function validateRequiredXftCustomFields(
|
||||
function missingRequiredXftCustomFields(
|
||||
fields: XftSubmissionField[],
|
||||
value: unknown,
|
||||
attachments: XftResolvedSubmissionAttachment[] = [],
|
||||
): XftSubmissionField[] {
|
||||
const entries = Array.isArray(value) ? value.filter(isSubmissionRecord) : [];
|
||||
return fields.filter((field) => field.required).filter((field) => !entries.some((entry) =>
|
||||
String(entry.fieldNumber ?? "") === field.fieldNumber && !isBlankSubmissionValue(entry.fieldValue),
|
||||
));
|
||||
const attachmentFields = new Set(attachments.map((attachment) => attachment.fieldNumber));
|
||||
return fields.filter((field) => field.required).filter((field) => {
|
||||
if (isXftAttachmentField(field) && attachmentFields.has(field.fieldNumber)) return false;
|
||||
return !entries.some((entry) => String(entry.fieldNumber ?? "") === field.fieldNumber
|
||||
&& !isBlankSubmissionValue(entry.fieldValue));
|
||||
});
|
||||
}
|
||||
|
||||
function validateXftCustomFieldValues(fields: XftSubmissionField[], value: unknown): void {
|
||||
@@ -2228,6 +2222,9 @@ function validateXftCustomFieldValues(fields: XftSubmissionField[], value: unkno
|
||||
if (field.selectionCapability?.status === "client_only") {
|
||||
throw new ApprovalSafetyError(`“${field.name}”当前无法通过 MCP 按业务名称安全填写,请在薪福通客户端完成`);
|
||||
}
|
||||
if (isXftAttachmentField(field)) {
|
||||
throw new ApprovalSafetyError(`附件字段“${field.name}”必须通过 attachments 使用本机文件,不能直接提供附件编号`);
|
||||
}
|
||||
if (typeof raw.fieldValue !== "string") throw new ApprovalSafetyError(`薪福通自定义字段“${field.name}”的 fieldValue 必须是字符串;数组和表格需传 JSON 字符串`);
|
||||
const type = field.type.toUpperCase();
|
||||
if (type.includes("TABLE")) validateXftTableField(field, raw.fieldValue);
|
||||
@@ -2244,6 +2241,60 @@ function validateXftCustomFieldValues(fields: XftSubmissionField[], value: unkno
|
||||
});
|
||||
}
|
||||
|
||||
function validateXftSubmissionAttachments(
|
||||
fields: XftSubmissionField[],
|
||||
attachments: XftSubmissionAttachment[],
|
||||
requireUploadReference: boolean,
|
||||
): XftResolvedSubmissionAttachment[] {
|
||||
if (!Array.isArray(attachments)) throw new ApprovalSafetyError("attachments 必须是数组");
|
||||
if (attachments.length > XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION) {
|
||||
throw new ApprovalSafetyError(`一次薪福通提单最多上传 ${XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION} 个附件`);
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
let totalBytes = 0;
|
||||
return attachments.map((attachment) => {
|
||||
if (!attachment || typeof attachment !== "object") throw new ApprovalSafetyError("附件信息格式无效");
|
||||
if (!attachment.fieldName || attachment.fieldName.length > 200) throw new ApprovalSafetyError("附件缺少有效的表单字段名称");
|
||||
if (!attachment.fileName || attachment.fileName.length > 240 || /[\\/\r\n\0"]/u.test(attachment.fileName)) {
|
||||
throw new ApprovalSafetyError("附件文件名无效,请重新选择本机普通文件");
|
||||
}
|
||||
if (!Number.isSafeInteger(attachment.size) || attachment.size <= 0 || attachment.size > XFT_ATTACHMENT_MAX_FILE_BYTES) {
|
||||
throw new ApprovalSafetyError(`附件“${attachment.fileName}”必须大于 0 字节且单个不超过 50MB`);
|
||||
}
|
||||
totalBytes += attachment.size;
|
||||
if (!Number.isSafeInteger(totalBytes) || totalBytes > XFT_ATTACHMENT_MAX_TOTAL_BYTES) {
|
||||
throw new ApprovalSafetyError("一次薪福通提单的附件总大小不能超过 50MB");
|
||||
}
|
||||
if (!/^[0-9a-f]{64}$/.test(attachment.sha256)) throw new ApprovalSafetyError(`附件“${attachment.fileName}”的 SHA-256 格式无效`);
|
||||
const matches = fields.filter((field) => field.name === attachment.fieldName && isXftAttachmentField(field));
|
||||
if (matches.length !== 1) {
|
||||
throw new ApprovalSafetyError(matches.length === 0
|
||||
? `实时薪福通表单中没有名为“${attachment.fieldName}”的附件字段,请重新读取表单`
|
||||
: `实时薪福通表单中“${attachment.fieldName}”附件字段不唯一,请重新选择`);
|
||||
}
|
||||
if (requireUploadReference && !attachment.attachmentRef) {
|
||||
throw new ApprovalSafetyError(`附件“${attachment.fileName}”尚未由本机 Bridge 上传,请重新执行最终提交`);
|
||||
}
|
||||
if (!requireUploadReference && attachment.attachmentRef) {
|
||||
throw new ApprovalSafetyError("预览阶段不得预上传附件,请移除上传引用后重新准备");
|
||||
}
|
||||
const field = matches[0]!;
|
||||
const duplicateKey = `${field.fieldNumber}\0${attachment.sha256}`;
|
||||
if (seen.has(duplicateKey)) throw new ApprovalSafetyError(`附件“${attachment.fileName}”在同一字段中重复`);
|
||||
seen.add(duplicateKey);
|
||||
return {
|
||||
...attachment,
|
||||
fieldNumber: field.fieldNumber,
|
||||
...(requireUploadReference ? { attachmentRef: attachment.attachmentRef! } : { attachmentRef: "" }),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function isXftAttachmentField(field: XftSubmissionField): boolean {
|
||||
const type = field.type.toUpperCase();
|
||||
return type.includes("ATTACH") || type.includes("ACCESSOR");
|
||||
}
|
||||
|
||||
function validateXftTableField(field: XftSubmissionField, value: string): void {
|
||||
let rows: unknown;
|
||||
try { rows = JSON.parse(value); } catch { throw new ApprovalSafetyError(`薪福通表格字段“${field.name}”必须是 JSON 数组字符串`); }
|
||||
@@ -2470,9 +2521,32 @@ function normalizedSubmissionInput(input: SubmissionInput): Record<string, unkno
|
||||
deptId: input.deptId ?? -1,
|
||||
targetSelectActioners: input.targetSelectActioners ?? [],
|
||||
allowDuplicateInvoices: input.allowDuplicateInvoices === true,
|
||||
attachments: submissionAttachmentManifest(input),
|
||||
};
|
||||
}
|
||||
|
||||
function submissionAttachmentManifest(input: SubmissionInput) {
|
||||
return (input.attachments ?? []).map(({ fieldName, fileName, size, sha256 }) => ({ fieldName, fileName, size, sha256 }));
|
||||
}
|
||||
|
||||
function submissionValuesForInvoiceDeduplication(input: SubmissionInput): Record<string, unknown> {
|
||||
if (!(input.attachments ?? []).length) return input.values;
|
||||
return {
|
||||
...input.values,
|
||||
invoiceAttachments: input.attachments!.map((attachment) => ({ sha256: attachment.sha256 })),
|
||||
};
|
||||
}
|
||||
|
||||
function attachmentPreview(attachments: XftResolvedSubmissionAttachment[]): Array<Record<string, unknown>> {
|
||||
return attachments.map((attachment) => ({
|
||||
field: attachment.fieldName,
|
||||
fileName: attachment.fileName,
|
||||
size: attachment.size,
|
||||
sha256: `…${attachment.sha256.slice(-8)}`,
|
||||
uploadStatus: "将在最终确认后上传",
|
||||
}));
|
||||
}
|
||||
|
||||
function summarizeValues(values: Record<string, unknown>): Record<string, unknown> {
|
||||
return Object.fromEntries(Object.entries(values).slice(0, 200).map(([key, value]) => [key, summarizeValue(value)]));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user