December 27, 2025
5 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. 缩略图可以预览(不是 404)
  2. 但显示的是原图质量
  3. 缩略图文件夹 thumbs/ 里有相应的文件,但大小很大,等同于原图

问题根因分析

前端问题

app_state.dart 中的 _generateSendImageThumbnail 方法:

Future<void> _generateSendImageThumbnail(String imagePath, String fileId) async {
  final file = io.File(imagePath);
  final bytes = await file.readAsBytes();
  await api.uploadThumbnail(fileId, bytes.toList());  // 直接上传原图!
}

问题:直接读取原图并上传,没有任何压缩处理。

后端问题

server.go 中的 downloadThumbnail 按需生成逻辑:

// 按需生成依赖 meta.Files
fileMeta, ok := meta.Files[fileID]
if !ok {
    c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
    return  // 找不到就 404!
}

问题:发送的文件使用 skipMetadata: true(设计决策,避免污染个人文件列表),不写入 meta.Files

后果

  • 前端上传缩略图时 → 上传原图,占用大量空间
  • 缩略图被清理后 → 后端找不到 meta.Files[fileID] → 返回 404,无法重新生成

方案讨论

方案 A:前端压缩 + 后端按需生成(混合方案)

上传时:前端用 image 包压缩 → 上传到 thumbs/
请求时:
├─ 有缩略图 → 直接返回
└─ 无缩略图 → 后端从 files/ 读取 → 压缩 → 保存 → 返回

优点

  • 上传时就生成,首次请求快
  • 缩略图被清理后可恢复

缺点

  • 两边都有压缩逻辑(前端 image 包 + 后端 imaging 库)
  • 参数需要同步维护(256px、80% 质量)
  • 前端增加 image 包依赖

方案 B:完全后端按需生成(选定方案)

上传时:只上传源文件,不上传缩略图
请求时:后端按需生成(有则返回,无则生成后缓存)

优点

  • 逻辑集中在后端,前端简单
  • 去掉前端的 image 包依赖
  • 参数统一在后端管理
  • 缩略图被清理后可恢复

缺点

  • 第一次请求稍慢(需下载源文件 + 压缩)

方案 C:完全前端生成上传

上传时:前端生成缩略图并上传
请求时:只返回已有缩略图,无则 404

优点

  • 简单,后端无需生成逻辑

缺点

  • 缩略图被清理后无法恢复
  • 前端需要压缩依赖

决策:选择方案 B

理由

  1. 视频 vs 图片的技术差异

    • 视频:Go 没有成熟的视频处理库,必须依赖前端原生接口(FFmpeg/MediaMetadataRetriever)
    • 图片:Go 有成熟的 imaging 库,可以在后端处理
  2. 架构一致性

    • 图片由后端统一处理
    • 视频由前端原生接口处理
    • 职责清晰,无重复逻辑
  3. 依赖精简

    • 去掉前端的 image 包依赖
    • 减少前端包体积

实现细节

1. 后端修改:server.go - downloadThumbnail

改动点:按需生成不再依赖 meta.Files,直接尝试从 S3 读取源文件。

修改前

// 缩略图不存在,尝试按需生成
meta, err := s.loadMetadataIndex(ctx)
if err != nil {
    c.JSON(http.StatusNotFound, gin.H{"error": "thumbnail not found"})
    return
}
fileMeta, ok := meta.Files[fileID]
if !ok {
    c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
    return  // 发送的文件找不到,直接 404
}
 
// 视频文件应在上传时生成缩略图
if isVideoFile(fileMeta.Name) {
    c.JSON(http.StatusNotFound, gin.H{"error": "video thumbnail not found"})
    return
}
 
// 非图片文件无法生成缩略图
if !isImageFile(fileMeta.Name) {
    c.JSON(http.StatusNotFound, gin.H{"error": "not an image or video file"})
    return
}
 
