feat: support WPS report material workbooks

This commit is contained in:
hectorzhao
2026-09-04 17:10:04 +08:00
parent 48d0363920
commit fb39c8b606
29 changed files with 2737 additions and 734 deletions
@@ -0,0 +1,463 @@
# WPS 单元格图片导入与双格式导出实现方案
日期:2026-09-04
状态:已按方案实施并通过本地验证
范围:报备资料导入、单条报备资料导出、批次通道文件下载和批次 ZIP 下载
## 1. 目标
在不改变现有报备资料、材料版本、审核、批次和通道报备状态逻辑的前提下:
1. 导入自动识别并解析两类 `.xlsx` 图片:
- ExcelJS 当前支持的标准 Drawing 图片;
- WPS `DISPIMG + xl/cellimages.xml` 单元格图片。
2. 导出时让用户选择:
- Excel 通用格式:保持系统现有标准 Drawing 图片格式;
- WPS 单元格图片格式:生成与样本相同机制的 `DISPIMG` 单元格图片文件。
3. 保持公式安全检查严格有效。只允许与包内受控图片一一对应的 WPS `DISPIMG`,不得直接放宽为允许任意公式。
4. 两种格式使用相同的真实 PostgreSQL 资料、批次快照和 MinIO 文件,格式选择不得改变业务数据、任务状态或历史批次内容。
本方案不涉及短信发送、Redis Stream、队列、计费、余额、通道连接参数或部署架构调整。
## 2. 样本核验结果
样本:`C:\Users\hectorzhao\Downloads\行业报备.xlsx`
| 项目 | 实测结果 |
| --- | --- |
| 文件大小 | 20,976,525 字节 |
| SHA-256 | `D5B5029B044EF5B8A86A68D4FE33E4F80D3AAB3A9B547D228145158D4B1518C1` |
| 工作表 | `行业` |
| 使用范围 | `A1:M17`1 行表头、16 行数据 |
| WPS 图片公式 | 43 个,位于 H2:K17 的非空图片单元格 |
| `cellimages.xml` 图片项 | 43 个 |
| 图片关系 | 43 个,和公式 ID 一一对应 |
| 业务图片 | `xl/media/image2.png``image44.png`,均为 PNG |
| 业务图片原始字节合计 | 20,672,756 字节,约占整个文件 98.55% |
| 图片像素范围 | 宽 4611226,高 276840 |
| 标准 Drawing | 3 个,均指向 A1 的同一张 1×1、84 字节 PNG 占位图 |
样本中每个图片单元格保存的不是标准 Drawing 锚点,而是以下公式:
```text
_xlfn.DISPIMG("ID_7C706627234641A7BCEA9177C8A3EACB",1)
```
其引用链为:
```text
sheet1.xml 中的单元格公式
-> 公式内 ID_xxx
-> xl/cellimages.xml 中 xdr:cNvPr@name
-> a:blip@r:embed
-> xl/_rels/cellimages.xml.rels
-> xl/media/imageN.png
```
`xl/_rels/workbook.xml.rels` 还包含 WPS 专用关系:
```text
Type="http://www.wps.cn/officeDocument/2020/cellImage"
Target="cellimages.xml"
```
`[Content_Types].xml` 包含:
```text
ContentType="application/vnd.wps-officedocument.cellimage+xml"
PartName="/xl/cellimages.xml"
```
样本中的图片显示尺寸较小,但 PNG 原始像素和原始字节仍完整保存在 XLSX 中。调整单元格显示尺寸不等于压缩图片,WPS 格式选择也不应被描述为压缩功能。
## 3. 与系统现有图片处理方式的差异
### 3.1 当前导入
当前实现使用 ExcelJS 加载工作簿,然后:
1. `assertSafeWorkbook` 拒绝任何公式或疑似公式文本;
2. `readEmbeddedImages` 只读取 `worksheet.getImages()`
3. 根据 Drawing 左上角锚点换算图片所在行列;
4. 提交导入时将图片上传至真实 MinIO,并把 `fileObjectId/fileName/contentType` 写入待审核资料。
对本次样本的实际结果是:
- ExcelJS 将 43 个 `DISPIMG` 识别为公式,因此现有安全检查会直接拒绝文件;
- ExcelJS `worksheet.getImages()` 只返回 3 个 A1 的 1×1 占位 Drawing
- 43 个真实业务图片虽然进入 ExcelJS 的媒体集合,但没有单元格锚点,当前代码无法关联到 H2:K17;
- 即使简单放宽公式检查,图片列仍会被识别为无图片,单元格文本还可能变成 `=DISPIMG(...)`,不能得到真实文件对象;
- 当前上传上限为 10 MiB,而样本约 20.0 MiB,会在工作簿解析前被上传中间件拒绝。
### 3.2 当前导出
当前导出使用 ExcelJS
1. 从真实 MinIO 下载 PNG/JPEG/GIF 原文件;
2. `workbook.addImage` 注册媒体;
3. `worksheet.addImage``oneCell` Drawing 锚点覆盖到目标单元格;
4. 按通道配置调整列宽、图片显示范围和行高;
5. 图片单元格本身仍保留文件名文本。
该格式是标准 DrawingML 图片:Excel 和 WPS 通常都能打开,兼容范围更广;但图片本质是浮动绘图对象,不是 WPS 的单元格图片值。
### 3.3 差异结论
| 对比项 | 系统现有 Excel 格式 | 样本 WPS 格式 |
| --- | --- | --- |
| 单元格内容 | 文件名文本 | `DISPIMG` 公式 |
| 图片定位 | 工作表 Drawing 锚点 | 公式 ID 关联 `cellimages.xml` |
| 图片关系文件 | `xl/drawings/*.xml(.rels)` | `xl/cellimages.xml` 和专用 rels |
| ExcelJS 直接读取 | 支持 | 不支持单元格映射 |
| Microsoft Excel 兼容性 | 较好 | 取决于 Excel 版本,可能显示 `_xlfn.DISPIMG` 或不显示图片 |
| WPS 行/列语义 | 浮动对象 | WPS 原生单元格图片 |
| 图片原始大小 | 默认保留原图 | 样本同样保留原图 |
因此不能用“允许 DISPIMG 公式”代替 WPS 图片支持,也不能把两种格式合并为同一解析路径。
## 4. 产品交互方案
### 4.1 导入
导入不要求用户预先选择格式。上传后由后端自动检测:
- `excel_drawing`:仅存在标准 Drawing 图片;
- `wps_cell_image`:存在有效 `cellimages.xml` 和匹配的 `DISPIMG`
- `mixed`:两种图片同时存在;
- `none`:没有图片。
解析结果页显示只读提示,例如:
```text
已识别:WPS 单元格图片格式,43 张图片
```
混合格式按单元格合并。若同一单元格同时存在 WPS 单元格图片和标准 Drawing,视为冲突并要求用户修正,不静默选择其中一个。
### 4.2 单条报备资料导出
“导出报备资料”点击后打开格式选择弹窗:
- `Excel 通用格式`,默认选中;说明“图片为标准 Excel 图片,兼容 Excel 和 WPS”;
- `WPS 单元格图片格式`;说明“图片作为 WPS 单元格图片,Microsoft Excel 兼容性取决于版本”。
确认后才请求导出。关闭弹窗不发请求,生成中禁止重复提交,失败保留选择并展示后端真实错误。
### 4.3 报备批次导出
现有“报备文件导出”已经是弹窗,不再叠加第二层弹窗。在弹窗顶部增加同一格式选择:
- 下载单个通道文件时应用当前选择;
- “全部下载”时,ZIP 内所有 XLSX 使用同一种选择格式;
- TXT 简报不受影响。
Excel 通用格式继续使用现有文件名。WPS 文件增加 `_WPS` 后缀以免用户混淆,例如:
```text
2026-09-04_通道名_RBxxxx_WPS.xlsx
```
默认值始终为 Excel 通用格式,不改变既有用户的下载结果和接口行为。
## 5. 后端实现设计
### 5.1 增加统一格式枚举
```ts
type ReportWorkbookFormat = 'excel_drawing' | 'wps_cell_image';
```
所有入口统一校验该枚举,缺省为 `excel_drawing`。不接受任意字符串、文件扩展名或由前端提交的 OOXML 片段。
建议接口调整:
```text
POST /api/admin/report-materials/single-export
body.outputFormat = excel_drawing | wps_cell_image
GET /api/admin/report-materials/batches/:id/files/:fileId/download
?outputFormat=excel_drawing|wps_cell_image
GET /api/admin/report-materials/batches/:id/download
?outputFormat=excel_drawing|wps_cell_image
```
权限、租户、批次、通道和文件归属校验保持现有逻辑;格式参数不参与业务对象定位。
### 5.2 导入前置 OOXML 检查器
在 ExcelJS 之前增加 `WorkbookPackageInspector`,使用项目已有 `jszip` 读取原始 XLSX 包,只做受限结构解析:
1. 验证 ZIP 路径规范化,拒绝绝对路径、`..` 路径穿越和重复关键部件;
2. 拒绝宏、ActiveX、OLE、外部链接、外部关系和非预期可执行部件;
3. 读取 workbook、worksheet、关系文件、`cellimages.xml` 和媒体清单;
4. 从原始 worksheet XML 提取真实公式单元格,避免 ExcelJS 因合并单元格复制公式造成重复图片;
5. 建立 `sheetName + row + column -> image` 映射;
6. 输出检测格式、图片数、冲突和安全诊断,再交给现有 ExcelJS 文本/样式解析流程。
本次样本在原始 XML 中是 43 个公式;ExcelJS 模型中会因合并单元格显示为 49 个公式。因此 WPS 图片定位必须以原始 worksheet XML 为准。
### 5.3 WPS 单元格图片解析器
新增 `WpsCellImageReader`,按以下顺序解析:
1. 从 workbook relationships 找到且仅找到一个 WPS cellImage 部件;
2. 解析 `cellimages.xml` 中每个 `xdr:cNvPr@name``a:blip@r:embed`
3. 通过 `cellimages.xml.rels` 解析内部媒体路径;
4. 只接受包内图片关系,不允许 `TargetMode="External"`
5. 从每个原始工作表单元格提取严格格式的 `DISPIMG` ID
6. 校验公式 ID、cellImage ID、relationship ID 和媒体对象一一可解析;
7. 返回与现有 `EmbeddedImage` 相同的 `{row, column, extension, buffer}`,让后续映射、MinIO 上传和审核流程继续复用。
不把 `cellimages.xml` 中的 `a:xfrm` 坐标当作单元格地址。样本的业务单元格位置来自公式本身,ID 才是图片关联键。
### 5.4 公式安全策略
保留现有“默认拒绝所有公式”原则,仅为已验证的 WPS 图片单元格建立精确白名单。允许条件必须全部满足:
1. 原始公式完整匹配:
```text
^_xlfn\.DISPIMG\("ID_[A-F0-9]{32}",1\)$
```
2. 公式所在工作表和单元格已由 OOXML 检查器登记;
3. ID 在 `cellimages.xml` 中唯一存在;
4. 图片关系是包内图片且目标文件通过类型、大小和签名校验;
5. 一个公式只引用一张图片,一个 ID 不允许被不受控地复用到多个业务单元格;
6. 除该单元格外,工作簿不存在其他公式、共享公式、数组公式、外部链接或疑似公式文本。
不要修改为“公式名包含 DISPIMG 即放行”,也不要只依赖 ExcelJS 解析后的 `cell.value.result`。
### 5.5 标准 Drawing 与 WPS 图片统一
`readEmbeddedImages` 调整为接收前置检查结果:
```text
标准 Drawing 解析结果
+ WPS cell image 解析结果
-> 按 sheet/row/column 合并
-> 冲突检测
-> 统一 EmbeddedImage[]
```
样本中 A1 的 3 个 1×1 Drawing 是占位对象,不应当被当作 H2:K17 的图片,也不应以“图片数量大于零”证明 WPS 图片已解析。是否在 WPS 导出中生成类似占位 Drawing,应通过最小原型在目标 WPS 版本中验证后决定;第一版不盲目复制样本中的冗余占位对象。
### 5.6 上传大小与资源保护
样本超过当前 10 MiB 上限。按最终业务要求将报备 XLSX 单文件上限调整为 100 MiB,同时增加解包级限制,而不是只提高 Multer 上限:
- ZIP 压缩文件最大 100 MiB
- 解压后总大小最大 500 MiB;
- ZIP 项目数最大 1,000
- 单张图片最大 10 MiB
- 单工作簿图片最大 500 张;
- 工作表最大 20 张、单表最大 20,000 行和 200 列;
- XML 单部件最大 10 MiB
- 图片必须校验文件签名和实际 MIME,不能只信扩展名或 relationship
- 分析和提交阶段均执行同一检查,不能只在预览阶段检查;
- 超限返回明确错误码和当前限制,不发生部分 MinIO 上传或部分数据库落库。
100 MiB 文件在 ExcelJS、JSZip 和图片 Buffer 并存时会产生数倍内存峰值。实施时需要记录 1、5、20、50、100 MiB 样本的解析耗时和 Node RSS,必要时限制并发分析数;不能仅根据压缩文件大小估算内存。
### 5.7 WPS 导出生成器
保留 ExcelJS 作为工作簿表格、样式、列宽、行高和标准格式的唯一生成入口,再增加 `WpsCellImageWorkbookTransformer` 对生成结果做受控 OOXML 后处理:
1. 先按现有代码生成标准 XLSX;
2. 根据标准 Drawing 锚点确定每张报备图片的目标单元格;
3. 为每张图片生成唯一 `ID_` 加 32 位大写十六进制标识;
4. 创建 `xl/cellimages.xml`
5. 创建 `xl/_rels/cellimages.xml.rels`
6. 在 workbook relationships 增加 WPS cellImage 关系;
7. 在 `[Content_Types].xml` 增加 WPS cellImage 类型;
8. 将目标单元格改写为严格的 `_xlfn.DISPIMG("ID",1)` 公式及缓存显示值;
9. 移除已经转换的 Drawing 图片锚点和失去引用的媒体关系;
10. 保留表头、文本、列宽、行高、冻结窗格、批次快照内容和未转换对象;
11. 重新打包并再次执行包结构、关系完整性和公式安全自检。
这样可以复用现有成熟导出逻辑,避免为 WPS 另写一套字段取值、转换、图片下载和样式代码。
### 5.8 当前批次文件的处理
格式选择发生在下载时,不修改历史批次和 MinIO 原文件:
- Excel 通用格式:直接返回现有 MinIO XLSX,字节和哈希保持不变;
- WPS 单元格图片格式:读取该批次现有 XLSX,在内存或受控临时目录中转换后返回;
- 批次 ZIP:逐通道转换 XLSX,再和原 TXT 简报一起打包;任一文件转换失败则整个 ZIP 明确失败,不输出不完整包;
- 第一版不缓存 WPS 派生文件,不新增数据库记录,也不覆盖原 `ReportExportFile`;如真实性能证明需要缓存,再单独设计派生文件生命周期。
单条导出则先按现有逻辑生成标准工作簿,再根据选择决定是否转换为 WPS 格式。
## 6. 前端修改范围
预计涉及:
- `src/apps/admin/AdminReportTasksPage.tsx`:单条导出格式弹窗;
- `src/apps/admin/AdminChannelReportPage.tsx`:单条导出格式弹窗;
- `src/apps/admin/AdminReportBatchesPage.tsx`:现有导出弹窗增加格式选择;
- `src/api/admin/channels-reports.api.ts`:传递 `outputFormat`
- 报备导入分析弹窗:显示自动识别的图片格式和数量,展示不支持/冲突原因;
- 共用一个格式选择组件和类型,不在三个页面复制状态及文案。
桌面端和 390px 窄屏均需验证弹窗选项、说明、生成中、失败重试和下载行为。格式选择不写入 localStorage,重新打开默认回到 Excel 通用格式。
## 7. 后端修改范围
预计涉及:
- `api/src/report-materials/report-materials.controller.ts`:上传上限和导出格式参数;
- `api/src/report-materials/report-materials.contracts.ts`:格式枚举及导入诊断类型;
- `api/src/report-materials/report-materials.helpers.ts`:保留通用 helper,公式检查改为接收精确白名单;
- `api/src/report-materials/import-parser.service.ts`、`import-review.service.ts`:分析及提交阶段统一解析;
- `api/src/report-materials/channel-export.service.ts`:单条导出选择;
- `api/src/report-materials/batch-download.service.ts`:批次单文件和 ZIP 选择;
- 新增独立 OOXML 包检查、WPS 图片读取和 WPS 输出转换服务;
- 继续复用 `FilesService`、MinIO、操作日志和现有字段映射。
不要把 ZIP/XML 细节继续堆入已经较大的 report-materials service。解析器和转换器应是无数据库副作用的纯服务,方便使用合成工作簿做完整安全测试。
## 8. 数据库、MinIO 和兼容性判断
### 8.1 数据库
第一版不需要 Prisma migration
- 导入格式、图片数量和诊断可写入现有导入批次 `preview` JSON
- 导入后的图片仍是现有 `FileObject`
- 导出格式是一次下载请求参数,不改变材料版本和批次记录。
若以后要求“记住每个通道默认格式”或缓存 WPS 派生文件,才需要单独设计配置或文件记录,不在本次顺带增加。
### 8.2 MinIO
- 导入后继续逐张保存真实图片对象;
- 原始导入 XLSX 继续按现有流程保存;
- WPS 导出第一版不永久保存,不覆盖现有批次文件;
- 任何解析失败都不得留下无法关联的部分文件。若提交阶段已上传部分行图片后某行失败,应沿用现有逐行结果并增加可追踪清理策略测试。
### 8.3 格式兼容
- `excel_drawing` 是跨 Excel/WPS 的默认格式;
- `wps_cell_image` 明确标注为 WPS 优先格式;
- 两者扩展名均为 `.xlsx`,但内部结构不同;
- 不承诺旧版 Microsoft Excel 原生显示 WPS `DISPIMG`
- WPS 目标版本、Windows Excel 365 和 LibreOffice 至少各做一次打开结果记录,不能只用 ExcelJS 回读判定可交付。
## 9. 错误处理
建议增加稳定错误码:
| 错误码 | 含义 |
| --- | --- |
| `WORKBOOK_TOO_LARGE` | 压缩文件超过上限 |
| `WORKBOOK_EXPANDED_TOO_LARGE` | 解包总量超过上限 |
| `WORKBOOK_UNSAFE_PART` | 宏、外链、OLE 或危险部件 |
| `WORKBOOK_FORMULA_NOT_ALLOWED` | 存在非白名单公式 |
| `WPS_CELL_IMAGE_RELATION_INVALID` | ID、关系或媒体缺失/重复 |
| `WPS_CELL_IMAGE_CONFLICT` | 同一单元格存在两种图片 |
| `WORKBOOK_IMAGE_TYPE_UNSUPPORTED` | 图片真实类型不支持 |
| `WORKBOOK_IMAGE_LIMIT_EXCEEDED` | 图片数量或单图大小超限 |
| `WPS_EXPORT_CONVERSION_FAILED` | 标准文件转 WPS 失败 |
错误信息要指出工作表、单元格或部件,但不得回显原始图片内容、服务器路径或敏感文件对象信息。
## 10. 测试方案
### 10.1 解析器单元测试
使用代码生成的小型 OOXML fixture,不把包含真实企业和个人信息的样本提交到仓库:
1. 2 张标准 Drawing 图片;
2. 2 张有效 WPS 单元格图片;
3. 标准/WPS 混合但不冲突;
4. 同单元格冲突;
5. 公式 ID 不存在;
6. cellImage ID 重复;
7. relationship 缺失、外链、路径穿越;
8. 图片扩展名与文件签名不一致;
9. 普通公式、共享公式、数组公式和伪造 `DISPIMG`
10. 合并单元格下不重复计数;
11. ZIP 项目数、单图、总解包大小超限;
12. PNG、JPEG、GIF 的支持结果。
### 10.2 当前样本回归
只在本地受控测试中使用当前样本,验收:
- 自动识别 `wps_cell_image`
- 识别 1 个工作表、16 条数据、43 张业务图片;
- 43 个公式 ID、cellImage 和媒体关系全部匹配;
- 不把 A1 的 3 个 1×1 占位 Drawing 计入业务字段;
- 图片列、行号和预览映射正确;
- 提交前后图片哈希一致;
- 非图片字段和手机号等文本/数字读取不被 WPS 解析器改变。
### 10.3 真实后端集成测试
在明确授权的测试环境使用真实 API、PostgreSQL 和 MinIO
1. 分析样本但不审核应用,确认原业务签名不变化;
2. 使用专用测试企业/应用提交少量合成行,确认 FileObject、导入批次和待审核项一致;
3. 审核通过后确认图片字段落入真实材料、材料版本只按既有规则变化一次;
4. 失败行不清空已有补资料字段;
5. 删除测试数据前单独确认清理边界,不触碰真实客户资料。
### 10.4 双格式导出测试
同一批次快照分别导出两种格式:
- 文本、字段顺序、默认值、转换、行数和图片内容哈希一致;
- Excel 格式仍使用标准 Drawing,既有文件字节不被修改;
- WPS 格式中每个图片单元格的公式、ID、关系和媒体一一对应;
- WPS 中图片按单元格显示,排序、筛选、调整行高后行为符合目标版本;
- Excel 365 打开两种格式并记录 WPS 格式的实际兼容结果;
- 单通道下载和全部 ZIP 下载均验证文件名、数量、内容和失败原子性;
- 没有图片的工作簿两种格式内容一致,WPS 格式不生成空的 cellImage 部件。
### 10.5 性能和资源
记录 1、5、20、50、100 MiB 文件的:
- 上传和分析总耗时;
- JSZip 检查耗时;
- ExcelJS 加载耗时;
- 峰值 RSS
- 43、100、500 张图片时的处理时间;
- 批次 ZIP 多通道转换的总耗时和临时空间。
达到上限时应快速、明确失败,不使 API 进程因并发大文件出现长时间无响应。
### 10.6 前端和门禁
- 导入自动识别提示、映射预览、空数据、失败、权限和超限状态;
- 三个导出入口的弹窗、默认选项、取消、生成中、失败重试和成功下载;
- 1600px 桌面和 390px 窄屏;
- 浏览器 Network 参数、响应文件名和 Console;
- API 定向与全量测试、前后端 TypeScript、Vite 构建、Prisma validate、依赖安全、部署契约和 `git diff --check`
- 同步 `docs/system-functional-test-cases.md` 和 `docs/testing-progress.md`。
构建、ExcelJS 回读或合成 fixture 通过都不能代替真实 WPS 打开和真实 API/PostgreSQL/MinIO 验收。
## 11. 实施顺序
1. 先做无业务依赖的最小 WPS OOXML 读写原型,使用 2 行、PNG/JPEG/GIF 小图在目标 WPS 和 Excel 365 实际打开。
2. 固化包安全限制和 `DISPIMG` 精确白名单。
3. 实现 `WorkbookPackageInspector` 与 `WpsCellImageReader`,接入分析和提交两阶段。
4. 调整上传上限并加入解包、图片和内存保护。
5. 实现标准 XLSX 到 WPS 单元格图片的纯转换器。
6. 接入单条导出、批次单文件和批次 ZIP 三个后端入口。
7. 实现共用格式选择 UI 和导入识别提示。
8. 完成合成 fixture、当前样本、真实 MinIO、真实数据库、浏览器及 WPS/Excel 客户端验收。
9. 更新测试用例和测试进度;是否提交、推送、部署分别等待明确授权。
## 12. 影响与风险结论
- 前端:中等,涉及三个导出入口和导入提示,但不改变报备业务表格主流程。
- 后端:较高,涉及不受 ExcelJS 支持的 WPS 私有 OOXML 扩展、ZIP 安全、内存峰值和批量转换。
- 数据库:预计无 migration。
- MinIO:复用现有对象;WPS 派生文件第一版不持久化。
- 短信链路:无影响,不应触发发送、补发、重投或重新入队。
- 最大风险不是 UI,而是错误放宽公式安全检查、ZIP 资源消耗、WPS 与 Excel 客户端兼容差异,以及把占位 Drawing 误认成业务图片。
最小充分范围是“自动读取 WPS 单元格图片 + 保留现有 Excel 导出 + 按需生成 WPS 变体”。不在本次加入图片压缩、通道默认格式、派生文件缓存、历史数据回写或更多办公格式。