|
|
|
|
# PRD — 文件缩略图预览
|
|
|
|
|
|
|
|
|
|
**Status:** ready-for-agent
|
|
|
|
|
**Backend module:** `crm-file`,接入点 `FileApi` 门面 + `FileController`(`/api/file/thumbnail`)
|
|
|
|
|
**Grilling 决策记录:** 2026-08-05,11 题逐项共识(见下"决策摘要")
|
|
|
|
|
**ADR:** [ADR-0013](../../docs/adr/0013-thumbnail-architecture.md)
|
|
|
|
|
**CONTEXT.md:** [crm-file/CONTEXT.md](../../crm-file/CONTEXT.md) 已新增 7 个术语
|
|
|
|
|
|
|
|
|
|
## 1. Problem Statement
|
|
|
|
|
|
|
|
|
|
前端文件列表页展示文件时,部分图片是高清原图(单张几 MB),在弱网环境下加载极慢。用户在浏览文件列表时并不需要看到原图,只需要一个能辨识文件内容的小图。点击后再加载原图或走 kkFileView 完整预览。当前系统只有 kkFileView 的全文档在线预览能力,没有缩略图生成与返回机制。
|
|
|
|
|
|
|
|
|
|
## 2. Solution
|
|
|
|
|
|
|
|
|
|
在 `crm-file` 模块新增缩略图能力:文件上传完成后由后台异步任务生成缩略图,首次请求缩略图时若后台尚未完成则同步兜底生成。缩略图存入 MinIO 同 bucket(`thumbnails/{fileId}.jpg`),通过后端 API 中转返回,设置差异化缓存策略(真缩略图强缓存 24h,占位图不缓存)。覆盖图片(jpg/png/webp/gif/bmp)、PDF、Office(docx/xlsx/pptx 及旧格式 doc/xls/ppt)三类,非视觉文件前置判断标记 `UNSUPPORTED` 跳过生成。
|
|
|
|
|
|
|
|
|
|
## 3. 决策摘要(11 题共识)
|
|
|
|
|
|
|
|
|
|
| # | 决策点 | 选择 |
|
|
|
|
|
|---|--------|------|
|
|
|
|
|
| 1 | 缩略图定位 | 前端文件列表展示小尺寸静态图片,点击再加载原图/完整预览(非 kkFileView 全文档预览) |
|
|
|
|
|
| 2 | 覆盖文件类型 | 图片 + PDF + Office 文档 |
|
|
|
|
|
| 3 | 生成时机 | 上传后异步生成 + 首次请求同步兜底(混合策略) |
|
|
|
|
|
| 4 | 未就绪行为 | 首次请求同步等待生成完成,始终返回真缩略图 |
|
|
|
|
|
| 5 | 两路冲突 | Redis 分布式锁防止并发重复生成 |
|
|
|
|
|
| 6 | 存储位置 | MinIO 同 bucket,objectKey = `thumbnails/{fileId}.jpg` |
|
|
|
|
|
| 7 | 返回方式 | 后端 API 中转 + 差异化缓存策略 |
|
|
|
|
|
| 8 | 渲染引擎 | LibreOffice headless(Office)+ PDFBox(PDF)+ Java ImageIO(图片),Docker 部署 |
|
|
|
|
|
| 9 | 失败策略 | 重试 3 次标记 `FAILED`,定时任务扫描重试 |
|
|
|
|
|
| 10 | 非视觉文件 | 前置类型判断,标记 `UNSUPPORTED`,返回占位图 |
|
|
|
|
|
| 11 | 尺寸/格式 | 固定宽度 200px,等比缩放,上限 400px,JPEG |
|
|
|
|
|
|
|
|
|
|
## 4. 范围(In / Out)
|
|
|
|
|
|
|
|
|
|
**In:**
|
|
|
|
|
- `FileApi` 新增 `getThumbnail(String fileId)` 方法
|
|
|
|
|
- `FileController` 新增 `GET /api/file/thumbnail?fileId=xxx` 端点
|
|
|
|
|
- `FileInfo` 实体新增 `thumbnail_status` / `thumbnail_retry_count` 字段
|
|
|
|
|
- 缩略图异步生成任务(上传后触发)
|
|
|
|
|
- 缩略图同步兜底生成(首次请求触发,分布式锁保护)
|
|
|
|
|
- `ThumbnailRenderer` 渲染抽象(图片缩放 / PDFBox 渲染第一页 / LibreOffice headless 渲染第一页)
|
|
|
|
|
- `FAILED` 缩略图定时重试任务
|
|
|
|
|
- 占位图返回机制(非视觉文件 + 未就绪 + 失败)
|
|
|
|
|
- 差异化 HTTP 缓存策略
|
|
|
|
|
- `FileProperties` 新增缩略图配置段
|
|
|
|
|
- `FileConstants` 新增缩略图相关错误码与常量
|
|
|
|
|
- 父 POM 新增 PDFBox 依赖版本管理
|
|
|
|
|
- Docker 镜像安装 LibreOffice headless
|
|
|
|
|
|
|
|
|
|
**Out(显式 no):**
|
|
|
|
|
- **秒传**——一期不实现,`fileHash` 字段保留占位
|
|
|
|
|
- **缩略图物理删除**——逻辑删除文件时缩略图 MinIO 对象保留(与原文件一致策略)
|
|
|
|
|
- **缩略图自定义尺寸**——一期固定 200px 宽,不做按需尺寸生成
|
|
|
|
|
- **kkFileView 缩略图集成**——不依赖 kkFileView 的截图能力,独立渲染管线
|
|
|
|
|
- **Office 文档纯 Java 渲染**——Apache POI 无法渲染成图片,必须用 LibreOffice
|
|
|
|
|
|
|
|
|
|
## 5. 数据模型变更(`FileInfo` / `crm_file_info`)
|
|
|
|
|
|
|
|
|
|
新增列:
|
|
|
|
|
|
|
|
|
|
| 列 | 类型 | 说明 |
|
|
|
|
|
|---|---|---|
|
|
|
|
|
| `thumbnail_status` | varchar(16) not null default 'PENDING' | 缩略图状态:`PENDING`(已入队待生成)/ `READY`(已生成)/ `FAILED`(重试 3 次后放弃,可由定时任务重试)/ `UNSUPPORTED`(文件类型不支持,永不生成) |
|
|
|
|
|
| `thumbnail_retry_count` | int not null default 0 | 缩略图生成失败重试计数,成功后归零 |
|
|
|
|
|
|
|
|
|
|
状态流转:
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
上传 ──→ PENDING ──(类型不支持)──→ UNSUPPORTED
|
|
|
|
|
│
|
|
|
|
|
├──(生成成功)──→ READY
|
|
|
|
|
│
|
|
|
|
|
└──(生成失败, retry_count < 3)──→ PENDING(重试)
|
|
|
|
|
│
|
|
|
|
|
└──(生成失败, retry_count = 3)──→ FAILED
|
|
|
|
|
│
|
|
|
|
|
└──(定时任务重试成功)──→ READY
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
## 6. API 设计
|
|
|
|
|
|
|
|
|
|
### 6.1 缩略图获取
|
|
|
|
|
|
|
|
|
|
`GET /api/file/thumbnail?fileId=xxx`
|
|
|
|
|
|
|
|
|
|
- 无权限门禁(缩略图不泄露原文件内容,且 fileId 为雪花 ID 不可枚举)
|
|
|
|
|
- 入参:`fileId`(String,必传)
|
|
|
|
|
- 返回:`ResponseEntity<byte[]>`(信封例外:成功返回 `image/jpeg` 二进制流,失败返回 JSON 信封)
|
|
|
|
|
- 行为:
|
|
|
|
|
1. 查 `FileInfo`,查无/已删除 → 40401
|
|
|
|
|
2. `thumbnail_status = READY` → 从 MinIO 拉 `thumbnails/{fileId}.jpg`,设 `Cache-Control: max-age=86400`,返回二进制流
|
|
|
|
|
3. `thumbnail_status = UNSUPPORTED` → 返回占位图(文件类型图标),设 `Cache-Control: no-cache`
|
|
|
|
|
4. `thumbnail_status = FAILED` → 返回占位图,设 `Cache-Control: no-cache`
|
|
|
|
|
5. `thumbnail_status = PENDING` → 尝试获取分布式锁;获锁成功则同步触发生成,完成后返回真缩略图 + `max-age=86400`;获锁失败(异步任务正在跑)→ 等待最多 10 秒轮询状态,超时返回占位图 + `no-cache` + HTTP 202
|
|
|
|
|
|
|
|
|
|
### 6.2 FileInfoDTO 扩展
|
|
|
|
|
|
|
|
|
|
`FileInfoDTO` 新增 `thumbnailStatus` 字段(String),前端列表页据此判断是否展示缩略图占位骨架。
|
|
|
|
|
|
|
|
|
|
## 7. 实现决策
|
|
|
|
|
|
|
|
|
|
### 7.1 模块修改
|
|
|
|
|
|
|
|
|
|
**FileApi 接口**新增:
|
|
|
|
|
- `ThumbnailDTO getThumbnail(String fileId)` — 获取缩略图(含同步兜底逻辑)。返回 DTO 而非纯 `byte[]`,因为需承载 §6.1 的 HTTP 202 与 no-cache/max-age 差异化缓存语义(实现后回填,替代原设计的 `byte[]` 签名)
|
|
|
|
|
|
|
|
|
|
**FileApiImpl**修改:
|
|
|
|
|
- `upload()` / `uploadDirect()` / `completeMultipart()` 完成后触发异步缩略图生成(状态置 `PENDING`,提交异步任务)
|
|
|
|
|
- 新增 `getThumbnail()` 实现:查状态 → READY 直接拉 MinIO → PENDING 走同步兜底 → UNSUPPORTED/FAILED 返回占位图
|
|
|
|
|
- 新增依赖注入:`ThumbnailRenderer`、`ThumbnailPlaceholderService`
|
|
|
|
|
|
|
|
|
|
**ThumbnailRenderer**(新建,接口 + 实现):
|
|
|
|
|
- `boolean supports(String contentType, String ext)` — 判断文件类型是否支持
|
|
|
|
|
- `byte[] render(InputStream original, String ext, int width, int maxHeight)` — 生成缩略图字节数组
|
|
|
|
|
- 三种实现策略内聚于此接口:
|
|
|
|
|
- 图片(jpg/png/webp/gif/bmp):Java ImageIO 读取 → 等比缩放 → JPEG 输出
|
|
|
|
|
- PDF:PDFBox `PDFRenderer` 渲染第一页为 BufferedImage → 缩放 → JPEG 输出
|
|
|
|
|
- Office(docx/xlsx/pptx,兼容旧格式 doc/xls/ppt):LibreOffice headless `soffice --convert-to png` 命令行 → 读输出 PNG → 缩放 → JPEG 输出;`libreoffice-path` 配置为 `.cmd/.bat` 时经 `cmd /c` 解释(Windows 本地开发场景,Docker/Linux 直执行)
|
|
|
|
|
|
|
|
|
|
**ThumbnailPlaceholderService**(新建):
|
|
|
|
|
- `byte[] getPlaceholder(String ext)` — 按文件扩展名返回对应的文件类型占位图。实现为运行时 Graphics2D 绘制 200×200 PNG + 扩展名文字(实现后回填,替代原设计的“预置静态资源”:免打包资源文件且扩展名文字更直观)
|
|
|
|
|
|
|
|
|
|
**ThumbnailGenerationTask**(新建,异步):
|
|
|
|
|
- 复用 `@Async` + `ThreadPoolTaskExecutor`(新增配置)
|
|
|
|
|
- 上传完成后提交,执行流程:加锁 → 调 ThumbnailRenderer → 写入 MinIO → 状态置 READY → 释放锁
|
|
|
|
|
- 失败时 `retry_count++`,小于 3 则重置为 PENDING,等于 3 则置 FAILED
|
|
|
|
|
|
|
|
|
|
**ThumbnailRetryTask**(新建,定时):
|
|
|
|
|
- `@Scheduled` 扫描 `thumbnail_status = FAILED` 的记录,重置为 PENDING 并提交异步生成
|
|
|
|
|
- 复用 `OrphanChunkCleanupTask` 的模式(cron 配置、异常不致命)
|
|
|
|
|
|
|
|
|
|
### 7.2 配置变更
|
|
|
|
|
|
|
|
|
|
`FileProperties` 新增 `Thumbnail` 内嵌类:
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
crm:
|
|
|
|
|
file:
|
|
|
|
|
thumbnail:
|
|
|
|
|
width: 200 # 固定宽度(px)
|
|
|
|
|
max-height: 400 # 等比缩放上限高度(px)
|
|
|
|
|
format: jpeg # 输出格式
|
|
|
|
|
sync-wait-timeout: 10s # 同步兜底等待超时
|
|
|
|
|
retry-limit: 3 # 最大重试次数
|
|
|
|
|
retry-cron: "0 0 4 * * ?" # FAILED 重试扫描周期
|
|
|
|
|
libreoffice-path: soffice # LibreOffice 可执行文件路径
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 7.3 错误码
|
|
|
|
|
|
|
|
|
|
| 码 | 含义 |
|
|
|
|
|
|----|------|
|
|
|
|
|
| 62007 | 预留:缩略图生成失败(渲染引擎异常)。一期不抛错误信封:生成失败走占位图降级(§6.1 行为 3/4/5) |
|
|
|
|
|
| 62008 | 预留:缩略图同步等待超时。一期不抛错误信封:超时返回占位图 + HTTP 202(§6.1 行为 5) |
|
|
|
|
|
|
|
|
|
|
> 注:62007/62008 与 §6.1 的降级行为不冲突——前者是预留的错误信封码(后续若改为报错即用),一期实现以 §6.1 为准,常量登记于 `FileConstants` 备用。
|
|
|
|
|
|
|
|
|
|
### 7.4 分布式锁
|
|
|
|
|
|
|
|
|
|
- 使用 Redis `SET key value NX EX` 实现互斥锁
|
|
|
|
|
- 锁 key:`crm:file:thumbnail:lock:{fileId}`
|
|
|
|
|
- 锁 TTL:可配置(`crm.file.thumbnail.lock-ttl`,默认 150 秒),必须大于 LibreOffice 转换超时(`office-convert-timeout` 默认 120 秒),否则慢渲染期间锁过期导致并发重复生成
|
|
|
|
|
- 获锁失败方进入轮询等待(每 500ms 查一次状态,超 sync-wait-timeout 后返回占位图)
|
|
|
|
|
|
|
|
|
|
### 7.5 依赖变更
|
|
|
|
|
|
|
|
|
|
父 POM `dependencyManagement` 新增:
|
|
|
|
|
- `org.apache.pdfbox:pdfbox`(版本统一管理,用于 PDF 第一页渲染)
|
|
|
|
|
- `net.coobird:thumbnailator`(可选,简化图片缩放代码;也可纯 Java ImageIO 不引第三方)
|
|
|
|
|
|
|
|
|
|
`crm-file/pom.xml` 新增对应依赖引用。
|
|
|
|
|
|
|
|
|
|
LibreOffice headless 为系统级依赖,在 Dockerfile 中安装(`apt-get install libreoffice --no-install-recommends`),非 Maven 依赖。另加装 `fonts-noto-cjk`:LibreOffice 渲染含中文的 Office/PDF 文档时缺中文字体会出方块字,占位图之外的真实缩略图需要可用的 CJK 字体。
|
|
|
|
|
|
|
|
|
|
### 7.6 缓存策略
|
|
|
|
|
|
|
|
|
|
| 状态 | Cache-Control | 说明 |
|
|
|
|
|
|------|---------------|------|
|
|
|
|
|
| READY | `max-age=86400` | 真缩略图不变,强缓存 24h |
|
|
|
|
|
| UNSUPPORTED | `no-cache` | 占位图,每次请求重新校验(万一类型判断有误可修正) |
|
|
|
|
|
| FAILED | `no-cache` | 占位图,定时任务可能恢复为 READY,不缓存旧占位图 |
|
|
|
|
|
| PENDING(超时) | `no-cache` | 占位图,异步任务可能即将完成,下次请求即可拿到真缩略图 |
|
|
|
|
|
|
|
|
|
|
### 7.7 MinIO objectKey 规则
|
|
|
|
|
|
|
|
|
|
缩略图:`thumbnails/{fileId}.jpg`,与原文件同 bucket,前缀 `thumbnails/` 与 `chunks/` 平级。
|
|
|
|
|
|
|
|
|
|
## 8. 测试决策
|
|
|
|
|
|
|
|
|
|
### 测试理念
|
|
|
|
|
|
|
|
|
|
只测外部行为,不测实现细节。Mock 所有外部依赖(MinioClient、RedisTemplate、ThumbnailRenderer),不依赖真实渲染引擎和 LibreOffice。
|
|
|
|
|
|
|
|
|
|
### 测试接缝
|
|
|
|
|
|
|
|
|
|
**唯一接缝:`FileApi` 接口层**(`FileApiImplTest`,Mockito 单元测试)
|
|
|
|
|
|
|
|
|
|
与现有 `FileApiImplTest` 完全一致的模式——构造 `FileApiImpl` 实例,注入 Mock 依赖,验证方法行为。新增 `ThumbnailRenderer` 和 `ThumbnailPlaceholderService` 作为 Mock 注入。
|
|
|
|
|
|
|
|
|
|
### 测试用例
|
|
|
|
|
|
|
|
|
|
**getThumbnail:**
|
|
|
|
|
1. `READY` 状态 → 从 MinIO 拉缩略图对象,返回二进制流,Cache-Control 为 max-age=86400
|
|
|
|
|
2. `UNSUPPORTED` 状态 → 返回占位图,Cache-Control 为 no-cache,不触碰 MinIO 缩略图路径
|
|
|
|
|
3. `FAILED` 状态 → 返回占位图,Cache-Control 为 no-cache
|
|
|
|
|
4. `PENDING` 状态 + 获锁成功 → 同步调 ThumbnailRenderer → 写 MinIO → 状态置 READY → 返回真缩略图
|
|
|
|
|
5. `PENDING` 状态 + 获锁失败(异步任务正在跑)→ 轮询等待,超时返回占位图 + HTTP 202
|
|
|
|
|
6. `PENDING` 状态 + 获锁失败 + 等待期间异步任务完成 → 返回真缩略图
|
|
|
|
|
7. 文件查无/已删除 → 40401
|
|
|
|
|
8. fileId 非法格式 → 40401(与现有 getInfo/download 一致)
|
|
|
|
|
|
|
|
|
|
**上传触发异步生成:**
|
|
|
|
|
9. `upload()` 成功后 → `thumbnail_status` 初始为 PENDING,异步任务被提交
|
|
|
|
|
10. `completeMultipart()` 成功后 → 同上
|
|
|
|
|
11. 扩展名为不支持类型(如 .txt)→ `thumbnail_status` 初始为 UNSUPPORTED,不提交异步任务
|
|
|
|
|
|
|
|
|
|
**ThumbnailGenerationTask(异步生成):**
|
|
|
|
|
12. 生成成功 → 写 MinIO 缩略图对象,状态置 READY,retry_count 归零
|
|
|
|
|
13. 生成失败 + retry_count < 3 → retry_count++,状态保持 PENDING
|
|
|
|
|
14. 生成失败 + retry_count = 3 → 状态置 FAILED
|
|
|
|
|
15. 并发两路(异步 + 同步兜底)→ 分布式锁保证只生成一次
|
|
|
|
|
|
|
|
|
|
**ThumbnailRetryTask(定时重试):**
|
|
|
|
|
16. 扫描 FAILED 记录 → 重置为 PENDING,提交异步任务
|
|
|
|
|
|
|
|
|
|
### Prior art
|
|
|
|
|
|
|
|
|
|
- `FileApiImplTest`(现有)— Mockito + AssertJ,构造真实 FileProperties + Mock MinioClient/RedisTemplate
|
|
|
|
|
- `OrphanChunkCleanupTask` 的测试模式(如有)— 定时任务的 public 方法直接调用测试
|
|
|
|
|
|
|
|
|
|
## 9. Out of Scope
|
|
|
|
|
|
|
|
|
|
- 秒传(`fileHash` 字段占位,一期不写入)
|
|
|
|
|
- 缩略图物理删除(逻辑删除文件时 MinIO 缩略图对象保留)
|
|
|
|
|
- 缩略图自定义尺寸(一期固定 200px 宽)
|
|
|
|
|
- kkFileView 截图集成(独立渲染管线,不依赖 kkFileView)
|
|
|
|
|
- Office 文档纯 Java 渲染(Apache POI 无法渲染成图片)
|
|
|
|
|
- 缩略图访问权限控制(fileId 为雪花 ID 不可枚举,一期不做权限门禁)
|
|
|
|
|
- GIF 动图缩略图取第一帧(按第一帧静态图处理)
|
|
|
|
|
|
|
|
|
|
## 10. Further Notes
|
|
|
|
|
|
|
|
|
|
- **Docker 镜像体积**:LibreOffice headless 约增加 400-500MB,需在 Dockerfile 中用 `--no-install-recommends` 精简安装。
|
|
|
|
|
- **LibreOffice 并发**:`soffice --convert-to` 不支持并发调用同一 profile,需用 `-env:UserInstallation=file:///tmp/lo-{uuid}` 隔离 profile 或加锁串行化。
|
|
|
|
|
- **缓存陷阱**:FAILED 重试成功变 READY 后,objectKey 不变但内容从占位图变为真缩略图。因占位图设 `no-cache`,前端不会命中旧缓存;READY 后的 `max-age=86400` 只缓存真缩略图,无脏数据风险。
|
|
|
|
|
- **FileInfoDTO 向后兼容**:新增 `thumbnailStatus` 字段为 nullable,不影响现有调用方。
|