// 下载并解密源文件
fileKey := crypto.DeriveFileKey(s.masterKey, fileID)
chunkMeta, err := s.loadChunkMeta(ctx, fileID, fileKey)
// ...

修改后

// 缩略图不存在,尝试按需生成
meta, err := s.loadMetadataIndex(ctx)
if err != nil {
    meta = nil // 元数据加载失败不影响按需生成
}
 
var fileName string
if meta != nil {
    if fileMeta, ok := meta.Files[fileID]; ok {
        fileName = fileMeta.Name
    }
}
 
// 尝试直接从 S3 读取源文件(支持发送的文件,它们不在 meta.Files 中)
fileKey := crypto.DeriveFileKey(s.masterKey, fileID)
chunkMeta, err := s.loadChunkMeta(ctx, fileID, fileKey)
if err != nil {
    c.JSON(http.StatusNotFound, gin.H{"error": "source file not found"})
    return
}
 
// 视频文件应在上传时生成缩略图
if fileName != "" && isVideoFile(fileName) {
    c.JSON(http.StatusNotFound, gin.H{"error": "video thumbnail not found"})
    return
}
 
// 下载并解密所有分块
var decryptedSource bytes.Buffer
for i := 0; i < chunkMeta.TotalChunks; i++ {
    // ...
}
 
// 生成缩略图(如果不是图片会在 decode 时失败)
thumbData, err := s.generateThumbnail(decryptedSource.Bytes())
if err != nil {
    c.JSON(http.StatusNotFound, gin.H{"error": "not an image file or thumbnail generation failed"})
    return
}

关键改动

  1. meta 加载失败不再返回错误,只是设为 nil
  2. 不再检查 meta.Files[fileID] 是否存在
  3. 直接尝试从 S3 读取源文件的 chunk 元数据
  4. 通过 imaging.Decode 的成功/失败来判断是否是图片

2. 前端修改:app_state.dart

删除的代码

  1. 删除 image 包导入和压缩函数:
// 删除
import 'package:image/image.dart' as img;
import 'package:flutter/foundation.dart' show Uint8List, compute;
 
List<int>? _compressImageToThumbnail(Uint8List bytes) {
  // ...压缩逻辑
}
  1. 删除 _generateSendImageThumbnail 方法:
// 删除整个方法
Future<void> _generateSendImageThumbnail(String imagePath, String fileId) async {
  // ...
}
  1. 修改缩略图生成调用:
// 修改前
if (isVideoFile(job.fileName) || isVideoFile(job.filePath)) {
  await _generateSendVideoThumbnail(job.filePath, uploadedFileId);
} else if (isImageFile(job.fileName) || isImageFile(job.filePath)) {
  await _generateSendImageThumbnail(job.filePath, uploadedFileId);  // 删除
}
 
// 修改后
// 视频缩略图需要前端生成上传(Go 没有成熟的视频处理库)
// 图片缩略图由后端按需生成,不需要前端上传
if (isVideoFile(job.fileName) || isVideoFile(job.filePath)) {
  await _generateSendVideoThumbnail(job.filePath, uploadedFileId);
}

最终架构

┌─────────────────────────────────────────────────────────────┐
│ 缩略图生成架构 │
├───────────┬─────────────────────────────────────────────────┤
│ 类型 │ 处理方式 │
├───────────┼─────────────────────────────────────────────────┤
│ 图片 │ 后端按需生成 (Go imaging 库) │
│ │ - 前端不上传 │
│ │ - 首次请求时后端生成并缓存到 thumbs/ │
│ │ - 参数:256px 最大边,JPEG 80% 质量 │
├───────────┼─────────────────────────────────────────────────┤
│ 视频 │ 前端原生接口生成上传 │
│ │ - Go 没有成熟的视频处理库 │
│ │ - Android: MediaMetadataRetriever │
│ │ - Windows: FFmpeg CLI │
│ │ - 上传时生成并上传到 thumbs/ │
└───────────┴─────────────────────────────────────────────────┘

