实现报销材料连续预览(07-27-yang-jun-reimbursement-readiness)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-08-01 00:17:54 +08:00
父节点 b6485a278e
当前提交 8a9279efce
共修改 26 个文件,包含 1211 行新增和 178 行删除
+9
查看文件
@@ -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) {
+236 -1
查看文件
@@ -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,
};