Browse Source

feat(file): thumbnail preview - async generation + sync fallback, PDFBox/LibreOffice renderers, retry (ADR-0013)

master
luoweijian 1 month ago
parent
commit
3499353b28
  1. 14
      .scratch/file-thumbnail/issues/01-foundation.md
  2. 19
      .scratch/file-thumbnail/issues/02-api-read-path-placeholder.md
  3. 18
      .scratch/file-thumbnail/issues/03-async-image-generation.md
  4. 17
      .scratch/file-thumbnail/issues/04-sync-fallback.md
  5. 16
      .scratch/file-thumbnail/issues/05-pdf-rendering.md
  6. 18
      .scratch/file-thumbnail/issues/06-office-rendering.md
  7. 18
      .scratch/file-thumbnail/issues/07-failure-retry.md
  8. 244
      .scratch/file-thumbnail/spec.md
  9. 24
      Dockerfile
  10. 28
      crm-file/CONTEXT.md
  11. 6
      crm-file/pom.xml
  12. 13
      crm-file/src/main/java/com/crm/file/api/FileApi.java
  13. 33
      crm-file/src/main/java/com/crm/file/config/FileProperties.java
  14. 7
      crm-file/src/main/java/com/crm/file/config/SchedulingConfig.java
  15. 22
      crm-file/src/main/java/com/crm/file/constant/FileConstants.java
  16. 15
      crm-file/src/main/java/com/crm/file/controller/FileController.java
  17. 4
      crm-file/src/main/java/com/crm/file/domain/dto/FileInfoDTO.java
  18. 27
      crm-file/src/main/java/com/crm/file/domain/dto/ThumbnailDTO.java
  19. 8
      crm-file/src/main/java/com/crm/file/domain/entity/FileInfo.java
  20. 55
      crm-file/src/main/java/com/crm/file/service/ThumbnailPlaceholderService.java
  21. 30
      crm-file/src/main/java/com/crm/file/service/ThumbnailRenderer.java
  22. 128
      crm-file/src/main/java/com/crm/file/service/impl/FileApiImpl.java
  23. 56
      crm-file/src/main/java/com/crm/file/service/impl/ImageThumbnailRenderer.java
  24. 161
      crm-file/src/main/java/com/crm/file/service/impl/OfficeThumbnailRenderer.java
  25. 54
      crm-file/src/main/java/com/crm/file/service/impl/PdfThumbnailRenderer.java
  26. 47
      crm-file/src/main/java/com/crm/file/service/impl/ThumbnailImageUtils.java
  27. 177
      crm-file/src/main/java/com/crm/file/task/ThumbnailGenerationTask.java
  28. 48
      crm-file/src/main/java/com/crm/file/task/ThumbnailRetryTask.java
  29. 261
      crm-file/src/test/java/com/crm/file/service/impl/FileApiImplTest.java
  30. 99
      crm-file/src/test/java/com/crm/file/service/impl/ImageThumbnailRendererTest.java
  31. 112
      crm-file/src/test/java/com/crm/file/service/impl/OfficeThumbnailRendererTest.java
  32. 67
      crm-file/src/test/java/com/crm/file/service/impl/PdfThumbnailRendererTest.java
  33. 261
      crm-file/src/test/java/com/crm/file/task/ThumbnailGenerationTaskTest.java
  34. 84
      crm-file/src/test/java/com/crm/file/task/ThumbnailRetryTaskTest.java
  35. 17
      docs/adr/0013-thumbnail-architecture.md
  36. 8
      pom.xml

14
.scratch/file-thumbnail/issues/01-foundation.md

@ -0,0 +1,14 @@
# 01 — 基础设施(prefactor)
**What to build:** 为缩略图功能准备数据模型、配置和依赖基础。`FileInfo` 实体新增 `thumbnail_status`(默认 PENDING)和 `thumbnail_retry_count`(默认 0)两个字段,ddl-auto 自动加列。`FileProperties` 新增 `Thumbnail` 内嵌配置类(width/max-height/format/sync-wait-timeout/retry-limit/retry-cron/libreoffice-path)。`FileConstants` 新增缩略图对象前缀 `THUMBNAIL_PREFIX = "thumbnails/"` 和错误码 62007(缩略图生成失败)、62008(同步等待超时)。父 POM `dependencyManagement` 新增 PDFBox 版本管理,`crm-file/pom.xml` 引入 PDFBox 依赖。无行为变更,纯基础设施。
**Blocked by:** None — can start immediately
**Status:** ready-for-agent
- [x] `FileInfo` 实体新增 `thumbnail_status`(varchar(16) not null default 'PENDING')和 `thumbnail_retry_count`(int not null default 0)字段,带 JPA @Column 注解和 @Comment
- [x] `FileProperties` 新增 `Thumbnail` 内嵌类,含 width(200)/maxHeight(400)/format(jpeg)/syncWaitTimeout(10s)/retryLimit(3)/retryCron("0 0 4 * * ?")/libreofficePath("soffice") 配置项
- [x] `FileConstants` 新增 `THUMBNAIL_PREFIX = "thumbnails/"`、`THUMBNAIL_LOCK_PREFIX = "crm:file:thumbnail:lock:"`、`CODE_THUMBNAIL_GENERATION_FAILED = 62007`、`CODE_THUMBNAIL_SYNC_TIMEOUT = 62008`
- [x] 父 POM `<properties>` 新增 `pdfbox.version`,`<dependencyManagement>` 新增 `org.apache.pdfbox:pdfbox` 依赖
- [x] `crm-file/pom.xml` 新增 PDFBox 依赖引用(不含版本号,走父 POM 管理)
- [x] 编译通过,现有测试不受影响

19
.scratch/file-thumbnail/issues/02-api-read-path-placeholder.md

@ -0,0 +1,19 @@
# 02 — 缩略图 API 读路径 + 占位图
**What to build:** 缩略图读取端点和按状态返回的行为。新增 `FileApi.getThumbnail(String fileId)` 方法返回 `byte[]`,`FileController` 新增 `GET /api/file/thumbnail?fileId=xxx` 端点(信封例外:成功返回 `image/jpeg` 二进制流,失败返回 JSON 信封)。新建 `ThumbnailPlaceholderService`,按文件扩展名返回预置的文件类型图标占位图。`getThumbnail` 行为:查 `FileInfo`(查无/已删除 → 40401)→ `thumbnail_status = READY` 时从 MinIO 拉 `thumbnails/{fileId}.jpg` 返回二进制 + `Cache-Control: max-age=86400`;`UNSUPPORTED`/`FAILED`/`PENDING` 返回占位图 + `Cache-Control: no-cache`。`FileInfoDTO` 新增 `thumbnailStatus` 字段(nullable,向后兼容)。可手动往 MinIO 塞缩略图并把状态设成 READY 来验证端到端。
**Blocked by:** 01 — 基础设施(prefactor)
**Status:** ready-for-agent
- [x] `FileApi` 接口新增 `byte[] getThumbnail(String fileId)` 方法声明
- [x] `FileApiImpl` 实现 `getThumbnail`:查 FileInfo → READY 拉缩略图对象返回 + max-age=86400;UNSUPPORTED/FAILED/PENDING 返回占位图 + no-cache
- [x] `ThumbnailPlaceholderService` 新建,按扩展名返回预置文件类型图标占位图(静态资源)
- [x] `FileController` 新增 `GET /api/file/thumbnail` 端点,返回 `ResponseEntity<byte[]>`(信封例外)
- [x] `FileInfoDTO` 新增 `thumbnailStatus` 字段,`from()` 方法补充映射
- [x] 测试:READY 状态返回 MinIO 缩略图二进制 + max-age=86400
- [x] 测试:UNSUPPORTED 状态返回占位图 + no-cache,不触碰 MinIO 缩略图路径
- [x] 测试:FAILED 状态返回占位图 + no-cache
- [x] 测试:PENDING 状态返回占位图 + no-cache(本票不含同步生成)
- [x] 测试:文件查无/已删除 → 40401
- [x] 测试:fileId 非法格式 → 40401

18
.scratch/file-thumbnail/issues/03-async-image-generation.md

@ -0,0 +1,18 @@
# 03 — 图片异步生成
**What to build:** 缩略图生成管线骨架——`ThumbnailRenderer` 接口 + 图片类型实现 + 异步生成任务 + 上传触发。新建 `ThumbnailRenderer` 接口(`supports(contentType, ext)` + `render(originalStream, ext, width, maxHeight)`),提供图片实现(Java ImageIO 读取 → 等比缩放固定宽度 200px 上限高度 400px → JPEG 输出)。新建 `ThumbnailGenerationTask`(@Async),执行流程:从 MinIO 拉原文件 → 调 ThumbnailRenderer.render → 缩略图字节数组写入 MinIO `thumbnails/{fileId}.jpg` → 更新 `thumbnail_status = READY` + `thumbnail_retry_count = 0`。`FileApiImpl` 的 `upload()`/`uploadDirect()`/`completeMultipart()` 完成后:判断文件类型——图片(jpg/png/webp/gif/bmp)置 `PENDING` 并提交异步任务,其余类型置 `UNSUPPORTED` 不提交。本票只覆盖图片类型,PDF/Office 在后续票扩展。完成后上传一张图片,等异步任务跑完,调 02 的 API 即可拿到真缩略图。
**Blocked by:** 02 — 缩略图 API 读路径 + 占位图
**Status:** ready-for-agent
- [x] `ThumbnailRenderer` 接口:`boolean supports(String contentType, String ext)` + `byte[] render(InputStream original, String ext, int width, int maxHeight)`
- [x] 图片实现(Java ImageIO):读取 → 等比缩放至 width=200px,高度上限 400px → JPEG 输出 byte[]
- [x] `ThumbnailGenerationTask` 新建(@Async),从 MinIO 拉原文件 → render → 写 `thumbnails/{fileId}.jpg` → 状态置 READY + retry_count 归零
- [x] `FileApiImpl` 注入 `ThumbnailRenderer`(作为可选依赖或列表注入,支持后续多实现)和 `ThumbnailGenerationTask`
- [x] `upload()`/`uploadDirect()`/`completeMultipart()` 完成后判断类型:图片 → 置 PENDING + 提交异步任务;非图片 → 置 UNSUPPORTED
- [x] 类型判断逻辑:从扩展名/contentType 判断是否为支持的图片格式(jpg/png/webp/gif/bmp)
- [x] 测试:upload 图片后 → thumbnail_status 初始为 PENDING,异步任务被提交
- [x] 测试:upload 非图片(如 .txt)→ thumbnail_status 初始为 UNSUPPORTED,不提交异步任务
- [x] 测试:completeMultipart 图片后 → 同上 PENDING + 提交
- [x] 测试:异步任务生成成功 → 写 MinIO 缩略图对象 + 状态置 READY + retry_count 归零

17
.scratch/file-thumbnail/issues/04-sync-fallback.md

@ -0,0 +1,17 @@
# 04 — 同步兜底(PENDING → 同步生成)
**What to build:** 缩略图首次请求时的同步兜底生成机制。当 `getThumbnail` 遇到 `thumbnail_status = PENDING`(异步任务尚未完成)时,尝试获取 Redis 分布式锁(`SET crm:file:thumbnail:lock:{fileId} 1 NX EX 30`)。获锁成功 → 同步调 `ThumbnailGenerationTask` 的生成逻辑 → 完成后返回真缩略图 + `max-age=86400`;获锁失败(异步任务正在跑)→ 每 500ms 轮询 `thumbnail_status`,超过 `sync-wait-timeout`(默认 10s)仍为 PENDING → 返回占位图 + `no-cache` + HTTP 202。锁 TTL 30 秒覆盖最慢的渲染场景。完成后即使异步任务没跑完,首次请求也能拿到真缩略图。
**Blocked by:** 03 — 图片异步生成
**Status:** ready-for-agent
- [x] `FileApiImpl.getThumbnail()` 的 PENDING 分支实现:尝试 Redis `SET ... NX EX 30` 获锁
- [x] 获锁成功 → 同步触发生成逻辑(复用 ThumbnailGenerationTask 的核心方法,不是 @Async 调用)→ 完成后返回真缩略图 + max-age=86400
- [x] 获锁失败 → 每 500ms 轮询 thumbnail_status,超 sync-wait-timeout(默认 10s)→ 返回占位图 + no-cache + HTTP 202
- [x] 获锁失败 + 等待期间异步任务完成(状态变 READY)→ 返回真缩略图 + max-age=86400
- [x] 生成逻辑抽取为可复用方法(异步任务和同步兜底共用,避免两套代码)
- [x] 测试:PENDING + 获锁成功 → 同步生成 → 返回真缩略图 + max-age=86400
- [x] 测试:PENDING + 获锁失败 → 轮询超时 → 返回占位图 + no-cache + 202
- [x] 测试:PENDING + 获锁失败 + 等待期间变 READY → 返回真缩略图
- [x] 测试:并发两路(异步 + 同步兜底)→ 分布式锁保证只生成一次

16
.scratch/file-thumbnail/issues/05-pdf-rendering.md

