状态: 已完成
问题发现
我在聊天界面上传图片后发现:
- 缩略图可以预览(不是 404)
- 但显示的是原图质量
- 缩略图文件夹
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
理由:
-
视频 vs 图片的技术差异:
- 视频:Go 没有成熟的视频处理库,必须依赖前端原生接口(FFmpeg/MediaMetadataRetriever)
- 图片:Go 有成熟的
imaging库,可以在后端处理
-
架构一致性:
- 图片由后端统一处理
- 视频由前端原生接口处理
- 职责清晰,无重复逻辑
-
依赖精简:
- 去掉前端的
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
}关键改动:
meta加载失败不再返回错误,只是设为 nil- 不再检查
meta.Files[fileID]是否存在 - 直接尝试从 S3 读取源文件的 chunk 元数据
- 通过
imaging.Decode的成功/失败来判断是否是图片
2. 前端修改:app_state.dart
删除的代码:
- 删除
image包导入和压缩函数:
// 删除
import 'package:image/image.dart' as img;
import 'package:flutter/foundation.dart' show Uint8List, compute;
List<int>? _compressImageToThumbnail(Uint8List bytes) {
// ...压缩逻辑
}- 删除
_generateSendImageThumbnail方法:
// 删除整个方法
Future<void> _generateSendImageThumbnail(String imagePath, String fileId) async {
// ...
}- 修改缩略图生成调用:
// 修改前
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
性能考量
首次请求延迟
图片缩略图首次请求时需要:
- 下载所有 chunks(加密后)
- 解密
- 解码图片
- 缩放
- 编码 JPEG
- 加密保存
对于大图片可能有几百毫秒延迟,但只有首次请求会有,后续请求直接返回缓存。
优化方向(未实现)
- 预生成:普通上传(非发送)时可以考虑异步预生成
- 渐进加载:先显示 loading 占位符,缩略图准备好后替换
相关文件
core/internal/api/server.go- downloadThumbnail 方法client/lib/core/state/app_state.dart- 删除图片缩略图前端生成逻辑client/lib/core/services/video_thumbnail_service.dart- 视频缩略图原生接口
经验总结
- 职责单一:图片处理能力后端有,就放后端;视频处理能力后端没有,就放前端
- 避免重复:不要前后端都实现同样的功能
- 依赖精简:不必要的依赖(如前端 image 包)应该去掉
- 兼容性设计:按需生成机制保证缩略图被清理后可恢复
后续修复:聊天页面视频缩略图重生成
问题:聊天页面的缩略图加载逻辑没有调用视频缩略图按需重生成。
原因:chat_page.dart 的 _loadThumbnailAsync 没有复用 file_tiles.dart 的 fetchOrGenerateThumbnail,而是自己实现了一个简化版本。
修复:
// 修改前
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, // 传入文件名以判断是否视频
);
}关键改动:
- 导入
file_tiles.dart show fetchOrGenerateThumbnail _buildCachedThumbnail增加fileName参数_buildImageThumbnail/_buildVideoThumbnail传递message.fileName- 删除不再使用的
thumbnail_cache.dart导入