feat: add engineering conventions and CI gates documentation

- Introduced a new document outlining SDK version locking, static analysis, formatting, generated artifacts management, branching and commit conventions, and CI gate checks.
- Updated README to include the new conventions document.
- Modified API design to use numeric error codes instead of strings, with a dedicated ErrorCode object for better maintainability.
- Adjusted global exception handling to return numeric error codes.
- Updated tests to reflect changes in error code handling.
This commit is contained in:
Guangfei.Zhao
2026-08-13 19:28:36 +08:00
parent be009ac15e
commit 444db49818
17 changed files with 3362 additions and 239 deletions
+179 -14
View File
@@ -2,28 +2,38 @@
## 决策
按数据类型分三档存储,全部封装在 `core_storage` 包内,`feature_*` 不直接依赖底层存储库:
按数据类型分三档存储,`feature_*` 不直接依赖底层存储库:
| 数据类型 | 方案 | 版本(2026-08 快照) |
|---|---|---|
| 结构化/关系型数据(门店列表缓存、订单历史等) | **[Drift](https://pub.dev/packages/drift)** | `^2.34.3` |
| 敏感数据(token、refresh token | **[flutter_secure_storage](https://pub.dev/packages/flutter_secure_storage)** | `^11.0.0` |
| 简单非敏感 KV(是否看过引导页、用户偏好设置) | **[shared_preferences](https://pub.dev/packages/shared_preferences)** | `^2.5.5` |
| 数据类型 | 方案 | 版本(2026-08 快照) | 归属包 |
|---|---|---|---|
| 结构化/关系型数据(门店列表缓存、订单历史等) | **[Drift](https://pub.dev/packages/drift)** | `^2.34.3` | `core_storage` |
| 敏感数据(token、refresh token | **[flutter_secure_storage](https://pub.dev/packages/flutter_secure_storage)** | `11.0.0`(锁死) | **`core_auth`** |
| 简单非敏感 KV(是否看过引导页、用户偏好设置) | **[shared_preferences](https://pub.dev/packages/shared_preferences)** | `^2.5.5` | `core_storage` |
> **secure storage 归 `core_auth` 独占,不放进 `core_storage`。** 唯一读写 token 的地方就是 `core_auth`,把它放进 `core_storage` 会逼出一条 `core_auth → core_storage` 的依赖,而 `core_storage` 里其他东西 `core_auth` 一样都用不上(见 [01-project-structure.md](./01-project-structure.md) 的依赖例外表)。代价是 `core_auth` 自己要依赖 `flutter_secure_storage`,这比多一条包间依赖划算。
## 依赖
```yaml
# core_storage
dependencies:
drift: ^2.34.3
sqlite3_flutter_libs: ^0.5.0
flutter_secure_storage: ^11.0.0
drift_flutter: ^0.3.1 # 打开数据库的官方 Flutter 胶水包
path_provider: ^2.1.6
shared_preferences: ^2.5.5
dev_dependencies:
drift_dev: ^2.34.3
build_runner: ^2.4.0
drift_dev: ^2.34.5
build_runner: ^2.15.2
# core_auth
dependencies:
flutter_secure_storage: 11.0.0 # 锁死,不用 ^,理由见下文
```
> **不要再写 `sqlite3_flutter_libs`。** 这个包已经 **EOL**(最新版本号就叫 `0.6.0+eol`),sqlite3 3.x 起不再需要它。drift 官方现在的推荐组合是 `drift_flutter` + `path_provider``driftDatabase()` 会帮你处理原生库加载、数据库文件路径、以及后台 isolate。
## 使用规则
- 全仓库**只有一个 Drift 数据库实例**,定义在 `core_storage` 里,不允许每个 `feature_*` 各自建一个 SQLite 文件——避免多个数据库文件之间做跨 feature 查询/事务的麻烦。
@@ -32,10 +42,155 @@ dev_dependencies:
- token / refresh token 只能经过 `core_auth` 包里封装的 secure storage 读写方法,不允许其他 `core_*`/`feature_*` 直接调用 `FlutterSecureStorage` 实例。
- 数据库表结构变更必须写 migration(`onUpgrade` + `schemaVersion` 递增),不允许直接改字段定义后期望"重装了事"——线上用户已有数据需要平滑迁移。
## 所有业务缓存表必须带 `storeId`
PRD §11.4 要求切换门店后购物车、待办、预警、订单上下文全部跟着切。本地缓存是最容易漏的一环——provider 失效了,但 Drift 里 A 门店的数据还在,切到 B 门店离线打开页面就会读到 A 门店的数据。
**硬性规则**:任何缓存业务数据的表都必须有 `storeId` 列,并且
- 所有查询都带 `where(tbl.storeId.equals(currentStoreId))`,不允许无门店条件的全表查询;
- `storeId` 建索引;
- 表的主键包含 `storeId`(或用 `(storeId, businessId)` 联合主键),避免不同门店的同 ID 记录互相覆盖。
```dart
class PurchaseOrderCache extends Table {
IntColumn get storeId => integer()();
TextColumn get orderId => text()();
TextColumn get payload => text()();
DateTimeColumn get cachedAt => dateTime()();
@override
Set<Column> get primaryKey => {storeId, orderId}; // 联合主键,天然按门店隔离
}
```
不设 `storeId` 的表只有一类:**与门店无关的全局数据**(如 App 配置、引导页标记),这类应该放 `shared_preferences` 而不是 Drift。
## 登出 / 切换门店的清理策略
| 场景 | Drift 业务表 | shared_preferences | secure storage (token) | H5 会话 |
|---|---|---|---|---|
| **切换门店** | 删除**非当前门店**的行(或全清,见下) | 保留 | 保留 | 失效(见 [10-webview-h5.md](./10-webview-h5.md) |
| **登出** | **全部清空** | 只清与用户相关的键,保留 App 级偏好 | **全部清空** | 失效 + 清 Cookie/LocalStorage |
| **切换账号** | 同登出 | 同登出 | 同登出 | 同登出 |
**切换门店时是"只留当前门店"还是"全清"**:选**全清**。理由是保留其他门店的旧数据没有实际收益(用户切回去时数据早已过期,还是要重新拉),但会带来"用户看到的是几天前的数据却没有任何提示"这类问题;而全清的代价只是切回去时多一次 loading。
```dart
// packages/core_storage/lib/src/app_database.dart
extension StoreScopedCleanup on AppDatabase {
/// 切换门店 / 登出时调用;在一个事务里清,避免清一半被杀进程留下不一致状态
Future<void> clearBusinessCache() => transaction(() async {
for (final table in allTables.where(_isBusinessCache)) {
await delete(table).go();
}
});
}
```
**清理动作由谁触发**:统一在 `11-store-context-and-session.md` 定义的会话编排里调用,各 feature 不自己监听门店变化去清自己的表——分散清理必然会漏。
**清理顺序也有讲究**:先切断新写入(让 provider 失效、请求取消),再清库。反过来会出现"刚清完,一个在途请求的回调又把旧门店数据写回去了"。
## 缓存 TTL
Drift 里的缓存**默认都是"降级用"的,不是"优先用"的**:正常路径永远走网络,缓存只在网络失败或首屏加载时先垫一下。这样 TTL 的作用就不是"过期就不能用",而是"过期了就不要再拿它当有效内容展示"。
| 数据 | TTL | 过期后行为 |
|---|---|---|
| 门店列表 | 24h | 仍展示,但顶部提示"数据可能不是最新" |
| 工作台 tile 数据 | 5min | 不展示缓存,直接走 loading |
| 订单/采购单列表 | 10min | 展示缓存 + 下拉刷新 |
| 经营/财务分析数据 | 不缓存 | — |
每张缓存表都有 `cachedAt` 列,判断逻辑写在 `local_datasource` 里,不散落在 UI。
**经营/财务类数据不落本地**:这类是敏感数据,手机丢失或被拿去 root 后 SQLite 文件可以直接读。收益(离线可看)远小于风险,直接不缓存最省事——也就不需要引入 SQLCipher 这类数据库加密方案(引入的话要处理密钥存哪、密钥丢了怎么办、以及原生库体积增加)。这条如果后续业务要求离线查看经营数据,再重新评估。
## Migration 必须被验证,不能只靠"写了"
"必须写 migration"这条规则没有配套验证手段的话,等于没有——migration 写错的表现是**线上用户升级后 App 一启动就崩**,而开发机上因为是全新安装,永远测不出来。
Drift 官方提供了 schema 快照 + 验证工具链,纳入流程:
```bash
# 1. 每次 schemaVersion 递增后,导出当前 schema 快照(产物入库)
fvm dart run drift_dev schema dump lib/src/app_database.dart drift_schemas/
# 2. 生成迁移测试的辅助代码
fvm dart run drift_dev schema generate drift_schemas/ test/generated_migrations/
```
```dart
// packages/core_storage/test/migration_test.dart
void main() {
late SchemaVerifier verifier;
setUpAll(() => verifier = SchemaVerifier(GeneratedHelper()));
test('从 v1 到最新版本的迁移都能跑通', () async {
for (var from = 1; from < AppDatabase.latestSchemaVersion; from++) {
final connection = await verifier.startAt(from);
final db = AppDatabase.forTesting(connection);
await verifier.migrateAndValidate(db, AppDatabase.latestSchemaVersion);
await db.close();
}
});
}
```
规则:
- `drift_schemas/` 下的 JSON 快照**入 git**,每次改表结构必须跟着生成新快照,PR 里能直接看到 schema diff。
- 迁移测试进 `melos run test`CI 卡点(见 [14-conventions-and-ci-gates.md](./14-conventions-and-ci-gates.md))。
- `migrateAndValidate` 只验证**结构**,不验证数据。涉及数据搬迁(拆表、改语义)的迁移要额外写一个"造老数据 → 迁移 → 断言新数据"的用例。
## flutter_secure_storage 11.0.0 的升级风险
`flutter_secure_storage 11.0.0` 是 2026-08 才发的大版本,**改了 Android 侧的默认加密实现**RSA OAEP + AES-GCM)。这意味着:
- 用旧版本写入的数据,升级后有**读不出来**的风险(返回 null 或抛异常)。对我们来说就是"用户升级 App 后被登出"。
- 版本号在 `pubspec.yaml` 里**写死 `11.0.0`,不用 `^`**。这个包的历史上出现过 minor 版本改加密实现的情况,`^` 会让某次 `pub upgrade` 悄悄换掉加密方式,而问题只在真机升级路径上暴露,CI 和新装都测不出来。升级它必须是一次显式的、带回归验证的动作。
- **首版是新 App,不存在历史数据**,所以本次没有实际迁移风险;这条规则是为**后续升级**立的。
读取失败的兜底必须写:
```dart
Future<String?> readAccessToken() async {
try {
return await _storage.read(key: _kAccessToken);
} catch (e, st) {
// 读不出来一律当作未登录:清空 + 跳登录页,而不是抛异常让用户卡在启动页
_logger.e('secure storage 读取失败,按未登录处理', error: e, stackTrace: st);
await clear();
return null;
}
}
```
**绝对不能让 secure storage 的异常向上冒到启动流程**——那会变成"升级后一打开就白屏/崩溃",比重新登录严重得多。
## 数据库在后台 isolate 打开
大批量写入(比如一次同步几百条订单)在主 isolate 上跑会掉帧。`drift_flutter``driftDatabase()` **默认就用后台 isolate**,只要不手动关掉即可:
```dart
// packages/core_storage/lib/src/connection.dart
QueryExecutor openConnection() => driftDatabase(
name: 'conti_app',
native: const DriftNativeOptions(
databaseDirectory: getApplicationSupportDirectory, // iOS 上不要用 Documents,会被 iCloud 备份
),
);
```
iOS 上数据库文件放 `Application Support` 而不是 `Documents``Documents` 会被 iCloud 备份,缓存数据没必要占用户的 iCloud 空间,苹果审核也可能因此提意见。
## 参考链接
- [Drift 官方文档](https://drift.simonbinder.eu/)
- [drift | Dart package](https://pub.dev/packages/drift)
- [drift_flutter | Dart package](https://pub.dev/packages/drift_flutter)
- [Drift: Migrations 与 schema 验证](https://drift.simonbinder.eu/Migrations/tests/)
- [flutter_secure_storage | Dart package](https://pub.dev/packages/flutter_secure_storage)
- [shared_preferences | Dart package](https://pub.dev/packages/shared_preferences)
@@ -96,12 +251,15 @@ class StoreDao extends DatabaseAccessor<AppDatabase> with _$StoreDaoMixin {
```dart
// packages/core_storage/lib/src/app_database.dart
@DriftDatabase(tables: [StoreCache, PaymentHistory], daos: [StoreDao, PaymentHistoryDao])
@DriftDatabase(tables: [StoreCache, PurchaseOrderCache], daos: [StoreDao, PurchaseOrderDao])
class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection());
AppDatabase() : super(openConnection());
AppDatabase.forTesting(super.connection); // 迁移测试用
static const latestSchemaVersion = 2;
@override
int get schemaVersion => 2;
int get schemaVersion => latestSchemaVersion;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -114,6 +272,8 @@ class AppDatabase extends _$AppDatabase {
}
```
> 门店列表这张表存的是"当前用户能访问哪些门店",属于用户级而不是门店级数据,所以没有 `storeId` 列——它是上文那条"业务缓存表必须带 `storeId`"规则的合理例外。`PurchaseOrderCache` 那种才是典型的门店级数据。
```dart
// feature_store 的 local_datasource 只依赖 StoreDao,不直接碰 AppDatabase
class StoreLocalDataSource {
@@ -156,3 +316,8 @@ class TokenStorage {
```
`TokenStorage` 是全仓库唯一直接持有 `FlutterSecureStorage` 实例的类,其他包只能通过 `core_auth` 暴露的 provider 间接读写 token。
## 待确认项
- 经营/财务数据是否需要离线查看。如果需要,要重新评估数据库加密(SQLCipher)方案,涉及密钥保管和原生库体积。
- 门店切换时"全清缓存"在门店数量多、切换频繁的用户上的实际体验,上线后看埋点再调。