@ -0,0 +1,16 @@
# 05 — PDF 缩略图渲染
**What to build:** `ThumbnailRenderer` 的 PDF 实现,扩展缩略图覆盖范围到 PDF 文件。新建 PDF 渲染实现类,使用 PDFBox 的 `PDFRenderer` 渲染 PDF 第一页为 `BufferedImage`(DPI 150 兼顾清晰度与性能),再走和图片缩略图相同的缩放 + JPEG 输出路径。`ThumbnailGenerationTask` 的类型判断逻辑扩展:上传 PDF 时 `thumbnail_status``PENDING` 并提交异步任务,渲染器路由到 PDF 实现。完成后上传一个 PDF,等异步任务跑完,调缩略图 API 能拿到第一页缩略图。
**Blocked by:** 03 — 图片异步生成
**Status:** ready-for-agent
- [x] `ThumbnailRenderer` 的 PDF 实现:PDFBox `PDDocument.load()``PDFRenderer.renderImageWithDPI(0, 150)` → BufferedImage
- [x] PDF 实现复用图片缩略图的缩放 + JPEG 输出逻辑(抽取为共用工具方法或基类)
- [x] `ThumbnailRenderer.supports()` 覆盖 PDF(contentType `application/pdf` 或扩展名 `.pdf`
- [x] `ThumbnailGenerationTask` 渲染器路由:根据文件类型选择对应的 ThumbnailRenderer 实现
- [x] 上传 PDF 时 `thumbnail_status` 置 PENDING + 提交异步任务(扩展 03 的类型判断)
- [x] 测试:upload PDF → thumbnail_status 为 PENDING + 提交异步任务
- [x] 测试:PDF 渲染成功 → 第一页缩略图写入 MinIO + 状态 READY
- [x] 测试:ThumbnailRenderer.supports 对 PDF 返回 true,对非图片非 PDF 返回 false

18
.scratch/file-thumbnail/issues/06-office-rendering.md

@ -0,0 +1,18 @@
# 06 — Office 缩略图渲染(LibreOffice)
**What to build:** `ThumbnailRenderer` 的 Office 文档实现,扩展缩略图覆盖范围到 docx/xlsx/pptx。使用 LibreOffice headless 命令行 `soffice --convert-to png --outdir {tmpDir} {inputFile}` 将 Office 文档转为 PNG(取第一页),再走缩放 + JPEG 输出路径。LibreOffice 不支持并发调用同一 user profile,需用 `-env:UserInstallation=file:///tmp/lo-{uuid}` 隔离每次调用的 profile。Dockerfile 新增 LibreOffice headless 安装(`apt-get install libreoffice --no-install-recommends`)。`ThumbnailGenerationTask` 类型判断扩展:上传 Office 文档时置 `PENDING` 并提交异步任务。完成后上传一个 docx,等异步任务跑完,调缩略图 API 能拿到第一页缩略图。
**Blocked by:** 03 — 图片异步生成
**Status:** ready-for-agent
- [x] `ThumbnailRenderer` 的 Office 实现:临时文件落地原文件 → `soffice --convert-to png -env:UserInstallation=file:///tmp/lo-{uuid}` → 读输出 PNG → 缩放 + JPEG 输出
- [x] LibreOffice profile 隔离:每次调用生成唯一 UUID 的 user installation 路径,防止并发冲突
- [x] `ThumbnailRenderer.supports()` 覆盖 Office(扩展名 docx/xlsx/pptx,或 contentType `application/vnd.openxmlformats-officedocument.*`
- [x] `ThumbnailGenerationTask` 类型判断扩展:Office 文档 → 置 PENDING + 提交异步任务
- [x] Dockerfile 新增 LibreOffice headless 安装(`apt-get update && apt-get install -y --no-install-recommends libreoffice`)
- [x] 清理临时文件(原文件落地 + LibreOffice 输出 PNG),finally 块保证清理
- [x] 测试:upload docx → thumbnail_status 为 PENDING + 提交异步任务
- [x] 测试:Office 渲染成功 → 第一页缩略图写入 MinIO + 状态 READY
- [x] 测试:ThumbnailRenderer.supports 对 docx/xlsx/pptx 返回 true
- [x] 测试:LibreOffice 不可用(命令找不到)→ 生成失败走重试逻辑(不 crash)

18
.scratch/file-thumbnail/issues/07-failure-retry.md

@ -0,0 +1,18 @@
# 07 — 失败处理 + 定时重试
**What to build:** 缩略图生成失败后的重试与恢复机制。`ThumbnailGenerationTask` 的生成逻辑包裹 try-catch:捕获异常时 `thumbnail_retry_count++`,若 `retry_count < retry-limit`(默认 3)则保持 `PENDING` 状态等待重试,等于 3 则置 `FAILED`。新建 `ThumbnailRetryTask`(@Scheduled,cron 走 `crm.file.thumbnail.retry-cron` 默认每天凌晨 4 点),扫描 `thumbnail_status = FAILED` 的记录,重置为 `PENDING` 并重新提交异步生成任务。`getThumbnail` 遇到 `FAILED` 状态时返回占位图 + `no-cache`(已由 02 实现)。完成后即使 LibreOffice 临时故障导致生成失败,定时任务会在故障恢复后自动重试。
**Blocked by:** 03 — 图片异步生成
**Status:** ready-for-agent
- [x] `ThumbnailGenerationTask` 生成逻辑 try-catch:异常时 retry_count++,小于 retry-limit 保持 PENDING,等于则置 FAILED
- [x] 生成成功后 retry_count 归零(与 03 的成功路径一致,本票确认行为)
- [x] `ThumbnailRetryTask` 新建(@Scheduled cron = `${crm.file.thumbnail.retry-cron:0 0 4 * * ?}`
- [x] 重试任务扫描 `thumbnail_status = FAILED` 的记录 → 重置 PENDING → 重新提交异步生成
- [x] 重试任务异常不致命(try-catch 包裹,日志告警,下个周期重试,复用 OrphanChunkCleanupTask 模式)
- [x] 测试:生成失败 + retry_count < 3 retry_count++状态保持 PENDING
- [x] 测试:生成失败 + retry_count = 3 → 状态置 FAILED
- [x] 测试:生成成功 → retry_count 归零
- [x] 测试:ThumbnailRetryTask 扫描 FAILED 记录 → 重置 PENDING + 提交异步任务
- [x] 测试:重试任务自身异常不 crash(日志告警,下周期继续)

244
.scratch/file-thumbnail/spec.md

@ -0,0 +1,244 @@
# 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)三类,非视觉文件前置判断标记 `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 接口**新增:
- `byte[] getThumbnail(String fileId)` — 获取缩略图二进制(含同步兜底逻辑)
**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):LibreOffice headless `soffice --convert-to png` 命令行 → 读输出 PNG → 缩放 → JPEG 输出
**ThumbnailPlaceholderService**(新建):
- `byte[] getPlaceholder(String ext)` — 按文件扩展名返回对应的文件类型图标占位图(预置静态资源)
**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 | 缩略图生成失败(渲染引擎异常,非文件不存在) |
| 62008 | 缩略图同步等待超时(异步任务未完成且同步兜底超时) |
### 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 依赖。
### 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,不影响现有调用方。

24
Dockerfile

@ -0,0 +1,24 @@
# crm-backend 部署镜像(含缩略图 Office 渲染所需的 LibreOffice headless)
# 构建阶段:Maven 编译打包
FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /build
COPY pom.xml .
COPY crm-base/pom.xml crm-base/
COPY crm-auth/pom.xml crm-auth/
COPY crm-file/pom.xml crm-file/
COPY crm-app/pom.xml crm-app/
# 先拉依赖(利用 Docker 层缓存)
RUN mvn dependency:go-offline -q || true
COPY . .
RUN mvn package -DskipTests -q
# 运行阶段:JRE 17 + LibreOffice headless(缩略图 Office 渲染)
FROM eclipse-temurin:17-jre
# LibreOffice headless 及中文渲染所需字体(--no-install-recommends 控制体积)
RUN apt-get update \
&& apt-get install -y --no-install-recommends libreoffice fonts-noto-cjk \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=build /build/crm-app/target/crm-app*.jar app.jar
ENV JAVA_OPTS=""
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

28
crm-file/CONTEXT.md

@ -51,3 +51,31 @@ _Avoid_: 预览链接、kk 地址
**回源**: **回源**:
kkFileView 服务端凭短时效凭证从 MinIO 拉取原始文件用于格式转换的动作。浏览器不直接接触 MinIO。 kkFileView 服务端凭短时效凭证从 MinIO 拉取原始文件用于格式转换的动作。浏览器不直接接触 MinIO。
_Avoid_: 代理下载、中转 _Avoid_: 代理下载、中转
**缩略图**:
从原文件提取或渲染出的小尺寸静态图片(固定宽度 200px,等比缩放,上限 400px 高,JPEG 格式),用于前端文件列表优先展示以降低弱网加载延迟。点击后再请求原图或完整预览。
_Avoid_: 小图、预览图、封面图
**缩略图状态**:
一个文件缩略图的生命周期阶段:`PENDING`(已入队待生成)、`READY`(已生成可返回)、`FAILED`(重试 3 次后放弃但可由定时任务重试)、`UNSUPPORTED`(文件类型不在支持范围内,永不触发生成)。
_Avoid_: 缩略图进度、生成标志
**缩略图生成**:
将原文件转换为缩略图的动作。图片走 Java 图像缩放;PDF 走 PDFBox 渲染第一页;Office 走 LibreOffice headless 渲染第一页。支持图片(jpg/png/webp/gif/bmp)、PDF、Office(docx/xlsx/pptx),其余类型前置标记 `UNSUPPORTED` 跳过。
_Avoid_: 缩略、截图
**异步生成**:
文件上传完成后由后台任务触发的缩略图生成。与首次请求同步兜底并行,两路通过分布式锁防止重复生成。
_Avoid_: 后台截图、延迟生成
**同步兜底**:
缩略图首次被请求时若后台任务尚未完成,请求线程同步触发生成并等待返回的策略。缩略图不变,后续请求走 MinIO 缓存。
_Avoid_: 实时生成、即时渲染
**占位图**:
缩略图处于 `PENDING`、`FAILED` 或 `UNSUPPORTED` 状态时返回给前端的文件类型图标静态图。前端无感知,始终拿到一张图。
_Avoid_: 默认图、fallback图
**缩略图对象**:
缩略图在 MinIO 中的对象,objectKey 为 `thumbnails/{fileId}.jpg`,与原文件同 bucket。通过后端 API 中转返回,设置 `Cache-Control: max-age=86400` 强缓存头,浏览器不直接接触 MinIO。
_Avoid_: 缩略图文件、thumb对象

6
crm-file/pom.xml

@ -28,6 +28,12 @@
<artifactId>minio</artifactId> <artifactId>minio</artifactId>
</dependency> </dependency>
<!-- PDF 渲染(缩略图第一页) -->
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.projectlombok</groupId> <groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId> <artifactId>lombok</artifactId>

13
crm-file/src/main/java/com/crm/file/api/FileApi.java

@ -3,6 +3,7 @@ package com.crm.file.api;
import com.crm.file.domain.dto.FileDownloadDTO; import com.crm.file.domain.dto.FileDownloadDTO;
import com.crm.file.domain.dto.FileInfoDTO; import com.crm.file.domain.dto.FileInfoDTO;
import com.crm.file.domain.dto.MultipartInitDTO; import com.crm.file.domain.dto.MultipartInitDTO;
import com.crm.file.domain.dto.ThumbnailDTO;
import java.io.InputStream; import java.io.InputStream;
@ -69,6 +70,18 @@ public interface FileApi {
*/ */
String getPreviewUrl(String fileId); String getPreviewUrl(String fileId);
/**
* 获取缩略图含同步兜底逻辑
* <p>状态 READY MinIO thumbnails/{fileId}.jpg 返回二进制 + cacheable=true
* 状态 UNSUPPORTED/FAILED返回占位图 + cacheable=false
* 状态 PENDING获锁同步生成后重查状态READY 返回真缩略图获锁失败则轮询等待
* sync-wait-timeout PENDING 返回占位图 + cacheable=false + statusCode=202</p>
*
* @param fileId 文件 ID字符串查无抛资源不存在40401
* @return 缩略图二进制 + 内容类型 + 缓存策略标记 + HTTP 状态码
*/
ThumbnailDTO getThumbnail(String fileId);
/*-------- 分片上传三段式(init → upload → complete,ADR-0004)--------*/ /*-------- 分片上传三段式(init → upload → complete,ADR-0004)--------*/
/** /**

33
crm-file/src/main/java/com/crm/file/config/FileProperties.java

@ -56,6 +56,8 @@ public class FileProperties {
private List<String> extBlacklist = List.of( private List<String> extBlacklist = List.of(
"exe", "dll", "bat", "cmd", "sh", "ps1", "jsp", "jspx", "php", "asp", "aspx"); "exe", "dll", "bat", "cmd", "sh", "ps1", "jsp", "jspx", "php", "asp", "aspx");
private Thumbnail thumbnail = new Thumbnail();
@Data @Data
public static class Minio { public static class Minio {
@ -80,4 +82,35 @@ public class FileProperties {
/** 预览回源 presigned GET 的有效期 */ /** 预览回源 presigned GET 的有效期 */
private Duration presignTtl = Duration.ofMinutes(10); private Duration presignTtl = Duration.ofMinutes(10);
} }
@Data
public static class Thumbnail {
/** 缩略图固定宽度(px),等比缩放 */
private int width = 200;
/** 缩略图等比缩放上限高度(px),防止超长截图 */
private int maxHeight = 400;
/** 输出格式(jpeg/png) */
private String format = "jpeg";
/** 同步兜底等待超时:PENDING 时获锁失败后的轮询上限 */
private Duration syncWaitTimeout = Duration.ofSeconds(10);
/** 最大重试次数,达到后标记 FAILED */
private int retryLimit = 3;
/** FAILED 重试扫描周期(cron),默认每天凌晨 4 点 */
private String retryCron = "0 0 4 * * ?";
/** LibreOffice 可执行文件路径 */
private String libreofficePath = "soffice";
/** LibreOffice 单次转换超时(大文档首页渲染可能较慢) */
private Duration officeConvertTimeout = Duration.ofSeconds(120);
/** 生成分布式锁 TTL:必须大于 officeConvertTimeout,否则慢渲染期间锁过期导致重复生成 */
private Duration lockTtl = Duration.ofSeconds(150);
}
} }

7
crm-file/src/main/java/com/crm/file/config/SchedulingConfig.java

