实现报销材料合并预览与差旅概要(07-27-yang-jun-reimbursement-readiness)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-08-01 09:25:44 +08:00
父节点 3d9ec4c5a7
当前提交 da35591bd5
共修改 29 个文件,包含 907 行新增和 527 行删除
@@ -697,9 +697,9 @@ describe("reimbursement attachment routes", () => {
}
});
it("issues an ordered image bundle and audits each token-bound page access", async () => {
it("issues one token-bound PDF bundle with exact size and audits access", async () => {
const bundleId = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee";
const previewBuffer = Buffer.from("synthetic-png-page");
const previewBuffer = Buffer.from("%PDF synthetic merged preview");
const materialPreviewStore = {
createBundle: vi.fn().mockImplementation(async ({ actorId, reimbursementId, materials, expiresAt }) => ({
bundleId,
@@ -707,16 +707,11 @@ describe("reimbursement attachment routes", () => {
actorId,
reimbursementId,
expiresAt,
pages: [{
buffer: previewBuffer,
materialId: materials[0].id,
materialType: materials[0].materialType,
materialIndex: materials[0].materialIndex,
pageNumber: 1,
}],
pageCount: 1,
},
fileSize: previewBuffer.length,
})),
getPage: vi.fn(),
getBundle: vi.fn(),
};
const { app, prisma, tempDir, reimbursementId } = await buildApp({ materialPreviewStore });
@@ -738,62 +733,58 @@ describe("reimbursement attachment routes", () => {
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(issued.json().data).toEqual(expect.objectContaining({
mimeType: "application/pdf",
fileSize: previewBuffer.length,
pageCount: 1,
}));
const previewUrl = new URL(issued.json().data.signedUrl);
const verified = verifyToken(previewUrl.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 issuedToken = previewUrl.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)}`,
url: `${previewUrl.pathname}?previewToken=${encodeURIComponent(tamperedToken)}`,
});
expect(tamperedResponse.statusCode).toBe(403);
expect(materialPreviewStore.getPage).not.toHaveBeenCalled();
expect(materialPreviewStore.getBundle).not.toHaveBeenCalled();
const missingBundleResponse = await app.inject({
method: "GET",
url: `${pageUrl.pathname}${pageUrl.search}`,
url: `${previewUrl.pathname}${previewUrl.search}`,
});
expect(missingBundleResponse.statusCode).toBe(403);
materialPreviewStore.getPage.mockReturnValue({
materialPreviewStore.getBundle.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,
},
artifact: { buffer: previewBuffer },
});
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");
const previewResponse = await app.inject({ method: "GET", url: `${previewUrl.pathname}${previewUrl.search}` });
expect(previewResponse.statusCode).toBe(200);
expect(previewResponse.rawPayload).toEqual(previewBuffer);
expect(previewResponse.headers["content-type"]).toContain("application/pdf");
expect(Number(previewResponse.headers["content-length"])).toBe(previewBuffer.length);
expect(previewResponse.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}` });
const auditFailureResponse = await app.inject({ method: "GET", url: `${previewUrl.pathname}${previewUrl.search}` });
expect(auditFailureResponse.statusCode).toBe(500);
expect(auditFailureResponse.body).not.toContain(previewBuffer.toString());
} finally {
@@ -238,6 +238,13 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
},
createdAt: "2026-04-10T09:00:00.000Z",
submittedAt: "2026-04-10T10:00:00.000Z",
travelSummary: {
isTravel: true,
purpose: "客户拜访报销",
location: null,
startDate: "2026-04-09",
endDate: "2026-04-09",
},
invoices: [
{
id: "invoice-1",
@@ -99,6 +99,9 @@ async function buildApp(options = {}) {
invoicePool: {
count: vi.fn(async () => 1),
},
reimbursementItem: {
findFirst: vi.fn(async () => (options.travelItem ? { id: "travel-item" } : null)),
},
budgetLedgerEntry: {
create: vi.fn(async () => ({ id: "budget-ledger-1" })),
},
@@ -230,6 +233,18 @@ describe("reimbursements routes expense standard integration", () => {
resolveRecipientsForAssigneeMock.mockResolvedValue([]);
});
it("blocks travel reimbursement submit until the structured summary is complete", async () => {
const { app, tx } = await buildApp({ travelItem: true });
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual(expect.objectContaining({
travelSummary: "incomplete",
missingFields: ["travelPurpose", "travelLocation", "travelStartDate", "travelEndDate"],
}));
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
await app.close();
});
it("returns standardChecks from reimbursement precheck", async () => {
resolveEntityPrecheckMock.mockResolvedValue(
buildPrecheck({
@@ -1,65 +1,100 @@
const {
MaterialPreviewError,
createReimbursementMaterialPreviewStore,
} = require("../../../src/services/reimbursementMaterialPreview");
const { PDFDocument } = require("pdf-lib");
const sharp = require("sharp");
const { mergeMaterialsToPdf } = require("../../../src/services/reimbursementMaterialPdf");
const { MaterialPreviewError, createReimbursementMaterialPreviewStore, runMergeWorker } = require("../../../src/services/reimbursementMaterialPreview");
describe("reimbursement material preview store", () => {
it("keeps material and PDF page order in a bounded short-lived bundle", async () => {
const limits = { maxPages: 40, maxImagePixels: 40_000_000, maxOutputBytes: 10 * 1024 * 1024 };
const ENCRYPTED_PDF = Buffer.from("JVBERi0xLjMKJeLjz9MKMSAwIG9iago8PAovUHJvZHVjZXIgPGQ1MTZlOTU0YzA+Cj4+CmVuZG9iagoyIDAgb2JqCjw8Ci9UeXBlIC9QYWdlcwovQ291bnQgMQovS2lkcyBbIDQgMCBSIF0KPj4KZW5kb2JqCjMgMCBvYmoKPDwKL1R5cGUgL0NhdGFsb2cKL1BhZ2VzIDIgMCBSCj4+CmVuZG9iago0IDAgb2JqCjw8Ci9UeXBlIC9QYWdlCi9SZXNvdXJjZXMgPDwKPj4KL01lZGlhQm94IFsgMC4wIDAuMCAyMDAgMjAwIF0KL1BhcmVudCAyIDAgUgo+PgplbmRvYmoKNSAwIG9iago8PAovViAyCi9SIDMKL0xlbmd0aCAxMjgKL1AgNDI5NDk2NzI5MgovRmlsdGVyIC9TdGFuZGFyZAovTyA8YTgxN2MwMzIxY2ViOTJjMDQ3MDkyMDc2MTA0NjJkZjViZWIwMzJjYzBhYTQ4YTY3MGY5YWVmNTM3NTk2MzdkNj4KL1UgPDIyOWI4ZWJjYTUzMTFkZWYzZjhiNzM4MzQ1MzExZTdmMjhiZjRlNWU0ZTc1OGE0MTY0MDA0ZTU2ZmZmYTAxMDg+Cj4+CmVuZG9iagp4cmVmCjAgNgowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMTUgMDAwMDAgbiAKMDAwMDAwMDA1OSAwMDAwMCBuIAowMDAwMDAwMTE4IDAwMDAwIG4gCjAwMDAwMDAxNjcgMDAwMDAgbiAKMDAwMDAwMDI2MSAwMDAwMCBuIAp0cmFpbGVyCjw8Ci9TaXplIDYKL1Jvb3QgMyAwIFIKL0luZm8gMSAwIFIKL0lEIFsgPDM1Mzk2MzMyMzA2MjYyNjE2NTYzMzgzMjY1MzE2MjM1NjMzNjMzNjM2MTYyNjU2NjM1NjE2NjYxNjU2NjMxMzE+IDwzNTM5NjMzMjMwNjI2MjYxNjU2MzM4MzI2NTMxNjIzNTYzMzYzMzYzNjE2MjY1NjYzNTYxNjY2MTY1NjYzMTMxPiBdCi9FbmNyeXB0IDUgMCBSCj4+CnN0YXJ0eHJlZgo0NzYKJSVFT0YK", "base64");
async function samplePdf(pageCount = 2) {
const pdf = await PDFDocument.create();
for (let index = 0; index < pageCount; index += 1) pdf.addPage([300 + index, 200 + index]);
return Buffer.from(await pdf.save({ useObjectStreams: true }));
}
describe("reimbursement material PDF preview", () => {
it("copies every PDF page, embeds images, and produces deterministic bytes", async () => {
const pdf = await samplePdf(2);
const png = await sharp({ create: { width: 80, height: 40, channels: 3, background: "white" } }).png().toBuffer();
const materials = [
{ id: "invoice-1", materialType: "invoice", mimeType: "application/pdf", source: pdf },
{ id: "attachment-1", materialType: "attachment", mimeType: "image/png", source: png },
];
const first = await mergeMaterialsToPdf(materials, limits);
const second = await mergeMaterialsToPdf(materials, limits);
expect(first.pageCount).toBe(3);
expect(first.buffer.equals(second.buffer)).toBe(true);
expect((await PDFDocument.load(first.buffer)).getPageCount()).toBe(3);
});
it("keeps one cached byte sequence per ordered collection and expires its grant", 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({
const merged = Buffer.from("deterministic-pdf");
const mergePdf = vi.fn().mockResolvedValue({ buffer: merged, pageCount: 3 });
const store = createReimbursementMaterialPreviewStore({ now: () => currentTime, randomUUID: () => "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", mergePdf });
const args = {
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");
materials: [{ id: "invoice-1", materialType: "invoice", mimeType: "application/pdf", fileSize: 10, fileSha256: "a" }],
readSource: async () => Buffer.from("source"),
};
const created = await store.createBundle(args);
expect(created.fileSize).toBe(merged.length);
expect(store.getBundle({ bundleId: created.bundleId })?.artifact.buffer).toBe(merged);
expect(mergePdf).toHaveBeenCalledTimes(1);
currentTime = 2_001;
expect(store.getPage({ bundleId: created.bundleId, pageIndex: 0 })).toBeNull();
expect(store.getBundle({ bundleId: created.bundleId })).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")]),
});
it("deduplicates concurrent generation for the same ordered collection", async () => {
const mergePdf = vi.fn(async () => ({ buffer: Buffer.from("one-pdf"), pageCount: 1 }));
let sequence = 0;
const store = createReimbursementMaterialPreviewStore({ mergePdf, randomUUID: () => `bundle-${sequence += 1}` });
const args = {
actorId: "actor-1", reimbursementId: "reimbursement-1", expiresAt: Date.now() + 10_000,
materials: [{ id: "invoice-1", materialType: "invoice", mimeType: "application/pdf", fileSize: 6, fileSha256: "same" }],
readSource: async () => Buffer.from("source"),
};
await Promise.all([store.createBundle(args), store.createBundle(args)]);
expect(mergePdf).toHaveBeenCalledTimes(1);
});
it("rejects material/source limits and never keeps a partial bundle", async () => {
const store = createReimbursementMaterialPreviewStore({ limits: { maxMaterials: 1, maxSourceBytes: 5 }, mergePdf: vi.fn() });
await expect(store.createBundle({
actorId: "actor-1",
reimbursementId: "reimbursement-1",
expiresAt: Date.now() + 1_000,
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 },
{ id: "one", materialType: "invoice", mimeType: "application/pdf", fileSize: 3 },
{ id: "two", materialType: "attachment", mimeType: "image/png", fileSize: 3 },
],
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"),
actorId: "actor-1", reimbursementId: "reimbursement-1", expiresAt: Date.now() + 1_000,
materials: [{ id: "one", materialType: "invoice", mimeType: "application/pdf", fileSize: 3 }],
readSource: async () => Buffer.from("source-too-large"),
})).rejects.toBeInstanceOf(MaterialPreviewError);
});
it("fails the whole merge for a damaged PDF", async () => {
await expect(mergeMaterialsToPdf([
{ id: "bad", materialType: "invoice", mimeType: "application/pdf", source: Buffer.from("not-a-pdf") },
], limits)).rejects.toThrow("material_preview_pdf_invalid");
});
it("fails the whole merge for an encrypted PDF without ignoreEncryption", async () => {
await expect(mergeMaterialsToPdf([
{ id: "encrypted", materialType: "invoice", mimeType: "application/pdf", source: ENCRYPTED_PDF },
], limits)).rejects.toThrow("material_preview_pdf_invalid");
});
it("merges a 25-page synthetic document in worker_threads without losing pages", async () => {
const pdf = await samplePdf(25);
const result = await runMergeWorker([
{ id: "multi-page", materialType: "invoice", mimeType: "application/pdf", source: pdf },
], { ...limits, mergeTimeoutMs: 10_000 });
expect(result.pageCount).toBe(25);
expect((await PDFDocument.load(result.buffer)).getPageCount()).toBe(25);
});
});