January 2, 2026
6 min read
By devshan

Table of Contents

This is a list of all the sections in this post. Click on any of them to jump to that section.

背景

原有架构

项目原有的缩略图生成策略为按需生成

  • 上传文件时,只上传原始文件
  • 浏览文件列表时,前端请求缩略图 API
  • 后端检查缩略图是否存在,不存在则下载源文件、生成缩略图、保存后返回

问题发现

我当时质疑:为什么只有视频在上传时生成缩略图,图片不生成?

初步分析认为按需生成是合理的:

  • 上传后可能不会立即浏览
  • 避免上传时的额外延迟
  • 懒加载思想

但我意识到一个关键问题

缩略图很小,可能就十几 KB,再次生成需要下载一整个原图,现代手机一张照片普遍十几兆,流量消耗更多

方案对比分析

多维度对比

维度按需生成上传时生成
流量消耗浏览时需下载原图 15MB只上传额外 15KB
首次浏览延迟需等待下载+生成即时显示
上传延迟无额外延迟增加 ~100ms
服务器负载浏览时集中处理上传时分散处理
关闭风险无影响异步处理,无影响

流量计算对比

假设上传一张 15MB 的图片,之后浏览一次:

按需生成模式

上传:15MB(原图)
浏览:15MB(后端下载原图生成)+ 15KB(返回缩略图)
总计:约 30MB

上传时生成模式

上传:15MB(原图)+ 15KB(上传缩略图)
浏览:15KB(直接返回)
总计:约 15MB

结论:上传时生成节省约 50% 流量

其他考量

  1. 上传完可能就关闭

    • 使用异步 goroutine 处理,不阻塞上传响应
    • 上传完成后立即返回成功,缩略图在后台生成
  2. 生成失败怎么办

    • 保留按需生成作为兜底机制
    • 首次浏览时仍可触发生成
  3. 路径上传场景

    • 可直接读取本地文件生成,零 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)
        }
    }()
}

关键设计决策

  1. 使用 go func(){}() 异步处理

    • 不阻塞上传响应
    • 可立即看到上传成功
    • 即使生成失败也不影响主流程
  2. 使用 context.Background() 而非请求 context

    • 请求 context 在响应后可能被取消
    • 后台生成需要独立的生命周期
  3. 路径上传优化

    • 直接读取本地文件,零 S3 流量
    • 比 HTTP 上传更高效
  4. 保留按需生成兜底

    • 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 次30MB15MB50%
上传 15MB 图片 + 浏览 N 次30MB15MB50%

体验

  • 上传:无感知,异步处理
  • 首次浏览:即时显示,无需等待生成
  • 重复浏览:本地缓存加速

代码质量

  • 逻辑统一:图片/视频生成逻辑清晰分离
  • 代码复用generateAndSaveImageThumbnail 作为统一入口
  • 健壮性:保留按需生成兜底,处理边缘情况

修改文件清单

  1. core/internal/api/thumbnail.go

    • 新增 generateAndSaveImageThumbnail
    • 新增 generateImageThumbnailFromS3
    • 新增 generateImageThumbnailFromLocalFile
  2. core/internal/api/files.go

    • uploadFile 中添加图片缩略图生成调用
    • doUploadByPath 中添加图片缩略图生成调用

验证

# Go 代码检查
go vet ./...
go build ./...

通过验证,无编译错误。