@ -1,13 +1,16 @@
package com.crm.file.config; package com.crm.file.config;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.EnableScheduling;
/** /**
* 定时任务开关项目此前无调度体系随文件模块引入 * 定时任务与异步任务开关项目此前无调度体系随文件模块引入
* <p>当前唯一任务孤儿临时分片清理 {@link com.crm.file.task.OrphanChunkCleanupTask}</p> * <p>定时任务孤儿临时分片清理 {@link com.crm.file.task.OrphanChunkCleanupTask}
* 异步任务缩略图生成 {@link com.crm.file.task.ThumbnailGenerationTask}</p>
*/ */
@Configuration @Configuration
@EnableScheduling @EnableScheduling
@EnableAsync
public class SchedulingConfig { public class SchedulingConfig {
} }

22
crm-file/src/main/java/com/crm/file/constant/FileConstants.java

@ -11,6 +11,12 @@ public interface FileConstants {
/** 临时分片在 MinIO 中的对象前缀,完整 key:chunks/{uploadId}/{分片序号} */ /** 临时分片在 MinIO 中的对象前缀,完整 key:chunks/{uploadId}/{分片序号} */
String CHUNK_PREFIX = "chunks/"; String CHUNK_PREFIX = "chunks/";
/** 缩略图在 MinIO 中的对象前缀,完整 key:thumbnails/{fileId}.jpg */
String THUMBNAIL_PREFIX = "thumbnails/";
/** 缩略图生成分布式锁在 Redis 中的 key 前缀,完整 key:crm:file:thumbnail:lock:{fileId} */
String THUMBNAIL_LOCK_PREFIX = "crm:file:thumbnail:lock:";
/*-------- 文件模块业务错误码(62xxx,登记于根 README)--------*/ /*-------- 文件模块业务错误码(62xxx,登记于根 README)--------*/
/** 文件大小超限(直传超上限 / 总大小超单文件上限) */ /** 文件大小超限(直传超上限 / 总大小超单文件上限) */
@ -30,4 +36,20 @@ public interface FileConstants {
/** 分片大小配置低于 5MB 硬下限(配置期防御,非用户操作错误) */ /** 分片大小配置低于 5MB 硬下限(配置期防御,非用户操作错误) */
int CODE_CHUNK_SIZE_MISCONFIGURED = 62006; int CODE_CHUNK_SIZE_MISCONFIGURED = 62006;
/** 缩略图生成失败(渲染引擎异常) */
int CODE_THUMBNAIL_GENERATION_FAILED = 62007;
/** 缩略图同步等待超时(异步任务未完成且同步兜底超时) */
int CODE_THUMBNAIL_SYNC_TIMEOUT = 62008;
/** HTTP 202 Accepted:缩略图仍在生成,前端应稍后重试(信封例外路径专用) */
int CODE_HTTP_ACCEPTED = 202;
/*-------- 缩略图状态常量 --------*/
String THUMBNAIL_STATUS_PENDING = "PENDING";
String THUMBNAIL_STATUS_READY = "READY";
String THUMBNAIL_STATUS_FAILED = "FAILED";
String THUMBNAIL_STATUS_UNSUPPORTED = "UNSUPPORTED";
} }

15
crm-file/src/main/java/com/crm/file/controller/FileController.java

@ -5,6 +5,7 @@ import com.crm.file.api.FileApi;
import com.crm.file.domain.dto.FileDownloadDTO; import com.crm.file.domain.dto.FileDownloadDTO;
import com.crm.file.domain.dto.FileInfoDTO; import com.crm.file.domain.dto.FileInfoDTO;
import com.crm.file.domain.dto.MultipartInitDTO; import com.crm.file.domain.dto.MultipartInitDTO;
import com.crm.file.domain.dto.ThumbnailDTO;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -76,6 +77,20 @@ public class FileController {
return Result.success(fileApi.getPreviewUrl(fileId)); return Result.success(fileApi.getPreviewUrl(fileId));
} }
@Operation(summary = "缩略图(信封例外:成功返回二进制图片流,失败 JSON 信封)")
@GetMapping("/thumbnail")
public ResponseEntity<byte[]> thumbnail(@RequestParam("fileId") String fileId) {
ThumbnailDTO thumb = fileApi.getThumbnail(fileId);
String cacheControl = thumb.isCacheable()
? "max-age=86400"
: "no-cache";
return ResponseEntity.status(thumb.getStatusCode())
.header(HttpHeaders.CACHE_CONTROL, cacheControl)
.contentType(MediaType.parseMediaType(thumb.getContentType()))
.contentLength(thumb.getContent().length)
.body(thumb.getContent());
}
@Operation(summary = "删除(逻辑删除)") @Operation(summary = "删除(逻辑删除)")
@PostMapping("/delete") @PostMapping("/delete")
public Result<Void> delete(@RequestParam("fileId") String fileId) { public Result<Void> delete(@RequestParam("fileId") String fileId) {

4
crm-file/src/main/java/com/crm/file/domain/dto/FileInfoDTO.java

@ -29,6 +29,9 @@ public class FileInfoDTO {
private LocalDateTime createTime; private LocalDateTime createTime;
/** 缩略图状态(PENDING/READY/FAILED/UNSUPPORTED),nullable 向后兼容 */
private String thumbnailStatus;
public static FileInfoDTO from(FileInfo entity) { public static FileInfoDTO from(FileInfo entity) {
FileInfoDTO dto = new FileInfoDTO(); FileInfoDTO dto = new FileInfoDTO();
dto.setFileId(String.valueOf(entity.getId())); dto.setFileId(String.valueOf(entity.getId()));
@ -38,6 +41,7 @@ public class FileInfoDTO {
dto.setBizDomain(entity.getBizDomain()); dto.setBizDomain(entity.getBizDomain());
dto.setCreatorId(entity.getCreatorId()); dto.setCreatorId(entity.getCreatorId());
dto.setCreateTime(entity.getCreateTime()); dto.setCreateTime(entity.getCreateTime());
dto.setThumbnailStatus(entity.getThumbnailStatus());
return dto; return dto;
} }
} }

27
crm-file/src/main/java/com/crm/file/domain/dto/ThumbnailDTO.java

@ -0,0 +1,27 @@
package com.crm.file.domain.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
/**
* 缩略图返回结果二进制 + 元数据 + 缓存策略标记
* <p>cacheable=true Controller Cache-Control: max-age=86400false 时设 no-cache</p>
*/
@Data
@Builder
public class ThumbnailDTO {
/** 缩略图/占位图二进制内容 */
private byte[] content;
/** 内容类型(真缩略图 image/jpeg,占位图 image/png) */
private String contentType;
/** 是否可强缓存:READY=true(max-age=86400),其余=false(no-cache) */
private boolean cacheable;
/** HTTP 状态码:正常 200;同步等待超时(仍 PENDING)返回 202 提示前端稍后重试 */
@Builder.Default
private int statusCode = 200;
}

8
crm-file/src/main/java/com/crm/file/domain/entity/FileInfo.java

@ -50,4 +50,12 @@ public class FileInfo extends BaseEntity {
@Comment("整文件 hash(秒传占位字段,一期不写入)") @Comment("整文件 hash(秒传占位字段,一期不写入)")
@Column(name = "file_hash", columnDefinition = "varchar(64) comment '整文件hash(秒传占位)'") @Column(name = "file_hash", columnDefinition = "varchar(64) comment '整文件hash(秒传占位)'")
private String fileHash; private String fileHash;
@Comment("缩略图状态:PENDING(待生成)/READY(已生成)/FAILED(重试耗尽)/UNSUPPORTED(类型不支持)")
@Column(name = "thumbnail_status", columnDefinition = "varchar(16) not null default 'PENDING' comment '缩略图状态'")
private String thumbnailStatus = "PENDING";
@Comment("缩略图生成失败重试计数,成功后归零")
@Column(name = "thumbnail_retry_count", columnDefinition = "int not null default 0 comment '缩略图重试计数'")
private Integer thumbnailRetryCount = 0;
} }

55
crm-file/src/main/java/com/crm/file/service/ThumbnailPlaceholderService.java

@ -0,0 +1,55 @@
package com.crm.file.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Locale;
/**
* 缩略图占位图服务按文件扩展名动态生成文件类型图标占位图PNG
* <p>200x200 灰底居中显示扩展名文字非视觉文件未就绪生成失败时返回</p>
*/
@Slf4j
@Service
public class ThumbnailPlaceholderService {
private static final int SIZE = 200;
private static final Color BG = new Color(240, 240, 240);
private static final Color TEXT = new Color(120, 120, 120);
/**
* 按扩展名生成占位图无扩展名或异常时回退通用占位图
*/
public byte[] getPlaceholder(String ext) {
String label = (ext == null || ext.isBlank()) ? "FILE" : ext.toUpperCase(Locale.ROOT);
if (label.length() > 8) {
label = label.substring(0, 8);
}
BufferedImage img = new BufferedImage(SIZE, SIZE, BufferedImage.TYPE_INT_RGB);
Graphics2D g = img.createGraphics();
try {
g.setColor(BG);
g.fillRect(0, 0, SIZE, SIZE);
g.setColor(TEXT);
g.setFont(new Font("SansSerif", Font.BOLD, 28));
FontMetrics fm = g.getFontMetrics();
int x = (SIZE - fm.stringWidth(label)) / 2;
int y = (SIZE - fm.getHeight()) / 2 + fm.getAscent();
g.drawString(label, x, y);
} finally {
g.dispose();
}
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
ImageIO.write(img, "png", out);
return out.toByteArray();
} catch (IOException e) {
log.error("占位图生成失败,ext={}", ext, e);
return new byte[0];
}
}
}

30
crm-file/src/main/java/com/crm/file/service/ThumbnailRenderer.java

@ -0,0 +1,30 @@
package com.crm.file.service;
import java.io.InputStream;
/**
* 缩略图渲染器接口将原始文件渲染为缩略图字节数组
* <p>每种文件类型一个实现图片/PDF/Office通过 {@link #supports} 声明自己处理的类型</p>
*/
public interface ThumbnailRenderer {
/**
* 判断此渲染器是否支持指定的文件类型
*
* @param contentType MIME 类型可空
* @param ext 小写扩展名不带点可空
* @return true 表示此渲染器可以处理该文件类型
*/
boolean supports(String contentType, String ext);
/**
* 将原始文件流渲染为缩略图字节数组
*
* @param original 原始文件输入流由调用方负责关闭
* @param ext 小写扩展名不带点
* @param width 缩略图固定宽度px
* @param maxHeight 等比缩放上限高度px
* @return 缩略图字节数组JPEG 格式
*/
byte[] render(InputStream original, String ext, int width, int maxHeight) throws Exception;
}

128
crm-file/src/main/java/com/crm/file/service/impl/FileApiImpl.java

