Template
chore: 初始化脚手架 — sunny_mochi 企业级 Flutter 模板
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
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
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',
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
import 'package:sunny_mochi/core/storage/app_database.dart';
|
||||
import 'package:sunny_mochi/core/storage/tables/users_table.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'users_dao.g.dart';
|
||||
|
||||
@DriftAccessor(tables: [Users])
|
||||
class UsersDao extends DatabaseAccessor<AppDatabase> with _$UsersDaoMixin {
|
||||
UsersDao(super.attachedDatabase);
|
||||
|
||||
Future<UserRow?> getById(String userId) =>
|
||||
(select(users)..where((u) => u.userId.equals(userId))).getSingleOrNull();
|
||||
|
||||
Stream<UserRow?> watchById(String userId) => (select(
|
||||
users,
|
||||
)..where((u) => u.userId.equals(userId))).watchSingleOrNull();
|
||||
|
||||
Future<void> upsert(UsersCompanion user) =>
|
||||
into(users).insertOnConflictUpdate(user);
|
||||
|
||||
Future<int> deleteById(String userId) =>
|
||||
(delete(users)..where((u) => u.userId.equals(userId))).go();
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
UsersDao usersDao(Ref ref) => UsersDao(ref.watch(appDatabaseProvider));
|
||||
@@ -0,0 +1,64 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'users_dao.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
mixin _$UsersDaoMixin on DatabaseAccessor<AppDatabase> {
|
||||
$UsersTable get users => attachedDatabase.users;
|
||||
UsersDaoManager get managers => UsersDaoManager(this);
|
||||
}
|
||||
|
||||
class UsersDaoManager {
|
||||
final _$UsersDaoMixin _db;
|
||||
UsersDaoManager(this._db);
|
||||
$$UsersTableTableManager get users =>
|
||||
$$UsersTableTableManager(_db.attachedDatabase, _db.users);
|
||||
}
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(usersDao)
|
||||
final usersDaoProvider = UsersDaoProvider._();
|
||||
|
||||
final class UsersDaoProvider
|
||||
extends $FunctionalProvider<UsersDao, UsersDao, UsersDao>
|
||||
with $Provider<UsersDao> {
|
||||
UsersDaoProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'usersDaoProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$usersDaoHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<UsersDao> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
UsersDao create(Ref ref) {
|
||||
return usersDao(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(UsersDao value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<UsersDao>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$usersDaoHash() => r'209c5286cb20f72750a77a007a5c04c7ef22bfea';
|
||||
@@ -0,0 +1,60 @@
|
||||
// ignore_for_file: one_member_abstracts — 是策略接口(PBKDF2 / 服务端下发等替换实现 P3 落地)
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
/// SQLCipher 数据库主密钥派生策略。
|
||||
///
|
||||
/// 当前实现:`RandomKeyStrategy` — 首次启动随机生成 32 字节,
|
||||
/// 通过 [FlutterSecureStorage](iOS Keychain / Android EncryptedSharedPreferences)持久化。
|
||||
/// 用户无感知,体验最佳;用户换设备会丢失本地数据(云端备份不影响)。
|
||||
///
|
||||
/// 备选策略(待 Q7 答复决议,仅替换 [DbKeyProvider.strategy] 即可,调用方零改动):
|
||||
/// - 用户密码派生(PBKDF2):强合规,改密复杂
|
||||
/// - 服务端下发:可主动撤销,离线不可解锁
|
||||
/// - 两段式(A+B 组合):最复杂
|
||||
///
|
||||
/// 详见 docs/flutter-architecture-design.md §九.1.2 与 §十一.5。
|
||||
abstract class KeyDerivationStrategy {
|
||||
Future<String> deriveKey();
|
||||
}
|
||||
|
||||
class RandomKeyStrategy implements KeyDerivationStrategy {
|
||||
RandomKeyStrategy({FlutterSecureStorage? storage})
|
||||
: _storage = storage ?? _defaultStorage;
|
||||
|
||||
static const _storageKey = 'db_master_key_v1';
|
||||
static const FlutterSecureStorage _defaultStorage = FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
|
||||
);
|
||||
|
||||
final FlutterSecureStorage _storage;
|
||||
|
||||
@override
|
||||
Future<String> deriveKey() async {
|
||||
final existing = await _storage.read(key: _storageKey);
|
||||
if (existing != null && existing.isNotEmpty) return existing;
|
||||
|
||||
final rng = Random.secure();
|
||||
final bytes = List<int>.generate(32, (_) => rng.nextInt(256));
|
||||
final key = base64Url.encode(bytes);
|
||||
await _storage.write(key: _storageKey, value: key);
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
class DbKeyProvider {
|
||||
DbKeyProvider({KeyDerivationStrategy? strategy})
|
||||
: _strategy = strategy ?? RandomKeyStrategy();
|
||||
|
||||
final KeyDerivationStrategy _strategy;
|
||||
|
||||
String? _cached;
|
||||
|
||||
Future<String> get key async {
|
||||
return _cached ??= await _strategy.deriveKey();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
/// Token / 用户标识等敏感数据的本地持久化(对应 iOS UserManager.shared.token)。
|
||||
///
|
||||
/// **DI 注入(R-TEST-1 / 2026-05-10)**:构造函数接受可选 [FlutterSecureStorage],
|
||||
/// 测试时可注入 in-memory 替代实现,避开真实 Keychain / EncryptedSharedPreferences。
|
||||
/// 生产代码通过 [secureStorageProvider] 获取实例,**禁止再使用 [SecureStorage.instance]**。
|
||||
class SecureStorage {
|
||||
SecureStorage({FlutterSecureStorage? storage})
|
||||
: _storage = storage ?? _defaultStorage;
|
||||
|
||||
/// 兼容旧调用点的静态实例(与 provider 默认值保持一致)。
|
||||
/// 新代码应通过 [secureStorageProvider] 注入。
|
||||
static final instance = SecureStorage();
|
||||
|
||||
static const FlutterSecureStorage _defaultStorage = FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
iOptions: IOSOptions(
|
||||
// 与 db_key_provider.dart 一致:解锁后台可读,避免锁屏后 token 不可用
|
||||
accessibility: KeychainAccessibility.first_unlock,
|
||||
),
|
||||
);
|
||||
|
||||
final FlutterSecureStorage _storage;
|
||||
|
||||
static const _keyToken = 'auth_token';
|
||||
/// sa-token 动态 header 名(对齐 iOS UserManager.tokenNameKey)。
|
||||
static const _keyTokenName = 'auth_token_name';
|
||||
static const _keyRefreshToken = 'refresh_token';
|
||||
static const _keyUserId = 'user_id';
|
||||
|
||||
Future<String?> getToken() => _storage.read(key: _keyToken);
|
||||
Future<void> setToken(String token) =>
|
||||
_storage.write(key: _keyToken, value: token);
|
||||
|
||||
/// sa-token 动态 header 名(如 `satoken`)— 必须从登录响应取,不能硬编码。
|
||||
Future<String?> getTokenName() => _storage.read(key: _keyTokenName);
|
||||
Future<void> setTokenName(String name) =>
|
||||
_storage.write(key: _keyTokenName, value: name);
|
||||
|
||||
Future<String?> getRefreshToken() => _storage.read(key: _keyRefreshToken);
|
||||
Future<void> setRefreshToken(String token) =>
|
||||
_storage.write(key: _keyRefreshToken, value: token);
|
||||
|
||||
Future<String?> getUserId() => _storage.read(key: _keyUserId);
|
||||
Future<void> setUserId(String id) =>
|
||||
_storage.write(key: _keyUserId, value: id);
|
||||
|
||||
Future<void> clearAll() => _storage.deleteAll();
|
||||
}
|
||||
|
||||
/// SecureStorage 全局 provider — storage DI 的单一定义点。
|
||||
///
|
||||
/// 构造注入规则(R-ARCH-2):所有需要访问安全存储的类(拦截器/Repository/Provider)
|
||||
/// 必须通过此 provider 获取,禁止直接引用 [SecureStorage.instance] 静态单例。
|
||||
final secureStorageProvider = Provider<SecureStorage>(
|
||||
(ref) => SecureStorage.instance,
|
||||
);
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
/// 本地错误日志表(keep last 200)。
|
||||
///
|
||||
/// [kind] — 错误分类(ErrorKind.name),前端展示用
|
||||
/// [message] — 技术细节,供后端追踪(不展示给用户)
|
||||
/// [displayMessage] — 用户看到的友好文案(Failure.userMessage)
|
||||
/// [code] — 业务错误码(ServerFailure.code,如 A0500)
|
||||
/// [statusCode] — HTTP 状态码(ServerFailure.statusCode)
|
||||
/// [reported] — 是否已通过"错误报告"功能上报过
|
||||
///
|
||||
/// 设备信息(v5 新增,nullable 兼容迁移):
|
||||
/// [deviceModel] / [osVersion] / [appVersion] / [appBuild]
|
||||
/// 在 log() 时快照,确保上报的是**错误发生时**的设备上下文,而非发送时。
|
||||
@DataClassName('ErrorLogRow')
|
||||
class ErrorLogs extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
TextColumn get kind => text()();
|
||||
TextColumn get message => text()();
|
||||
TextColumn get displayMessage => text()();
|
||||
TextColumn get code => text().nullable()();
|
||||
IntColumn get statusCode => integer().nullable()();
|
||||
DateTimeColumn get occurredAt => dateTime()();
|
||||
BoolColumn get reported =>
|
||||
boolean().withDefault(const Constant(false))();
|
||||
// v5: 设备上下文(事发时快照)
|
||||
TextColumn get deviceModel => text().nullable()();
|
||||
TextColumn get osVersion => text().nullable()();
|
||||
TextColumn get appVersion => text().nullable()();
|
||||
TextColumn get appBuild => text().nullable()();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
import 'package:sunny_mochi/core/sync/sync_status.dart';
|
||||
|
||||
/// 所有需要 Offline-First 同步的 Drift 表必须 mixin 这组 4 字段。
|
||||
///
|
||||
/// - [syncStatus] 当前状态([SyncStatus])
|
||||
/// - [localUpdatedAt] 本地最后修改时间(用于决定 dirty)
|
||||
/// - [serverUpdatedAt] 服务端最后修改时间(last-write-wins 比较基准)
|
||||
/// - [conflictPayload] 冲突时备份的服务端版本 JSON(人工或策略恢复)
|
||||
///
|
||||
/// 详见 docs/flutter-architecture-design.md §五.21。
|
||||
mixin SyncColumns on Table {
|
||||
IntColumn get syncStatus =>
|
||||
intEnum<SyncStatus>().withDefault(const Constant(0))();
|
||||
DateTimeColumn get localUpdatedAt =>
|
||||
dateTime().withDefault(currentDateAndTime)();
|
||||
DateTimeColumn get serverUpdatedAt => dateTime().nullable()();
|
||||
TextColumn get conflictPayload => text().nullable()();
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:sunny_mochi/core/storage/tables/sync_columns.dart';
|
||||
|
||||
/// 用户本地缓存表(对应 iOS UserManager.currentUser 持久化)。
|
||||
///
|
||||
/// token 不存 DB — 由 SecureStorage 独占(Keychain / EncryptedSharedPreferences);
|
||||
/// refreshToken 镜像存入 DB 仅用于可观测性,TokenRefreshInterceptor 仍读 SecureStorage。
|
||||
@DataClassName('UserRow')
|
||||
class Users extends Table with SyncColumns {
|
||||
TextColumn get userId => text()();
|
||||
TextColumn get username => text().nullable()();
|
||||
TextColumn get realName => text().nullable()();
|
||||
TextColumn get phone => text().nullable()();
|
||||
TextColumn get avatar => text().nullable()();
|
||||
IntColumn get gender => integer().nullable()();
|
||||
IntColumn get age => integer().nullable()();
|
||||
TextColumn get refreshToken => text().nullable()();
|
||||
TextColumn get email => text().nullable()();
|
||||
DateTimeColumn get birthday => dateTime().nullable()();
|
||||
TextColumn get employeeNo => text().nullable()();
|
||||
TextColumn get company => text().nullable()();
|
||||
TextColumn get department => text().nullable()();
|
||||
|
||||
@override
|
||||
Set<Column<Object>> get primaryKey => {userId};
|
||||
}
|
||||
Reference in New Issue
Block a user