修复报销附件上传与中文文件名(07-28-travel-reimbursement-guide)
这个提交包含在:
@@ -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
|
||||
|
||||
在新工单中引用
屏蔽一个用户