December 21, 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.

一、问题背景

实现 E2E 便携式加密文件的导入导出功能时,我们经历了一个”越做越复杂”的过程,最终意识到走入了过度设计的误区。


二、第一版实现(走入误区)

2.1 最初的 API 设计

// 导出:返回加密后的字节流
POST /api/v1/e2e/export/:id
Body: { "password": "xxx" }
Response: 二进制流
 
// 导入:multipart 上传
POST /api/v1/e2e/import
Form: file, password, path
Response: FileMetadata

问题

  • 导出返回整个文件到内存,大文件 OOM
  • 导入使用 multipart,虽然能工作但没有复用上传逻辑

2.2 优化后的”路径模式”(过度设计)

为了解决大文件问题,我们参考 upload-by-path 模式,设计了独立的路径模式 API:

// 导出到本地路径(核心直接写文件)
POST /api/v1/e2e/export-by-path
Body: { "fileId", "password", "savePath" }
Response: { "taskId" }
 
// 从本地路径导入(核心直接读文件)
POST /api/v1/e2e/import-by-path
Body: { "filePath", "password", "targetPath" }
Response: { "taskId" }
 
// 进度查询
GET /api/v1/e2e/progress/:taskId
Response: E2EProgress

2.3 实现细节

后端 (server.go)

type E2EProgress struct {
    TaskID    string        `json:"taskId"`
    Phase     string        `json:"phase"`
    Progress  int           `json:"progress"`
    FileName  string        `json:"fileName"`
    FileSize  int64         `json:"fileSize"`
    Error     string        `json:"error,omitempty"`
    Result    *FileMetadata `json:"result,omitempty"`
    SavedPath string        `json:"savedPath,omitempty"`
}
 
// doExportByPath 实际执行导出(后台 goroutine)
func (s *Server) doExportByPath(taskID, fileID, password, savePath string, chunkMeta *crypto.ChunkMeta) {
    // 阶段1:下载并解密所有分块 (0-60%)
    progress.Phase = "downloading"
    for i := 0; i < chunkMeta.TotalChunks; i++ {
        chunkData := s.s3.DownloadBytes(...)
        decrypted := s.encryptor.DecryptChunkSimple(chunkData, fileKey)
        fileData = append(fileData, decrypted...)
        progress.Progress = (i + 1) * 60 / chunkMeta.TotalChunks
    }
    
    // 阶段2:用新密码加密为 E2E 格式 (60-90%)
    progress.Phase = "encrypting"
    e2eData := crypto.ExportToE2E(fileData, e2eMeta, password)
    
    // 阶段3:写入本地文件 (90-100%)
    progress.Phase = "writing"
    os.WriteFile(savePath, e2eData, 0644)
    
    progress.Phase = "done"
}
 
// doImportByPath 实际执行导入(后台 goroutine)
func (s *Server) doImportByPath(taskID, filePath, password, targetPath string, fileSize int64) {
    // 阶段1:读取本地文件 (0-20%)
    e2eData := os.ReadFile(filePath)
    
    // 阶段2:解密 E2E 文件 (20-40%)
    fileData, e2eMeta := crypto.ImportFromE2E(e2eData, password)
    
    // 阶段3:分块加密并上传到 S3 (40-90%)
    for i := 0; i < totalChunks; i++ {
        encrypted := s.encryptor.EncryptChunkSimple(chunk, fileKey)
        s.s3.UploadBytes(ctx, chunkKey, encrypted, ...)
    }
    
    // 阶段4:保存元数据 (90-100%)
    s.updateMetadataIndex(ctx, &metadata)
    
    progress.Phase = "done"
    progress.Result = &metadata
}

客户端 (app_state.dart)

/// 导出路径模式:核心直接写文件到本地路径
Future<void> _doExportByPath(_DownloadJob job) async {
  // 计算保存路径
  final savePath = '${downloadDir.path}/$fileName';
 
  // 发起导出请求,获取 taskId
  final startResult = await api.exportToE2EByPath(
    fileId: job.file.id,
    password: job.exportPassword!,
    savePath: savePath,
  );
  final taskId = startResult.data!;
 
  // 轮询进度
  while (true) {
    await Future.delayed(const Duration(milliseconds: 500));
    final progressResult = await api.getE2EProgress(taskId);
    final progress = progressResult.data!;
    
    // 更新 UI 进度
    _transfers[idx] = _transfers[idx].copyWith(
      progress: progress.progress / 100.0,
    );
    
    if (progress.isDone) break;
  }
}
 
/// 导入任务(使用路径模式 API)
void _startImportJob(_ImportJob job) {
  // 类似的轮询逻辑...
}

三、问题分析:为什么这是过度设计?