@ -11,10 +11,13 @@ import com.crm.file.constant.FileConstants;
import com.crm.file.domain.dto.FileDownloadDTO; import com.crm.file.domain.dto.FileDownloadDTO;
import com.crm.file.domain.dto.FileInfoDTO; import com.crm.file.domain.dto.FileInfoDTO;
import com.crm.file.domain.dto.MultipartInitDTO; import com.crm.file.domain.dto.MultipartInitDTO;
import com.crm.file.domain.dto.ThumbnailDTO;
import com.crm.file.domain.dto.UploadSession; import com.crm.file.domain.dto.UploadSession;
import com.crm.file.domain.entity.FileInfo; import com.crm.file.domain.entity.FileInfo;
import com.crm.file.service.IFileInfoService; import com.crm.file.service.IFileInfoService;
import com.crm.file.service.KkFileViewClient; import com.crm.file.service.KkFileViewClient;
import com.crm.file.service.ThumbnailPlaceholderService;
import com.crm.file.task.ThumbnailGenerationTask;
import io.minio.ComposeObjectArgs; import io.minio.ComposeObjectArgs;
import io.minio.ComposeSource; import io.minio.ComposeSource;
import io.minio.GetObjectArgs; import io.minio.GetObjectArgs;
@ -56,6 +59,9 @@ public class FileApiImpl implements FileApi {
private static final String DEFAULT_CONTENT_TYPE = "application/octet-stream"; private static final String DEFAULT_CONTENT_TYPE = "application/octet-stream";
/** 缩略图同步兜底轮询间隔(获锁失败时等待异步任务完成) */
private static final long THUMBNAIL_POLL_INTERVAL_MS = 500L;
/** 分片大小硬下限:composeObject 对除末片外分片有 5MB 最小值约束,配置低于此值合并必败 */ /** 分片大小硬下限:composeObject 对除末片外分片有 5MB 最小值约束,配置低于此值合并必败 */
private static final long CHUNK_SIZE_FLOOR = 5L * 1024 * 1024; private static final long CHUNK_SIZE_FLOOR = 5L * 1024 * 1024;
@ -65,6 +71,8 @@ public class FileApiImpl implements FileApi {
private final IdentifierGenerator identifierGenerator; private final IdentifierGenerator identifierGenerator;
private final RedisTemplate<String, Object> redisTemplate; private final RedisTemplate<String, Object> redisTemplate;
private final KkFileViewClient kkFileViewClient; private final KkFileViewClient kkFileViewClient;
private final ThumbnailPlaceholderService thumbnailPlaceholderService;
private final ThumbnailGenerationTask thumbnailGenerationTask;
@Override @Override
public FileInfoDTO upload(InputStream inputStream, long size, String originalName, public FileInfoDTO upload(InputStream inputStream, long size, String originalName,
@ -103,7 +111,18 @@ public class FileApiImpl implements FileApi {
info.setFileSize(size); info.setFileSize(size);
info.setContentType(StringUtils.hasText(contentType) ? contentType : DEFAULT_CONTENT_TYPE); info.setContentType(StringUtils.hasText(contentType) ? contentType : DEFAULT_CONTENT_TYPE);
info.setBizDomain(bizDomain); info.setBizDomain(bizDomain);
// 缩略图状态:支持类型 PENDING(异步生成),不支持类型 UNSUPPORTED
String effectiveCt = StringUtils.hasText(contentType) ? contentType : DEFAULT_CONTENT_TYPE;
if (thumbnailGenerationTask.isSupported(effectiveCt, extOf(originalName))) {
info.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_PENDING);
} else {
info.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_UNSUPPORTED);
}
fileInfoService.save(info); fileInfoService.save(info);
// 触发异步缩略图生成(不阻塞上传链路)
if (FileConstants.THUMBNAIL_STATUS_PENDING.equals(info.getThumbnailStatus())) {
thumbnailGenerationTask.generate(fileId);
}
return FileInfoDTO.from(info); return FileInfoDTO.from(info);
} }
@ -180,6 +199,105 @@ public class FileApiImpl implements FileApi {
return kkFileViewClient.buildPreviewUrl(presignedUrl); return kkFileViewClient.buildPreviewUrl(presignedUrl);
} }
@Override
public ThumbnailDTO getThumbnail(String fileId) {
FileInfo info = requireFileInfo(fileId);
String status = info.getThumbnailStatus();
if (FileConstants.THUMBNAIL_STATUS_READY.equals(status)) {
ThumbnailDTO dto = readThumbnail(info.getId());
if (dto != null) {
return dto;
}
} else if (FileConstants.THUMBNAIL_STATUS_PENDING.equals(status)) {
return handlePending(info);
}
// UNSUPPORTED / FAILED / READY读取失败 → 占位图
return placeholderOf(info, 200);
}
/**
* PENDING 同步兜底先尝试获锁同步生成获锁失败异步任务在跑则轮询等待
*/
private ThumbnailDTO handlePending(FileInfo info) {
if (thumbnailGenerationTask.tryGenerateSync(info.getId())) {
// 获锁成功且生成逻辑已跑完,重查状态定结果
return toThumbnailAfterGeneration(info);
}
// 获锁失败 → 每 POLL_INTERVAL 轮询状态,超 sync-wait-timeout 返回占位图 + 202
long deadline = System.currentTimeMillis()
+ fileProperties.getThumbnail().getSyncWaitTimeout().toMillis();
while (true) {
FileInfo fresh = fileInfoService.getById(info.getId());
if (fresh != null && FileConstants.THUMBNAIL_STATUS_READY.equals(fresh.getThumbnailStatus())) {
ThumbnailDTO dto = readThumbnail(fresh.getId());
if (dto != null) {
return dto;
}
return placeholderOf(info, 200);
}
if (fresh == null || !FileConstants.THUMBNAIL_STATUS_PENDING.equals(fresh.getThumbnailStatus())) {
// 终态非 READY(FAILED/UNSUPPORTED/删除)不再等待
return placeholderOf(info, 200);
}
if (System.currentTimeMillis() >= deadline) {
log.info("缩略图同步等待超时,返回占位图 + 202,fileId={}", info.getId());
return placeholderOf(info, FileConstants.CODE_HTTP_ACCEPTED);
}
try {
Thread.sleep(THUMBNAIL_POLL_INTERVAL_MS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return placeholderOf(info, 200);
}
}
}
/**
* 同步生成后按最新状态返回READY 真缩略图否则占位图
*/
private ThumbnailDTO toThumbnailAfterGeneration(FileInfo info) {
FileInfo fresh = fileInfoService.getById(info.getId());
if (fresh != null && FileConstants.THUMBNAIL_STATUS_READY.equals(fresh.getThumbnailStatus())) {
ThumbnailDTO dto = readThumbnail(fresh.getId());
if (dto != null) {
return dto;
}
}
return placeholderOf(info, 200);
}
/**
* MinIO 读缩略图对象读取失败返回 null 由调用方回退占位图
*/
private ThumbnailDTO readThumbnail(Long id) {
String thumbKey = FileConstants.THUMBNAIL_PREFIX + id + ".jpg";
try (InputStream in = minioClient.getObject(GetObjectArgs.builder()
.bucket(fileProperties.getMinio().getBucket())
.object(thumbKey)
.build())) {
return ThumbnailDTO.builder()
.content(in.readAllBytes())
.contentType("image/jpeg")
.cacheable(true)
.build();
} catch (Exception e) {
log.error("缩略图读取失败,objectKey={},回退占位图", thumbKey, e);
return null;
}
}
private ThumbnailDTO placeholderOf(FileInfo info, int statusCode) {
byte[] placeholder = thumbnailPlaceholderService.getPlaceholder(extOf(info.getOriginalName()));
return ThumbnailDTO.builder()
.content(placeholder)
.contentType("image/png")
.cacheable(false)
.statusCode(statusCode)
.build();
}
/*-------- 分片上传三段式 --------*/ /*-------- 分片上传三段式 --------*/
@Override @Override
@ -289,7 +407,17 @@ public class FileApiImpl implements FileApi {
info.setBizDomain(session.getBizDomain()); info.setBizDomain(session.getBizDomain());
// 发起人以 init 时记录为准(strictInsertFill 不覆盖非 null 值) // 发起人以 init 时记录为准(strictInsertFill 不覆盖非 null 值)
info.setCreatorId(session.getCreatorId()); info.setCreatorId(session.getCreatorId());
// 缩略图状态:支持类型 PENDING(异步生成),不支持类型 UNSUPPORTED
if (thumbnailGenerationTask.isSupported(info.getContentType(), extOf(session.getOriginalName()))) {
info.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_PENDING);
} else {
info.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_UNSUPPORTED);
}
fileInfoService.save(info); fileInfoService.save(info);
// 触发异步缩略图生成(不阻塞合并链路)
if (FileConstants.THUMBNAIL_STATUS_PENDING.equals(info.getThumbnailStatus())) {
thumbnailGenerationTask.generate(fileId);
}
// 成功路径自行清理临时分片与会话;清理失败不影响结果,孤儿分片清理任务兜底 // 成功路径自行清理临时分片与会话;清理失败不影响结果,孤儿分片清理任务兜底
cleanupChunks(uploadId, session.getTotalChunks()); cleanupChunks(uploadId, session.getTotalChunks());

56
crm-file/src/main/java/com/crm/file/service/impl/ImageThumbnailRenderer.java

@ -0,0 +1,56 @@
package com.crm.file.service.impl;
import com.crm.file.service.ThumbnailRenderer;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.InputStream;
import java.util.Locale;
import java.util.Set;
/**
* 图片缩略图渲染器Java ImageIO 读取 等比缩放 JPEG 输出
* <p>支持 jpg/jpeg/png/gif/bmpwebp 需运行时 ImageIO 有对应 reader插件
* 不可用时 supports 返回 false UNSUPPORTED避免生成必然失败被定时任务无限重试</p>
*/
@Slf4j
@Service
public class ImageThumbnailRenderer implements ThumbnailRenderer {
private static final Set<String> SUPPORTED_EXTS = Set.of("jpg", "jpeg", "png", "gif", "bmp");
/** webp 解码能力探测:JDK 内置 ImageIO 无 webp reader,需额外插件才可用 */
private static final boolean WEBP_AVAILABLE = ImageIO.getImageReadersByFormatName("webp").hasNext();
@Override
public boolean supports(String contentType, String ext) {
if (ext != null) {
String e = ext.toLowerCase(Locale.ROOT);
if (SUPPORTED_EXTS.contains(e)) {
return true;
}
if ("webp".equals(e)) {
return WEBP_AVAILABLE;
}
}
if (contentType != null) {
String ct = contentType.toLowerCase(Locale.ROOT);
if (ct.contains("webp")) {
return WEBP_AVAILABLE;
}
return ct.startsWith("image/") && !ct.contains("svg") && !ct.contains("tiff");
}
return false;
}
@Override
public byte[] render(InputStream original, String ext, int width, int maxHeight) throws Exception {
BufferedImage src = ImageIO.read(original);
if (src == null) {
throw new IllegalStateException("ImageIO 无法解码图片,可能格式不支持");
}
return ThumbnailImageUtils.scaleToJpeg(src, width, maxHeight);
}
}

161
crm-file/src/main/java/com/crm/file/service/impl/OfficeThumbnailRenderer.java

@ -0,0 +1,161 @@
package com.crm.file.service.impl;
import com.crm.file.config.FileProperties;
import com.crm.file.service.ThumbnailRenderer;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* Office 缩略图渲染器LibreOffice headless 将文档转 PNG第一页 等比缩放 JPEG 输出
* <p>每次调用使用唯一 UUID UserInstallation profile避免并发冲突
* 临时文件原文件落地 + 转换输出 + profile finally 中清理</p>
*/
@Slf4j
@Service
public class OfficeThumbnailRenderer implements ThumbnailRenderer {
private static final Set<String> SUPPORTED_EXTS = Set.of("docx", "xlsx", "pptx", "doc", "xls", "ppt");
private static final Set<String> OFFICE_CONTENT_PREFIXES = Set.of(
"application/vnd.openxmlformats-officedocument",
"application/msword",
"application/vnd.ms-excel",
"application/vnd.ms-powerpoint");
private final FileProperties fileProperties;
public OfficeThumbnailRenderer(FileProperties fileProperties) {
this.fileProperties = fileProperties;
}
@Override
public boolean supports(String contentType, String ext) {
if (ext != null && SUPPORTED_EXTS.contains(ext.toLowerCase(Locale.ROOT))) {
return true;
}
if (contentType != null) {
String ct = contentType.toLowerCase(Locale.ROOT).trim();
return OFFICE_CONTENT_PREFIXES.stream().anyMatch(ct::startsWith);
}
return false;
}
@Override
public byte[] render(InputStream original, String ext, int width, int maxHeight) throws Exception {
Path workDir = Files.createTempDirectory("crm-thumb-office-");
Path profileDir = null;
try {
// 原文件落地(LibreOffice 只接受文件路径;保留扩展名供其识别格式)
String safeExt = (ext == null || ext.isBlank()) ? "docx" : ext.toLowerCase(Locale.ROOT);
Path inputFile = workDir.resolve("input." + safeExt);
Files.copy(original, inputFile);
Path outDir = Files.createDirectories(workDir.resolve("out"));
profileDir = Files.createDirectories(workDir.resolve("lo-" + UUID.randomUUID()));
runConversion(workDir, inputFile, outDir, profileDir);
Path png = findConvertedPng(outDir);
BufferedImage pageImage = ImageIO.read(png.toFile());
if (pageImage == null) {
throw new IllegalStateException("LibreOffice 输出 PNG 无法解码:" + png.getFileName());
}
return ThumbnailImageUtils.scaleToJpeg(pageImage, width, maxHeight);
} finally {
deleteRecursively(workDir);
}
}
/**
* soffice --convert-to pngprofile 隔离 + 超时保护
*/
private void runConversion(Path workDir, Path inputFile, Path outDir, Path profileDir) throws IOException, InterruptedException {
String soffice = fileProperties.getThumbnail().getLibreofficePath();
long timeoutSeconds = fileProperties.getThumbnail().getOfficeConvertTimeout().toSeconds();
List<String> command = new ArrayList<>();
// Windows 下 .cmd/.bat 需经 cmd /c 解释(本机测试场景;Docker/Linux 走 else 分支)
if (isWindowsScript(soffice)) {
command.add("cmd");
command.add("/c");
}
command.add(soffice);
command.add("--headless");
command.add("--norestore");
command.add("-env:UserInstallation=" + profileDir.toUri());
command.add("--convert-to");
command.add("png");
command.add("--outdir");
command.add(outDir.toString());
command.add(inputFile.toString());
Process process = new ProcessBuilder(command)
.redirectErrorStream(true)
// 输出落文件而非同步 readAllBytes:进程挂死不关 stdout 时,readAllBytes 会永久阻塞导致超时保护失效
.redirectOutput(workDir.resolve("soffice.log").toFile())
.start();
boolean finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS);
if (!finished) {
process.destroyForcibly();
// 等待真正终止,避免与 finally 的临时目录清理竞争
process.waitFor();
throw new IllegalStateException("LibreOffice 转换超时(" + timeoutSeconds + "s)");
}
int exit = process.exitValue();
if (exit != 0) {
String output = Files.exists(workDir.resolve("soffice.log"))
? Files.readString(workDir.resolve("soffice.log"))
: "";
log.warn("LibreOffice 转换失败,exit={},output={}", exit, output);
throw new IllegalStateException("LibreOffice 转换失败,exit=" + exit);
}
}
private boolean isWindowsScript(String executable) {
String lower = executable.toLowerCase(Locale.ROOT);
return lower.endsWith(".cmd") || lower.endsWith(".bat");
}
/**
* LibreOffice 输出文件名为 {输入文件主名}.png容错扫描 outDir 下第一个 PNG
*/
private Path findConvertedPng(Path outDir) throws IOException {
try (DirectoryStream<Path> stream = Files.newDirectoryStream(outDir, "*.png")) {
for (Path p : stream) {
return p;
}
}
throw new IllegalStateException("LibreOffice 未产出 PNG 输出");
}
private void deleteRecursively(Path root) {
if (root == null || !Files.exists(root)) {
return;
}
try (var walk = Files.walk(root)) {
walk.sorted(java.util.Comparator.reverseOrder()).forEach(p -> {
try {
Files.deleteIfExists(p);
} catch (IOException ignored) {
// 临时文件清理失败不影响主流程,由系统临时目录兜底
}
});
} catch (IOException e) {
log.warn("Office 缩略图临时目录清理失败:{}", root, e);
}
}
}

54
crm-file/src/main/java/com/crm/file/service/impl/PdfThumbnailRenderer.java

