Files
lislgosms/docs/wps-cell-image-import-export-plan-20260904.md
T

464 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 变体”。不在本次加入图片压缩、通道默认格式、派生文件缓存、历史数据回写或更多办公格式。