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:
SkyJourney
2026-05-14 12:51:05 +08:00
commit 61017f1c39
204 changed files with 15386 additions and 0 deletions
+152
View File
@@ -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-10PRAGMA 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
+28
View File
@@ -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));
+64
View File
@@ -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';
+60
View File
@@ -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();
}
}
+59
View File
@@ -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()();
}
+20
View File
@@ -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()();
}
+26
View File
@@ -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};
}