36 changed files with 2213 additions and 3 deletions
@ -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] 编译通过,现有测试不受影响 |
|||
@ -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 |
|||
@ -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 归零 |
|||
@ -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] 测试:并发两路(异步 + 同步兜底)→ 分布式锁保证只生成一次 |
|||
@ -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 |
|||
@ -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) |
|||
@ -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(日志告警,下周期继续) |
|||
@ -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,不影响现有调用方。 |
|||
@ -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"] |
|||
@ -1,13 +1,16 @@ |
|||
package com.crm.file.config; |
|||
|
|||
import org.springframework.context.annotation.Configuration; |
|||
import org.springframework.scheduling.annotation.EnableAsync; |
|||
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 |
|||
@EnableScheduling |
|||
@EnableAsync |
|||
public class SchedulingConfig { |
|||
} |
|||
|
|||
@ -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=86400;false 时设 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; |
|||
} |
|||
@ -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]; |
|||
} |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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/bmp;webp 需运行时 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); |
|||
} |
|||
} |
|||
@ -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 png;profile 隔离 + 超时保护 |
|||
*/ |
|||
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); |
|||
} |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
} |
|||
@ -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(); |
|||
} |
|||
} |
|||
} |
|||
@ -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(); |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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} 单元测试(票06):supports 判定、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); |
|||
} |
|||
} |
|||
@ -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(); |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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} 单元测试(票07):FAILED 记录重置 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); |
|||
} |
|||
} |
|||
@ -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`。占位图不被强缓存,真缩略图一旦就绪即被缓存住。 |
|||
Loading…
Reference in new issue