@ -0,0 +1,54 @@
package com.crm.file.service.impl;
import com.crm.file.service.ThumbnailRenderer;
import lombok.extern.slf4j.Slf4j;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.rendering.ImageType;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.springframework.stereotype.Service;
import java.awt.image.BufferedImage;
import java.io.InputStream;
import java.util.Locale;
import java.util.Set;
/**
* PDF 缩略图渲染器PDFBox 渲染第一页为图 等比缩放 JPEG 输出
* <p>DPI 150 兼顾清晰度与性能仅渲染第一页</p>
*/
@Slf4j
@Service
public class PdfThumbnailRenderer implements ThumbnailRenderer {
/** 渲染 DPI:150 在 200px 缩略图宽度下清晰度足够,且渲染耗时可控 */
private static final float RENDER_DPI = 150f;
private static final Set<String> SUPPORTED_EXTS = Set.of("pdf");
@Override
public boolean supports(String contentType, String ext) {
if (ext != null && SUPPORTED_EXTS.contains(ext.toLowerCase(Locale.ROOT))) {
return true;
}
return contentType != null && "application/pdf".equalsIgnoreCase(contentType.trim());
}
@Override
public byte[] render(InputStream original, String ext, int width, int maxHeight) throws Exception {
byte[] pdfBytes = original.readAllBytes();
try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
if (doc.getNumberOfPages() == 0) {
throw new IllegalStateException("PDF 无页面内容");
}
// 空白页(无媒体盒)渲染会失败,提前抛出可读异常
PDPage firstPage = doc.getPage(0);
if (firstPage.getMediaBox() == null) {
throw new IllegalStateException("PDF 第一页缺少 MediaBox,无法渲染");
}
BufferedImage pageImage = new PDFRenderer(doc).renderImageWithDPI(0, RENDER_DPI, ImageType.RGB);
return ThumbnailImageUtils.scaleToJpeg(pageImage, width, maxHeight);
}
}
}

47
crm-file/src/main/java/com/crm/file/service/impl/ThumbnailImageUtils.java

@ -0,0 +1,47 @@
package com.crm.file.service.impl;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
/**
* 缩略图共用图像处理工具等比缩放 + JPEG 输出
* <p>图片/PDF/Office 渲染器将源图BufferedImage转缩略图字节的共用路径</p>
*/
final class ThumbnailImageUtils {
private ThumbnailImageUtils() {
}
/**
* 等比缩放至固定宽度与上限高度两个约束内不变形输出 JPEG 字节数组
*
* @param src 源图渲染器产出可能远大于缩略图尺寸
* @param width 缩略图目标宽度px
* @param maxHeight 等比缩放上限高度px
* @return JPEG 编码的缩略图字节数组
*/
static byte[] scaleToJpeg(BufferedImage src, int width, int maxHeight) throws Exception {
int srcW = src.getWidth();
int srcH = src.getHeight();
// 等比缩放:固定宽度与上限高度两个约束取较小比例,保证不变形
double scale = Math.min((double) width / srcW, (double) maxHeight / srcH);
int dstW = Math.max((int) Math.round(srcW * scale), 1);
int dstH = Math.max((int) Math.round(srcH * scale), 1);
BufferedImage dst = new BufferedImage(dstW, dstH, BufferedImage.TYPE_INT_RGB);
Graphics2D g = dst.createGraphics();
try {
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g.drawImage(src, 0, 0, dstW, dstH, null);
} finally {
g.dispose();
}
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
ImageIO.write(dst, "jpg", out);
return out.toByteArray();
}
}
}

177
crm-file/src/main/java/com/crm/file/task/ThumbnailGenerationTask.java

@ -0,0 +1,177 @@
package com.crm.file.task;
import com.crm.file.config.FileProperties;
import com.crm.file.constant.FileConstants;
import com.crm.file.domain.entity.FileInfo;
import com.crm.file.service.IFileInfoService;
import com.crm.file.service.ThumbnailRenderer;
import io.minio.GetObjectArgs;
import io.minio.MinioClient;
import io.minio.PutObjectArgs;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.time.Duration;
import java.util.List;
/**
* 缩略图异步生成任务
* <p>上传完成后由 {@link com.crm.file.service.impl.FileApiImpl} 提交
* MinIO 拉原文件 路由到对应 {@link ThumbnailRenderer} 缩略图写回 MinIO 状态置 READY</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ThumbnailGenerationTask {
private final MinioClient minioClient;
private final FileProperties fileProperties;
private final IFileInfoService fileInfoService;
private final List<ThumbnailRenderer> renderers;
private final RedisTemplate<String, Object> redisTemplate;
/**
* 判断文件类型是否支持缩略图生成上传时调用决定 PENDING 还是 UNSUPPORTED
*/
public boolean isSupported(String contentType, String ext) {
if (renderers == null || renderers.isEmpty()) {
return false;
}
return renderers.stream().anyMatch(r -> r.supports(contentType, ext));
}
/**
* 异步生成缩略图@Async 保证不阻塞上传链路
*
* @param fileId 文件主键
*/
@Async
public void generate(Long fileId) {
generateWithLock(fileId);
}
/**
* 同步兜底尝试获锁后立即生成首次请求缩略图时异步任务未完成走此路径
*
* @param fileId 文件主键
* @return true 表示获锁成功且生成逻辑已执行完毕最终状态由调用方重查
* false 表示获锁失败另有生成正在进行调用方应轮询等待
*/
public boolean tryGenerateSync(Long fileId) {
return generateWithLock(fileId);
}
/**
* 分布式锁保护下执行生成异步任务与同步兜底共用保证并发只生成一次
*/
private boolean generateWithLock(Long fileId) {
String lockKey = FileConstants.THUMBNAIL_LOCK_PREFIX + fileId;
// 锁 TTL 须大于最慢渲染(officeConvertTimeout),否则慢渲染期间锁过期导致重复生成
Duration lockTtl = fileProperties.getThumbnail().getLockTtl();
Boolean acquired;
try {
acquired = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", lockTtl);
} catch (Exception e) {
log.warn("缩略图锁获取异常,跳过本次生成,fileId={}", fileId, e);
return false;
}
if (!Boolean.TRUE.equals(acquired)) {
return false;
}
try {
generateSync(fileId);
return true;
} finally {
redisTemplate.delete(lockKey);
}
}
/**
* 核心生成逻辑无锁 {@link #generateWithLock} 保护
*/
void generateSync(Long fileId) {
FileInfo info = fileInfoService.getById(fileId);
if (info == null) {
log.warn("缩略图生成跳过:文件不存在,fileId={}", fileId);
return;
}
// 已处理过的不重复生成
String status = info.getThumbnailStatus();
if (!FileConstants.THUMBNAIL_STATUS_PENDING.equals(status)) {
log.debug("缩略图生成跳过:状态={},fileId={}", status, fileId);
return;
}
String ext = extOf(info.getOriginalName());
ThumbnailRenderer renderer = findRenderer(info.getContentType(), ext);
if (renderer == null) {
info.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_UNSUPPORTED);
fileInfoService.updateById(info);
log.info("缩略图标记 UNSUPPORTED:fileId={}, ext={}", fileId, ext);
return;
}
String thumbKey = FileConstants.THUMBNAIL_PREFIX + fileId + ".jpg";
try (InputStream original = minioClient.getObject(GetObjectArgs.builder()
.bucket(fileProperties.getMinio().getBucket())
.object(info.getObjectKey())
.build())) {
byte[] thumbnail = renderer.render(original, ext,
fileProperties.getThumbnail().getWidth(),
fileProperties.getThumbnail().getMaxHeight());
minioClient.putObject(PutObjectArgs.builder()
.bucket(fileProperties.getMinio().getBucket())
.object(thumbKey)
.stream(new ByteArrayInputStream(thumbnail), thumbnail.length, -1)
.contentType("image/jpeg")
.build());
info.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_READY);
info.setThumbnailRetryCount(0);
fileInfoService.updateById(info);
log.info("缩略图生成成功:fileId={}, objectKey={}", fileId, thumbKey);
} catch (Exception e) {
log.error("缩略图生成失败:fileId={}, thumbKey={}", fileId, thumbKey, e);
// 失败重试语义(票07):retry_count++,未耗尽保持 PENDING 等下次触发,耗尽置 FAILED
int retryCount = info.getThumbnailRetryCount() == null ? 0 : info.getThumbnailRetryCount();
retryCount++;
info.setThumbnailRetryCount(retryCount);
if (retryCount >= fileProperties.getThumbnail().getRetryLimit()) {
info.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_FAILED);
log.warn("缩略图重试次数耗尽,标记 FAILED:fileId={}, retryCount={}", fileId, retryCount);
} else {
info.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_PENDING);
}
fileInfoService.updateById(info);
}
}
private ThumbnailRenderer findRenderer(String contentType, String ext) {
if (renderers == null) {
return null;
}
return renderers.stream()
.filter(r -> r.supports(contentType, ext))
.findFirst()
.orElse(null);
}
private String extOf(String originalName) {
if (originalName == null) {
return "";
}
int dot = originalName.lastIndexOf('.');
if (dot < 0 || dot == originalName.length() - 1) {
return "";
}
return originalName.substring(dot + 1).toLowerCase();
}
}

48
crm-file/src/main/java/com/crm/file/task/ThumbnailRetryTask.java

@ -0,0 +1,48 @@
package com.crm.file.task;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.crm.file.constant.FileConstants;
import com.crm.file.domain.entity.FileInfo;
import com.crm.file.service.IFileInfoService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 缩略图失败重试定时任务
* <p>扫描 thumbnail_status = FAILED 的记录重置为 PENDING 并重新提交异步生成
* LibreOffice 等渲染引擎临时故障恢复后下个周期自动补齐缩略图
* 任务自身异常不致命try-catch 包裹记日志下个周期继续复用孤儿分片清理任务模式</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ThumbnailRetryTask {
private final IFileInfoService fileInfoService;
private final ThumbnailGenerationTask thumbnailGenerationTask;
@Scheduled(cron = "${crm.file.thumbnail.retry-cron:0 0 4 * * ?}")
public void retryFailedThumbnails() {
try {
List<FileInfo> failed = fileInfoService.list(new LambdaQueryWrapper<FileInfo>()
.eq(FileInfo::getThumbnailStatus, FileConstants.THUMBNAIL_STATUS_FAILED));
if (failed.isEmpty()) {
return;
}
for (FileInfo info : failed) {
// 重置 PENDING + 重试计数归零(重新获得完整重试额度)后提交异步生成
info.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_PENDING);
info.setThumbnailRetryCount(0);
fileInfoService.updateById(info);
thumbnailGenerationTask.generate(info.getId());
}
log.info("缩略图失败重试:已重新提交 {} 条 FAILED 记录", failed.size());
} catch (Exception e) {
log.error("缩略图失败重试任务执行异常,下个周期继续", e);
}
}
}

261
crm-file/src/test/java/com/crm/file/service/impl/FileApiImplTest.java

