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
+89 -12
View File
@@ -43,12 +43,80 @@ domain → 不依赖 presentation / data
data → 依赖 domain 的接口(若有),依赖 core_network / core_storage
```
`domain` 层禁止 import 任何 Flutter SDK`package:flutter/...`)——保持纯 Dart,可脱离 UI 单独做 unit test。
`domain` 层禁止 import 的东西,不只是 Flutter SDK
- `package:flutter/...`UI 框架)
- `package:dio/...`(网络库)
- `package:drift/...`(数据库)
- 任何做 IO 的第三方库
`domain` 只允许 `dart:core`/`dart:async` 这类纯语言能力和项目内的纯 Dart 类型。这条如果松了,"domain 可以脱离 UI 和网络单独跑 unit test"就名存实亡——只要 import 了 `dio`,测试就得处理它的初始化和平台依赖。
## 数据模型与 JSON 序列化
**决策**DTO 用 [json_serializable](https://pub.dev/packages/json_serializable) 生成 `fromJson`/`toJson`,不手写;**不引入 freezed**。
```yaml
dependencies:
json_annotation: ^4.9.0
dev_dependencies:
json_serializable: ^6.9.0
build_runner: ^2.15.2
```
- **为什么不上 freezed**freezed 主要提供不可变类、`copyWith`、联合类型(sealed class)。Dart 3 已经原生支持 `sealed class`/`final class` 和模式匹配,联合类型这块的收益大幅缩水;而 `copyWith` 的收益不足以抵消"再加一个 codegen 目标 + 生成文件体积翻倍 + 编译变慢"的成本。项目里已经有 `riverpod_generator``drift_dev``json_serializable``pigeon` 四个 codegen 目标,能不加就不加(同 [09-testing.md](./09-testing.md) 里不选 `mockito` 的理由)。
- **DTO 与 entity 是否分两套类型**:默认**不分**,`data` 层的 DTO 直接当 `domain` 的 entity 用,只在下面两种情况才拆两套并写转换函数:
1. 后端字段结构明显不适合业务使用(比如时间戳是字符串、状态是魔法数字、嵌套层级很深)。
2. 同一个业务概念由多个接口拼出来(比如首页 tile 聚合了多个 Mini 域的返回)。
拆两套要付出双份类型 + 一份转换代码的成本,多数简单 CRUD 场景不值得。
-`domain` 层的 feature 如果拆了两套类型,转换函数放在 `data` 层(`domain` 不能知道 JSON 长什么样)。
## 后端统一响应包装在哪一层解开
后端所有接口返回 `ApiResult<T> { code, message, data, traceId }`(见 [backend/06-api-design.md](./backend/06-api-design.md))。**解包统一发生在 `core_network` 的拦截器里,不在各 feature 的 repository 里重复写**
- `code == 0` → 把 `data` 取出来交给 repositoryrepository 的 `fromJson` 只需要认识 `data` 的结构,完全不用感知外层包装。
- `code != 0` → 直接抛 `BusinessException(code, message, traceId)`
- `traceId` 无论成功失败都记录进日志。
完整契约见 [12-error-and-api-contract.md](./12-error-and-api-contract.md)。这条规则的意义是:以后如果后端调整了包装格式,只有 `core_network` 一个地方要改。
## 分页的统一约定
PRD §21.1 要求列表页支持分页/分段加载。repository 层的分页方法统一签名,不让每个 feature 各自发明一套参数名:
```dart
// core_network 里定义的通用分页类型
class PageQuery {
const PageQuery({required this.page, this.size = 20});
final int page; // 从 1 开始
final int size;
}
class PageResult<T> {
const PageResult({required this.items, required this.total, required this.page});
final List<T> items;
final int total;
final int page;
bool get hasMore => items.length + (page - 1) * items.length < total;
}
// feature 侧
abstract class PurchaseOrderRepository {
Future<PageResult<PurchaseOrder>> fetchOrders(PageQuery query);
}
```
具体字段名以后端最终约定为准(backend 06 的「待补充」里也挂着分页约定这一项),联调前需要跟后端对齐一次。
## 附录:分层架构是什么,为什么要分层
给还没接触过这套分层习惯的同学看的入门说明。
> 下面示例里的 `feature_payment` / `feature_store` 是为了讲清分层概念用的简化例子,不是最终包清单(实际包清单见 [01-project-structure.md](./01-project-structure.md))。
### 要解决的问题
如果 UI 代码里直接写网络请求、直接 new 一个 `Dio` 实例、直接操作数据库——短期能跑,但会导致两个问题:
@@ -107,22 +175,25 @@ class ConfirmPaymentUseCase {
// data/repository/payment_repository_impl.dart
class PaymentRepositoryImpl implements PaymentRepository {
final Dio _dio; // 来自 core_network
PaymentRepositoryImpl(this._dio);
final ApiClient _api; // 来自 core_network,不是裸 Dio,见 05-networking.md
PaymentRepositoryImpl(this._api);
@override
Future<PaymentOrder> fetchOrder(String orderId) async {
final res = await _dio.get('/orders/$orderId');
// 注意:返回的已经是 ApiResult 里的 data 部分——
// { code, message, data, traceId } 这层包装由 core_network 的拦截器统一解开,
// repository 不感知它的存在(见上文「后端统一响应包装在哪一层解开」)
final json = await _api.get<Map<String, dynamic>>('/api/v1/orders/$orderId');
return PaymentOrder(
orderId: res.data['orderId'],
amountCents: res.data['amountCents'],
status: PaymentStatus.values.byName(res.data['status']),
orderId: json['orderId'] as String,
amountCents: json['amountCents'] as int,
status: PaymentStatus.values.byName(json['status'] as String),
);
}
@override
Future<void> confirmPayment(String orderId, String pinToken) =>
_dio.post('/orders/$orderId/confirm', data: {'pinToken': pinToken});
_api.post('/api/v1/orders/$orderId/confirm', data: {'pinToken': pinToken});
}
```
@@ -137,13 +208,17 @@ abstract class StoreRepository {
}
class StoreRepositoryImpl implements StoreRepository {
final Dio _dio;
StoreRepositoryImpl(this._dio);
final ApiClient _api;
StoreRepositoryImpl(this._api);
@override
Future<List<Store>> fetchNearbyStores(double lat, double lng) async {
final res = await _dio.get('/stores', queryParameters: {'lat': lat, 'lng': lng});
return (res.data as List).map((e) => Store.fromJson(e)).toList();
// 同上:拿到的是解开 ApiResult 包装之后的 data
final list = await _api.get<List<dynamic>>(
'/api/v1/stores',
query: {'lat': lat, 'lng': lng},
);
return list.map((e) => Store.fromJson(e as Map<String, dynamic>)).toList();
}
}
```
@@ -155,3 +230,5 @@ class StoreRepositoryImpl implements StoreRepository {
- [Flutter 官方状态管理文档](https://docs.flutter.dev/data-and-backend/state-mgmt)
- [The Clean ArchitectureUncle Bob 原文)](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)
- [依赖倒置原则(Dependency Inversion Principle](https://en.wikipedia.org/wiki/Dependency_inversion_principle)
- [json_serializable | Dart package](https://pub.dev/packages/json_serializable)
- [Dart 3 sealed class 与模式匹配](https://dart.dev/language/patterns)