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

背景

原有设计中,S3 配置通过 Settings 页面的弹窗设置,存储在 SharedPreferences 中,只支持单个 S3 配置。使用上需要在不同 S3 存储之间切换时非常不便。本次重构实现:

  1. S3 配置独立页面(类似 Debug 页面)
  2. 支持多个 S3 配置的管理和切换
  3. 所有本地数据按 S3 配置隔离
  4. 端到端加密密钥移入 S3 配置,并实现密钥验证

一、数据库 Schema 设计

1.1 新增 S3Configs 表

@DataClassName('S3ConfigRecord')
class S3Configs extends Table {
  TextColumn get id => text()();
  TextColumn get name => text()();           // 配置名称
  TextColumn get endpoint => text().nullable()();
  TextColumn get accessKey => text().nullable()();
  TextColumn get secretKey => text().nullable()();
  TextColumn get bucket => text().nullable()();
  BoolColumn get useSsl => boolean().withDefault(const Constant(false))();
  TextColumn get vaultPassword => text().nullable()();  // 端到端加密密钥
  BoolColumn get isActive => boolean().withDefault(const Constant(false))();
  DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
  DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
 
  @override
  Set<Column> get primaryKey => {id};
}

1.2 Files 表添加 s3ConfigId

class Files extends Table {
  TextColumn get id => text()();
  TextColumn get s3ConfigId => text()();  // 关联的 S3 配置 ID
  // ... 其他字段
}

1.3 TransferTasks 表添加 s3ConfigId

class TransferTasks extends Table {
  TextColumn get id => text()();
  TextColumn get s3ConfigId => text()();  // 关联的 S3 配置 ID
  // ... 其他字段
}

1.4 删除废弃的 DownloadTasks 表

旧的 DownloadTasks 表已被 TransferTasks 完全替代,在 schema v5 中删除。


二、数据隔离策略

2.1 核心原则

数据类型隔离方式切换 S3 时全局清空时
文件索引按 s3ConfigId加载对应数据全部清空
传输任务按 s3ConfigId加载对应任务全部清空
缩略图不隔离(本地文件)保留全部清空
S3 配置独立存储仅切换激活状态保留

2.2 切换 S3 配置的实现

Future<void> switchToS3Config(String configId) async {
  await db.setActiveS3Config(configId);
  
  final config = await db.getS3ConfigById(configId);
  if (config == null) return;
  
  _applyS3Config(config);
  
  // 清空内存状态
  _files = [];
  _transfers.clear();
  // ...
  
  // 从数据库加载对应 S3 的缓存数据(不是清空!)
  await _loadFromLocalDb();
  await _loadTransfersFromDb();
  
  // 重启内核应用新配置
  await restartEmbeddedCoreIfNeeded();
}

2.3 数据库查询按 s3ConfigId 过滤

// 获取指定 S3 配置的文件
Future<List<FileRecord>> getAllFiles(String s3ConfigId) {
  return (select(files)
    ..where((f) => f.s3ConfigId.equals(s3ConfigId))).get();
}
 
// 获取指定 S3 配置的传输任务
Future<List<TransferRecord>> getAllTransfers(String s3ConfigId) {
  return (select(transferTasks)
    ..where((t) => t.s3ConfigId.equals(s3ConfigId))).get();
}
 
// 清空完成的传输任务(按 S3 配置)
Future<int> clearCompletedTransfers({required String s3ConfigId, int? type}) {
  return (delete(transferTasks)
    ..where((t) => t.s3ConfigId.equals(s3ConfigId) & t.status.equals(2)))
    .go();
}

三、端到端加密密钥管理

3.1 密钥存储位置变更

  • 旧方案: SharedPreferences 中的 vault_password
  • 新方案: S3Configs 表的 vaultPassword 字段

3.2 密钥操作边界

S3 配置页面中的密钥:

  • ✅ 可以查看(点击眼睛图标显示)
  • ✅ 可以复制(点击复制按钮)
  • ✅ 可以清除(点击删除按钮后确认)
  • ❌ 不能修改(只能在文件界面重新设置)
// 密钥区块只在有密钥时显示
if (_hasVaultPassword) ...[
  Container(
    child: Row(
      children: [
        Text(_showVaultPassword ? _vaultPassword! : '•' * 16),
        IconButton(icon: Icon(Icons.visibility), onPressed: _toggleShow),
        IconButton(icon: Icon(Icons.copy), onPressed: _copyVaultPassword),
        IconButton(icon: Icon(Icons.delete_outline), onPressed: _clearVaultPassword),
      ],
    ),
  ),
]

3.3 后端密钥验证机制

问题: 原有 unlockVault 只派生密钥,不验证正确性,密钥错误时后续解密才会失败。

解决方案: 添加验证文件

  1. 初始化时:用派生密钥加密 E2EEPAN_VERIFY,存储为 .e2eepan/verify
  2. 解锁时:尝试解密验证文件,失败则返回 401