3.1 代码重复

功能upload-by-pathimport-by-path
分块加密✅ 已实现❌ 重复实现
上传到 S3✅ 已实现❌ 重复实现
元数据更新✅ 已实现❌ 重复实现
进度追踪✅ 已实现❌ 新建独立系统
取消机制✅ 已实现❌ 需要额外实现

3.2 违反设计原则

根据项目规范:

“导入导出功能应复用上传下载的接口与UI逻辑,实现统一的传输体验” “接口设计应注重高复用率,避免重复实现,统一逻辑入口”

我们的实现:

  • ❌ 没有复用上传逻辑,而是重新实现了一遍
  • ❌ 独立的进度系统,没有复用传输队列
  • ❌ 代码膨胀:后端 +430 行,客户端 +200 行

3.3 本质问题

误区:把”导入导出”当作独立功能来实现

正确理解

  • 导入 = E2E 解密 + 上传
  • 导出 = 下载 + E2E 加密

E2E 加解密只是一个”转换层”,不应该重新实现上传下载的核心逻辑。


四、正确方案:最小化 E2E 模块

4.1 后端只提供纯粹的加解密接口

// 解密 E2E 文件 → 保存到临时目录,返回路径和元数据
POST /api/v1/e2e/decrypt
{
  "filePath": "/path/to/file.e2e",
  "password": "xxx"
}
{ "tempPath": "/tmp/xxx", "fileName": "原始文件名", "mimeType": "xxx", "size": 123 }
 
// 下载文件并 E2E 加密 → 保存到指定路径
POST /api/v1/e2e/encrypt
{
  "fileId": "xxx",
  "password": "xxx",
  "savePath": "/path/to/output.e2e"
}
{ "savedPath": "...", "size": 123 }

4.2 客户端流程

导入

void enqueueImport(String e2eFilePath, String password) {
  // 1. 调用 e2e/decrypt 解密到临时文件
  final decryptResult = await api.decryptE2E(e2eFilePath, password);
  // decryptResult = { tempPath, fileName, mimeType, size }
  
  // 2. 加入上传队列(复用现有上传逻辑)
  enqueueUploadToPath(
    filePath: decryptResult.tempPath,
    fileName: decryptResult.fileName,
    remotePath: targetPath,
    displayName: '导入: ${decryptResult.fileName}', // UI 显示为"导入"
  );
}

导出

void enqueueExport(FileMetadata file, String password) {
  // 1. 加入下载队列
  // 2. 下载完成回调中:
  //    - 调用 e2e/encrypt 加密
  //    - 或者复用 _DownloadJob 的 isExport 标记,下载完成后自动加密
}

4.3 优势对比

维度过度设计方案简化方案
后端新增代码~430 行~50 行
客户端新增代码~200 行~30 行
进度条独立轮询复用上传下载
取消机制需要额外实现自动继承
缩略图生成需要额外处理上传流程自动处理
临时文件清理需要额外处理上传流程自动清理

五、经验教训

5.1 设计原则

  1. 先问”能否复用”,再考虑”如何新建”
  2. 功能分解:把复杂功能拆成小模块,看哪些已经存在
  3. 最小化新代码:新功能应该尽量利用现有基础设施

5.2 导入导出的本质

导入 ≠ 一个独立的复杂操作
导入 = 解密(E2E文件) → 得到原始数据 → 上传(复用现有逻辑)
导出 ≠ 一个独立的复杂操作
导出 = 下载(复用现有逻辑) → 得到原始数据 → 加密(E2E格式)

5.3 反思

直觉是对的:

“我本来以为,我们只是做一个加解密 E2E 文件的模块,然后和上传下载接口顺序调用一下就行了”

这才是正确的思路。我们不应该被”性能优化”带偏,而忽视了架构的简洁性。


六、实施完成 ✅

6.1 后端清理

删除了过度设计的代码(~430 行):

  • E2EProgress 结构体
  • e2eProgress map 和 mutex
  • exportToE2EByPath() / doExportByPath()
  • importFromE2EByPath() / doImportByPath()
  • getE2EProgress()

添加了简化的 API(~170 行):

// POST /api/v1/e2e/decrypt
// 解密 E2E 文件到临时目录,返回路径和元数据
func (s *Server) decryptE2E(c *gin.Context)
 
// POST /api/v1/e2e/encrypt  
// 下载文件并加密为 E2E 格式,保存到指定路径
func (s *Server) encryptToE2E(c *gin.Context)

6.2 客户端重构

导入流程_startImportJob_doImport):

// 1. 解密 E2E 文件到临时目录
final decryptResult = await api.decryptE2E(
  filePath: job.filePath,
  password: job.password,
);
 
