# 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`(信封例外:成功返回 `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,不影响现有调用方。