修复报销附件上传与中文文件名(07-28-travel-reimbursement-guide)

这个提交包含在:
2026-07-30 14:41:08 +08:00
父节点 531d2846a6
当前提交 734153847b
共修改 15 个文件,包含 434 行新增和 65 行删除
@@ -473,6 +473,98 @@ const TRAVEL_TRANSPORT_EXPENSE_TYPE_CODES = { bus: "travel_bus" };
// model and fee-control service both support eligible-day multiplication.
```
## Scenario: Reimbursement Attachment Batch And UTF-8 Filename Boundary
### 1. Scope / Trigger
- Trigger: changing reimbursement supporting-material upload limits, multipart
parsing, attachment/invoice file responses or tests that register multipart.
- Purpose: preserve the global one-file safety default while supporting the
reimbursement UI's bounded multi-file request and legal Chinese file names.
### 2. Signatures
- Upload: `POST /api/reimbursements/:id/attachments`, multipart field `files`,
one to five PDF/JPG/PNG/WebP files per request.
- Route parser: `request.parts({ limits: { files: 5, fileSize } })`; the global
`@fastify/multipart` registration remains `limits.files=1`.
- Download headers: `buildAttachmentContentDisposition(name, {
disposition: "inline" })` for reimbursement attachments and invoice PDFs.
- Client constant: `MAX_REIMBURSEMENT_ATTACHMENT_FILES=5` is shared by PC and
mobile reimbursement detail pages.
### 3. Contracts
- Do not relax the application-wide multipart file count. Only the
reimbursement supporting-material route overrides it to five.
- Parse the complete multipart request before storage/database writes so a
sixth file cannot leave a partial five-file upload.
- `FST_FILES_LIMIT` maps to `400 VALID_001` with
`{ file: "too_many_files", maxFiles: 5 }`; PC and mobile show the same
bounded message. Generic error mapping must not assume every module has the
reimbursement limit when `maxFiles` is absent.
- Preserve the original name in the database. HTTP `Content-Disposition` uses
an ASCII `filename` fallback plus RFC 5987 UTF-8 `filename*`; never place raw
Chinese, CR/LF, quotes, path separators or control characters in the legacy
quoted parameter.
- Reimbursement attachments and invoice PDFs retain `inline`; purchase,
employee and payment receipt downloads keep their existing default
`attachment` behavior through the same shared builder.
### 4. Validation & Error Matrix
- One to five allowed files -> parse and persist all files.
- Six or more files -> `400 VALID_001 / file=too_many_files`, with zero storage,
database and audit writes.
- Unsupported MIME, empty file or oversized file -> existing stable file
validation error; no partial persistence.
- Chinese name -> `inline; filename="attachment.<ext>"; filename*=UTF-8''...`.
- Invalid disposition value -> `TypeError`; callers may use only `inline` or
`attachment`.
### 5. Good/Base/Bad Cases
- Good: PC selects five payment screenshots; the route succeeds even though
the test app and production app both register global `files=1`.
- Base: one ASCII PDF keeps an ASCII fallback and an equivalent `filename*`.
- Bad: set the test-only global limit to five, which hides a missing route
override; or interpolate a Chinese database name directly into a Node header.
### 6. Tests Required
- Register multipart with the production global `files=1` in reimbursement
route tests, then assert five files succeed.
- Send six files and assert `400`, `VALID_001`, `too_many_files`, and no
attachment/audit writes.
- Download an existing Chinese-name reimbursement attachment and invoice PDF;
assert `inline`, ASCII `filename`, percent-encoded `filename*`, and no raw
Chinese in the header.
- Cover CR/LF, quote and path cleanup in the shared Content-Disposition helper,
while retaining existing purchase `attachment` regression tests.
- Run backend full tests and frontend typecheck/build because the limit and
error message cross API and both UI variants.
### 7. Wrong vs Correct
#### Wrong
```js
await fastify.register(multipart, { limits: { files: 5 } }); // test-only drift
reply.header("content-disposition", `inline; filename="${originalName}"`);
```
#### Correct
```js
for await (const part of request.parts({ limits: { files: 5, fileSize } })) {
// Buffer and validate the bounded request before persistence.
}
reply.header(
"content-disposition",
buildAttachmentContentDisposition(originalName, { disposition: "inline" }),
);
```
## Scenario: Payment Batch Receipt Attention Count
### 1. Scope / Trigger