背景
原有设计中,S3 配置通过 Settings 页面的弹窗设置,存储在 SharedPreferences 中,只支持单个 S3 配置。使用上需要在不同 S3 存储之间切换时非常不便。本次重构实现:
- S3 配置独立页面(类似 Debug 页面)
- 支持多个 S3 配置的管理和切换
- 所有本地数据按 S3 配置隔离
- 端到端加密密钥移入 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 只派生密钥,不验证正确性,密钥错误时后续解密才会失败。
解决方案: 添加验证文件
- 初始化时:用派生密钥加密
E2EEPAN_VERIFY,存储为.e2eepan/verify - 解锁时:尝试解密验证文件,失败则返回 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 删除的内容
- SharedPreferences 迁移代码 (
_loadOrMigrateS3Config) - 旧 S3 setter 方法 (
setS3Endpoint,setS3AccessKey等) - DownloadTasks 表 及相关方法
_syncS3ConfigToPrefs方法
6.2 数据库迁移历史
| Version | 变更 |
|---|---|
| v2 | 添加 TransferTasks 表 |
| v3 | 添加 S3Configs 表 |
| v4 | Files/TransferTasks 添加 s3ConfigId 字段 |
| v5 | 删除废弃的 DownloadTasks 表 |
七、经验总结
-
数据隔离的正确方式: 在数据库层面按配置 ID 过滤,切换时加载对应数据而不是清空
-
密钥验证的必要性: 端到端加密必须在解锁时验证密钥正确性,否则只能在后续操作失败时才发现密钥错误
-
进度更新节流: 高频进度回调会严重影响 UI 性能,需要合理节流
-
流式响应: 大文件下载必须使用流式响应才能让客户端获取实时进度
-
虚拟文件夹陷阱:
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- 密钥验证机制