// 2. 复用 uploadByPath 上传(完全复用现有上传逻辑)
final taskResult = await api.uploadByPath(
  filePath: decrypted.tempPath,
  fileName: decrypted.fileName,
  remotePath: job.targetPath,
);
 
// 3. 轮询上传进度(复用 getUploadProgress)
while (true) {
  final progress = await api.getUploadProgress(taskId);
  // ...
}

导出流程_doExportByPath):

// 直接调用 encryptToE2E,后端一步完成下载+加密+写文件
final result = await api.encryptToE2E(
  fileId: job.file.id,
  password: job.exportPassword!,
  savePath: savePath,
);

6.3 代码统计

项目过度设计方案简化方案减少
后端新增代码~430 行~170 行-60%
客户端导入逻辑~105 行(独立实现)~220 行(但复用 uploadByPath)复用率↑
独立进度系统无(复用上传进度)统一
取消机制需额外实现复用上传取消自动
临时文件清理需额外处理复用上传清理自动

七、代码清理清单

7.1 后端待删除

  • server.go:
    • E2EProgress 结构体
    • e2eProgress map 和 mutex
    • ExportByPathRequest / ImportByPathRequest
    • exportToE2EByPath() / doExportByPath()
    • importFromE2EByPath() / doImportByPath()
    • getE2EProgress()
    • 相关路由注册

7.2 客户端待删除

  • api_client.dart:

    • exportToE2EByPath()
    • importFromE2EByPath()
    • getE2EProgress()
  • models/e2e_progress.dart: 整个文件

  • app_state.dart:

    • _doExportByPath()
    • _startImportJob() 中的轮询逻辑
    • _finishImportJob()

八、架构图对比

8.1 过度设计(当前)

┌─────────────────────────────────────────────────────────────┐
│ 客户端 │
├─────────────────────────────────────────────────────────────┤
│ enqueueExport() ──→ _doExportByPath() ──→ 轮询进度 │
│ enqueueImport() ──→ _startImportJob() ──→ 轮询进度 │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 后端 API │
├─────────────────────────────────────────────────────────────┤
│ export-by-path ──→ doExportByPath() ──→ 下载+解密+加密+写 │
│ import-by-path ──→ doImportByPath() ──→ 读+解密+加密+上传 │
│ e2e/progress ──→ 返回进度 │
│ │
│ (重复了 uploadByPath 和 downloadFile 的逻辑) │
└─────────────────────────────────────────────────────────────┘

8.2 简化方案(目标)

┌─────────────────────────────────────────────────────────────┐
│ 客户端 │
├─────────────────────────────────────────────────────────────┤
│ 导入: e2e/decrypt ──→ enqueueUpload() ──→ 复用上传逻辑 │
│ 导出: enqueueDownload() ──→ e2e/encrypt ──→ 保存文件 │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 后端 API │
├─────────────────────────────────────────────────────────────┤
│ e2e/decrypt ──→ 解密到临时文件,返回路径 │
│ e2e/encrypt ──→ 下载+解密+加密+写文件 │
│ │
│ upload-by-path ──→ (复用) 分块加密+上传+元数据 │
│ downloadFile ──→ (复用) 分块下载+解密 │
└─────────────────────────────────────────────────────────────┘

结论:功能设计时,先考虑“组合现有能力”,而不是“重新实现一切”。


九、代码清理实施过程

重构完成后,进行了彻底的代码清理,删除所有废弃的 API 和函数。

9.1 后端清理明细

删除的路由注册

// 删除前
api.POST("/e2e/export/:id", s.exportToE2E)
api.POST("/e2e/import", s.importFromE2E)
 
// 保留
// E2E 简化 API(纯加解密,复用上传下载逻辑)
api.POST("/e2e/decrypt", s.decryptE2E)
api.POST("/e2e/encrypt", s.encryptToE2E)

删除的函数

  • exportToE2E() - ~80 行,返回整个文件字节流(大文件 OOM)
  • importFromE2E() - ~126 行,multipart 上传方式
  • 总计删除 ~206 行

9.2 客户端清理明细

api_client.dart 删除

// 删除旧版 API(~74 行)
Future<ApiResult<Uint8List>> exportToE2E(...) // 返回字节数据
Future<ApiResult<FileMetadata>> importFromE2E(...) // multipart 上传

最终保留的简化 API

/// 解密 E2E 文件到临时目录
Future<ApiResult<E2EDecryptResult>> decryptE2E({
  required String filePath,
  required String password,
})
 
/// 下载文件并加密为 E2E 格式
Future<ApiResult<E2EEncryptResult>> encryptToE2E({
  required String fileId,
  required String password,
  required String savePath,
})