视频流式解密播放:从技术挑战到完整解决方案

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

背景

E2EEPAN 是一个端到端加密的网盘应用,存储在 S3 上的都是密文。对于视频文件,需要实现:

  1. 流式播放 - 可以边下载边播放,支持进度条拖动
  2. 视频缩略图 - 文件列表中显示视频预览

这两个看似简单的需求,在端到端加密场景下充满挑战。


技术挑战

挑战一:加密数据如何响应 Range 请求?

视频播放器发送的请求:

GET /stream/xxx
Range: 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),而不是整个文件!


二、视频缩略图解决方案

初始方案(失败):按需下载部分数据生成

最初设计:

  1. 打开文件列表时请求缩略图
  2. 后端返回 needClientGenerate: true
  3. 前端下载视频的前 N MB 数据
  4. 使用原生 API 生成缩略图
  5. 上传到后端缓存

发现的问题: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 个分块

经验教训

  1. 不要假设文件格式统一:MP4 的 moov 位置不固定
  2. 完整数据优于部分数据:上传时有完整文件,是处理的最佳时机
  3. 我这边用的时候发现要及时:进度卡住会造成焦虑,分阶段显示更友好
  4. 限制响应大小:流式播放需要控制每次返回的数据量