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:
+126
-32
@@ -19,11 +19,131 @@ dependencies:
|
||||
- 底部导航等常驻 UI 用 `ShellRoute`/`StatefulShellRoute` 包裹对应的 feature 路由,不在每个页面里重复搭一遍导航栏。
|
||||
- 登录态校验统一在 `core_router` 聚合层用 `redirect` 实现,不在每个页面里各自判断 token 是否过期。
|
||||
- 跨 feature 跳转只能传**可序列化参数**(path 参数、query 参数,或可序列化的 `extra`),不允许把一个 feature 内部的 Dart 类实例通过 `extra` 传给另一个 feature——这是 [01-project-structure.md](./01-project-structure.md) "Feature 间通信" 规则在路由层的具体落地。
|
||||
- `feature_*` 不直接依赖 `go_router`,而是依赖 `core_router`,由 `core_router` re-export `GoRoute`/`RouteBase`/`GoRouterState` 等类型。这样将来换路由库或升大版本时,只有 `core_router` 一个地方要动。
|
||||
|
||||
## `GoRouter` 实例不能因为登录态变化被重建
|
||||
|
||||
这是 go_router + Riverpod 组合里最常见的一个坑,写错了表现是"用户在三级页面停留时 token 刷新了一下,人被弹回首页"。
|
||||
|
||||
`GoRouter` 内部持有导航栈。如果 provider 里写 `ref.watch(authStateProvider)`,登录态一变整个 provider 重建、旧 `GoRouter` 被丢弃、新的从 `initialLocation` 开始——导航栈就没了。
|
||||
|
||||
**正确写法**:`redirect` 里用 `ref.read` 读当前登录态,外面用 `ref.listen` 监听变化并调 `router.refresh()` 让 go_router 重跑一次 `redirect`。
|
||||
|
||||
```dart
|
||||
// packages/core_router/lib/src/app_router.dart
|
||||
final rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
final goRouterProvider = Provider<GoRouter>((ref) {
|
||||
final router = GoRouter(
|
||||
navigatorKey: rootNavigatorKey, // 全局 dialog / 顶层跳转需要它
|
||||
initialLocation: '/home',
|
||||
observers: [NavigationObserver(ref.read(crashReporterProvider))], // 崩溃前的页面路径,见 13
|
||||
redirect: (context, state) {
|
||||
// read 不是 watch:这里只要当前值,订阅由下面的 listen 负责
|
||||
final auth = ref.read(authStateProvider);
|
||||
final loggingIn = state.matchedLocation == '/login';
|
||||
|
||||
if (!auth.isLoggedIn) {
|
||||
if (loggingIn) return null;
|
||||
// 带上原目标,登录成功后回跳
|
||||
return '/login?from=${Uri.encodeComponent(state.uri.toString())}';
|
||||
}
|
||||
if (loggingIn) {
|
||||
final from = state.uri.queryParameters['from'];
|
||||
return (from == null || from.isEmpty) ? '/home' : Uri.decodeComponent(from);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
errorBuilder: (context, state) => RouteNotFoundPage(location: state.uri.toString()),
|
||||
routes: [
|
||||
GoRoute(path: '/login', builder: (context, state) => const LoginPage()),
|
||||
StatefulShellRoute.indexedStack(
|
||||
builder: (context, state, navigationShell) => MainShell(navigationShell: navigationShell),
|
||||
branches: [
|
||||
StatefulShellBranch(routes: buildHomeRoutes()),
|
||||
StatefulShellBranch(routes: buildPurchaseRoutes()),
|
||||
StatefulShellBranch(routes: buildProfileRoutes()),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// 登录态变化时只重跑 redirect,不重建 router,导航栈得以保留
|
||||
ref.listen(authStateProvider, (_, __) => router.refresh());
|
||||
ref.onDispose(router.dispose);
|
||||
return router;
|
||||
});
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- `redirect` 里**只能 `ref.read`**,不能 `ref.watch`(`Provider` 的 `create` 已经跑完了,`watch` 在回调里语义也不对)。
|
||||
- `ref.onDispose(router.dispose)` 不能漏:`GoRouter` 持有 `Listenable`,不 dispose 在热重载和测试里会泄漏。
|
||||
- 用 `ref.listen` 而不是 `refreshListenable`,是因为登录态本身是一个 Riverpod provider,用 `refreshListenable` 还要额外包一个 `ChangeNotifier` 适配层,没必要。
|
||||
|
||||
### `errorBuilder` 是必须的
|
||||
|
||||
不写 `errorBuilder`,遇到未注册的路径(深链接拼错、后端下发了一个 App 还不认识的菜单 code、H5 回跳的 URL 有问题)go_router 会显示一个英文的默认错误页,对门店一线员工来说等于崩溃。统一给一个"页面不存在,请检查是否需要升级 App"的兜底页,并把 `state.uri` 上报(见 [13-observability-analytics.md](./13-observability-analytics.md))——这个上报很有价值,能直接暴露出后端下发了 App 不支持的菜单。
|
||||
|
||||
## 后端动态菜单 → 本地路由的映射
|
||||
|
||||
PRD §22.2:工作台菜单由后端按角色权限下发,不是写死在 App 里的。但**路由表必须是编译期写死的**(页面是 Dart 代码,不可能动态下发)。所以中间需要一张映射表。
|
||||
|
||||
约定:后端下发的每个菜单项带一个稳定的 `code`(如 `PURCHASE_ORDER`、`INVENTORY_CHECK`),`core_router` 里维护 `code → 路由路径` 的映射。
|
||||
|
||||
```dart
|
||||
// packages/core_router/lib/src/menu_route_map.dart
|
||||
const menuRouteMap = <String, String>{
|
||||
'PURCHASE_ORDER': '/purchase/orders',
|
||||
'INVENTORY_CHECK': '/inventory/check',
|
||||
'QUOTE_ORDER': '/webview?target=QUOTE_ORDER', // H5 承载的功能也走这张表
|
||||
// ...
|
||||
};
|
||||
|
||||
/// 未知 code 返回 null,调用方据此决定隐藏还是提示升级
|
||||
String? resolveMenuRoute(String code) => menuRouteMap[code];
|
||||
```
|
||||
|
||||
**未知 `code` 的兜底策略**:直接**隐藏**该菜单项,同时上报一条 `menu_code_unsupported` 事件(带 code 和 App 版本)。
|
||||
|
||||
- 不选"提示升级":老版本 App 上会因为后端加了新菜单就弹升级提示,对完全不需要这个新功能的门店是骚扰。
|
||||
- 隐藏 + 上报的组合能让我们从数据上看到"有多少用户因为版本旧看不到新功能",需要推升级时再针对性推。
|
||||
|
||||
`code` 一旦定义就不能改含义(改了等于老版本 App 跳错页面),新增功能只能加新 `code`。这条要在后端接口评审时对齐。
|
||||
|
||||
## H5 页面的路由约定
|
||||
|
||||
PRD §7 的核心功能(报价开单、施工查车、结算收银)走 Embedded H5。这些页面在路由表里的形态统一为:
|
||||
|
||||
```
|
||||
/webview?target=<TARGET_CODE>&title=<可选标题>
|
||||
```
|
||||
|
||||
**只传目标标识,不传裸 URL。** 真实 URL 由 `core_webview` 拿 `target` 去 App Backend 换票后拿到(见 [10-webview-h5.md](./10-webview-h5.md))。
|
||||
|
||||
理由:如果路由里能直接塞 URL,那么任何能构造深链接的地方(推送、H5 内跳转、剪贴板)都能让 App 打开任意网页,是一个明确的安全洞。`target` 是一个白名单枚举,能打开哪些页面完全由后端和 App 共同决定。
|
||||
|
||||
即便如此,`core_webview` 拿到后端返回的 URL 后**仍要做一次域名白名单校验**——纵深防御,后端被打穿或配置写错时还有一道。
|
||||
|
||||
## 门店切换后的路由重置
|
||||
|
||||
PRD §11.4:切换门店后所有业务上下文跟着切。导航栈是其中一部分——用户在 A 门店的"采购单详情 `/purchase/orders/123`"页面切到 B 门店,这个订单 ID 在 B 门店可能不存在,或者更糟,存在但是另一张单。
|
||||
|
||||
**规则:切换门店成功后,清空导航栈回工作台。**
|
||||
|
||||
```dart
|
||||
// 门店切换成功的回调里
|
||||
ref.read(goRouterProvider).go('/home'); // go 而不是 push:替换整个栈
|
||||
```
|
||||
|
||||
`StatefulShellRoute` 的各 branch 栈也会跟着重置。这个动作和 provider 失效、缓存清理、H5 会话失效是一组,统一在 `11-store-context-and-session.md` 里编排,不散在各处调用。
|
||||
|
||||
## 参考链接
|
||||
|
||||
- [go_router 官方文档](https://pub.dev/packages/go_router)
|
||||
- [go_router | Dart package](https://pub.dev/packages/go_router)
|
||||
- [go_router: Redirection](https://pub.dev/documentation/go_router/latest/topics/Redirection-topic.html)
|
||||
- [go_router: Navigation(go vs push)](https://pub.dev/documentation/go_router/latest/topics/Navigation-topic.html)
|
||||
- [StatefulShellRoute API](https://pub.dev/documentation/go_router/latest/go_router/StatefulShellRoute-class.html)
|
||||
|
||||
## 附录:go_router 是什么,日常怎么用
|
||||
|
||||
@@ -47,38 +167,12 @@ dependencies:
|
||||
4. **`redirect`**:每次路由变化前会先跑一遍 `redirect` 回调,返回非空字符串就强制跳转——这是实现"未登录访问需要登录的页面 → 自动跳登录页"的地方。
|
||||
5. **`context.go()` / `context.push()`**:`go` 是替换当前路由(浏览器前进后退语义),`push` 是在当前栈上叠加一层(可以 `pop` 回去)——日常最容易混淆的两个 API,选错会导致返回键行为不符合预期。
|
||||
|
||||
### 使用示例(底部导航 + 门店详情页 + 登录拦截)
|
||||
### 使用示例(底部导航 + 门店详情页)
|
||||
|
||||
> 完整的 `goRouterProvider`(含登录拦截、回跳、错误兜底)见上文「`GoRouter` 实例不能因为登录态变化被重建」,这里只演示 feature 侧怎么声明自己的路由。
|
||||
|
||||
```dart
|
||||
// packages/core_router/lib/src/app_router.dart
|
||||
final goRouterProvider = Provider<GoRouter>((ref) {
|
||||
final authState = ref.watch(authStateProvider);
|
||||
|
||||
return GoRouter(
|
||||
initialLocation: '/store',
|
||||
redirect: (context, state) {
|
||||
final loggingIn = state.matchedLocation == '/login';
|
||||
if (!authState.isLoggedIn && !loggingIn) return '/login';
|
||||
if (authState.isLoggedIn && loggingIn) return '/store';
|
||||
return null; // 不需要重定向
|
||||
},
|
||||
routes: [
|
||||
GoRoute(path: '/login', builder: (context, state) => const LoginPage()),
|
||||
StatefulShellRoute.indexedStack(
|
||||
builder: (context, state, navigationShell) =>
|
||||
MainShell(navigationShell: navigationShell),
|
||||
branches: [
|
||||
StatefulShellBranch(routes: buildStoreRoutes()),
|
||||
StatefulShellBranch(routes: buildPaymentRoutes()),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
```dart
|
||||
// packages/feature_store/lib/feature_store.dart
|
||||
// packages/feature_store_mgmt/lib/feature_store_mgmt.dart
|
||||
List<RouteBase> buildStoreRoutes() => [
|
||||
GoRoute(
|
||||
path: '/store',
|
||||
@@ -101,4 +195,4 @@ List<RouteBase> buildStoreRoutes() => [
|
||||
context.push('/store/${store.id}');
|
||||
```
|
||||
|
||||
`buildStoreRoutes()` 只在 `feature_store` 包内声明,`app_router.dart` 里只 import 这个函数、不 import `feature_store` 的任何页面 widget 类型——保持 [01-project-structure.md](./01-project-structure.md) 的编译期边界。
|
||||
`buildStoreRoutes()` 只在 `feature_store_mgmt` 包内声明,`app_router.dart` 里只 import 这个函数、不 import 该 feature 的任何页面 widget 类型——保持 [01-project-structure.md](./01-project-structure.md) 的编译期边界。
|
||||
|
||||
Reference in New Issue
Block a user