14 KiB
PRD — 文件缩略图预览
Status: ready-for-agent
Backend module: crm-file,接入点 FileApi 门面 + FileController(/api/file/thumbnail)
Grilling 决策记录: 2026-08-05,11 题逐项共识(见下"决策摘要")
ADR: ADR-0013
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 信封) - 行为:
- 查
FileInfo,查无/已删除 → 40401 thumbnail_status = READY→ 从 MinIO 拉thumbnails/{fileId}.jpg,设Cache-Control: max-age=86400,返回二进制流thumbnail_status = UNSUPPORTED→ 返回占位图(文件类型图标),设Cache-Control: no-cachethumbnail_status = FAILED→ 返回占位图,设Cache-Control: no-cachethumbnail_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:
READY状态 → 从 MinIO 拉缩略图对象,返回二进制流,Cache-Control 为 max-age=86400UNSUPPORTED状态 → 返回占位图,Cache-Control 为 no-cache,不触碰 MinIO 缩略图路径FAILED状态 → 返回占位图,Cache-Control 为 no-cachePENDING状态 + 获锁成功 → 同步调 ThumbnailRenderer → 写 MinIO → 状态置 READY → 返回真缩略图PENDING状态 + 获锁失败(异步任务正在跑)→ 轮询等待,超时返回占位图 + HTTP 202PENDING状态 + 获锁失败 + 等待期间异步任务完成 → 返回真缩略图- 文件查无/已删除 → 40401
- 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/RedisTemplateOrphanChunkCleanupTask的测试模式(如有)— 定时任务的 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,不影响现有调用方。