修复(08-28-reimbursement-detail-material-preview-fixes): 收紧撤回展示并校正材料图片尺寸
这个提交包含在:
@@ -24,15 +24,14 @@ function materialCollectionHash(materials, mergerVersion) {
|
||||
|
||||
async function normalizeImageForPdf(source, limits) {
|
||||
const image = sharp(source, { failOn: "error", limitInputPixels: limits.maxImagePixels }).rotate();
|
||||
const metadata = await image.metadata();
|
||||
if (!metadata.width || !metadata.height || metadata.width * metadata.height > limits.maxImagePixels) {
|
||||
throw new Error("material_preview_image_invalid");
|
||||
}
|
||||
const normalized = await image
|
||||
const { data: normalized, info } = await image
|
||||
.flatten({ background: "#ffffff" })
|
||||
.jpeg({ quality: 90, chromaSubsampling: "4:4:4", mozjpeg: false })
|
||||
.toBuffer();
|
||||
return { buffer: normalized, width: metadata.width, height: metadata.height };
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
if (!info.width || !info.height || info.width * info.height > limits.maxImagePixels) {
|
||||
throw new Error("material_preview_image_invalid");
|
||||
}
|
||||
return { buffer: normalized, width: info.width, height: info.height };
|
||||
}
|
||||
|
||||
function fitPage(width, height) {
|
||||
|
||||
@@ -3,7 +3,7 @@ const path = require("node:path");
|
||||
const { Worker } = require("node:worker_threads");
|
||||
const { materialCollectionHash } = require("./reimbursementMaterialPdf");
|
||||
|
||||
const MATERIAL_PREVIEW_MERGER_VERSION = "pdf-v1";
|
||||
const MATERIAL_PREVIEW_MERGER_VERSION = "pdf-v2";
|
||||
const MATERIAL_PREVIEW_LIMITS = Object.freeze({
|
||||
maxMaterials: 20,
|
||||
maxSourceBytes: 50 * 1024 * 1024,
|
||||
|
||||
@@ -162,6 +162,7 @@ function buildPrisma({ processedTaskCount = 0 } = {}) {
|
||||
async function buildApp(options = {}) {
|
||||
const { prisma, tx, state } = buildPrisma(options);
|
||||
const permissionCodes = options.permissionCodes ?? ["expense.withdraw", "invoice.upload"];
|
||||
const employeeId = options.employeeId ?? OWNER_ID;
|
||||
const app = Fastify();
|
||||
|
||||
app.decorate("prisma", prisma);
|
||||
@@ -178,7 +179,7 @@ async function buildApp(options = {}) {
|
||||
throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 });
|
||||
}
|
||||
request.employee = {
|
||||
id: OWNER_ID,
|
||||
id: employeeId,
|
||||
department_id: "dept-1",
|
||||
roleCodes: [],
|
||||
permissionCodes,
|
||||
@@ -222,6 +223,28 @@ async function buildApp(options = {}) {
|
||||
}
|
||||
|
||||
describe("POST /api/reimbursements/:id/withdraw invoice release", () => {
|
||||
it("rejects a non-participant even when the employee has withdraw permission", async () => {
|
||||
const { app, tx, state } = await buildApp({ employeeId: "77777777-7777-4777-8777-777777777777" });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/reimbursements/${REIMBURSEMENT_ID}/withdraw`,
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: { comment: "不应允许非参与人撤回" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(response.json().error).toMatchObject({
|
||||
code: "PERM_001",
|
||||
details: { withdraw: "only_owner_or_submitter" },
|
||||
});
|
||||
expect(tx.reimbursementForm.update).not.toHaveBeenCalled();
|
||||
expect(tx.invoicePool.updateMany).not.toHaveBeenCalled();
|
||||
expect(state.reimbursement.status).toBe("reviewing");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("releases bound invoice on withdraw and allows rebinding to the draft reimbursement", async () => {
|
||||
const { app, tx, state } = await buildApp();
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
const { PDFDocument } = require("pdf-lib");
|
||||
const sharp = require("sharp");
|
||||
const { mergeMaterialsToPdf } = require("../../../src/services/reimbursementMaterialPdf");
|
||||
const { MaterialPreviewError, createReimbursementMaterialPreviewStore, runMergeWorker } = require("../../../src/services/reimbursementMaterialPreview");
|
||||
const {
|
||||
MATERIAL_PREVIEW_MERGER_VERSION,
|
||||
MaterialPreviewError,
|
||||
createReimbursementMaterialPreviewStore,
|
||||
runMergeWorker,
|
||||
} = require("../../../src/services/reimbursementMaterialPreview");
|
||||
|
||||
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");
|
||||
@@ -13,6 +18,39 @@ async function samplePdf(pageCount = 2) {
|
||||
}
|
||||
|
||||
describe("reimbursement material PDF preview", () => {
|
||||
it("uses normalized output dimensions for EXIF-rotated and ordinary images", async () => {
|
||||
const oriented6 = await sharp({ create: { width: 400, height: 200, channels: 3, background: "white" } })
|
||||
.jpeg()
|
||||
.withMetadata({ orientation: 6 })
|
||||
.toBuffer();
|
||||
const oriented8 = await sharp({ create: { width: 600, height: 300, channels: 3, background: "white" } })
|
||||
.jpeg()
|
||||
.withMetadata({ orientation: 8 })
|
||||
.toBuffer();
|
||||
const verticalJpeg = await sharp({ create: { width: 120, height: 240, channels: 3, background: "white" } })
|
||||
.jpeg()
|
||||
.toBuffer();
|
||||
const horizontalWebp = await sharp({ create: { width: 160, height: 80, channels: 3, background: "white" } })
|
||||
.webp()
|
||||
.toBuffer();
|
||||
|
||||
const result = await mergeMaterialsToPdf([
|
||||
{ id: "orientation-6", materialType: "attachment", mimeType: "image/jpeg", source: oriented6 },
|
||||
{ id: "orientation-8", materialType: "attachment", mimeType: "image/jpeg", source: oriented8 },
|
||||
{ id: "vertical-jpeg", materialType: "attachment", mimeType: "image/jpeg", source: verticalJpeg },
|
||||
{ id: "horizontal-webp", materialType: "attachment", mimeType: "image/webp", source: horizontalWebp },
|
||||
], limits);
|
||||
const pages = (await PDFDocument.load(result.buffer)).getPages();
|
||||
|
||||
expect(pages.map((page) => [page.getWidth(), page.getHeight()])).toEqual([
|
||||
[200, 400],
|
||||
[300, 600],
|
||||
[120, 240],
|
||||
[160, 80],
|
||||
]);
|
||||
expect(MATERIAL_PREVIEW_MERGER_VERSION).toBe("pdf-v2");
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
在新工单中引用
屏蔽一个用户