@ -10,10 +10,13 @@ import com.crm.file.constant.FileConstants;
import com.crm.file.domain.dto.FileDownloadDTO; import com.crm.file.domain.dto.FileDownloadDTO;
import com.crm.file.domain.dto.FileInfoDTO; import com.crm.file.domain.dto.FileInfoDTO;
import com.crm.file.domain.dto.MultipartInitDTO; import com.crm.file.domain.dto.MultipartInitDTO;
import com.crm.file.domain.dto.ThumbnailDTO;
import com.crm.file.domain.dto.UploadSession; import com.crm.file.domain.dto.UploadSession;
import com.crm.file.domain.entity.FileInfo; import com.crm.file.domain.entity.FileInfo;
import com.crm.file.service.IFileInfoService; import com.crm.file.service.IFileInfoService;
import com.crm.file.service.KkFileViewClient; import com.crm.file.service.KkFileViewClient;
import com.crm.file.service.ThumbnailPlaceholderService;
import com.crm.file.task.ThumbnailGenerationTask;
import io.minio.ComposeObjectArgs; import io.minio.ComposeObjectArgs;
import io.minio.GetObjectArgs; import io.minio.GetObjectArgs;
import io.minio.GetObjectResponse; import io.minio.GetObjectResponse;
@ -76,6 +79,10 @@ class FileApiImplTest {
private ValueOperations<String, Object> valueOperations; private ValueOperations<String, Object> valueOperations;
@Mock @Mock
private KkFileViewClient kkFileViewClient; private KkFileViewClient kkFileViewClient;
@Mock
private ThumbnailPlaceholderService thumbnailPlaceholderService;
@Mock
private ThumbnailGenerationTask thumbnailGenerationTask;
private FileProperties fileProperties; private FileProperties fileProperties;
@ -87,7 +94,7 @@ class FileApiImplTest {
fileProperties = new FileProperties(); fileProperties = new FileProperties();
fileProperties.getMinio().setBucket("crm"); fileProperties.getMinio().setBucket("crm");
fileApi = new FileApiImpl(minioClient, fileProperties, fileInfoService, identifierGenerator, fileApi = new FileApiImpl(minioClient, fileProperties, fileInfoService, identifierGenerator,
redisTemplate, kkFileViewClient); redisTemplate, kkFileViewClient, thumbnailPlaceholderService, thumbnailGenerationTask);
} }
private InputStream streamOf(String content) { private InputStream streamOf(String content) {
@ -228,6 +235,84 @@ class FileApiImplTest {
e -> assertThat(e.getCode()).isEqualTo(FileConstants.CODE_FILE_SIZE_EXCEEDED)); e -> assertThat(e.getCode()).isEqualTo(FileConstants.CODE_FILE_SIZE_EXCEEDED));
} }
/*-------- 缩略图触发:上传后 PENDING/UNSUPPORTED --------*/
@Test
@DisplayName("上传图片 -> isSupported=true -> thumbnail_status=PENDING + 提交异步生成")
void upload_image_triggersThumbnailGeneration() throws Exception {
when(identifierGenerator.nextId(any())).thenReturn(FILE_ID);
when(thumbnailGenerationTask.isSupported("image/png", "png")).thenReturn(true);
fileApi.upload(streamOf("x"), 1L, "photo.png", "image/png", BIZ_DOMAIN);
ArgumentCaptor<FileInfo> saveCap = ArgumentCaptor.forClass(FileInfo.class);
verify(fileInfoService).save(saveCap.capture());
assertThat(saveCap.getValue().getThumbnailStatus()).isEqualTo(FileConstants.THUMBNAIL_STATUS_PENDING);
verify(thumbnailGenerationTask).generate(FILE_ID);
}
@Test
@DisplayName("上传 PDF -> isSupported=true -> thumbnail_status=PENDING + 提交异步生成")
void upload_pdf_triggersThumbnailGeneration() throws Exception {
when(identifierGenerator.nextId(any())).thenReturn(FILE_ID);
when(thumbnailGenerationTask.isSupported("application/pdf", "pdf")).thenReturn(true);
fileApi.upload(streamOf("x"), 1L, "contract.pdf", "application/pdf", BIZ_DOMAIN);
ArgumentCaptor<FileInfo> saveCap = ArgumentCaptor.forClass(FileInfo.class);
verify(fileInfoService).save(saveCap.capture());
assertThat(saveCap.getValue().getThumbnailStatus()).isEqualTo(FileConstants.THUMBNAIL_STATUS_PENDING);
verify(thumbnailGenerationTask).generate(FILE_ID);
}
@Test
@DisplayName("上传 Office(docx)-> isSupported=true -> thumbnail_status=PENDING + 提交异步生成")
void upload_office_triggersThumbnailGeneration() throws Exception {
when(identifierGenerator.nextId(any())).thenReturn(FILE_ID);
when(thumbnailGenerationTask.isSupported(any(), org.mockito.ArgumentMatchers.eq("docx"))).thenReturn(true);
fileApi.upload(streamOf("x"), 1L, "report.docx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", BIZ_DOMAIN);
ArgumentCaptor<FileInfo> saveCap = ArgumentCaptor.forClass(FileInfo.class);
verify(fileInfoService).save(saveCap.capture());
assertThat(saveCap.getValue().getThumbnailStatus()).isEqualTo(FileConstants.THUMBNAIL_STATUS_PENDING);
verify(thumbnailGenerationTask).generate(FILE_ID);
}
@Test
@DisplayName("上传非图片(txt)-> isSupported=false -> thumbnail_status=UNSUPPORTED + 不提交异步生成")
void upload_nonImage_marksUnsupported() throws Exception {
when(identifierGenerator.nextId(any())).thenReturn(FILE_ID);
fileApi.upload(streamOf("x"), 1L, "data.txt", null, BIZ_DOMAIN);
ArgumentCaptor<FileInfo> saveCap = ArgumentCaptor.forClass(FileInfo.class);
verify(fileInfoService).save(saveCap.capture());
assertThat(saveCap.getValue().getThumbnailStatus()).isEqualTo(FileConstants.THUMBNAIL_STATUS_UNSUPPORTED);
verify(thumbnailGenerationTask, never()).generate(anyLong());
}
@Test
@DisplayName("分片合并图片 -> isSupported=true -> thumbnail_status=PENDING + 提交异步生成")
void completeMultipart_image_triggersThumbnailGeneration() throws Exception {
UploadSession session = buildSession();
session.setOriginalName("photo.jpg");
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
when(valueOperations.get(SESSION_KEY)).thenReturn(session);
stubUploadedChunks("chunks/" + UPLOAD_ID + "/1", "chunks/" + UPLOAD_ID + "/2", "chunks/" + UPLOAD_ID + "/3");
when(identifierGenerator.nextId(any())).thenReturn(FILE_ID);
when(minioClient.removeObjects(any(RemoveObjectsArgs.class))).thenReturn(List.of());
when(thumbnailGenerationTask.isSupported(any(), any())).thenReturn(true);
fileApi.completeMultipart(UPLOAD_ID);
ArgumentCaptor<FileInfo> saveCap = ArgumentCaptor.forClass(FileInfo.class);
verify(fileInfoService).save(saveCap.capture());
assertThat(saveCap.getValue().getThumbnailStatus()).isEqualTo(FileConstants.THUMBNAIL_STATUS_PENDING);
verify(thumbnailGenerationTask).generate(FILE_ID);
}
/*-------- getInfo:详情查询 --------*/ /*-------- getInfo:详情查询 --------*/
@Test @Test
@ -396,6 +481,180 @@ class FileApiImplTest {
verify(minioClient, never()).getPresignedObjectUrl(any(GetPresignedObjectUrlArgs.class)); verify(minioClient, never()).getPresignedObjectUrl(any(GetPresignedObjectUrlArgs.class));
} }
/*-------- getThumbnail:缩略图读路径 --------*/
private static final byte[] THUMB_BYTES = "thumbnail-data".getBytes(StandardCharsets.UTF_8);
private static final byte[] PLACEHOLDER_BYTES = "placeholder-data".getBytes(StandardCharsets.UTF_8);
@Test
@DisplayName("READY 状态 -> 从 MinIO 拉缩略图,返回 image/jpeg + cacheable=true")
void getThumbnail_ready_returnsFromMinio() throws Exception {
FileInfo entity = new FileInfo();
entity.setId(FILE_ID);
entity.setOriginalName("photo.jpg");
entity.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_READY);
when(fileInfoService.getById(FILE_ID)).thenReturn(entity);
GetObjectResponse mockStream = mock(GetObjectResponse.class);
when(mockStream.readAllBytes()).thenReturn(THUMB_BYTES);
when(minioClient.getObject(any(GetObjectArgs.class))).thenReturn(mockStream);
ThumbnailDTO dto = fileApi.getThumbnail(String.valueOf(FILE_ID));
ArgumentCaptor<GetObjectArgs> getCap = ArgumentCaptor.forClass(GetObjectArgs.class);
verify(minioClient).getObject(getCap.capture());
assertThat(getCap.getValue().bucket()).isEqualTo("crm");
assertThat(getCap.getValue().object()).isEqualTo(FileConstants.THUMBNAIL_PREFIX + FILE_ID + ".jpg");
assertThat(dto.getContent()).isEqualTo(THUMB_BYTES);
assertThat(dto.getContentType()).isEqualTo("image/jpeg");
assertThat(dto.isCacheable()).isTrue();
verify(thumbnailPlaceholderService, never()).getPlaceholder(any());
}
@Test
@DisplayName("UNSUPPORTED 状态 -> 返回占位图 + cacheable=false,不触碰 MinIO 缩略图路径")
void getThumbnail_unsupported_returnsPlaceholder() {
FileInfo entity = new FileInfo();
entity.setId(FILE_ID);
entity.setOriginalName("data.txt");
entity.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_UNSUPPORTED);
when(fileInfoService.getById(FILE_ID)).thenReturn(entity);
when(thumbnailPlaceholderService.getPlaceholder("txt")).thenReturn(PLACEHOLDER_BYTES);
ThumbnailDTO dto = fileApi.getThumbnail(String.valueOf(FILE_ID));
assertThat(dto.getContent()).isEqualTo(PLACEHOLDER_BYTES);
assertThat(dto.getContentType()).isEqualTo("image/png");
assertThat(dto.isCacheable()).isFalse();
verifyNoInteractions(minioClient);
}
@Test
@DisplayName("FAILED 状态 -> 返回占位图 + cacheable=false")
void getThumbnail_failed_returnsPlaceholder() {
FileInfo entity = new FileInfo();
entity.setId(FILE_ID);
entity.setOriginalName("report.pdf");
entity.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_FAILED);
when(fileInfoService.getById(FILE_ID)).thenReturn(entity);
when(thumbnailPlaceholderService.getPlaceholder("pdf")).thenReturn(PLACEHOLDER_BYTES);
ThumbnailDTO dto = fileApi.getThumbnail(String.valueOf(FILE_ID));
assertThat(dto.getContent()).isEqualTo(PLACEHOLDER_BYTES);
assertThat(dto.getContentType()).isEqualTo("image/png");
assertThat(dto.isCacheable()).isFalse();
verifyNoInteractions(minioClient);
}
@Test
@DisplayName("PENDING + 获锁成功同步生成 -> 重查 READY -> 返回真缩略图 + cacheable=true")
void getThumbnail_pending_syncFallback_generatesAndReturns() throws Exception {
FileInfo pending = new FileInfo();
pending.setId(FILE_ID);
pending.setOriginalName("photo.jpg");
pending.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_PENDING);
FileInfo ready = new FileInfo();
ready.setId(FILE_ID);
ready.setOriginalName("photo.jpg");
ready.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_READY);
// 首次查 PENDING,同步生成后重查 READY
when(fileInfoService.getById(FILE_ID)).thenReturn(pending, ready);
when(thumbnailGenerationTask.tryGenerateSync(FILE_ID)).thenReturn(true);
GetObjectResponse mockStream = mock(GetObjectResponse.class);
when(mockStream.readAllBytes()).thenReturn(THUMB_BYTES);
when(minioClient.getObject(any(GetObjectArgs.class))).thenReturn(mockStream);
ThumbnailDTO dto = fileApi.getThumbnail(String.valueOf(FILE_ID));
assertThat(dto.getContent()).isEqualTo(THUMB_BYTES);
assertThat(dto.getContentType()).isEqualTo("image/jpeg");
assertThat(dto.isCacheable()).isTrue();
assertThat(dto.getStatusCode()).isEqualTo(200);
verify(thumbnailGenerationTask).tryGenerateSync(FILE_ID);
}
@Test
@DisplayName("PENDING + 获锁失败 + 轮询超时仍 PENDING -> 占位图 + no-cache + statusCode=202")
void getThumbnail_pending_lockFailed_timeoutReturns202() {
fileProperties.getThumbnail().setSyncWaitTimeout(java.time.Duration.ofMillis(100));
FileInfo entity = new FileInfo();
entity.setId(FILE_ID);
entity.setOriginalName("photo.jpg");
entity.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_PENDING);
when(fileInfoService.getById(FILE_ID)).thenReturn(entity);
when(thumbnailGenerationTask.tryGenerateSync(FILE_ID)).thenReturn(false);
when(thumbnailPlaceholderService.getPlaceholder("jpg")).thenReturn(PLACEHOLDER_BYTES);
ThumbnailDTO dto = fileApi.getThumbnail(String.valueOf(FILE_ID));
assertThat(dto.getContent()).isEqualTo(PLACEHOLDER_BYTES);
assertThat(dto.getContentType()).isEqualTo("image/png");
assertThat(dto.isCacheable()).isFalse();
assertThat(dto.getStatusCode()).isEqualTo(202);
}
@Test
@DisplayName("PENDING + 获锁失败 + 等待期间异步任务完成(READY)-> 返回真缩略图")
void getThumbnail_pending_lockFailed_readyDuringWait_returnsThumbnail() throws Exception {
fileProperties.getThumbnail().setSyncWaitTimeout(java.time.Duration.ofSeconds(5));
FileInfo pending = new FileInfo();
pending.setId(FILE_ID);
pending.setOriginalName("photo.jpg");
pending.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_PENDING);
FileInfo ready = new FileInfo();
ready.setId(FILE_ID);
ready.setOriginalName("photo.jpg");
ready.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_READY);
// 首查 PENDING,轮询时异步任务已完成变 READY
when(fileInfoService.getById(FILE_ID)).thenReturn(pending, ready);
when(thumbnailGenerationTask.tryGenerateSync(FILE_ID)).thenReturn(false);
GetObjectResponse mockStream = mock(GetObjectResponse.class);
when(mockStream.readAllBytes()).thenReturn(THUMB_BYTES);
when(minioClient.getObject(any(GetObjectArgs.class))).thenReturn(mockStream);
ThumbnailDTO dto = fileApi.getThumbnail(String.valueOf(FILE_ID));
assertThat(dto.getContent()).isEqualTo(THUMB_BYTES);
assertThat(dto.isCacheable()).isTrue();
assertThat(dto.getStatusCode()).isEqualTo(200);
}
@Test
@DisplayName("PENDING + 获锁成功但生成后仍非 READY(生成失败)-> 占位图 + 200")
void getThumbnail_pending_syncGenerateFailed_returnsPlaceholder() {
FileInfo entity = new FileInfo();
entity.setId(FILE_ID);
entity.setOriginalName("photo.jpg");
entity.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_PENDING);
when(fileInfoService.getById(FILE_ID)).thenReturn(entity);
when(thumbnailGenerationTask.tryGenerateSync(FILE_ID)).thenReturn(true);
when(thumbnailPlaceholderService.getPlaceholder("jpg")).thenReturn(PLACEHOLDER_BYTES);
ThumbnailDTO dto = fileApi.getThumbnail(String.valueOf(FILE_ID));
assertThat(dto.getContent()).isEqualTo(PLACEHOLDER_BYTES);
assertThat(dto.isCacheable()).isFalse();
assertThat(dto.getStatusCode()).isEqualTo(200);
verifyNoInteractions(minioClient);
}
@Test
@DisplayName("缩略图查无(文件不存在) -> 40401")
void getThumbnail_notFound_throwsResourceNotExist() {
when(fileInfoService.getById(FILE_ID)).thenReturn(null);
assertThatThrownBy(() -> fileApi.getThumbnail(String.valueOf(FILE_ID)))
.isInstanceOf(ResourceNotExistException.class);
verifyNoInteractions(minioClient, thumbnailPlaceholderService);
}
@Test
@DisplayName("缩略图 fileId 非法格式 -> 40401")
void getThumbnail_malformedFileId_throwsResourceNotExist() {
assertThatThrownBy(() -> fileApi.getThumbnail("not-a-number"))
.isInstanceOf(ResourceNotExistException.class);
verify(fileInfoService, never()).getById(anyLong());
}
/*-------- 分片上传三段式 --------*/ /*-------- 分片上传三段式 --------*/
private static final String UPLOAD_ID = "u-0001"; private static final String UPLOAD_ID = "u-0001";

