Template
Core 基础设施:Flavor 三包体系、Drift+SQLCipher 加密数据库 7 拦截器 Dio 网络栈、CrashReporter 崩溃日志、Sealed Failure 错误体系 5 色板主题系统、Auth 认证骨架(Clean Architecture)、Dev Panel、Mock Adapter 通过验证:flutter analyze(0 errors)、flutter test(全绿)、staging APK 74.8MB
153 lines
6.1 KiB
Dart
153 lines
6.1 KiB
Dart
import 'dart:ffi';
|
||
import 'dart:io';
|
||
|
||
import 'package:drift/drift.dart';
|
||
import 'package:drift/native.dart';
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:path/path.dart' as p;
|
||
import 'package:path_provider/path_provider.dart';
|
||
import 'package:sunny_mochi/core/storage/db_key_provider.dart';
|
||
import 'package:sunny_mochi/core/storage/tables/error_logs_table.dart';
|
||
import 'package:sunny_mochi/core/storage/tables/users_table.dart';
|
||
import 'package:sunny_mochi/core/sync/sync_status.dart';
|
||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||
import 'package:sqlcipher_flutter_libs/sqlcipher_flutter_libs.dart';
|
||
// ignore: depend_on_referenced_packages — sqlite3 是 sqlcipher_flutter_libs 的传递依赖
|
||
import 'package:sqlite3/open.dart' as sqlite3_open;
|
||
// ignore: depend_on_referenced_packages — sqlite3 是 sqlcipher_flutter_libs 的传递依赖
|
||
import 'package:sqlite3/sqlite3.dart' as sqlite3_pkg;
|
||
|
||
part 'app_database.g.dart';
|
||
|
||
/// SQLCipher 库注册函数,同时用于主 Isolate 和后台 Isolate。
|
||
///
|
||
/// 必须是**顶层函数**(不能是匿名闭包)——Drift 通过 Isolate.spawn 将其传递给
|
||
/// 后台 Isolate 时走 SendPort.send 序列化路径,顶层函数引用保证可靠传递;
|
||
/// 匿名闭包在某些 Drift 2.x / Dart VM 版本组合下会静默失效(无错误,仅 setup
|
||
/// 不执行),导致后台 Isolate 继续使用系统 plain sqlite3。
|
||
void _sqlCipherIsolateSetup() {
|
||
if (kIsWeb) return;
|
||
if (Platform.isAndroid) {
|
||
sqlite3_open.open.overrideFor(
|
||
sqlite3_open.OperatingSystem.android,
|
||
openCipherOnAndroid,
|
||
);
|
||
} else if (Platform.isIOS) {
|
||
sqlite3_open.open.overrideFor(
|
||
sqlite3_open.OperatingSystem.iOS,
|
||
DynamicLibrary.process,
|
||
);
|
||
} else if (Platform.isMacOS) {
|
||
sqlite3_open.open.overrideFor(
|
||
sqlite3_open.OperatingSystem.macOS,
|
||
DynamicLibrary.process,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 应用本地加密数据库(Drift + SQLCipher AES-256)。
|
||
///
|
||
/// 脚手架简化版(schemaVersion = 1):
|
||
/// - 只包含 UsersTable 和 ErrorLogsTable
|
||
/// - 无历史迁移路径,初始 v1 schema 即为全量 schema
|
||
///
|
||
/// 启动序列:
|
||
/// 1. 注册 SQLCipher 原生库(主 Isolate + 后台 Isolate 各自注册)
|
||
/// 2. 拿到加密密钥(来自 [DbKeyProvider],详见 T-0.5)
|
||
/// 3. 探测现有文件是否可用当前密钥打开;失败则删除重建
|
||
/// 4. NativeDatabase setup 时执行 `PRAGMA key = '...'` 解锁
|
||
///
|
||
/// 详见 docs/flutter-architecture-design.md §九.1.1 + §十一.5。
|
||
@DriftDatabase(tables: [Users, ErrorLogs])
|
||
class AppDatabase extends _$AppDatabase {
|
||
AppDatabase._(super.e);
|
||
|
||
/// 测试专用:内存 SQLite(不走 SQLCipher,用于纯 schema/逻辑测试)。
|
||
///
|
||
/// 生产路径必须用 [open](SQLCipher AES-256)。
|
||
@visibleForTesting
|
||
factory AppDatabase.testInMemory() => AppDatabase._(NativeDatabase.memory());
|
||
|
||
static Future<AppDatabase> open({DbKeyProvider? keyProvider}) async {
|
||
// 必须在主 Isolate 执行(内部用 MethodChannel):确保 libsqlcipher.so 已通过
|
||
// Java System.loadLibrary 加载到进程内存,后台 Isolate 的 dlopen 才能找到它。
|
||
if (!kIsWeb && Platform.isAndroid) {
|
||
await applyWorkaroundToOpenSqlCipherOnOldAndroidVersions();
|
||
}
|
||
|
||
// 主 Isolate 也注册 SQLCipher override,供下方探测检查使用。
|
||
// 各 Isolate 全局状态独立,此处设置不影响后台 Isolate(由 isolateSetup 负责)。
|
||
if (!kIsWeb) _sqlCipherIsolateSetup();
|
||
|
||
final dir = await getApplicationDocumentsDirectory();
|
||
final file = File(p.join(dir.path, 'app.db'));
|
||
final key = await (keyProvider ?? DbKeyProvider()).key;
|
||
final escaped = key.replaceAll("'", "''");
|
||
|
||
// 探测:在后台 Isolate 启动前,用当前密钥尝试打开现有文件。
|
||
// 捕获所有不可恢复情况:
|
||
// - plain SQLite 遗留文件(未加密,PRAGMA key 被忽略)
|
||
// - SQLCipher 文件但密钥不匹配(SecureStorage 重置等场景)
|
||
// - 文件损坏
|
||
// 检测失败直接删除 —— 开发阶段文件无不可恢复的用户数据。
|
||
if (!kIsWeb && file.existsSync()) {
|
||
if (!_canOpenWithKey(file.path, escaped)) {
|
||
file.deleteSync();
|
||
}
|
||
}
|
||
|
||
return AppDatabase._(
|
||
NativeDatabase.createInBackground(
|
||
file,
|
||
// 顶层函数引用(见 _sqlCipherIsolateSetup 注释)
|
||
isolateSetup: _sqlCipherIsolateSetup,
|
||
setup: (db) {
|
||
// R-SEC-1 / 2026-05-10:PRAGMA key 字符串格式 — 必须做 SQL 单引号转义,
|
||
// 防止未来策略升级(PBKDF2 / 服务端下发)后 key 含 ' 字符触发注入。
|
||
//
|
||
// 注意:不切换到 SQLCipher 推荐的 raw key HEX 格式(x'...'),原因:
|
||
// 该格式要求恰好 64 个 hex 字符(32 字节 raw key),现有 RandomKeyStrategy
|
||
// 产出的 base64Url(32 bytes) ≈ 43 字符不符合,切换会破坏已加密 DB 的兼容。
|
||
// 完整迁移到 raw key 需独立设计(含数据迁移路径),见 readiness checklist。
|
||
db.execute("PRAGMA key = '$escaped';");
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 用当前密钥同步探测文件是否可正常读取。
|
||
///
|
||
/// 在主 Isolate 执行(open() 在 runApp 之前 await),不阻塞 UI。
|
||
/// 主 Isolate 已通过 [_sqlCipherIsolateSetup] 注册 SQLCipher,
|
||
/// 所以 sqlite3.open 走的是 SQLCipher,与后台 Isolate 行为一致。
|
||
static bool _canOpenWithKey(String path, String escapedKey) {
|
||
try {
|
||
final probe = sqlite3_pkg.sqlite3.open(path);
|
||
try {
|
||
probe
|
||
..execute("PRAGMA key = '$escapedKey';")
|
||
..select('PRAGMA user_version;');
|
||
return true;
|
||
} finally {
|
||
probe.dispose();
|
||
}
|
||
} on Exception catch (_) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
@override
|
||
int get schemaVersion => 1;
|
||
|
||
@override
|
||
MigrationStrategy get migration => MigrationStrategy(
|
||
onCreate: (m) => m.createAll(),
|
||
);
|
||
}
|
||
|
||
/// 全局 AppDatabase provider。在 main.dart 通过 override 注入实例。
|
||
@Riverpod(keepAlive: true)
|
||
AppDatabase appDatabase(Ref ref) => throw UnimplementedError(
|
||
'appDatabaseProvider must be overridden in main.dart',
|
||
);
|