数据流

图片缩略图

上传图片:
Client ──[原图]──→ files/{fileId}/chunks/*.enc
请求缩略图:
Client ──[GET /thumbnail/{fileId}]──→ Server
├─ thumbs/{fileId}.enc 存在?
│ ├─ YES → 解密返回
│ └─ NO → 从 files/ 读取
│ → imaging 压缩
│ → 加密保存到 thumbs/
│ → 返回
Client ←──[256px JPEG]────────────────────

视频缩略图

上传视频:
Client ──[视频文件]──→ files/{fileId}/chunks/*.enc
└─[原生API生成帧]──→ thumbs/{fileId}.enc
请求缩略图:
Client ──[GET /thumbnail/{fileId}]──→ Server
├─ thumbs/{fileId}.enc 存在?
│ ├─ YES → 解密返回
│ └─ NO → 404 (无法后端生成)
Client ←──[视频帧 JPEG]───────────────────

边界情况处理

1. 发送的文件(不在 meta.Files 中)

  • 图片:后端直接尝试从 S3 读取 files/{fileId}/,通过 imaging.Decode 判断是否图片
  • 视频:前端上传时已生成缩略图,后端只需返回

2. 缩略图被清理

  • 图片:后端按需重新生成
  • 视频:返回 404,无法恢复(除非重新上传)

3. 非图片/视频文件

  • 不生成缩略图,请求时返回 404

4. 文件名未知(发送的文件)

  • 后端不知道文件名时,无法判断是否视频
  • 直接尝试 imaging.Decode,失败则返回 404

性能考量

首次请求延迟

图片缩略图首次请求时需要:

  1. 下载所有 chunks(加密后)
  2. 解密
  3. 解码图片
  4. 缩放
  5. 编码 JPEG
  6. 加密保存

对于大图片可能有几百毫秒延迟,但只有首次请求会有,后续请求直接返回缓存。

优化方向(未实现)

  1. 预生成:普通上传(非发送)时可以考虑异步预生成
  2. 渐进加载:先显示 loading 占位符,缩略图准备好后替换

相关文件

  • core/internal/api/server.go - downloadThumbnail 方法
  • client/lib/core/state/app_state.dart - 删除图片缩略图前端生成逻辑
  • client/lib/core/services/video_thumbnail_service.dart - 视频缩略图原生接口

经验总结

  1. 职责单一:图片处理能力后端有,就放后端;视频处理能力后端没有,就放前端
  2. 避免重复:不要前后端都实现同样的功能
  3. 依赖精简:不必要的依赖(如前端 image 包)应该去掉
  4. 兼容性设计:按需生成机制保证缩略图被清理后可恢复

后续修复:聊天页面视频缩略图重生成

问题:聊天页面的缩略图加载逻辑没有调用视频缩略图按需重生成。

原因chat_page.dart_loadThumbnailAsync 没有复用 file_tiles.dartfetchOrGenerateThumbnail,而是自己实现了一个简化版本。

修复

// 修改前
Future<void> _loadThumbnailAsync(String fileId) async {
  final result = await appState.api.getThumbnailWithInfo(fileId);
  // 没有重生成逻辑
}
 
// 修改后
Future<void> _loadThumbnailAsync(String fileId, {String? fileName}) async {
  // 复用 fetchOrGenerateThumbnail,支持视频缩略图按需重生成
  final thumbData = await fetchOrGenerateThumbnail(
    appState.api,
    fileId,
    fileName: fileName,  // 传入文件名以判断是否视频
  );
}

关键改动

  1. 导入 file_tiles.dart show fetchOrGenerateThumbnail
  2. _buildCachedThumbnail 增加 fileName 参数
  3. _buildImageThumbnail/_buildVideoThumbnail 传递 message.fileName
  4. 删除不再使用的 thumbnail_cache.dart 导入