缩略图生成逻辑迁移到后端

December 19, 2025
3 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.

背景

之前缩略图生成逻辑在前端实现:

  1. 前端请求缩略图 → 服务器没有
  2. 前端下载源文件 → 本地生成缩略图 → 上传到服务器

这种方式的问题:

  • 每个 UI 端(Flutter、未来的 Web)都需要实现生成逻辑
  • 前端需要下载完整源文件,浪费带宽
  • 依赖前端的图片处理库(Dart image 包)

设计决策

按需生成 vs 上传时生成

方案优点缺点
按需生成不阻塞上传、只生成需要的首次请求稍慢
上传时生成浏览时已存在阻塞上传流程

选择按需生成:使用时可能上传很多图片但从不浏览,按需生成更高效。

新流程

前端请求 getThumbnail(fileId)
后端检查 thumbs/{fileId}.enc
↓ 存在 → 解密返回
↓ 不存在 → 下载源文件 → 解密 → 生成缩略图 → 加密保存 → 返回

实现细节

后端改动

添加依赖

go get github.com/disintegration/imaging

修改 downloadThumbnail 函数

func (s *Server) downloadThumbnail(c *gin.Context) {
    // ... 权限检查
    
    // 尝试获取已有缩略图
    s3Key := fmt.Sprintf("thumbs/%s.enc", fileID)
    data, err := s.s3.DownloadBytes(ctx, s3Key)
    if err == nil {
        // 存在,解密返回
        fileKey := crypto.DeriveFileKey(s.masterKey, fileID+"#thumb")
        // ... 解密并返回
        return
    }
 
    // 不存在,按需生成
    // 1. 检查源文件是否是图片
    meta, _ := s.loadMetadataIndex(ctx)
    fileMeta, ok := meta.Files[fileID]
    if !ok || !strings.HasPrefix(fileMeta.MimeType, "image/") {
        c.JSON(404, gin.H{"error": "not an image file"})
        return
    }
 
    // 2. 下载并解密源文件
    sourceData, _ := s.s3.DownloadBytes(ctx, fmt.Sprintf("files/%s.enc", fileID))
    // ... 解密
 
    // 3. 生成缩略图
    thumbData, _ := s.generateThumbnail(decryptedSource.Bytes())
 
    // 4. 加密并保存(失败不影响返回)
    thumbKey := crypto.DeriveFileKey(s.masterKey, fileID+"#thumb")
    // ... 加密上传
 
    c.Data(200, "image/jpeg", thumbData)
}

生成函数

func (s *Server) generateThumbnail(sourceData []byte) ([]byte, error) {
    img, _ := imaging.Decode(bytes.NewReader(sourceData))
    
    // 等比例缩放,最大边 256px
    const maxSize = 256
    bounds := img.Bounds()
    w, h := bounds.Dx(), bounds.Dy()
    var resized *image.NRGBA
    if w >= h {
        resized = imaging.Resize(img, maxSize, 0, imaging.Lanczos)
    } else {
        resized = imaging.Resize(img, 0, maxSize, imaging.Lanczos)
    }
 
    var buf bytes.Buffer
    jpeg.Encode(&buf, resized, &jpeg.Options{Quality: 80})
    return buf.Bytes(), nil
}

前端改动

简化 _fetchOrGenerateThumbnail

// 之前:本地缓存 → 服务器 → 下载源文件 → 生成 → 上传
// 之后:本地缓存 → 服务器(后端按需生成)
 
Future<Uint8List?> _fetchOrGenerateThumbnail(ApiClient api, String fileId) async {
  // 先查本地缓存
  final cachedThumb = await ThumbnailCacheManager().loadThumbnail(fileId);
  if (cachedThumb != null) return cachedThumb;
 
  // 后端按需生成并返回缩略图
  final serverThumb = await api.getThumbnail(fileId);
  if (serverThumb.isSuccess && serverThumb.data != null) {
    final data = Uint8List.fromList(serverThumb.data!);
    await ThumbnailCacheManager().saveThumbnail(fileId, data);
    return data;
  }
 
  return null;
}

删除的代码

  • ThumbnailCacheManager.generateAndSaveThumbnail() - 不再本地生成
  • _decodeAndResizeThumbnail() - 图片处理函数
  • ApiClient.uploadThumbnail() - 不再由前端上传
  • import 'package:image/image.dart' - 不再需要图片处理库

性能优化保留

前端的以下性能优化全部保留,它们是加载优化而非生成优化:

优化作用
scrollIdle滚动时不加载,停止后才触发
VisibilityDetector只加载可见区域的缩略图
_thumbDelayTimer (800ms)延迟加载,防止快速滚动触发大量请求
本地磁盘缓存减少网络请求
内存缓存减少磁盘 IO

关键注意点

  1. Content-Type:必须设置为 image/jpeg,否则前端无法渲染
  2. JPEG 编码:使用 jpeg.Encode() 而非 imaging.Encode(),确保输出格式正确
  3. 保存失败容错:缩略图保存到 S3 失败不影响返回,下次请求会重新生成
  4. 导入顺序:Go 需要同时导入 imageimage/jpeg

代码量变化

模块变化
后端 Go+80 行(生成逻辑)
前端 Dart-60 行(移除生成+上传)
净变化+20 行,但逻辑集中到后端

收益

  1. UI 解耦:换 UI 端不需要重新实现缩略图生成
  2. 减少依赖:前端不再需要 image 包(减小 APK 体积)
  3. 带宽优化:前端不再需要下载完整源文件来生成缩略图
  4. 维护集中:缩略图质量、尺寸等参数统一在后端调整