99
crm-file/src/test/java/com/crm/file/service/impl/ImageThumbnailRendererTest.java

@ -0,0 +1,99 @@
package com.crm.file.service.impl;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* {@link ImageThumbnailRenderer} 单元测试票03等比缩放不变形上限高度约束JPEG 输出supports 判定
*/
class ImageThumbnailRendererTest {
private final ImageThumbnailRenderer renderer = new ImageThumbnailRenderer();
/** 内存生成指定尺寸的 PNG 字节流 */
private byte[] pngOf(int w, int h) throws Exception {
BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
Graphics2D g = img.createGraphics();
try {
g.setColor(Color.BLUE);
g.fillRect(0, 0, w, h);
} finally {
g.dispose();
}
ByteArrayOutputStream out = new ByteArrayOutputStream();
ImageIO.write(img, "png", out);
return out.toByteArray();
}
@Test
@DisplayName("宽图 1000x500 -> 等比缩放至宽 200,高 100,输出 JPEG 可解码")
void render_wideImage_proportionalScale() throws Exception {
byte[] thumb = renderer.render(new ByteArrayInputStream(pngOf(1000, 500)), "png", 200, 400);
BufferedImage decoded = ImageIO.read(new ByteArrayInputStream(thumb));
assertThat(decoded).isNotNull();
assertThat(decoded.getWidth()).isEqualTo(200);
assertThat(decoded.getHeight()).isEqualTo(100);
// JPEG 魔数:FF D8
assertThat(thumb[0] & 0xFF).isEqualTo(0xFF);
assertThat(thumb[1] & 0xFF).isEqualTo(0xD8);
}
@Test
@DisplayName("竖图 100x1000 -> 高度上限 400 约束生效,宽按等比缩至 40,不变形")
void render_tallImage_cappedByMaxHeight() throws Exception {
byte[] thumb = renderer.render(new ByteArrayInputStream(pngOf(100, 1000)), "png", 200, 400);
BufferedImage decoded = ImageIO.read(new ByteArrayInputStream(thumb));
assertThat(decoded).isNotNull();
assertThat(decoded.getHeight()).isEqualTo(400);
assertThat(decoded.getWidth()).isEqualTo(40);
}
@Test
@DisplayName("小图 50x50 -> 仍放大至宽 200(固定宽度语义)")
void render_smallImage_upscaledToWidth() throws Exception {
byte[] thumb = renderer.render(new ByteArrayInputStream(pngOf(50, 50)), "png", 200, 400);
BufferedImage decoded = ImageIO.read(new ByteArrayInputStream(thumb));
assertThat(decoded.getWidth()).isEqualTo(200);
assertThat(decoded.getHeight()).isEqualTo(200);
}
@Test
@DisplayName("无法解码的流 -> 抛 IllegalStateException")
void render_undecodable_throws() {
byte[] garbage = "not-an-image".getBytes();
assertThatThrownBy(() -> renderer.render(new ByteArrayInputStream(garbage), "png", 200, 400))
.isInstanceOf(IllegalStateException.class);
}
@Test
@DisplayName("supports:图片扩展名/image/* contentType 支持,svg/tiff/非图片不支持")
void supports_typeJudgement() {
assertThat(renderer.supports(null, "jpg")).isTrue();
assertThat(renderer.supports(null, "png")).isTrue();
assertThat(renderer.supports("image/png", null)).isTrue();
assertThat(renderer.supports("image/svg+xml", "svg")).isFalse();
assertThat(renderer.supports("image/tiff", null)).isFalse();
assertThat(renderer.supports("application/pdf", "pdf")).isFalse();
assertThat(renderer.supports(null, null)).isFalse();
}
@Test
@DisplayName("supports:webp 跟随运行时 ImageIO reader 可用性,不可用时落 UNSUPPORTED 防无限重试")
void supports_webp_followsRuntimeAvailability() {
boolean webpAvailable = ImageIO.getImageReadersByFormatName("webp").hasNext();
assertThat(renderer.supports(null, "webp")).isEqualTo(webpAvailable);
assertThat(renderer.supports("image/webp", null)).isEqualTo(webpAvailable);
}
}

112
crm-file/src/test/java/com/crm/file/service/impl/OfficeThumbnailRendererTest.java

@ -0,0 +1,112 @@
package com.crm.file.service.impl;
import com.crm.file.config.FileProperties;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* {@link OfficeThumbnailRenderer} 单元测试票06supports 判定LibreOffice 不可用走异常
* soffice 脚本模拟转换成功验证全链路落地 转换 缩放 JPEG 清理
*/
class OfficeThumbnailRendererTest {
@TempDir
Path tempDir;
private OfficeThumbnailRenderer rendererWith(String sofficePath, Duration timeout) {
FileProperties props = new FileProperties();
props.getThumbnail().setLibreofficePath(sofficePath);
props.getThumbnail().setOfficeConvertTimeout(timeout);
return new OfficeThumbnailRenderer(props);
}
private byte[] pngOf(int w, int h) throws Exception {
BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
Graphics2D g = img.createGraphics();
try {
g.setColor(Color.RED);
g.fillRect(0, 0, w, h);
} finally {
g.dispose();
}
ByteArrayOutputStream out = new ByteArrayOutputStream();
ImageIO.write(img, "png", out);
return out.toByteArray();
}
/** 创建假 soffice .cmd 脚本:把预制 PNG 复制到 --outdir(模拟 LibreOffice 转换产物) */
private Path fakeSoffice(Path preparedPng) throws Exception {
Path script = tempDir.resolve("fake-soffice.cmd");
String body = "@echo off\r\n"
+ "set OUT=\r\n"
+ ":loop\r\n"
+ "if \"%~1\"==\"\" goto done\r\n"
+ "if \"%~1\"==\"--outdir\" (set OUT=%~2)\r\n"
+ "shift\r\n"
+ "goto loop\r\n"
+ ":done\r\n"
+ "copy /y \"" + preparedPng.toAbsolutePath() + "\" \"%OUT%\\input.png\"\r\n"
+ "exit /b 0\r\n";
Files.writeString(script, body);
return script;
}
@Test
@DisplayName("supports:docx/xlsx/pptx/doc/xls/ppt 与 Office contentType 支持,其余不支持")
void supports_typeJudgement() {
OfficeThumbnailRenderer renderer = rendererWith("soffice", Duration.ofSeconds(1));
assertThat(renderer.supports(null, "docx")).isTrue();
assertThat(renderer.supports(null, "xlsx")).isTrue();
assertThat(renderer.supports(null, "pptx")).isTrue();
assertThat(renderer.supports(null, "DOC")).isTrue();
assertThat(renderer.supports("application/vnd.openxmlformats-officedocument.wordprocessingml.document", null)).isTrue();
assertThat(renderer.supports("application/msword", null)).isTrue();
assertThat(renderer.supports("application/pdf", "pdf")).isFalse();
assertThat(renderer.supports("image/png", "png")).isFalse();
assertThat(renderer.supports(null, null)).isFalse();
}
@Test
@DisplayName("LibreOffice 不可用(可执行文件不存在)-> 抛异常(走失败重试语义,不 crash)")
void render_sofficeMissing_throws() {
OfficeThumbnailRenderer renderer = rendererWith("definitely-not-exist-soffice-xyz", Duration.ofSeconds(5));
InputStream fakeDoc = new ByteArrayInputStream("fake-docx-content".getBytes());
assertThatThrownBy(() -> renderer.render(fakeDoc, "docx", 200, 400))
.isInstanceOf(Exception.class);
}
@Test
@DisplayName("转换成功(假 soffice 产出 PNG)-> 等比缩放输出 JPEG + 临时目录已清理")
void render_conversionSuccess_scalesToJpeg() throws Exception {
// 预制一张 800x600 PNG 作为 LibreOffice 的"转换产物"
Path preparedPng = tempDir.resolve("prepared.png");
Files.write(preparedPng, pngOf(800, 600));
Path script = fakeSoffice(preparedPng);
OfficeThumbnailRenderer renderer = rendererWith(script.toAbsolutePath().toString(), Duration.ofSeconds(30));
byte[] thumb = renderer.render(new ByteArrayInputStream("fake-docx".getBytes()), "docx", 200, 400);
BufferedImage decoded = ImageIO.read(new ByteArrayInputStream(thumb));
assertThat(decoded).isNotNull();
assertThat(decoded.getWidth()).isEqualTo(200);
assertThat(decoded.getHeight()).isEqualTo(150);
// JPEG 魔数
assertThat(thumb[0] & 0xFF).isEqualTo(0xFF);
assertThat(thumb[1] & 0xFF).isEqualTo(0xD8);
}
}

67
crm-file/src/test/java/com/crm/file/service/impl/PdfThumbnailRendererTest.java

@ -0,0 +1,67 @@
package com.crm.file.service.impl;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* {@link PdfThumbnailRenderer} 单元测试票05第一页渲染 + 等比缩放 + JPEG 输出 + supports 判定
*/
class PdfThumbnailRendererTest {
private final PdfThumbnailRenderer renderer = new PdfThumbnailRenderer();
/** 内存生成一页 A4 竖版 PDF */
private byte[] a4Pdf() throws Exception {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage(PDRectangle.A4));
ByteArrayOutputStream out = new ByteArrayOutputStream();
doc.save(out);
return out.toByteArray();
}
}
@Test
@DisplayName("A4 竖版 PDF -> 渲染第一页等比缩放至宽 200,输出 JPEG 可解码")
void render_a4Portrait_scalesToWidth200() throws Exception {
byte[] thumb = renderer.render(new ByteArrayInputStream(a4Pdf()), "pdf", 200, 400);
BufferedImage decoded = ImageIO.read(new ByteArrayInputStream(thumb));
assertThat(decoded).isNotNull();
// A4 竖版(595x842pt)渲染后宽高比约 1:1.414,宽 200 时高约 283,未触及 400 上限
assertThat(decoded.getWidth()).isEqualTo(200);
assertThat(decoded.getHeight()).isBetween(280, 290);
// JPEG 魔数:FF D8
assertThat(thumb[0] & 0xFF).isEqualTo(0xFF);
assertThat(thumb[1] & 0xFF).isEqualTo(0xD8);
}
@Test
@DisplayName("非 PDF 字节流 -> 抛异常(PDFBox 解析失败)")
void render_garbage_throws() {
byte[] garbage = "not-a-pdf".getBytes();
assertThatThrownBy(() -> renderer.render(new ByteArrayInputStream(garbage), "pdf", 200, 400))
.isInstanceOf(Exception.class);
}
@Test
@DisplayName("supports:pdf 扩展名/application/pdf 支持,其余不支持")
void supports_typeJudgement() {
assertThat(renderer.supports(null, "pdf")).isTrue();
assertThat(renderer.supports(null, "PDF")).isTrue();
assertThat(renderer.supports("application/pdf", null)).isTrue();
assertThat(renderer.supports("image/png", "png")).isFalse();
assertThat(renderer.supports("text/plain", "txt")).isFalse();
assertThat(renderer.supports(null, null)).isFalse();
}
}

261
crm-file/src/test/java/com/crm/file/task/ThumbnailGenerationTaskTest.java

