You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

14 KiB

PRD — 文件缩略图预览

Status: ready-for-agent Backend module: crm-file,接入点 FileApi 门面 + FileController/api/file/thumbnailGrilling 决策记录: 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 信封)
  • 行为:
    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 返回占位图
  • 新增依赖注入:ThumbnailRendererThumbnailPlaceholderService

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 依赖,验证方法行为。新增 ThumbnailRendererThumbnailPlaceholderService 作为 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,不影响现有调用方。