实现报销材料连续预览(07-27-yang-jun-reimbursement-readiness)
这个提交包含在:
@@ -8,6 +8,7 @@ const { buildAttachmentContentDisposition } = require("../../utils/contentDispos
|
||||
const { openReadStream, StorageNotFoundError } = require("../../services/storage");
|
||||
const { toMoneyString } = require("../../utils/workflow");
|
||||
const { resolveInvoiceAvailability } = require("../../services/invoiceGovernance");
|
||||
const { mapInvoiceDocumentMeta } = require("../../services/invoiceDocumentMeta");
|
||||
const {
|
||||
mapInvoiceSummary,
|
||||
normalizeInvoiceResponse,
|
||||
@@ -201,11 +202,13 @@ function registerInvoicePoolReadRoutes(fastify) {
|
||||
invoice_number: true,
|
||||
invoice_date: true,
|
||||
seller_name: true,
|
||||
buyer_name: true,
|
||||
total_amount: true,
|
||||
original_name: true,
|
||||
ocr_attempts: true,
|
||||
ocr_last_error: true,
|
||||
ocr_completed_at: true,
|
||||
ocr_result: { select: { result_json: true } },
|
||||
updated_at: true,
|
||||
linked_reimbursement: {
|
||||
select: {
|
||||
@@ -221,6 +224,7 @@ function registerInvoicePoolReadRoutes(fastify) {
|
||||
items: readableRows.map((invoice) => {
|
||||
const statusReason = resolveInvoiceStatusReason(invoice);
|
||||
return {
|
||||
...mapInvoiceDocumentMeta(invoice),
|
||||
id: invoice.id,
|
||||
status: invoice.status,
|
||||
reason: statusReason.reason,
|
||||
@@ -231,6 +235,7 @@ function registerInvoicePoolReadRoutes(fastify) {
|
||||
invoiceNumber: invoice.invoice_number ?? null,
|
||||
invoiceDate: invoice.invoice_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
sellerName: invoice.seller_name ?? null,
|
||||
buyerName: invoice.buyer_name ?? null,
|
||||
totalAmount: toMoneyString(invoice.total_amount),
|
||||
originalName: invoice.original_name ?? null,
|
||||
linkedReimbursementId: invoice.linked_reimbursement_id ?? null,
|
||||
@@ -319,19 +324,23 @@ function registerInvoicePoolReadRoutes(fastify) {
|
||||
invoice_number: true,
|
||||
invoice_date: true,
|
||||
seller_name: true,
|
||||
buyer_name: true,
|
||||
total_amount: true,
|
||||
original_name: true,
|
||||
created_at: true,
|
||||
ocr_result: { select: { result_json: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
items: items.map((item) => ({
|
||||
...mapInvoiceDocumentMeta(item),
|
||||
id: item.id,
|
||||
invoiceCode: item.invoice_code ?? null,
|
||||
invoiceNumber: item.invoice_number ?? null,
|
||||
invoiceDate: item.invoice_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
sellerName: item.seller_name ?? null,
|
||||
buyerName: item.buyer_name ?? null,
|
||||
totalAmount: toMoneyString(item.total_amount),
|
||||
originalName: item.original_name ?? null,
|
||||
createdAt: item.created_at?.toISOString?.() ?? null,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const { toMoneyString } = require("../../../utils/workflow");
|
||||
const { resolveInvoiceAvailability } = require("../../../services/invoiceGovernance");
|
||||
const { mapInvoiceDocumentMeta } = require("../../../services/invoiceDocumentMeta");
|
||||
|
||||
function mapVerificationRecord(item) {
|
||||
if (!item) {
|
||||
@@ -30,6 +31,7 @@ function mapInvoiceSummary(item) {
|
||||
const availability = resolveInvoiceAvailability(item);
|
||||
|
||||
return {
|
||||
...mapInvoiceDocumentMeta(item),
|
||||
id: item.id,
|
||||
status: item.status,
|
||||
availabilityReason: availability.reason,
|
||||
@@ -78,6 +80,7 @@ function normalizeInvoiceResponse(invoice) {
|
||||
const availability = resolveInvoiceAvailability(invoice);
|
||||
|
||||
return {
|
||||
...mapInvoiceDocumentMeta(invoice),
|
||||
id: invoice.id,
|
||||
status: invoice.status,
|
||||
availabilityReason: availability.reason,
|
||||
|
||||
@@ -49,6 +49,7 @@ function buildInvoiceSummarySelect() {
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
verification_records: buildLatestVerificationSelect(),
|
||||
ocr_result: { select: { result_json: true } },
|
||||
owner_employee: {
|
||||
select: { id: true, employee_no: true, name: true },
|
||||
},
|
||||
@@ -95,6 +96,7 @@ function buildInvoiceDetailSelect(options = {}) {
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
verification_records: buildLatestVerificationSelect(),
|
||||
ocr_result: { select: { result_json: true } },
|
||||
};
|
||||
|
||||
if (options.includeLinkedReimbursement) {
|
||||
|
||||
@@ -22,6 +22,7 @@ const {
|
||||
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
|
||||
const { sendNotificationEmailSafe } = require("../services/email");
|
||||
const { enqueueReimbursementJournalExport } = require("../services/integrations/erpJournal");
|
||||
const { mapInvoiceDocumentMeta } = require("../services/invoiceDocumentMeta");
|
||||
const { dispatchNotificationBatch, flushNotificationDispatchTasks } = require("../services/notificationDispatcher");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
const { ensureCutoverAllowed } = require("../services/cutover");
|
||||
@@ -32,7 +33,11 @@ const {
|
||||
resolveEntityPrecheck,
|
||||
} = require("../services/feeControl");
|
||||
const { issueDocumentNumber } = require("../services/documentSequence");
|
||||
const { openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage");
|
||||
const { openReadStream, readFileBuffer, StorageNotFoundError, writeBuffer } = require("../services/storage");
|
||||
const {
|
||||
MaterialPreviewError,
|
||||
createReimbursementMaterialPreviewStore,
|
||||
} = require("../services/reimbursementMaterialPreview");
|
||||
const {
|
||||
ATTACHMENT_TYPE,
|
||||
DOWNLOAD_POLICY,
|
||||
@@ -80,6 +85,8 @@ const REIMBURSEMENT_ATTACHMENT_DELETE_BODY_SCHEMA = z
|
||||
const REIMBURSEMENT_ATTACHMENT_PREVIEW_BODY_SCHEMA = z
|
||||
.object({ reason: z.string().trim().max(200).optional() })
|
||||
.strict();
|
||||
const REIMBURSEMENT_MATERIAL_PREVIEW_BODY_SCHEMA = REIMBURSEMENT_ATTACHMENT_PREVIEW_BODY_SCHEMA;
|
||||
const REIMBURSEMENT_MATERIAL_PREVIEW_PAGE_SCHEMA = z.coerce.number().int().min(0).max(39);
|
||||
|
||||
function resolveSignedFileOrigin(config, request) {
|
||||
const configured = String(config.appOrigin ?? "")
|
||||
@@ -94,6 +101,21 @@ function resolvePreviewUrlTtlMs(config) {
|
||||
return Math.min(configuredSeconds, 120) * 1000;
|
||||
}
|
||||
|
||||
function throwMaterialPreviewAppError(error) {
|
||||
if (error instanceof StorageNotFoundError) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
if (!(error instanceof MaterialPreviewError)) {
|
||||
throw error;
|
||||
}
|
||||
const limitError = /_limit$/.test(error.code);
|
||||
throw new AppError({
|
||||
code: limitError ? "VALID_001" : "MATERIAL_PREVIEW_001",
|
||||
message: limitError ? "报销材料较多,暂时无法连续预览" : "部分材料暂时无法生成连续预览",
|
||||
statusCode: limitError ? 409 : 422,
|
||||
});
|
||||
}
|
||||
|
||||
const REIMBURSEMENT_CREATE_BODY_SCHEMA = z
|
||||
.object({
|
||||
ownerEmployeeId: z.string().uuid().optional(),
|
||||
@@ -1361,6 +1383,9 @@ async function finalizeReimbursementSubmitSideEffects({
|
||||
}
|
||||
|
||||
async function reimbursementRoutes(fastify) {
|
||||
const materialPreviewStore = fastify.reimbursementMaterialPreviewStore
|
||||
?? createReimbursementMaterialPreviewStore();
|
||||
|
||||
fastify.post(
|
||||
"/reimbursements",
|
||||
{
|
||||
@@ -2121,11 +2146,13 @@ async function reimbursementRoutes(fastify) {
|
||||
invoice_number: true,
|
||||
invoice_date: true,
|
||||
seller_name: true,
|
||||
buyer_name: true,
|
||||
total_amount: true,
|
||||
original_name: true,
|
||||
mime_type: true,
|
||||
file_size: true,
|
||||
linked_item_id: true,
|
||||
ocr_result: { select: { result_json: true } },
|
||||
},
|
||||
},
|
||||
workflow_instances: {
|
||||
@@ -2291,12 +2318,14 @@ async function reimbursementRoutes(fastify) {
|
||||
attachments: reimbursement.attachments.map(mapReimbursementAttachment),
|
||||
budgetHint,
|
||||
invoices: reimbursement.invoice_pool_items.map((item) => ({
|
||||
...mapInvoiceDocumentMeta(item),
|
||||
id: item.id,
|
||||
status: item.status,
|
||||
invoiceCode: item.invoice_code,
|
||||
invoiceNumber: item.invoice_number,
|
||||
invoiceDate: item.invoice_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
sellerName: item.seller_name ?? null,
|
||||
buyerName: item.buyer_name ?? null,
|
||||
totalAmount: toMoneyString(item.total_amount),
|
||||
originalName: item.original_name ?? null,
|
||||
mimeType: item.mime_type ?? null,
|
||||
@@ -2951,6 +2980,212 @@ async function reimbursementRoutes(fastify) {
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/reimbursements/:id/materials/preview-bundle",
|
||||
{ preHandler: fastify.authenticate },
|
||||
async (request, reply) => {
|
||||
const reimbursementId = parseReimbursementId(request.params?.id);
|
||||
const parsedBody = REIMBURSEMENT_MATERIAL_PREVIEW_BODY_SCHEMA.safeParse(request.body ?? {});
|
||||
if (!parsedBody.success) {
|
||||
throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400 });
|
||||
}
|
||||
|
||||
const reimbursement = await fastify.prisma.reimbursementForm.findUnique({
|
||||
where: { id: reimbursementId },
|
||||
select: {
|
||||
id: true,
|
||||
owner_id: true,
|
||||
submitter_id: true,
|
||||
invoice_pool_items: {
|
||||
orderBy: [{ created_at: "asc" }, { id: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
storage_path: true,
|
||||
mime_type: true,
|
||||
file_size: true,
|
||||
},
|
||||
},
|
||||
attachments: {
|
||||
orderBy: [{ created_at: "asc" }, { id: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
storage_path: true,
|
||||
mime_type: true,
|
||||
file_size: true,
|
||||
governance_status: true,
|
||||
sensitivity_level: true,
|
||||
attachment_type: true,
|
||||
download_policy: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!reimbursement) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
ensureOwnOrCanViewAll(fastify, request, reimbursement);
|
||||
|
||||
const reason = sanitizeDownloadReason(parsedBody.data.reason);
|
||||
if (reimbursement.attachments.some((attachment) => requiresDownloadReason(attachment)) && !reason) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { reason: "required" },
|
||||
});
|
||||
}
|
||||
|
||||
const materials = [
|
||||
...reimbursement.invoice_pool_items.map((invoice, index) => ({
|
||||
id: invoice.id,
|
||||
materialType: "invoice",
|
||||
materialIndex: index + 1,
|
||||
storagePath: invoice.storage_path,
|
||||
mimeType: invoice.mime_type,
|
||||
fileSize: invoice.file_size,
|
||||
})),
|
||||
...reimbursement.attachments.map((attachment, index) => ({
|
||||
id: attachment.id,
|
||||
materialType: "attachment",
|
||||
materialIndex: index + 1,
|
||||
storagePath: attachment.storage_path,
|
||||
mimeType: attachment.mime_type,
|
||||
fileSize: attachment.file_size,
|
||||
})),
|
||||
];
|
||||
if (materials.some((material) => !ATTACHMENT_MIME_ALLOWLIST.has(material.mimeType))) {
|
||||
throw new AppError({
|
||||
code: "MATERIAL_PREVIEW_001",
|
||||
message: "部分材料格式暂不支持连续预览",
|
||||
statusCode: 422,
|
||||
});
|
||||
}
|
||||
|
||||
const expiresAt = Date.now() + resolvePreviewUrlTtlMs(fastify.config);
|
||||
let created;
|
||||
try {
|
||||
created = await materialPreviewStore.createBundle({
|
||||
actorId: request.employee.id,
|
||||
reimbursementId,
|
||||
materials,
|
||||
expiresAt,
|
||||
readSource: (material) => readFileBuffer({
|
||||
config: fastify.config,
|
||||
storagePath: material.storagePath,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
throwMaterialPreviewAppError(error);
|
||||
}
|
||||
|
||||
const origin = resolveSignedFileOrigin(fastify.config, request);
|
||||
const pages = created.bundle.pages.map((page, pageIndex) => {
|
||||
const previewToken = signPayload({
|
||||
purpose: "reimbursement_material_preview",
|
||||
reimbursementId,
|
||||
bundleId: created.bundleId,
|
||||
pageIndex,
|
||||
actorId: request.employee.id,
|
||||
exp: expiresAt,
|
||||
}, fastify.config.jwtSecret);
|
||||
return {
|
||||
url: `${origin}/api/reimbursements/${reimbursementId}/materials/preview/${created.bundleId}/${pageIndex}?previewToken=${encodeURIComponent(previewToken)}`,
|
||||
materialType: page.materialType,
|
||||
materialIndex: page.materialIndex,
|
||||
pageNumber: page.pageNumber,
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "REIMBURSEMENT_MATERIAL_PREVIEW_BUNDLE_ISSUED",
|
||||
targetType: "reimbursement",
|
||||
targetId: reimbursementId,
|
||||
extra: {
|
||||
invoiceCount: reimbursement.invoice_pool_items.length,
|
||||
attachmentCount: reimbursement.attachments.length,
|
||||
pageCount: pages.length,
|
||||
reason,
|
||||
expiresAt,
|
||||
},
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
materialPreviewStore.deleteBundle?.(created.bundleId);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return sendData(reply, request, {
|
||||
expiresAt: new Date(expiresAt).toISOString(),
|
||||
pages,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
"/reimbursements/:id/materials/preview/:bundleId/:pageIndex",
|
||||
{},
|
||||
async (request, reply) => {
|
||||
const reimbursementId = parseReimbursementId(request.params?.id);
|
||||
const parsedBundleId = UUID_SCHEMA.safeParse(request.params?.bundleId);
|
||||
const parsedPageIndex = REIMBURSEMENT_MATERIAL_PREVIEW_PAGE_SCHEMA.safeParse(request.params?.pageIndex);
|
||||
const previewToken = typeof request.query?.previewToken === "string" ? request.query.previewToken : "";
|
||||
const verified = previewToken ? verifyToken(previewToken, fastify.config.jwtSecret) : { ok: false };
|
||||
const payload = verified.ok ? verified.payload : null;
|
||||
const parsedActorId = UUID_SCHEMA.safeParse(payload?.actorId);
|
||||
if (
|
||||
!parsedBundleId.success ||
|
||||
!parsedPageIndex.success ||
|
||||
!verified.ok ||
|
||||
payload?.purpose !== "reimbursement_material_preview" ||
|
||||
payload?.reimbursementId !== reimbursementId ||
|
||||
payload?.bundleId !== parsedBundleId.data ||
|
||||
payload?.pageIndex !== parsedPageIndex.data ||
|
||||
typeof payload?.exp !== "number" ||
|
||||
payload.exp <= Date.now() ||
|
||||
!parsedActorId.success
|
||||
) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
}
|
||||
|
||||
const resolved = materialPreviewStore.getPage({
|
||||
bundleId: parsedBundleId.data,
|
||||
pageIndex: parsedPageIndex.data,
|
||||
});
|
||||
if (
|
||||
!resolved ||
|
||||
resolved.bundle.reimbursementId !== reimbursementId ||
|
||||
resolved.bundle.actorId !== parsedActorId.data ||
|
||||
resolved.bundle.expiresAt !== payload.exp
|
||||
) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
}
|
||||
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: parsedActorId.data,
|
||||
action: "REIMBURSEMENT_MATERIAL_PREVIEWED",
|
||||
targetType: "reimbursement",
|
||||
targetId: reimbursementId,
|
||||
extra: {
|
||||
materialId: resolved.page.materialId,
|
||||
materialType: resolved.page.materialType,
|
||||
materialIndex: resolved.page.materialIndex,
|
||||
pageNumber: resolved.page.pageNumber,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
reply.type("image/png");
|
||||
reply.header("cache-control", "private, no-store");
|
||||
reply.header("referrer-policy", "no-referrer");
|
||||
reply.header("content-disposition", "inline");
|
||||
return reply.send(resolved.page.buffer);
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/reimbursements/:id/items",
|
||||
{
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
const SAFE_DOCUMENT_CATEGORIES = new Set([
|
||||
"vat_invoice",
|
||||
"full_vat_invoice",
|
||||
"electronic_train_ticket",
|
||||
]);
|
||||
|
||||
function normalizeSafeMetaString(value, maxLength = 64) {
|
||||
if (typeof value !== "string") return null;
|
||||
const normalized = value.trim();
|
||||
return normalized ? normalized.slice(0, maxLength) : null;
|
||||
}
|
||||
|
||||
function mapInvoiceDocumentMeta(invoice) {
|
||||
const result = invoice?.ocr_result?.result_json;
|
||||
const category = normalizeSafeMetaString(result?.documentCategory);
|
||||
return {
|
||||
documentCategory: SAFE_DOCUMENT_CATEGORIES.has(category) ? category : null,
|
||||
documentSubtype: normalizeSafeMetaString(result?.documentSubtype),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { mapInvoiceDocumentMeta };
|
||||
@@ -0,0 +1,210 @@
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs/promises");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { promisify } = require("node:util");
|
||||
const { execFile } = require("node:child_process");
|
||||
const sharp = require("sharp");
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const MATERIAL_PREVIEW_LIMITS = Object.freeze({
|
||||
maxMaterials: 20,
|
||||
maxSourceBytes: 50 * 1024 * 1024,
|
||||
maxPages: 40,
|
||||
maxPageWidth: 1800,
|
||||
maxPagePixels: 40_000_000,
|
||||
maxOutputBytes: 40 * 1024 * 1024,
|
||||
maxCacheBytes: 80 * 1024 * 1024,
|
||||
rasterDpi: 144,
|
||||
rasterTimeoutMs: 20_000,
|
||||
});
|
||||
|
||||
class MaterialPreviewError extends Error {
|
||||
constructor(code) {
|
||||
super(code);
|
||||
this.name = "MaterialPreviewError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function pageNumberFromName(fileName) {
|
||||
const matched = fileName.match(/-(\d+)\.png$/i);
|
||||
return matched ? Number(matched[1]) : Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
async function normalizePreviewImage(buffer, limits = MATERIAL_PREVIEW_LIMITS) {
|
||||
try {
|
||||
return await sharp(buffer, { failOn: "error", limitInputPixels: limits.maxPagePixels })
|
||||
.rotate()
|
||||
.resize({ width: limits.maxPageWidth, withoutEnlargement: true })
|
||||
.png({ compressionLevel: 8, adaptiveFiltering: true })
|
||||
.toBuffer();
|
||||
} catch {
|
||||
throw new MaterialPreviewError("material_preview_image_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
async function rasterizePdfWithPoppler(buffer, limits = MATERIAL_PREVIEW_LIMITS) {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hyb-oa-material-preview-"));
|
||||
const sourcePath = path.join(tempDir, "source.pdf");
|
||||
const outputPrefix = path.join(tempDir, "page");
|
||||
|
||||
try {
|
||||
await fs.writeFile(sourcePath, buffer, { flag: "wx" });
|
||||
try {
|
||||
await execFileAsync(
|
||||
"pdftoppm",
|
||||
[
|
||||
"-png",
|
||||
"-r",
|
||||
String(limits.rasterDpi),
|
||||
"-f",
|
||||
"1",
|
||||
"-l",
|
||||
String(limits.maxPages + 1),
|
||||
"-scale-to-x",
|
||||
String(limits.maxPageWidth),
|
||||
"-scale-to-y",
|
||||
"-1",
|
||||
sourcePath,
|
||||
outputPrefix,
|
||||
],
|
||||
{
|
||||
timeout: limits.rasterTimeoutMs,
|
||||
maxBuffer: 1024 * 1024,
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") {
|
||||
throw new MaterialPreviewError("material_preview_rasterizer_unavailable");
|
||||
}
|
||||
if (error?.killed || error?.signal) {
|
||||
throw new MaterialPreviewError("material_preview_raster_timeout");
|
||||
}
|
||||
throw new MaterialPreviewError("material_preview_pdf_invalid");
|
||||
}
|
||||
|
||||
const fileNames = (await fs.readdir(tempDir))
|
||||
.filter((fileName) => /^page-\d+\.png$/i.test(fileName))
|
||||
.sort((left, right) => pageNumberFromName(left) - pageNumberFromName(right));
|
||||
if (!fileNames.length) {
|
||||
throw new MaterialPreviewError("material_preview_pdf_empty");
|
||||
}
|
||||
|
||||
const pages = [];
|
||||
for (const fileName of fileNames) {
|
||||
pages.push(await normalizePreviewImage(await fs.readFile(path.join(tempDir, fileName)), limits));
|
||||
}
|
||||
return pages;
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function createReimbursementMaterialPreviewStore(options = {}) {
|
||||
const limits = { ...MATERIAL_PREVIEW_LIMITS, ...(options.limits ?? {}) };
|
||||
const rasterizePdf = options.rasterizePdf ?? ((buffer) => rasterizePdfWithPoppler(buffer, limits));
|
||||
const normalizeImage = options.normalizeImage ?? ((buffer) => normalizePreviewImage(buffer, limits));
|
||||
const now = options.now ?? (() => Date.now());
|
||||
const randomUUID = options.randomUUID ?? (() => crypto.randomUUID());
|
||||
const bundles = new Map();
|
||||
let cachedBytes = 0;
|
||||
|
||||
function deleteBundle(bundleId) {
|
||||
const bundle = bundles.get(bundleId);
|
||||
if (!bundle) return;
|
||||
cachedBytes -= bundle.outputBytes;
|
||||
bundles.delete(bundleId);
|
||||
}
|
||||
|
||||
function pruneExpired() {
|
||||
const current = now();
|
||||
for (const [bundleId, bundle] of bundles) {
|
||||
if (bundle.expiresAt <= current) deleteBundle(bundleId);
|
||||
}
|
||||
}
|
||||
|
||||
async function createBundle({ actorId, reimbursementId, materials, expiresAt, readSource }) {
|
||||
pruneExpired();
|
||||
if (!Array.isArray(materials) || materials.length === 0) {
|
||||
throw new MaterialPreviewError("material_preview_empty");
|
||||
}
|
||||
if (materials.length > limits.maxMaterials) {
|
||||
throw new MaterialPreviewError("material_preview_material_limit");
|
||||
}
|
||||
const sourceBytes = materials.reduce((sum, material) => sum + Number(material.fileSize ?? 0), 0);
|
||||
if (!Number.isSafeInteger(sourceBytes) || sourceBytes <= 0 || sourceBytes > limits.maxSourceBytes) {
|
||||
throw new MaterialPreviewError("material_preview_source_size_limit");
|
||||
}
|
||||
|
||||
const pages = [];
|
||||
let outputBytes = 0;
|
||||
let actualSourceBytes = 0;
|
||||
for (const material of materials) {
|
||||
const source = await readSource(material);
|
||||
if (!Buffer.isBuffer(source) || source.length === 0) {
|
||||
throw new MaterialPreviewError("material_preview_source_missing");
|
||||
}
|
||||
actualSourceBytes += source.length;
|
||||
if (actualSourceBytes > limits.maxSourceBytes) {
|
||||
throw new MaterialPreviewError("material_preview_source_size_limit");
|
||||
}
|
||||
const renderedPages = material.mimeType === "application/pdf"
|
||||
? await rasterizePdf(source)
|
||||
: [await normalizeImage(source)];
|
||||
for (let index = 0; index < renderedPages.length; index += 1) {
|
||||
if (pages.length >= limits.maxPages) {
|
||||
throw new MaterialPreviewError("material_preview_page_limit");
|
||||
}
|
||||
const image = renderedPages[index];
|
||||
outputBytes += image.length;
|
||||
if (outputBytes > limits.maxOutputBytes) {
|
||||
throw new MaterialPreviewError("material_preview_output_size_limit");
|
||||
}
|
||||
pages.push({
|
||||
buffer: image,
|
||||
materialId: material.id,
|
||||
materialType: material.materialType,
|
||||
materialIndex: material.materialIndex,
|
||||
pageNumber: index + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (cachedBytes + outputBytes > limits.maxCacheBytes) {
|
||||
pruneExpired();
|
||||
if (cachedBytes + outputBytes > limits.maxCacheBytes) {
|
||||
throw new MaterialPreviewError("material_preview_cache_limit");
|
||||
}
|
||||
}
|
||||
|
||||
const bundleId = randomUUID();
|
||||
const bundle = { actorId, reimbursementId, expiresAt, outputBytes, pages };
|
||||
bundles.set(bundleId, bundle);
|
||||
cachedBytes += outputBytes;
|
||||
const timer = setTimeout(() => deleteBundle(bundleId), Math.max(1, expiresAt - now()));
|
||||
timer.unref?.();
|
||||
return { bundleId, bundle };
|
||||
}
|
||||
|
||||
function getPage({ bundleId, pageIndex }) {
|
||||
pruneExpired();
|
||||
const bundle = bundles.get(bundleId);
|
||||
if (!bundle || !Number.isInteger(pageIndex) || pageIndex < 0 || pageIndex >= bundle.pages.length) {
|
||||
return null;
|
||||
}
|
||||
return { bundle, page: bundle.pages[pageIndex] };
|
||||
}
|
||||
|
||||
return { createBundle, getPage, deleteBundle };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MATERIAL_PREVIEW_LIMITS,
|
||||
MaterialPreviewError,
|
||||
createReimbursementMaterialPreviewStore,
|
||||
normalizePreviewImage,
|
||||
rasterizePdfWithPoppler,
|
||||
};
|
||||
@@ -44,19 +44,22 @@ function buildMultipartRequest({ files, fields = {} }) {
|
||||
};
|
||||
}
|
||||
|
||||
async function buildApp({ reimbursementStatus = "draft" } = {}) {
|
||||
async function buildApp({ reimbursementStatus = "draft", materialPreviewStore = null } = {}) {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hyb-oa-r111-reimbursement-attachments-"));
|
||||
const reimbursementId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
const createdAttachments = [];
|
||||
|
||||
const prisma = {
|
||||
reimbursementForm: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
findUnique: vi.fn().mockImplementation(async ({ select }) => ({
|
||||
id: reimbursementId,
|
||||
owner_id: ACTOR_ID,
|
||||
submitter_id: ACTOR_ID,
|
||||
status: reimbursementStatus,
|
||||
}),
|
||||
...(select?.invoice_pool_items
|
||||
? { invoice_pool_items: [], attachments: createdAttachments }
|
||||
: {}),
|
||||
})),
|
||||
},
|
||||
reimbursementAttachment: {
|
||||
create: vi.fn().mockImplementation(async ({ data, select }) => {
|
||||
@@ -141,6 +144,9 @@ async function buildApp({ reimbursementStatus = "draft" } = {}) {
|
||||
|
||||
const app = Fastify();
|
||||
app.decorate("prisma", prisma);
|
||||
if (materialPreviewStore) {
|
||||
app.decorate("reimbursementMaterialPreviewStore", materialPreviewStore);
|
||||
}
|
||||
app.decorate("config", {
|
||||
appTimezone: "Asia/Shanghai",
|
||||
storageDir: tempDir,
|
||||
@@ -612,10 +618,18 @@ describe("reimbursement attachment routes", () => {
|
||||
headers: { authorization: "Bearer test", "content-type": "application/json" },
|
||||
payload: {},
|
||||
});
|
||||
const missingBundleReason = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/reimbursements/${reimbursementId}/materials/preview-bundle`,
|
||||
headers: { authorization: "Bearer test", "content-type": "application/json" },
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(unauthenticated.statusCode).toBe(401);
|
||||
expect(missingReason.statusCode).toBe(400);
|
||||
expect(missingReason.json().error.details).toEqual({ reason: "required" });
|
||||
expect(missingBundleReason.statusCode).toBe(400);
|
||||
expect(missingBundleReason.json().error.details).toEqual({ reason: "required" });
|
||||
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await app.close();
|
||||
@@ -682,4 +696,109 @@ describe("reimbursement attachment routes", () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("issues an ordered image bundle and audits each token-bound page access", async () => {
|
||||
const bundleId = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee";
|
||||
const previewBuffer = Buffer.from("synthetic-png-page");
|
||||
const materialPreviewStore = {
|
||||
createBundle: vi.fn().mockImplementation(async ({ actorId, reimbursementId, materials, expiresAt }) => ({
|
||||
bundleId,
|
||||
bundle: {
|
||||
actorId,
|
||||
reimbursementId,
|
||||
expiresAt,
|
||||
pages: [{
|
||||
buffer: previewBuffer,
|
||||
materialId: materials[0].id,
|
||||
materialType: materials[0].materialType,
|
||||
materialIndex: materials[0].materialIndex,
|
||||
pageNumber: 1,
|
||||
}],
|
||||
},
|
||||
})),
|
||||
getPage: vi.fn(),
|
||||
};
|
||||
const { app, prisma, tempDir, reimbursementId } = await buildApp({ materialPreviewStore });
|
||||
|
||||
try {
|
||||
const requestPayload = buildMultipartRequest({
|
||||
files: [{ filename: "payment.png", contentType: "image/png", content: "png-content" }],
|
||||
});
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/reimbursements/${reimbursementId}/attachments`,
|
||||
headers: { authorization: "Bearer test", ...requestPayload.headers },
|
||||
payload: requestPayload.payload,
|
||||
});
|
||||
|
||||
const issued = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/reimbursements/${reimbursementId}/materials/preview-bundle`,
|
||||
headers: { authorization: "Bearer test", "content-type": "application/json" },
|
||||
payload: {},
|
||||
});
|
||||
expect(issued.statusCode).toBe(200);
|
||||
expect(issued.json().data.pages).toEqual([
|
||||
expect.objectContaining({ materialType: "attachment", materialIndex: 1, pageNumber: 1 }),
|
||||
]);
|
||||
const pageUrl = new URL(issued.json().data.pages[0].url);
|
||||
const verified = verifyToken(pageUrl.searchParams.get("previewToken"), JWT_SECRET);
|
||||
expect(verified).toEqual(expect.objectContaining({
|
||||
ok: true,
|
||||
payload: expect.objectContaining({
|
||||
purpose: "reimbursement_material_preview",
|
||||
reimbursementId,
|
||||
bundleId,
|
||||
pageIndex: 0,
|
||||
actorId: ACTOR_ID,
|
||||
}),
|
||||
}));
|
||||
|
||||
const issuedToken = pageUrl.searchParams.get("previewToken");
|
||||
const tamperedToken = `${issuedToken.slice(0, -1)}${issuedToken.endsWith("a") ? "b" : "a"}`;
|
||||
const tamperedResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `${pageUrl.pathname}?previewToken=${encodeURIComponent(tamperedToken)}`,
|
||||
});
|
||||
expect(tamperedResponse.statusCode).toBe(403);
|
||||
expect(materialPreviewStore.getPage).not.toHaveBeenCalled();
|
||||
|
||||
const missingBundleResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `${pageUrl.pathname}${pageUrl.search}`,
|
||||
});
|
||||
expect(missingBundleResponse.statusCode).toBe(403);
|
||||
|
||||
materialPreviewStore.getPage.mockReturnValue({
|
||||
bundle: {
|
||||
actorId: ACTOR_ID,
|
||||
reimbursementId,
|
||||
expiresAt: verified.payload.exp,
|
||||
},
|
||||
page: {
|
||||
buffer: previewBuffer,
|
||||
materialId: "bbbbbbbb-bbbb-4bbb-8bbb-000000000001",
|
||||
materialType: "attachment",
|
||||
materialIndex: 1,
|
||||
pageNumber: 1,
|
||||
},
|
||||
});
|
||||
const pageResponse = await app.inject({ method: "GET", url: `${pageUrl.pathname}${pageUrl.search}` });
|
||||
expect(pageResponse.statusCode).toBe(200);
|
||||
expect(pageResponse.rawPayload).toEqual(previewBuffer);
|
||||
expect(pageResponse.headers["content-type"]).toContain("image/png");
|
||||
expect(pageResponse.headers["cache-control"]).toBe("private, no-store");
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({ action: "REIMBURSEMENT_MATERIAL_PREVIEWED", actor_id: ACTOR_ID }),
|
||||
}));
|
||||
|
||||
prisma.auditLog.create.mockRejectedValueOnce(new Error("synthetic audit outage"));
|
||||
const auditFailureResponse = await app.inject({ method: "GET", url: `${pageUrl.pathname}${pageUrl.search}` });
|
||||
expect(auditFailureResponse.statusCode).toBe(500);
|
||||
expect(auditFailureResponse.body).not.toContain(previewBuffer.toString());
|
||||
} finally {
|
||||
await app.close();
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
const { mapInvoiceDocumentMeta } = require("../../../src/services/invoiceDocumentMeta");
|
||||
|
||||
describe("invoice document metadata", () => {
|
||||
it("exposes only supported document categories and a bounded subtype", () => {
|
||||
expect(mapInvoiceDocumentMeta({
|
||||
ocr_result: {
|
||||
result_json: {
|
||||
documentCategory: " electronic_train_ticket ",
|
||||
documentSubtype: `ElectronicTrainTicketFull${"x".repeat(80)}`,
|
||||
raw: { sensitive: true },
|
||||
},
|
||||
},
|
||||
})).toEqual({
|
||||
documentCategory: "electronic_train_ticket",
|
||||
documentSubtype: `ElectronicTrainTicketFull${"x".repeat(39)}`,
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed for unknown, blank, or malformed OCR metadata", () => {
|
||||
expect(mapInvoiceDocumentMeta({
|
||||
ocr_result: { result_json: { documentCategory: "unknown_invoice", documentSubtype: " " } },
|
||||
})).toEqual({ documentCategory: null, documentSubtype: null });
|
||||
expect(mapInvoiceDocumentMeta(null)).toEqual({ documentCategory: null, documentSubtype: null });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
const {
|
||||
MaterialPreviewError,
|
||||
createReimbursementMaterialPreviewStore,
|
||||
} = require("../../../src/services/reimbursementMaterialPreview");
|
||||
|
||||
describe("reimbursement material preview store", () => {
|
||||
it("keeps material and PDF page order in a bounded short-lived bundle", async () => {
|
||||
let currentTime = 1_000;
|
||||
const store = createReimbursementMaterialPreviewStore({
|
||||
now: () => currentTime,
|
||||
randomUUID: () => "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
rasterizePdf: vi.fn().mockResolvedValue([Buffer.from("pdf-page-1"), Buffer.from("pdf-page-2")]),
|
||||
normalizeImage: vi.fn().mockResolvedValue(Buffer.from("image-page")),
|
||||
});
|
||||
|
||||
const created = await store.createBundle({
|
||||
actorId: "actor-1",
|
||||
reimbursementId: "reimbursement-1",
|
||||
expiresAt: 2_000,
|
||||
materials: [
|
||||
{ id: "invoice-1", materialType: "invoice", materialIndex: 1, mimeType: "application/pdf", fileSize: 10 },
|
||||
{ id: "attachment-1", materialType: "attachment", materialIndex: 1, mimeType: "image/png", fileSize: 10 },
|
||||
],
|
||||
readSource: async (material) => Buffer.from(material.id),
|
||||
});
|
||||
|
||||
expect(created.bundle.pages.map((page) => [page.materialType, page.materialIndex, page.pageNumber])).toEqual([
|
||||
["invoice", 1, 1],
|
||||
["invoice", 1, 2],
|
||||
["attachment", 1, 1],
|
||||
]);
|
||||
expect(store.getPage({ bundleId: created.bundleId, pageIndex: 1 })?.page.buffer.toString()).toBe("pdf-page-2");
|
||||
|
||||
currentTime = 2_001;
|
||||
expect(store.getPage({ bundleId: created.bundleId, pageIndex: 0 })).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects source and page limits without keeping a partial bundle", async () => {
|
||||
const store = createReimbursementMaterialPreviewStore({
|
||||
limits: { maxMaterials: 1, maxSourceBytes: 10, maxPages: 1 },
|
||||
rasterizePdf: vi.fn().mockResolvedValue([Buffer.from("one"), Buffer.from("two")]),
|
||||
});
|
||||
|
||||
await expect(store.createBundle({
|
||||
actorId: "actor-1",
|
||||
reimbursementId: "reimbursement-1",
|
||||
expiresAt: Date.now() + 1_000,
|
||||
materials: [
|
||||
{ id: "one", materialType: "invoice", materialIndex: 1, mimeType: "application/pdf", fileSize: 6 },
|
||||
{ id: "two", materialType: "attachment", materialIndex: 1, mimeType: "image/png", fileSize: 6 },
|
||||
],
|
||||
readSource: async () => Buffer.from("source"),
|
||||
})).rejects.toMatchObject({ code: "material_preview_material_limit" });
|
||||
|
||||
await expect(store.createBundle({
|
||||
actorId: "actor-1",
|
||||
reimbursementId: "reimbursement-1",
|
||||
expiresAt: Date.now() + 1_000,
|
||||
materials: [
|
||||
{ id: "one", materialType: "invoice", materialIndex: 1, mimeType: "application/pdf", fileSize: 6 },
|
||||
],
|
||||
readSource: async () => Buffer.from("source"),
|
||||
})).rejects.toBeInstanceOf(MaterialPreviewError);
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户