背景
原有架构
项目原有的缩略图生成策略为按需生成:
- 上传文件时,只上传原始文件
- 浏览文件列表时,前端请求缩略图 API
- 后端检查缩略图是否存在,不存在则下载源文件、生成缩略图、保存后返回
问题发现
我当时质疑:为什么只有视频在上传时生成缩略图,图片不生成?
初步分析认为按需生成是合理的:
- 上传后可能不会立即浏览
- 避免上传时的额外延迟
- 懒加载思想
但我意识到一个关键问题:
缩略图很小,可能就十几 KB,再次生成需要下载一整个原图,现代手机一张照片普遍十几兆,流量消耗更多
方案对比分析
多维度对比
| 维度 | 按需生成 | 上传时生成 |
|---|---|---|
| 流量消耗 | 浏览时需下载原图 15MB | 只上传额外 15KB |
| 首次浏览延迟 | 需等待下载+生成 | 即时显示 |
| 上传延迟 | 无额外延迟 | 增加 ~100ms |
| 服务器负载 | 浏览时集中处理 | 上传时分散处理 |
| 关闭风险 | 无影响 | 异步处理,无影响 |
流量计算对比
假设上传一张 15MB 的图片,之后浏览一次:
按需生成模式:
上传:15MB(原图)浏览:15MB(后端下载原图生成)+ 15KB(返回缩略图)总计:约 30MB上传时生成模式:
上传:15MB(原图)+ 15KB(上传缩略图)浏览:15KB(直接返回)总计:约 15MB结论:上传时生成节省约 50% 流量
其他考量
-
上传完可能就关闭:
- 使用异步 goroutine 处理,不阻塞上传响应
- 上传完成后立即返回成功,缩略图在后台生成
-
生成失败怎么办:
- 保留按需生成作为兜底机制
- 首次浏览时仍可触发生成
-
路径上传场景:
- 可直接读取本地文件生成,零 S3 流量
- HTTP 上传需从 S3 下载后生成
实现方案
架构设计
┌─────────────────────────────────────────────────────────────────┐│ 上传时生成流程 │├─────────────────────────────────────────────────────────────────┤│ ││ 【图片 - HTTP 上传】 ││ ┌──────────┐ 完成 ┌──────────────┐ ┌───────────┐ ││ │ 上传文件 │ ──────── │ 后端异步生成 │ ── │ S3 缩略图 │ ││ └──────────┘ │ (从S3下载源) │ └───────────┘ ││ └──────────────┘ ││ ││ 【图片 - 路径上传】 ││ ┌──────────┐ 完成 ┌──────────────┐ ┌───────────┐ ││ │ 上传文件 │ ──────── │ 后端异步生成 │ ── │ S3 缩略图 │ ││ └──────────┘ │ (读本地文件) │ └───────────┘ ││ └──────────────┘ ││ ││ 【视频】 ││ ┌──────────┐ 完成 ┌──────────────┐ ┌───────────┐ ││ │ 前端上传 │ ──────── │ 前端本地生成 │ ── │ 上传后端 │ ││ └──────────┘ └──────────────┘ └───────────┘ ││ │└─────────────────────────────────────────────────────────────────┘职责划分
| 文件类型 | 生成方 | 原因 |
|---|---|---|
| 图片 | 后端 | Go 图像库成熟,跨平台一致 |
| 视频 | 前端 | 需要 FFmpeg/平台 API,前端有本地文件访问权 |
代码实现
1. 新增统一生成方法 (thumbnail.go)
// generateAndSaveImageThumbnail 从原始数据生成并保存图片缩略图
// 统一入口,复用于上传时生成和按需生成
func (s *Server) generateAndSaveImageThumbnail(ctx context.Context, fileID string, sourceData []byte) error {
thumbData, err := s.generateThumbnail(sourceData)
if err != nil {
return err
}
// 加密并保存缩略图
thumbKey := crypto.DeriveFileKey(s.getMasterKey(), fileID+"#thumb")
var encryptedThumb bytes.Buffer
if err := s.encryptor.EncryptStream(bytes.NewReader(thumbData), &encryptedThumb, thumbKey, int64(len(thumbData))); err != nil {
return fmt.Errorf("thumbnail encryption failed: %w", err)
}
s3Key := fmt.Sprintf("thumbs/%s.enc", fileID)
if err := s.s3.UploadBytes(ctx, s3Key, encryptedThumb.Bytes(), "application/octet-stream"); err != nil {
return fmt.Errorf("upload thumbnail failed: %w", err)
}
log.Printf("[THUMB] Generated and saved thumbnail for %s (%d bytes)", fileID, len(thumbData))
return nil
}2. HTTP 上传场景 (thumbnail.go)
// generateImageThumbnailFromS3 从 S3 下载源文件并生成缩略图
// 用于 HTTP 上传完成后异步生成
func (s *Server) generateImageThumbnailFromS3(ctx context.Context, fileID string) error {
fileKey := s.getFileKey(fileID)
chunkMeta, err := s.loadChunkMeta(ctx, fileID, fileKey)
if err != nil {
return fmt.Errorf("load chunk meta failed: %w", err)
}
// 下载并解密所有分块
var decryptedSource bytes.Buffer
for i := 0; i < chunkMeta.TotalChunks; i++ {
chunkKey := fmt.Sprintf("files/%s/chunks/%d.enc", fileID, i)
chunkData, err := s.s3.DownloadBytes(ctx, chunkKey)
if err != nil {
return fmt.Errorf("download chunk %d failed: %w", i, err)
}
decrypted, err := s.encryptor.DecryptChunkSimple(chunkData, fileKey)
if err != nil {
return fmt.Errorf("decrypt chunk %d failed: %w", i, err)
}
decryptedSource.Write(decrypted)
}
return s.generateAndSaveImageThumbnail(ctx, fileID, decryptedSource.Bytes())
}3. 路径上传场景 (thumbnail.go)
// generateImageThumbnailFromLocalFile 从本地文件生成缩略图
// 用于路径上传完成后,零 S3 流量
func (s *Server) generateImageThumbnailFromLocalFile(ctx context.Context, fileID, filePath string) error {
data, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("read local file failed: %w", err)
}
return s.generateAndSaveImageThumbnail(ctx, fileID, data)
}4. 上传函数中调用 (files.go)
HTTP 上传 (uploadFile):
// 图片文件上传完成后,异步生成缩略图
if isImageFile(header.Filename) {
go func() {
if err := s.generateImageThumbnailFromS3(context.Background(), fileID); err != nil {
log.Printf("[UPLOAD] Failed to generate thumbnail for %s: %v", header.Filename, err)
}
}()
}路径上传 (doUploadByPath):
// 图片文件上传完成后,从本地文件生成缩略图(零 S3 流量)
if isImageFile(fileName) {
go func() {
if err := s.generateImageThumbnailFromLocalFile(context.Background(), fileID, filePath); err != nil {
log.Printf("[UPLOAD-PATH] Failed to generate thumbnail for %s: %v", fileName, err)
}
}()
}关键设计决策
-
使用
go func(){}()异步处理- 不阻塞上传响应
- 可立即看到上传成功
- 即使生成失败也不影响主流程
-
使用
context.Background()而非请求 context- 请求 context 在响应后可能被取消
- 后台生成需要独立的生命周期
-
路径上传优化
- 直接读取本地文件,零 S3 流量
- 比 HTTP 上传更高效
-
保留按需生成兜底
downloadThumbnail中的按需生成逻辑保留- 处理历史数据和生成失败的情况
前后端完整逻辑
后端功能点
| 功能 | API | 说明 |
|---|---|---|
| 图片缩略图 - 上传时生成 | 内部调用 | HTTP/路径上传完成后异步 |
| 图片缩略图 - 按需生成 | GET /thumbnails/:id?autoGen=true | 兜底机制 |
| 视频缩略图 - FFmpeg生成 | POST /videos/thumbnail/generate | 桌面端专用 |
| 缩略图上传 | POST /thumbnails/:id | 接收前端生成的 |
| 缩略图下载 | GET /thumbnails/:id | 返回已有缩略图 |
| 缩略图删除 | DELETE /thumbnails/:id | 随文件删除 |
前端功能点
| 功能 | 位置 | 说明 |
|---|---|---|
| 视频上传生成 | app_state.dart | 本地生成后上传 |
| 视频导入生成 | app_state.dart | 从临时文件生成后上传 |
| 视频重生成 | file_operation_service.dart | 从流式 URL 生成 |
| 本地缓存 | thumbnail_cache.dart | 按 S3 配置隔离 |
| 统一服务 | thumbnail_service.dart | 类型判断 + 加载 |
逻辑闭环验证
| 场景 | 图片 | 视频 | 状态 |
|---|---|---|---|
| HTTP 上传 | ✅ 后端异步生成 | ✅ 前端生成后上传 | 闭环 |
| 路径上传 | ✅ 后端从本地生成 | ✅ 前端生成后上传 | 闭环 |
| 导入 .e2e | ✅ 按需生成兜底 | ✅ 前端从临时文件生成 | 闭环 |
| 浏览加载 | ✅ 直接返回 | ✅ 直接返回 | 闭环 |
| 手动重生成 | ✅ 后端按需生成 | ✅ 前端流式生成 | 闭环 |
效果总结
流量优化
| 场景 | 修改前 | 修改后 | 节省 |
|---|---|---|---|
| 上传 15MB 图片 + 浏览 1 次 | 30MB | 15MB | 50% |
| 上传 15MB 图片 + 浏览 N 次 | 30MB | 15MB | 50% |
体验
- 上传:无感知,异步处理
- 首次浏览:即时显示,无需等待生成
- 重复浏览:本地缓存加速
代码质量
- 逻辑统一:图片/视频生成逻辑清晰分离
- 代码复用:
generateAndSaveImageThumbnail作为统一入口 - 健壮性:保留按需生成兜底,处理边缘情况
修改文件清单
-
core/internal/api/thumbnail.go- 新增
generateAndSaveImageThumbnail - 新增
generateImageThumbnailFromS3 - 新增
generateImageThumbnailFromLocalFile
- 新增
-
core/internal/api/files.gouploadFile中添加图片缩略图生成调用doUploadByPath中添加图片缩略图生成调用
验证
# Go 代码检查
go vet ./...
go build ./...通过验证,无编译错误。