修复(08-28-reimbursement-detail-material-preview-fixes): 收紧撤回展示并校正材料图片尺寸

这个提交包含在:
2026-08-28 21:53:26 +08:00
父节点 f178aa0de3
当前提交 46bfa7e00f
共修改 14 个文件,包含 342 行新增和 13 行删除
@@ -689,10 +689,18 @@ reply.header(
- PDF originals contribute every original page; JPG/PNG/WebP materials are
normalized with `sharp` and embedded one per page. The merged PDF is a
derived approval view, never an original or digital-signature fact source.
- Image page geometry must use the width and height returned by Sharp's final
normalized output (`toBuffer({ resolveWithObject: true }).info`), not input
`metadata()`. `rotate()` applies EXIF orientation during output, so input
metadata may describe the opposite portrait/landscape shape.
- A stable collection hash covers the explicitly ordered material type, ID,
file hash, MIME and byte count. The cache key also includes reimbursement ID
and merger version. Fix CreationDate, ModificationDate, Creator, Producer and
save options so identical input produces byte-identical output.
- Any visible merge-algorithm change, including corrected image page geometry,
increments the merger version. Byte determinism is required across repeated
generation within one version; different versions may intentionally produce
different bytes for the same ordered source collection.
- The in-memory bundle is bounded to 20 materials, 50 MiB source bytes, 40
pages, 40 megapixels per image, 50 MiB output and 100 MiB process cache.
Worker execution has a fixed timeout; concurrent requests for one collection
@@ -732,6 +740,7 @@ reply.header(
### 6. Tests Required
- Service tests assert deterministic bytes, all-page copy, image embedding,
EXIF orientation 6/8 output geometry, ordinary JPG/PNG/WebP geometry,
20–30-page worker behavior, concurrent deduplication, expiry and limits.
- Route tests assert authorization, reason gate, token binding, cache miss,
issue/access audit, audit-failure fail-closed behavior and exact PDF size.
@@ -748,6 +757,12 @@ reply.header(
const pages = await rasterizeEveryPdfOnThePhone(); // slow, blank-prone and duplicates PDF rendering
```
```js
const metadata = await sharp(source).rotate().metadata();
// metadata describes the input and can disagree with the rotated output.
const page = output.addPage([metadata.width, metadata.height]);
```
#### Correct
```tsx
@@ -755,6 +770,14 @@ const bundle = await fetchReimbursementMaterialPreviewBundle(reimbursementId);
await previewFileInWecom({ url: bundle.signedUrl, name: bundle.originalName, size: bundle.fileSize });
```
```js
const { data, info } = await sharp(source)
.rotate()
.jpeg()
.toBuffer({ resolveWithObject: true });
const page = output.addPage([info.width, info.height]);
```
## Scenario: Lightweight Travel Summary On Reimbursements
### 1. Scope / Trigger
@@ -0,0 +1,68 @@
# 设计:报销详情权限与材料预览缺陷修复
## 边界与原则
本任务是两个小型缺陷修复加一项生产只读诊断,不改变业务权限、审批状态或数据模型。前端修复负责把服务端已有的对象资格正确投影为可见动作;后端修复只校正图片规范化结果的尺寸事实。真实材料诊断与代码修复分离,诊断结果不能成为扩大实现范围的默许授权。
## R1 动作资格数据流
```text
报销详情 DTO ownerId / submitterId + 当前用户 me.id
-> isReimbursementParticipant
-> ReimbursementActions.showWithdrawAction
-> reviewing 状态下按“参与人 AND 状态”显示撤回
```
- 复用 `ReimbursementDetailPage` 已有 `isReimbursementParticipant`,不新增页面级权限枚举或 API 字段。
- `ReimbursementActions.showWithdrawAction` 改为必填 prop 并移除默认值。全仓库当前只有 PC 报销详情一个调用点,由 TypeScript 强制调用方显式传入参与人资格,后续新增调用也默认 fail-closed。
- owner/submitter 的既有交互、默认撤回说明、mutation 和反馈不变。
- Playwright 使用两类合成身份验证:现有 owner 夹具继续看到撤回;复用 `r102-reimbursement-main-flow.spec.ts` 中 `adminMe + ownerId=employee-other-r102 + status=reviewing + scope=all` 的非参与管理员夹具断言撤回不可见。管理员覆盖当前 fail-open 权限面的最宽视角,服务端既有 403 测试继续作为真正安全边界。
## R2 图片规范化数据流
```text
源 JPG/PNG/WebP
-> sharp(source).rotate() 应用 EXIF
-> flatten + JPEG 编码
-> toBuffer({ resolveWithObject: true })
-> info.width / info.height(实际输出尺寸)
-> fitPage
-> PDF page + drawImage
```
- 不再在输出前使用 Sharp 输入 metadata 作为页面尺寸。
- `resolveWithObject` 同时返回规范化 buffer 和实际输出 info,避免为了取尺寸再解码一次。
- 输出宽高仍执行存在性、整数性和 `maxImagePixels` 检查;输入解码继续由 Sharp 的 `limitInputPixels` 限制。若 Sharp 输出缺少合法尺寸,沿用 `material_preview_image_invalid`。
- 合并器版本从 `pdf-v1` 升级为下一稳定版本,使集合哈希和 cache key 明确区分旧算法。缓存仍只在进程内、按 grant 生命周期管理,不迁移数据。
- 测试用 Sharp 生成无敏感内容的方向 6/8 JPEG。除页数和确定性外,读取合并 PDF page size,断言其纵横方向等于自动旋转后的实际输出;保留普通 PNG/JPG/WebP 基线。
## R3 真实单据只读诊断
```text
受控生产只读定位业务单号
-> 冻结有序材料最小元数据
-> 必要时流式读取材料到安全临时目录/内存
-> 读取 EXIF 和规范化输出尺寸
-> 用当前合并器生成临时派生 PDF
-> 核对页序、页数、目标页尺寸与是否存在图像对象
-> 删除临时材料
-> 仅保留脱敏结论
```
- 现有顺序与单据事实已确认两张单页 PDF 发票在前、两张 JPG 附件在后,因此用户口中的“第一张图”固定对应合并 PDF 第 3 页;诊断直接从第 3 页开始,同时核对总页数,避免把 PDF 第 1 页误作目标图片。
- 只在元数据不足以定因时读取最小原件字节;不进行 OCR、不展示内容、不输出完整文件名和存储路径。
- 诊断记录只允许:材料类别、相对顺序、MIME、脱敏尺寸/方向、合并页数/页尺寸、错误类别和修复覆盖结论。
- 若现象可由旋转后尺寸不一致完整解释,R2 为修复;若 PDF 页本身缺少可复制内容或存在表单/可选内容依赖,停止在证据结论,另行规划兼容方案。
## 兼容性与回滚
- 无 API、schema、迁移、权限或状态机变更。
- 前端回滚:同时恢复 prop 默认值和调用点合同才会恢复旧投影;正常修复不允许只移除调用点参数,因为必填类型应在编译期阻断该回退错误。服务端安全边界始终不变。
- 后端回滚:恢复旧图片尺寸读取并回退 merger version。已签发短时 bundle 本就随进程/TTL 失效,无持久化产物迁移。
- 未部署前仅能报告本地验证与只读诊断结论;公网修复需要单独批准部署并完成构建号和关键路径检查。
## 关键权衡
- 选择复用现有参与人布尔值,而不是增加新的通用授权抽象:当前只有一个明确调用缺口,扩大抽象不会增加安全性。
- 选择读取 Sharp 输出 info,而不是交换 metadata 宽高:EXIF 不只有 6/8,输出事实比枚举推断更可靠。
- 不在本任务替换 `pdf-lib`:首图的 PDF 特殊内容兼容仍未被证据确认,且更换渲染链会改变确定性、资源上限和签名事实边界。
@@ -0,0 +1,59 @@
# 实施清单:报销详情权限与材料预览缺陷修复
## 1. 实施前只读诊断
- [x] 确认当前任务已获最终方案实施批准,并加载 `trellis-before-dev`。
- [x] 使用受控只读路径冻结 `BX-20260828-0017` 的材料顺序与最小元数据,不打印敏感字段;按“两张单页 PDF 发票在前、两张 JPG 附件在后”的既有事实,将第一张图片锁定为合并 PDF 第 3 页。
- [x] 在运行中 server 容器内以只读事务和内存缓冲复现当前合并结果,从第 3 页核对目标图片的 EXIF、规范化输出尺寸、PDF 页序和页尺寸;未写生产、未落临时原件。
- [x] 诊断确认两张 JPG 均无 EXIF 方向标记,第 3/4 页比例正确且存在图像内容;两张源 PDF 也均有普通内容流,不存在 AcroForm/可选内容层依赖。当前数据无法复现“第 3 页无内容”,因此 EXIF 修复不宣称解决 `0017` 现象,本轮不扩展 PDF 引擎。
## 2. PC 撤回动作资格
- [x] 将 `ReimbursementActions.showWithdrawAction` 改为无默认值的必填 prop,并在 PC 报销详情调用点显式传入 `showWithdrawAction={isReimbursementParticipant}`。
- [x] 保留 owner/submitter reviewing 状态的撤回能力、mutation 和反馈,不改组件外的权限或状态判断。
- [x] 在 `r102-reimbursement-main-flow.spec.ts` 复用现有 admin 非参与人全范围夹具,断言撤回按钮和意见框均不存在;保留现有 owner 可见回归和 PWA Service Worker 隔离合同。
## 3. EXIF 合并修复
- [x] 调整图片规范化函数,从实际 JPEG 输出 info 读取宽高并校验合法性。
- [x] 更新材料合并器版本,确保缓存身份与算法版本一致。
- [x] 增加 orientation 6/8 合成图片回归,断言合并页方向/比例;保留普通图片、页数、确定性、限额和失败路径测试。
## 脱敏只读诊断结论
- 材料顺序为 2 张单页 PDF 发票、2 张 JPEG 附件,共 4 页;用户所称“第一张图”为第 3 页。
- 两张 JPEG 的输入与规范化尺寸比例一致,均无 EXIF orientation;当前合并 PDF 第 3、4 页均存在内容,页面比例与图片一致。
- 两张源 PDF 均存在普通页面内容流,未发现 AcroForm 或可选内容层;当前合并结果也保留对应页面内容。
- 因而:代码中的 EXIF 尺寸读取缺陷成立并应修复,但不是 `BX-20260828-0017` 当前材料的直接原因;该单的“第一张图未显示”在本次只读内存复现中未重现,可能与当时的短时 bundle/阅读器状态有关,不能无证据归因。
- 下一步:服务端材料链路已排除。若现象复现,需向用户确认具体页码、浏览器或企业微信 WebView 版本,以及是否稳定复现,再决定是否创建新的客户端诊断任务;本轮不在服务端继续扩面。
## 4. 验证与只读复核
- [x] 运行聚焦后端 Vitest、修改文件 `node --check` 和 Prisma validate。
- [x] 运行前端 typecheck/build 与聚焦 Playwright。
- [x] 运行流程、文档、Trellis 治理及 `git diff --check`。
- [x] 只读检查 diff,确认未改变服务端撤回授权、权限矩阵、审批状态机、材料顺序、授权 grant、审计或原件降级合同。
- [x] 按 `trellis-update-spec` 将“旋转后以输出尺寸建页”和“可见算法变化升级 merger version”沉淀到报销材料规约。
## 5. 交付与回滚点
- [x] 汇总代码变化、合成测试结果和 `BX-20260828-0017` 脱敏诊断结论,不附真实原件或敏感标识。
- [x] 将 `BX-20260828-0017` 的真实处置作为单独业务决定提醒:按现行 PDF-only 规则由业务选择退回重传或财务留痕例外接受,本任务不代为处理。
- [ ] 提交信息使用中文并引用 `08-28-reimbursement-detail-material-preview-fixes`。
- [x] 未经单独批准不部署;如后续批准部署,必须完成公网构建号和关键路径只读检查后才能通知用户测试。
## 关键文件
- `client/src/pages/ReimbursementDetailPage.tsx`:PC 撤回动作资格投影。
- 覆盖 PC 报销详情的现有或聚焦 Playwright spec:owner/财务双视角回归。
- `server/src/services/reimbursementMaterialPdf.js`:Sharp 输出尺寸读取。
- `server/src/services/reimbursementMaterialPreview.js`:合并器版本。
- `server/tests/unit/services/reimbursementMaterialPreview.test.js`:EXIF、页面尺寸和确定性回归。
## 验证记录
- 聚焦后端:3 个测试文件、23 项测试全部通过;含材料合并、附件路由和非参与人撤回 403 回归。
- 前端:typecheck 与 production build 通过;admin 非参与人撤回不可见、owner 撤回可用两条聚焦 Playwright 均通过。
- 完整 `r102-reimbursement-main-flow.spec.ts` 为 5/6:本次新增用例通过;唯一失败为既有用例仍查找旧按钮名“编辑”,当前页面实际为“编辑报销日期”,与本次修改无关,未在本任务扩面修复。
- 全量后端为 179 个测试文件通过、2 个失败,1295/1298 项通过;3 项失败均来自既有付款单 BIFF8 生成器在当前环境不可用,本任务未修改付款单代码。
- process contract 与 Trellis governance 通过;docs registry 为 0 error、既有 22 个未登记文档 warning;敏感内容检查与 `git diff --check` 通过。
@@ -0,0 +1,84 @@
# 报销详情权限与材料预览缺陷修复
## Goal
修复报销只读查看者误看到“撤回报销”操作,以及带 EXIF 方向信息的图片进入材料合并预览后页面尺寸错误的问题;同时对 `BX-20260828-0017` 的首张图片未正常显示现象做受控、脱敏、只读诊断,让财务只看到职责内动作,并让图片材料在合并 PDF 中保持正确方向和比例。
## Background
- PC 报销详情已经计算 `isReimbursementParticipant`,但调用 `ReimbursementActions` 时未传 `showWithdrawAction`;该组件默认允许在 `reviewing` 状态显示撤回,因此具备全量查看权限的财务人员也会看到撤回入口。
- 服务端撤回接口仍按 owner/submitter 做对象资格校验,非参与人会被拒绝;当前问题是前端动作资格投影错误,不是服务端越权或数据损坏。
- 移动报销详情当前没有撤回操作,本轮只修复存在缺陷的 PC 入口,不增加移动操作。
- 材料合并服务先对图片执行 Sharp `rotate()` 自动应用 EXIF 方向,但页面尺寸读取自旋转前 metadata。方向为 6/8 的手机图片会出现“输出像素已转向、PDF 页面仍按原宽高”的不一致。
- 现有合并预览按发票在前、补充附件在后排序;`BX-20260828-0017` 有两张 PDF 发票和两张 JPG 补充附件。此前只读核查已确认 JPG 实际为发票图片,但未打开或持久化原件,尚不能仅凭附件列表断定“首张图片未显示”的最终技术原因。
- 现行业务规则要求:只读审批人不得看到编辑、解绑、删除等数据变更动作;图片各占合并 PDF 一页,合并失败时保留逐份原件查看路径。
## Scope
- 修复 PC 报销详情中 owner/submitter 与财务只读查看者的撤回动作可见性。
- 修复服务端材料合并时 EXIF 自动旋转后仍沿用输入尺寸的问题,并更新合并器版本。
- 使用合成数据覆盖前后端回归;对 `BX-20260828-0017` 仅执行受控、脱敏、生产只读诊断。
- 保持服务端授权、审批状态机、材料顺序、原件查看与失败降级合同不变。
## Requirements
### R1 PC 撤回动作资格投影
- PC 报销详情仅在当前用户是报销单 owner 或 submitter 且状态为 `reviewing` 时显示“撤回报销”。
- 财务、审批人、管理员等因 `expense.view_all` 或当前审批职责获得只读详情访问时,不得看到撤回输入框或按钮。
- owner/submitter 在 `reviewing` 状态下继续看到并可使用现有撤回入口;草稿、驳回、已通过、已支付等既有状态行为不变。
- `ReimbursementActions.showWithdrawAction` 必须改为无默认值的必填 prop,让遗漏资格投影在 TypeScript 编译期失败,不保留 fail-open 默认行为。
- 服务端撤回资格校验、权限矩阵、审批状态机和审计合同保持不变,前端不能把隐藏按钮当作安全边界。
### R2 EXIF 图片合并尺寸修复
- 图片经 Sharp 自动旋转、扁平化和 JPEG 规范化后,必须使用实际输出的宽高创建 PDF 页面和绘制图片,不得使用旋转前 metadata。
- 方向为 6/8 的合成 JPEG 应在输出 PDF 中保持正确的纵横方向和比例;无 EXIF 或无需旋转的 PNG/JPG/WebP 行为保持不变。
- 继续执行现有图片像素、页数、输出大小、确定性字节、整包失败和逐份原件降级合同;不引入新依赖、不把合并件当作原件。
- 合并算法的可见输出发生变化后应更新 merger version,避免新旧算法产物共用同一集合缓存身份。
### R3 `BX-20260828-0017` 首图只读诊断
- 在实施获批后,使用既有受控生产只读访问,确认材料实际顺序、MIME、字节/像素元数据、EXIF 方向、合并页数和对应页尺寸;现有事实已将用户所称“第一张图”锁定为合并 PDF 第 3 页,诊断从该页开始,仅在必要时生成短生命周期的本地临时派生预览。
- 诊断不得写生产数据库、报销、审批、付款、对象存储或企业微信;不得把原始票据、图片、完整票号、存储位置、签名 URL、原始 payload 或凭据写入仓库、任务文档或聊天。
- 临时原件或渲染页若确有必要,只能放在明确的安全临时目录,诊断完成即删除;交付只记录脱敏元数据和结论。
- 若证据确认问题由 EXIF 尺寸错误造成,使用 R2 修复覆盖;若属于 PDF `AcroForm`、可选内容组、损坏源文件或其他超出当前图片合并边界的原因,只形成证据并返回规划,不临时替换 PDF 引擎或扩大任务。
## Acceptance Criteria
- [x] 复用现有 `adminMe + ownerId=employee-other-r102 + status=reviewing + scope=all` 合成场景;管理员作为非参与只读查看者打开报销详情时,页面不存在 `reimbursement-withdraw` 和撤回意见输入框。
- [x] 同一状态下,合成 owner/submitter 仍能看到现有撤回入口;其他状态的既有动作投影不变。
- [x] 服务端非 owner/submitter 撤回拒绝合同保持通过,未修改权限矩阵、状态机、工作流或数据库结构。
- [x] 带 EXIF orientation 6 和 8 的合成图片合并后,PDF 页面宽高与规范化输出一致,方向和比例正确;普通横图、普通竖图和 PNG/WebP 回归通过。
- [x] 相同有序材料在同一 merger version 内重复生成时字节完全一致;跨 merger version 允许因修复产生不同字节。材料顺序、页数上限、整包失败和原件降级合同不变。
- [x] merger version 随算法修复更新,新的集合缓存身份不会复用旧算法产物。
- [x] `BX-20260828-0017` 首图形成一份不含原件和敏感字段的只读诊断结论,明确材料顺序、问题类别、是否由 EXIF 修复覆盖,以及未覆盖时的下一步。
- [x] 前端 typecheck/build、后端聚焦 Vitest/语法检查、相关 Playwright、Trellis 治理和 `git diff --check` 通过。
- [x] 未经另行批准不部署公网;部署与公网检查完成前不告知用户线上可测试。
## Out of Scope
- 财务审批页一页式重构、按钮“处理”的改名或待办信息结构调整。
- 费用类型、会计科目、发票—明细关联、票面金额差异门禁、多票拆分、图片发票上传提示或附件用途治理;这些属于 Task B。
- 修改、撤回、退回、审批或付款任何真实报销;重分类或重新上传 `BX-20260828-0017` 的图片附件。
- 修改服务端撤回授权、角色/权限矩阵、审批规则、状态机、schema、迁移或生产配置。
- 引入 PDF.js、Poppler、新 PDF 引擎,或在未重新规划时处理 PDF 表单域/可选内容组兼容。
- 修改 `ReimbursementActions` 现有撤回默认说明文案“申请撤回并补充说明后重提”。
- 对 `BX-20260828-0017` 执行真实单据处置,包括退回、例外接受、重传 PDF、重分类附件或修正票面合计;该单据应按现行规则另行作出业务处理决定。
- 公网部署、生产写入、企微写入和真实付款。
## Risks and Constraints
- 隐藏动作只能改善正确性和体验,安全仍由服务端对象资格校验负责。
- EXIF 修复可以解决旋转后尺寸不一致,但不能预先证明 `BX-20260828-0017` 的首图问题只有一个原因。
- 真实材料含敏感信息,诊断必须最小读取、只保留脱敏摘要,并对临时文件 fail closed。
- 现有视觉回归夹具中 owner 视角明确期待撤回按钮可见,修复不能简单删除该操作;需要新增非参与高权限查看者断言。
## Test Plan
- 后端聚焦:`cd server && npx vitest run tests/unit/services/reimbursementMaterialPreview.test.js tests/unit/routes/reimbursements.attachments.test.js`。
- 后端基础:`cd server && node --check src/services/reimbursementMaterialPdf.js && node --check src/services/reimbursementMaterialPreview.js && npm run prisma:validate`。
- 前端:`cd client && npm run typecheck && npm run build`。
- Playwright:复用 `r102-reimbursement-main-flow.spec.ts` 的 admin 非参与人全范围夹具增加撤回不可见断言,并保留现有 owner 可见断言;route mock 场景继续阻断 Service Worker。
- 只读诊断:以脱敏元数据核对 `BX-20260828-0017` 材料顺序、EXIF/像素和合并页尺寸,不保留真实材料证据。
- 治理:`node scripts/qa/check-process-contract.mjs --baseline --json`、`node scripts/qa/audit-docs.mjs --check-registry`、`node scripts/qa/check-trellis-governance.mjs`、`git diff --check`。
@@ -0,0 +1,26 @@
{
"id": "reimbursement-detail-material-preview-fixes",
"name": "reimbursement-detail-material-preview-fixes",
"title": "报销详情权限与材料预览缺陷修复",
"description": "修复财务只读查看报销时误显示撤回操作、EXIF 图片材料合并尺寸错误,并对 BX-20260828-0017 首图异常做脱敏只读诊断。",
"status": "in_progress",
"dev_type": null,
"scope": "client reimbursement action projection; server material PDF image normalization; production read-only preview diagnostics",
"package": null,
"priority": "P0",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-08-28",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "任务范围内聚焦验证通过。完整 r102 为 5/6,唯一失败是既有用例仍查找已不存在的‘编辑’按钮;本任务新增的管理员撤回不可见断言通过,不把该历史失败静默视为绿色基线。",
"meta": {}
}