// initKey 中保存验证文件
verifyData := []byte("E2EEPAN_VERIFY")
verifyKey := crypto.DeriveFileKey(s.masterKey, "verify")
encrypted, _ := s.encryptor.EncryptChunkSimple(verifyData, verifyKey)
s.s3.UploadBytes(ctx, ".e2eepan/verify", encrypted, "application/octet-stream")
 
// unlockVault 中验证密钥
verifyEncrypted, err := s.s3.DownloadBytes(ctx, ".e2eepan/verify")
if err != nil {
    // 旧版本没有验证文件,跳过验证
    s.masterKey = masterKey
    return
}
decrypted, err := encryptor.DecryptChunkSimple(verifyEncrypted, verifyKey)
if err != nil || string(decrypted) != "E2EEPAN_VERIFY" {
    c.JSON(http.StatusUnauthorized, gin.H{"error": "密码错误"})
    return
}

四、上传/下载进度优化

4.1 上传进度节流

问题: 进度回调频率过高导致 UI 卡顿

解决方案: 只在进度变化 ≥1% 时更新

int lastNotifyProgress = -1;
onProgress: (sent, total) {
  final p = total > 0 ? ((sent * 100) ~/ total) : 0;
  final shouldNotify = p >= 100 || p > lastNotifyProgress;
  if (shouldNotify) {
    lastNotifyProgress = p;
    notifyListeners();
    db.updateTransfer(id: job.id, progress: p, size: total);
  }
}

4.2 下载改为流式响应

问题: 服务端使用 c.Data() 一次性返回,客户端只收到一次进度回调

解决方案: 设置 Content-Length + 流式写入

func (s *Server) downloadFile(c *gin.Context) {
  // 设置 Content-Length 让客户端能获取进度
  c.Header("Content-Length", fmt.Sprintf("%d", chunkMeta.OriginalSize))
  c.Header("Content-Type", chunkMeta.MimeType)
 
  c.Status(http.StatusOK)
  for i := 0; i < chunkMeta.TotalChunks; i++ {
    // 下载解密分块...
    c.Writer.Write(decrypted)
    c.Writer.Flush()
  }
}

五、UI 优化

5.1 S3 配置列表菜单改为底部弹出

与 App 其他页面保持一致的交互体验:

void _showActionSheet(BuildContext context) {
  showModalBottomSheet(
    context: context,
    shape: const RoundedRectangleBorder(
      borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
    ),
    builder: (ctx) => SafeArea(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          if (!config.isActive)
            ListTile(
              leading: Icon(Icons.check_circle_outline),
              title: Text('切换到此配置'),
              onTap: () { Navigator.pop(ctx); onActivate(); },
            ),
          ListTile(
            leading: Icon(Icons.delete_outline, color: Colors.red),
            title: Text('删除', style: TextStyle(color: Colors.red)),
            onTap: () { Navigator.pop(ctx); onDelete(); },
          ),
        ],
      ),
    ),
  );
}

5.2 修复初始化/解密提示不显示的问题

问题: 开启缩略图/游离文件夹开关后,currentFiles 包含虚拟系统文件夹,导致 files.isEmpty 为 false

解决方案: 使用 state.files 判断实际文件数量

final hasRealFiles = state.files.isNotEmpty;  // 实际文件,不含虚拟文件夹
 
child: !hasRealFiles && showVaultInline
    ? Center(child: _buildVaultInline(state))
    : files.isEmpty
        ? Center(child: _buildEmptyPlaceholder(state))
        : RefreshIndicator(...)

六、废弃代码清理

6.1 删除的内容

  1. SharedPreferences 迁移代码 (_loadOrMigrateS3Config)
  2. 旧 S3 setter 方法 (setS3Endpoint, setS3AccessKey 等)
  3. DownloadTasks 表 及相关方法
  4. _syncS3ConfigToPrefs 方法

6.2 数据库迁移历史

Version变更
v2添加 TransferTasks 表
v3添加 S3Configs 表
v4Files/TransferTasks 添加 s3ConfigId 字段
v5删除废弃的 DownloadTasks 表

七、经验总结

  1. 数据隔离的正确方式: 在数据库层面按配置 ID 过滤,切换时加载对应数据而不是清空

  2. 密钥验证的必要性: 端到端加密必须在解锁时验证密钥正确性,否则只能在后续操作失败时才发现密钥错误

  3. 进度更新节流: 高频进度回调会严重影响 UI 性能,需要合理节流

  4. 流式响应: 大文件下载必须使用流式响应才能让客户端获取实时进度

  5. 虚拟文件夹陷阱: currentFiles 可能包含虚拟文件夹,判断文件列表是否为空时要使用原始 files


文件变更清单

  • client/lib/core/database/database.dart - 新增 S3Configs 表,修改 Files/TransferTasks 表
  • client/lib/core/state/app_state.dart - S3 配置管理、数据隔离逻辑
  • client/lib/ui/s3_config_page.dart - 新建 S3 配置独立页面
  • client/lib/ui/settings_page.dart - 移除旧的 S3 弹窗
  • client/lib/ui/files_page.dart - 修复初始化提示判断
  • core/internal/api/server.go - 密钥验证机制