@ -0,0 +1,261 @@
package com.crm.file.task;
import com.crm.file.config.FileProperties;
import com.crm.file.constant.FileConstants;
import com.crm.file.domain.entity.FileInfo;
import com.crm.file.service.IFileInfoService;
import com.crm.file.service.ThumbnailRenderer;
import io.minio.GetObjectArgs;
import io.minio.GetObjectResponse;
import io.minio.MinioClient;
import io.minio.PutObjectArgs;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import java.io.InputStream;
import java.time.Duration;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
* {@link ThumbnailGenerationTask} 单元测试票03
* 生成成功写 MinIO + 状态 READY + retry_count 归零 PENDING 跳过无渲染器置 UNSUPPORTED
*/
@ExtendWith(MockitoExtension.class)
class ThumbnailGenerationTaskTest {
private static final Long FILE_ID = 1001L;
private static final byte[] THUMB_BYTES = "thumbnail-data".getBytes();
@Mock
private MinioClient minioClient;
@Mock
private IFileInfoService fileInfoService;
@Mock
private ThumbnailRenderer renderer;
@Mock
private RedisTemplate<String, Object> redisTemplate;
@Mock
private ValueOperations<String, Object> valueOperations;
private FileProperties fileProperties;
private ThumbnailGenerationTask task;
@BeforeEach
void setUp() {
fileProperties = new FileProperties();
fileProperties.getMinio().setBucket("crm");
task = new ThumbnailGenerationTask(minioClient, fileProperties, fileInfoService, List.of(renderer),
redisTemplate);
}
/** 默认打桩:获锁成功 */
private void stubLockAcquired(boolean acquired) {
org.mockito.Mockito.lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations);
org.mockito.Mockito.lenient().when(valueOperations.setIfAbsent(anyString(), any(), any(Duration.class)))
.thenReturn(acquired);
}
private FileInfo pendingInfo() {
FileInfo info = new FileInfo();
info.setId(FILE_ID);
info.setOriginalName("photo.png");
info.setContentType("image/png");
info.setObjectKey("files/photo.png");
info.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_PENDING);
info.setThumbnailRetryCount(2);
return info;
}
@Test
@DisplayName("生成成功 -> 写 MinIO thumbnails/{fileId}.jpg + 状态 READY + retry_count 归零 + 释放锁")
void generate_success_writesMinioAndMarksReady() throws Exception {
stubLockAcquired(true);
FileInfo info = pendingInfo();
when(fileInfoService.getById(FILE_ID)).thenReturn(info);
when(renderer.supports("image/png", "png")).thenReturn(true);
GetObjectResponse mockStream = mock(GetObjectResponse.class);
when(minioClient.getObject(any(GetObjectArgs.class))).thenReturn(mockStream);
when(renderer.render(any(InputStream.class), anyString(), anyInt(), anyInt())).thenReturn(THUMB_BYTES);
task.generate(FILE_ID);
// 缩略图写入 MinIO:objectKey = thumbnails/{fileId}.jpg,内容类型 image/jpeg
ArgumentCaptor<PutObjectArgs> putCap = ArgumentCaptor.forClass(PutObjectArgs.class);
verify(minioClient).putObject(putCap.capture());
assertThat(putCap.getValue().bucket()).isEqualTo("crm");
assertThat(putCap.getValue().object()).isEqualTo("thumbnails/1001.jpg");
assertThat(putCap.getValue().contentType()).isEqualTo("image/jpeg");
// 状态置 READY + retry_count 归零
ArgumentCaptor<FileInfo> updateCap = ArgumentCaptor.forClass(FileInfo.class);
verify(fileInfoService).updateById(updateCap.capture());
assertThat(updateCap.getValue().getThumbnailStatus()).isEqualTo(FileConstants.THUMBNAIL_STATUS_READY);
assertThat(updateCap.getValue().getThumbnailRetryCount()).isZero();
// 生成完毕后释放锁
verify(redisTemplate).delete("crm:file:thumbnail:lock:1001");
}
@Test
@DisplayName("获锁失败(另有生成在跑)-> 返回 false,不执行生成逻辑")
void generate_lockNotAcquired_skips() {
stubLockAcquired(false);
assertThat(task.tryGenerateSync(FILE_ID)).isFalse();
verify(fileInfoService, never()).getById(any());
verifyNoInteractions(minioClient);
}
@Test
@DisplayName("并发两路 -> 分布式锁 SET NX 保证只有一路执行生成")
void generate_concurrent_lockEnsuresSingleGeneration() throws Exception {
stubLockAcquired(true);
FileInfo info = pendingInfo();
when(fileInfoService.getById(FILE_ID)).thenReturn(info);
when(renderer.supports("image/png", "png")).thenReturn(true);
GetObjectResponse mockStream = mock(GetObjectResponse.class);
when(minioClient.getObject(any(GetObjectArgs.class))).thenReturn(mockStream);
when(renderer.render(any(InputStream.class), anyString(), anyInt(), anyInt())).thenReturn(THUMB_BYTES);
assertThat(task.tryGenerateSync(FILE_ID)).isTrue();
// 锁以 NX 语义获取:同一 key 的第二次尝试由 setIfAbsent 返回 false 拦截(打桩模拟)
ArgumentCaptor<String> lockKeyCap = ArgumentCaptor.forClass(String.class);
verify(valueOperations).setIfAbsent(lockKeyCap.capture(), any(), any(Duration.class));
assertThat(lockKeyCap.getValue()).isEqualTo("crm:file:thumbnail:lock:1001");
// 只生成了一次
verify(minioClient).putObject(any(PutObjectArgs.class));
}
@Test
@DisplayName("文件不存在 -> 静默跳过,不触 MinIO")
void generate_fileNotFound_skips() {
stubLockAcquired(true);
when(fileInfoService.getById(FILE_ID)).thenReturn(null);
task.generate(FILE_ID);
verifyNoInteractions(minioClient);
verify(fileInfoService, never()).updateById(any());
}
@Test
@DisplayName("状态非 PENDING(已 READY)-> 跳过不重复生成")
void generate_alreadyReady_skips() {
stubLockAcquired(true);
FileInfo info = pendingInfo();
info.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_READY);
when(fileInfoService.getById(FILE_ID)).thenReturn(info);
task.generate(FILE_ID);
verifyNoInteractions(minioClient);
verify(fileInfoService, never()).updateById(any());
}
@Test
@DisplayName("无匹配渲染器 -> 状态置 UNSUPPORTED")
void generate_noRenderer_marksUnsupported() {
stubLockAcquired(true);
FileInfo info = pendingInfo();
when(fileInfoService.getById(FILE_ID)).thenReturn(info);
when(renderer.supports("image/png", "png")).thenReturn(false);
task.generate(FILE_ID);
ArgumentCaptor<FileInfo> updateCap = ArgumentCaptor.forClass(FileInfo.class);
verify(fileInfoService).updateById(updateCap.capture());
assertThat(updateCap.getValue().getThumbnailStatus())
.isEqualTo(FileConstants.THUMBNAIL_STATUS_UNSUPPORTED);
verifyNoInteractions(minioClient);
}
@Test
@DisplayName("isSupported 委托渲染器列表:任一 supports=true 即支持")
void isSupported_delegatesToRenderers() {
when(renderer.supports("image/png", "png")).thenReturn(true);
assertThat(task.isSupported("image/png", "png")).isTrue();
when(renderer.supports("text/plain", "txt")).thenReturn(false);
assertThat(task.isSupported("text/plain", "txt")).isFalse();
}
/*-------- 票07:失败重试语义 --------*/
private void stubRenderThrows() throws Exception {
stubLockAcquired(true);
FileInfo info = pendingInfo();
when(fileInfoService.getById(FILE_ID)).thenReturn(info);
when(renderer.supports("image/png", "png")).thenReturn(true);
GetObjectResponse mockStream = mock(GetObjectResponse.class);
when(minioClient.getObject(any(GetObjectArgs.class))).thenReturn(mockStream);
when(renderer.render(any(InputStream.class), anyString(), anyInt(), anyInt()))
.thenThrow(new IllegalStateException("渲染失败"));
}
@Test
@DisplayName("生成失败 + retry_count(2) < retry-limit(5) -> retry_count++ 保持 PENDING")
void generate_failure_belowLimit_staysPending() throws Exception {
stubRenderThrows();
// 调高 limit,确保 2+1=3 未耗尽
fileProperties.getThumbnail().setRetryLimit(5);
task.generate(FILE_ID);
ArgumentCaptor<FileInfo> updateCap = ArgumentCaptor.forClass(FileInfo.class);
verify(fileInfoService).updateById(updateCap.capture());
assertThat(updateCap.getValue().getThumbnailRetryCount()).isEqualTo(3);
assertThat(updateCap.getValue().getThumbnailStatus()).isEqualTo(FileConstants.THUMBNAIL_STATUS_PENDING);
}
@Test
@DisplayName("生成失败 + 重试耗尽(retry_count 达 retry-limit)-> 置 FAILED")
void generate_failure_limitReached_marksFailed() throws Exception {
stubRenderThrows();
// pendingInfo() 默认 retry_count=2,失败后 +1 = 3 = retry-limit,耗尽
fileProperties.getThumbnail().setRetryLimit(3);
task.generate(FILE_ID);
ArgumentCaptor<FileInfo> updateCap = ArgumentCaptor.forClass(FileInfo.class);
verify(fileInfoService).updateById(updateCap.capture());
assertThat(updateCap.getValue().getThumbnailRetryCount()).isEqualTo(3);
assertThat(updateCap.getValue().getThumbnailStatus()).isEqualTo(FileConstants.THUMBNAIL_STATUS_FAILED);
}
@Test
@DisplayName("MinIO 拉原文件异常 -> 同样计入重试,不 crash")
void generate_minioFailure_countsRetry() throws Exception {
stubLockAcquired(true);
FileInfo info = pendingInfo();
info.setThumbnailRetryCount(0);
when(fileInfoService.getById(FILE_ID)).thenReturn(info);
when(renderer.supports("image/png", "png")).thenReturn(true);
when(minioClient.getObject(any(GetObjectArgs.class))).thenThrow(new RuntimeException("MinIO 不可用"));
task.generate(FILE_ID);
ArgumentCaptor<FileInfo> updateCap = ArgumentCaptor.forClass(FileInfo.class);
verify(fileInfoService).updateById(updateCap.capture());
assertThat(updateCap.getValue().getThumbnailRetryCount()).isEqualTo(1);
assertThat(updateCap.getValue().getThumbnailStatus()).isEqualTo(FileConstants.THUMBNAIL_STATUS_PENDING);
}
}

84
crm-file/src/test/java/com/crm/file/task/ThumbnailRetryTaskTest.java

@ -0,0 +1,84 @@
package com.crm.file.task;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.crm.file.constant.FileConstants;
import com.crm.file.domain.entity.FileInfo;
import com.crm.file.service.IFileInfoService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import static org.assertj.core.api.Assertions.assertThat;
/**
* {@link ThumbnailRetryTask} 单元测试票07FAILED 记录重置 PENDING + 重新提交自身异常不 crash
*/
@ExtendWith(MockitoExtension.class)
class ThumbnailRetryTaskTest {
@Mock
private IFileInfoService fileInfoService;
@Mock
private ThumbnailGenerationTask thumbnailGenerationTask;
@InjectMocks
private ThumbnailRetryTask retryTask;
private FileInfo failedInfo(Long id) {
FileInfo info = new FileInfo();
info.setId(id);
info.setOriginalName("photo.png");
info.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_FAILED);
info.setThumbnailRetryCount(3);
return info;
}
@Test
@DisplayName("扫描 FAILED 记录 -> 重置 PENDING + retry_count 归零 + 提交异步生成")
void retry_resetsAndResubmits() {
when(fileInfoService.list(any(Wrapper.class))).thenReturn(List.of(failedInfo(101L), failedInfo(102L)));
retryTask.retryFailedThumbnails();
ArgumentCaptor<FileInfo> updateCap = ArgumentCaptor.forClass(FileInfo.class);
verify(fileInfoService, org.mockito.Mockito.times(2)).updateById(updateCap.capture());
for (FileInfo updated : updateCap.getAllValues()) {
assertThat(updated.getThumbnailStatus()).isEqualTo(FileConstants.THUMBNAIL_STATUS_PENDING);
assertThat(updated.getThumbnailRetryCount()).isZero();
}
verify(thumbnailGenerationTask).generate(101L);
verify(thumbnailGenerationTask).generate(102L);
}
@Test
@DisplayName("无 FAILED 记录 -> 静默结束,不提交任何任务")
void retry_noFailedRecords_noop() {
when(fileInfoService.list(any(Wrapper.class))).thenReturn(List.of());
retryTask.retryFailedThumbnails();
verify(fileInfoService, never()).updateById(any());
verifyNoInteractions(thumbnailGenerationTask);
}
@Test
@DisplayName("任务自身异常不 crash(查询抛异常被吞掉,下周期继续)")
void retry_queryThrows_swallowed() {
when(fileInfoService.list(any(Wrapper.class))).thenThrow(new RuntimeException("DB 不可用"));
assertThatCode(() -> retryTask.retryFailedThumbnails()).doesNotThrowAnyException();
verifyNoInteractions(thumbnailGenerationTask);
}
}

17
docs/adr/0013-thumbnail-architecture.md

@ -0,0 +1,17 @@
# 缩略图架构:异步生成 + 同步兜底 + 后端中转缓存
文件缩略图覆盖图片、PDF、Office 三类。采用混合生成策略:上传后异步生成 + 首次请求同步兜底,两路加分布式锁防重复。缩略图存 MinIO 同 bucket(`thumbnails/{fileId}.jpg`),通过后端 API 中转返回 + `Cache-Control: max-age=86400` 强缓存,浏览器不直接接触 MinIO。渲染引擎选 LibreOffice headless(Office)+ PDFBox(PDF)+ Java ImageIO(图片),部署在 Docker 镜像中。失败重试 3 次标记 `FAILED`,定时任务扫描重试;非视觉文件前置标记 `UNSUPPORTED` 跳过。
## Considered Options
- **纯懒生成(首次请求同步)**:无后台任务,第一个请求者等待。简单但首次体验差。
- **纯异步生成**:上传后后台任务跑,未就绪时返回占位图。需要前端处理两态,冷数据浪费资源。
- **presigned URL 直返**:前端直接从 MinIO 拉缩略图。违反"MinIO 不暴公网"铁则。
## Consequences
- Docker 镜像体积增加约 400-500MB(LibreOffice headless)。
- 需要 Redis 分布式锁(已有 Redis 基础设施)。锁 TTL 可配置(`crm.file.thumbnail.lock-ttl`,默认 150s),必须大于 LibreOffice 转换超时(`office-convert-timeout` 默认 120s),否则慢渲染期间锁过期导致并发重复生成。
- webp 依赖运行时 ImageIO reader 可用性探测,无插件时落 `UNSUPPORTED`(避免生成必然失败被定时任务无限重试)。
- `FileInfo` 表新增 `thumbnail_status``thumbnail_retry_count` 字段。
- 缩略图不变,缓存命中后零后端负载。但 `FAILED` 重试恢复后 objectKey 不变内容变,浏览器会命中旧占位图缓存。对策:占位图(`PENDING`/`FAILED`/`UNSUPPORTED`)返回时设 `Cache-Control: no-cache`;真缩略图(`READY`)返回时设 `max-age=86400`。占位图不被强缓存,真缩略图一旦就绪即被缓存住。

8
pom.xml

@ -39,6 +39,7 @@
<easyexcel.version>4.0.3</easyexcel.version> <easyexcel.version>4.0.3</easyexcel.version>
<guava.version>33.2.1-jre</guava.version> <guava.version>33.2.1-jre</guava.version>
<knife4j.version>4.5.0</knife4j.version> <knife4j.version>4.5.0</knife4j.version>
<pdfbox.version>3.0.3</pdfbox.version>
</properties> </properties>
<dependencyManagement> <dependencyManagement>
@ -101,6 +102,13 @@
<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId> <artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
<version>${knife4j.version}</version> <version>${knife4j.version}</version>
</dependency> </dependency>
<!-- PDF 渲染(缩略图第一页) -->
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>${pdfbox.version}</version>
</dependency>
</dependencies> </dependencies>
</dependencyManagement> </dependencyManagement>

Loading…
Cancel
Save