背景
E2EEPAN 是一个端到端加密的网盘应用,存储在 S3 上的都是密文。对于视频文件,需要实现:
- 流式播放 - 可以边下载边播放,支持进度条拖动
- 视频缩略图 - 文件列表中显示视频预览
这两个看似简单的需求,在端到端加密场景下充满挑战。
技术挑战
挑战一:加密数据如何响应 Range 请求?
视频播放器发送的请求:
GET /stream/xxxRange: bytes=50000000-意思是:从第 50MB 开始给我数据。
问题:服务器存储的是加密数据,Range 请求的字节偏移对应的是明文位置,但服务器只有密文。
使用视角(明文): |-------- 跳过 --------|-------- 需要 --------|密文存储: |████████████████████████████████████████████| ↑ 这里对应明文的哪个位置?挑战二:分块加密如何定位?
我们使用分块加密,每个分块独立加密:
原始文件: [Block 0][Block 1][Block 2]...[Block N]加密后: [Enc 0 ][Enc 1 ][Enc 2 ]...[Enc N ]每个加密块比原始块大(多了 nonce 和 tag),如何从明文位置计算出需要哪些加密块?
挑战三:视频缩略图生成
后端是 Go 程序,没有 FFmpeg,如何生成视频缩略图?
解决方案
架构设计
┌─────────────────────────────────────────────────────────────┐│ Flutter 客户端 │├─────────────────────────────────────────────────────────────┤│ ││ ┌─────────────┐ ┌─────────────────┐ ┌─────────────┐ ││ │ media_kit │ │ 视频缩略图生成 │ │ 文件列表 │ ││ │ 视频播放器 │ │ (上传时生成) │ │ │ ││ └──────┬──────┘ └────────┬────────┘ └──────┬──────┘ ││ │ │ │ ││ │ HTTP 流式请求 │ 上传缩略图 │ 获取缩略图││ ▼ ▼ ▼ │└─────────────────────────────────────────────────────────────┘ │ ▼┌─────────────────────────────────────────────────────────────┐│ Go 核心 (嵌入式) │├─────────────────────────────────────────────────────────────┤│ ││ ┌─────────────────────────────────────────────────────────┐││ │ /stream/:id (支持 Range 请求) │││ │ │││ │ 1. 解析 Range 请求 → 明文字节范围 │││ │ 2. 计算需要的分块范围 (startChunk ~ endChunk) │││ │ 3. 从 S3 下载需要的分块 │││ │ 4. 逐块解密 │││ │ 5. 截取到请求的范围 │││ │ 6. 返回 206 Partial Content │││ └─────────────────────────────────────────────────────────┘││ │ │└─────────────────────────────┼───────────────────────────────┘ ▼ ┌───────────────────┐ │ S3 │ │ files/{uuid}/ │ │ ├── meta.enc │ │ └── chunks/ │ │ ├── 0.enc │ │ ├── 1.enc │ │ └── ... │ └───────────────────┘一、流式解密播放实现
核心算法:Range 请求到分块映射
func (s *Server) streamFile(c *gin.Context) {
// 1. 加载分块元数据
chunkMeta, _ := s.loadChunkMeta(ctx, fileID, fileKey)
originalSize := chunkMeta.OriginalSize // 原始文件大小
chunkSize := int64(chunkMeta.ChunkSize) // 分块大小 (5MB)
totalChunks := int64(chunkMeta.TotalChunks)
// 2. 解析 Range 请求
// 例如: "Range: bytes=50000000-60000000"
rangeHeader := c.GetHeader("Range") // "bytes=50000000-60000000"
rangeStart, rangeEnd := parseRange(rangeHeader)
// 3. 计算需要的分块范围
// 假设 chunkSize = 5MB = 5242880
// rangeStart = 50000000
// startChunk = 50000000 / 5242880 = 9
// endChunk = 60000000 / 5242880 = 11
startChunk := rangeStart / chunkSize // 第 9 个分块
endChunk := rangeEnd / chunkSize // 第 11 个分块
// 4. 只下载需要的分块(不是整个文件!)
var decryptedData bytes.Buffer
for i := startChunk; i <= endChunk; i++ {
chunkKey := fmt.Sprintf("files/%s/chunks/%d.enc", fileID, i)
chunkData, _ := s.s3.DownloadBytes(ctx, chunkKey)
decrypted, _ := s.encryptor.DecryptChunkSimple(chunkData, fileKey)
decryptedData.Write(decrypted)
}
// 5. 从解密数据中截取精确的请求范围
fullData := decryptedData.Bytes()
offsetInFirstChunk := rangeStart - startChunk*chunkSize
responseLen := rangeEnd - rangeStart + 1
responseData := fullData[offsetInFirstChunk : offsetInFirstChunk+responseLen]
// 6. 返回 206 Partial Content
c.Header("Content-Range", fmt.Sprintf("bytes %d-%d/%d", rangeStart, rangeEnd, originalSize))
c.Header("Accept-Ranges", "bytes")
c.Data(http.StatusPartialContent, contentType, responseData)
}关键细节:响应大小限制
问题:视频播放器首次请求通常是 Range: bytes=0-(没有结束位置),意思是”从头开始给我所有数据”。
如果真的返回整个 500MB 视频,就失去了流式播放的意义。
解决方案:限制单次响应最大 10MB
const maxResponseSize = 10 * 1024 * 1024 // 10MB
// Range 请求没有指定结束位置时,限制响应大小
if len(parts) > 1 && parts[1] != "" {
rangeEnd, _ = strconv.ParseInt(parts[1], 10, 64)
} else {
// bytes=0- 这种请求,限制最多返回 10MB
rangeEnd = rangeStart + maxResponseSize - 1
if rangeEnd >= originalSize {
rangeEnd = originalSize - 1
}
}
// 非 Range 请求也限制响应大小
if !isRangeRequest && originalSize > maxResponseSize {
rangeEnd = maxResponseSize - 1
}播放器收到 10MB 数据后会继续请求下一段,实现真正的流式播放。
日志示例
[STREAM] Request for file: e1a159a0-..., Range: bytes=0-[STREAM] ChunkMeta loaded: size=481089144, chunks=92, chunkSize=5242880[STREAM] Responding range 0-10485759 (total 481089144)播放器拖动进度条时:
[STREAM] Request for file: e1a159a0-..., Range: bytes=240000000-[STREAM] Responding range 240000000-250485759 (total 481089144)只下载了 2 个分块(分块 45-46),而不是整个文件!
二、视频缩略图解决方案
初始方案(失败):按需下载部分数据生成
最初设计:
- 打开文件列表时请求缩略图
- 后端返回
needClientGenerate: true - 前端下载视频的前 N MB 数据
- 使用原生 API 生成缩略图
- 上传到后端缓存
发现的问题:MP4 结构导致失败
测试结果:
| 文件大小 | 缩略图结果 | 分析 |
|---|---|---|
| 20MB | 成功 | 完整下载,moov 可读 |
| 34MB | 成功 | 完整下载,moov 可读 |
| 57MB | 失败 | 只下载了 50MB,moov 在末尾 |
| 59MB | 失败 | 只下载了 50MB,moov 在末尾 |
根因:MP4 文件的 moov atom(元数据)位置不固定:
方案 A: moov 在前(流媒体优化)┌─────────────────────────────────────────────────────────┐│ ftyp │ moov (元数据) │ mdat (音视频数据) ││ │ │ ││ ◄────────────────── 可从部分数据生成缩略图 │└─────────────────────────────────────────────────────────┘
方案 B: moov 在后(常见于非优化视频)┌─────────────────────────────────────────────────────────┐│ ftyp │ mdat (音视频数据) │ moov (元数据)││ │ │ ││ ◄──────────────────── 无法解析! ││ 下载了 50MB 但没有元数据,无法定位关键帧 │└─────────────────────────────────────────────────────────┘最终方案:上传时生成缩略图
核心思路:上传时客户端有完整的本地文件,是生成缩略图的最佳时机。
void _startUploadJob(_UploadJob job) {
// ... 上传逻辑 ...
if (result.isSuccess && result.data != null) {
_files.add(result.data!);
// 视频文件上传成功后,生成并上传缩略图
if (isVideoFile(result.data!.mimeType, result.data!.name)) {
_generateAndUploadVideoThumbnail(
job.filePath, // 本地原始文件路径(完整文件)
result.data!.id,
);
}
}
}
Future<void> _generateAndUploadVideoThumbnail(
String videoPath,
String fileId,
) async {
try {
final tempDir = await getTemporaryDirectory();
final thumbPath = path.join(tempDir.path, 'thumb_$fileId.jpg');
// 使用原生 API 生成缩略图(Android: MediaMetadataRetriever)
final plugin = FcNativeVideoThumbnail();
final success = await plugin.getVideoThumbnail(
srcFile: videoPath, // 完整的本地文件,100% 成功
destFile: thumbPath,
width: 256,
height: 256,
format: 'jpeg',
quality: 85,
);
if (success) {
final thumbFile = io.File(thumbPath);
if (await thumbFile.exists()) {
final thumbData = await thumbFile.readAsBytes();
await api.uploadThumbnail(fileId, thumbData); // 上传到后端
await thumbFile.delete();
}
}
} catch (e) {
// 缩略图生成失败不影响上传流程
debugPrint('[VideoThumb] Failed: $e');
}
}缩略图存储
后端将缩略图存储在 S3:thumbs/{fileId}.enc
func (s *Server) uploadThumbnail(c *gin.Context) {
fileID := c.Param("id")
data, _ := io.ReadAll(c.Request.Body)
// 加密缩略图
thumbKey := crypto.DeriveFileKey(s.masterKey, fileID+"-thumb")
encrypted, _ := s.encryptor.EncryptChunkSimple(data, thumbKey)
// 上传到 S3
s3Key := fmt.Sprintf("thumbs/%s.enc", fileID)
s.s3.UploadBytes(ctx, s3Key, encrypted, "application/octet-stream")
}三、上传进度优化
问题:100% 后卡住
我这边用的时候发现:上传进度显示 100% 后要等一会儿才弹窗完成。
根因:Dio 的 onSendProgress 报告的是数据发送到网络缓冲区的进度,不是服务器实际处理完成的进度。
客户端进度 100% 服务器还在处理 ↓ ↓[数据发送完成] → 网络传输 → [接收] → [加密] → [上传S3] → [更新元数据] → 响应解决方案:分阶段显示
新增 finishing 状态:
enum TransferStatus {
queued, // 排队中
running, // 传输中
finishing, // 处理中 ← 新增
success, // 完成
failed // 失败
}上传流程:
onProgress: (sent, total) {
final prog = total > 0 ? sent / total : 0.0;
final isFinishing = prog >= 1.0;
_transfers[idx] = _transfers[idx].copyWith(
progress: prog,
// 进度到 100% 时切换到 finishing 状态
status: isFinishing ? TransferStatus.finishing : TransferStatus.running,
);
}UI 显示:
if (item.status == TransferStatus.running)
LinearProgressIndicator(value: item.progress), // 确定进度
if (item.status == TransferStatus.finishing)
LinearProgressIndicator(), // 不确定进度(动画)
Text('处理中...'),使用体验:
- 0-99%:显示进度条和百分比
- 100%:显示”处理中…”和动画进度条
- 完成:显示”上传完成”
总结
解决的核心问题
| 问题 | 解决方案 |
|---|---|
| 加密数据如何支持 Range 请求 | 从明文位置计算分块范围,按需下载解密 |
| 流式播放不下载整个文件 | 限制单次响应最大 10MB |
| 视频缩略图生成 | 上传时客户端生成,避免 moov 位置问题 |
| 上传进度 100% 后卡住 | 分阶段显示:传输中 → 处理中 → 完成 |
技术栈
| 组件 | 技术 |
|---|---|
| 视频播放器 | media_kit (基于 mpv) |
| 缩略图生成 | fc_native_video_thumbnail (原生 API) |
| HTTP 流式 | Go gin + Range 请求处理 |
| 加密算法 | AES-256-GCM 分块加密 |
性能数据
| 场景 | 表现 |
|---|---|
| 视频启动 | 下载 1-2 个分块即可开始播放 |
| 进度跳转 | 只下载目标位置的分块 |
| 缩略图生成 | 上传完成后异步生成,不阻塞 |
| 单次响应 | 最大 10MB,约 2 个分块 |
经验教训
- 不要假设文件格式统一:MP4 的 moov 位置不固定
- 完整数据优于部分数据:上传时有完整文件,是处理的最佳时机
- 我这边用的时候发现要及时:进度卡住会造成焦虑,分阶段显示更友好
- 限制响应大小:流式播放需要控制每次返回的数据量