app scaffold

This commit is contained in:
Guangfei.Zhao
2026-08-17 15:29:55 +08:00
commit 681688dfae
301 changed files with 18414 additions and 0 deletions
@@ -0,0 +1 @@
include: ../../analysis_options.yaml
@@ -0,0 +1,10 @@
/// 客户端埋点。来源:conti-docs/13-observability-analytics.md §三。
///
/// 边界约定:业务代码只用 [Analytics] 接口 + `AnalyticsEvent` 常量,
/// **不直接 import 神策 SDK,也不在调用处写事件名字面量**。
library;
export 'src/analytics.dart';
export 'src/analytics_event.dart';
export 'src/noop_analytics.dart';
export 'src/providers.dart';
@@ -0,0 +1,30 @@
/// 埋点门面。来源:13 §三「实现约定:神策 SDK,外面包一层」。
library;
/// 业务代码唯一可见的埋点接口。
///
/// 理由和 `CrashReporter` 一样:测试里能 mock`feature_*` 不多一条对三方
/// SDK 的直接依赖。**另外它也是采购未落地时的缓冲**——接口先定、事件方案
/// 先做,实现类换成一个最小的 `POST /api/v1/events/batch` 也只改一个文件。
///
/// 硬约束:**埋点失败绝不能影响业务**。实现类的每个方法内部都要 try-catch
/// 兜住,任何异常只记日志不外抛。
abstract interface class Analytics {
/// 上报一个事件。[event] 必须取自 `AnalyticsEvent` 常量,不允许字面量。
void track(String event, [Map<String, Object?> params = const <String, Object?>{}]);
/// 注册超级属性(公共属性),注册一次后全局附加。
///
/// `storeId` 尤其重要——运营侧几乎所有分析都按门店维度看,靠每个调用点
/// 自己传一定会漏。**门店切换后必须重新注册**(见 11 的级联清单)。
void registerSuperProperties(Map<String, Object?> props);
/// 登录成功后用后端的 `userId` 关联匿名 ID。
void identify(String userId);
/// 登出时断开关联。
///
/// 不调的话,同一台设备上换人登录的数据会串到一起——**门店设备是共用的,
/// 这个场景一定会发生**。
void reset();
}
@@ -0,0 +1,148 @@
/// 客户端事件名与参数名常量表。来源:13 §三「客户端事件表」「命名约定」。
///
/// **客户端埋点的判据只有一条:这件事会不会产生一次后端请求?不会,才由
/// 客户端上报。** 按这条筛下来只剩下面这几个——其余 6 类 PRD §22.1 事件
/// (登录、首页曝光、门店切换、采购下单、入库、待办点击)全部由后端从请求
/// 日志出,客户端不重复报。
///
/// 命名:`snake_case``对象_动作` 或 `对象_动作_结果`。结果类用过去式
/// `succeeded` / `failed`),动作类用现在式(`clicked`)。
///
/// **事件名和参数名一旦上线不再改**——改名意味着历史数据断裂,运营报表要
/// 重做。要加维度就加参数。
library;
/// 事件名常量。调用处禁止写字符串字面量:拼写错误编译期发现不了,
/// 在报表里表现为"这个事件怎么没数据"。
abstract final class AnalyticsEvent {
/// 扫码成功。参数:[AnalyticsParam.mode]、[AnalyticsParam.durationMs]。
static const String scanSucceeded = 'scan_succeeded';
/// 扫码失败。参数:[AnalyticsParam.mode]、[AnalyticsParam.failReason]。
///
/// 扫码是 App 原生实现(见 07),**不产生任何请求**,后端完全看不到。
static const String scanFailed = 'scan_failed';
/// H5 页关闭。参数:[AnalyticsParam.target]、[AnalyticsParam.stayDurationMs]。
static const String h5Closed = 'h5_closed';
/// H5 白屏 / 超时 / 加载失败。
///
/// 参数:[AnalyticsParam.target]、[AnalyticsParam.errorCode]、
/// [AnalyticsParam.elapsedMs]、[AnalyticsParam.traceId]。
/// 「打开」有 `/h5/launch` 请求后端能看到;**关闭、白屏、超时、加载失败
/// 后端看不到**。
static const String h5Failed = 'h5_failed';
/// H5 首屏完成。
///
/// 参数:[AnalyticsParam.target]、[AnalyticsParam.ticketMs]、
/// [AnalyticsParam.loadMs]。
/// **耗时必须拆成两段**:合成一个数字的话,慢了不知道该找 App Backend /
/// F6 / 还是网络——这是这个 App 里最长的一条跨系统链路。
static const String h5FirstPaint = 'h5_first_paint';
/// 客服入口点击。参数:[AnalyticsParam.channel]。
static const String supportClicked = 'support_clicked';
/// 请求失败。
///
/// 参数:[AnalyticsParam.path]、[AnalyticsParam.code]、
/// [AnalyticsParam.httpStatus]、[AnalyticsParam.traceId]。
/// 虽然后端也能看到失败,但**后端看不到"请求根本没发出去"和"响应没收到"**
/// 超时、连接失败、DNS 失败、运营商劫持。门店网络不稳时这类占大头。
static const String apiFailed = 'api_failed';
/// 冷启动完成。参数:[AnalyticsParam.durationMs]。
static const String appColdStart = 'app_cold_start';
/// 登出。参数:[AnalyticsParam.reason]。
///
/// **被动登出没有对应的接口调用**(见 11),所以这一条必须客户端报。
static const String logout = 'logout';
/// 冷启动恢复会话失败。参数:[AnalyticsParam.stage]。
///
/// 卡在读 secure storage 时不产生任何网络请求。
static const String sessionRestoreFailed = 'session_restore_failed';
/// 后端下发了本端路由表里没有的菜单编码(04 §菜单编码到路由的映射)。
///
/// 参数:[AnalyticsParam.code]。
/// 这条事件是**新功能灰度期唯一的可见信号**:后端配了菜单、App 还没发版,
/// 客户端的处理是隐藏该入口——不报的话,现场表现为"菜单配了但看不见",
/// 而两边都以为是对方的问题。后端从请求日志里看不到"客户端没渲染"。
static const String menuCodeUnsupported = 'menu_code_unsupported';
}
/// 事件参数名常量。同样禁止字面量。
abstract final class AnalyticsParam {
/// 扫码模式:`barcode` / `vin` / `plate`。
static const String mode = 'mode';
/// 耗时(毫秒)。
static const String durationMs = 'durationMs';
/// 失败原因。
static const String failReason = 'failReason';
/// H5 业务标识(不是 URL——URL 的 query 里带票据)。
static const String target = 'target';
/// H5 页面停留时长(毫秒)。
static const String stayDurationMs = 'stayDurationMs';
/// 错误码。
static const String errorCode = 'errorCode';
/// 从开始到失败经过的时间(毫秒)。
static const String elapsedMs = 'elapsedMs';
/// 换票耗时(毫秒)。
static const String ticketMs = 'ticketMs';
/// 页面加载耗时(毫秒)。
static const String loadMs = 'loadMs';
/// 链路 ID,与后端 ELK 对齐(见 05)。
static const String traceId = 'traceId';
/// 客服渠道:`hotline` / `dealer` / `o2o`。
static const String channel = 'channel';
/// 接口路径(不含 query)。
static const String path = 'path';
/// 业务错误码。
static const String code = 'code';
/// HTTP 状态码。
static const String httpStatus = 'httpStatus';
/// 登出原因:`userInitiated` / `tokenExpired` / `sessionRevoked`。
static const String reason = 'reason';
/// 会话恢复失败的阶段:`storage` / `me` / `stores`。
static const String stage = 'stage';
}
/// 超级属性(公共属性)的 key。
///
/// 这五个由 `registerSuperProperties` 注册一次全局附加,**不在每个调用点
/// 手写**。门店切换后必须重新注册。
abstract final class AnalyticsSuperProperty {
/// 当前门店 ID。
static const String storeId = 'storeId';
/// 当前角色码。
static const String roleCode = 'roleCode';
/// 环境:dev / uat / prod。
static const String flavor = 'flavor';
/// 版本名。
static const String appVersion = 'appVersion';
/// 构建号。
static const String buildNumber = 'buildNumber';
}
@@ -0,0 +1,61 @@
/// 埋点的空实现与调试实现。
library;
import 'analytics.dart';
/// 什么都不做的埋点实现。
///
/// **神策的采购尚未落地**(见 13 待确认项:公司有没有在用的神策服务)。
/// 接口先定、事件方案先做,这两块工作量与最终用什么 SDK 无关;真接上
/// `sensors_analytics_flutter_plugin` 时只新增一个实现类并改
/// `analyticsProvider` 的 override,调用点一行不动。
class NoopAnalytics implements Analytics {
/// 创建一个什么都不做的埋点实现。
const NoopAnalytics();
@override
void track(String event, [Map<String, Object?> params = const <String, Object?>{}]) {}
@override
void registerSuperProperties(Map<String, Object?> props) {}
@override
void identify(String userId) {}
@override
void reset() {}
}
/// 把事件收集到内存里,供测试断言和 dev 下人工核对。
///
/// **不要在 prod 用**:它只涨不清,且事件参数里可能有业务信息。
class RecordingAnalytics implements Analytics {
/// 创建一个记录型埋点实现。
RecordingAnalytics();
/// 已记录的事件,按调用顺序。
final List<({String event, Map<String, Object?> params})> events =
<({String event, Map<String, Object?> params})>[];
/// 当前已注册的超级属性合集。
final Map<String, Object?> superProperties = <String, Object?>{};
/// 最后一次 [identify] 传入的 userId[reset] 后为 null。
String? currentUserId;
@override
void track(String event, [Map<String, Object?> params = const <String, Object?>{}]) =>
events.add((event: event, params: Map<String, Object?>.of(params)));
@override
void registerSuperProperties(Map<String, Object?> props) => superProperties.addAll(props);
@override
void identify(String userId) => currentUserId = userId;
@override
void reset() {
currentUserId = null;
superProperties.clear();
}
}
@@ -0,0 +1,17 @@
/// core_analytics 的 Riverpod 接线。
library;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'analytics.dart';
import 'noop_analytics.dart';
/// 全局埋点入口。
///
/// 默认 [NoopAnalytics]。接入神策后由 `app/` 的 `bootstrap()` override——
/// 且必须在**用户同意隐私政策之后**才初始化 SDK(见 13 待确认项),
/// 所以 override 的时机由 `feature_auth` 的协议弹窗流程决定,不能无条件放在
/// 启动最早期。
final Provider<Analytics> analyticsProvider = Provider<Analytics>(
(Ref ref) => const NoopAnalytics(),
);
+19
View File
@@ -0,0 +1,19 @@
name: core_analytics
description: 埋点接口与事件常量。具体 SDK(神策)待定,当前提供 Noop 实现。
publish_to: none
version: 0.1.0
resolution: workspace
environment:
sdk: ^3.12.0
dependencies:
core_foundation: ^0.1.0
flutter:
sdk: flutter
flutter_riverpod: ^3.3.2
dev_dependencies:
flutter_lints: ^6.0.0
flutter_test:
sdk: flutter
@@ -0,0 +1,77 @@
import 'package:core_analytics/core_analytics.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('AnalyticsEvent 命名约定', () {
// 13 §三:snake_case,结果类过去式,动作类现在式。事件名一旦上线不再改,
// 所以这条测试的作用是在「加新事件」时挡住拼写风格漂移。
const List<String> all = <String>[
AnalyticsEvent.scanSucceeded,
AnalyticsEvent.scanFailed,
AnalyticsEvent.h5Closed,
AnalyticsEvent.h5Failed,
AnalyticsEvent.h5FirstPaint,
AnalyticsEvent.supportClicked,
AnalyticsEvent.apiFailed,
AnalyticsEvent.appColdStart,
AnalyticsEvent.logout,
AnalyticsEvent.sessionRestoreFailed,
];
test('全部是 snake_case', () {
for (final String e in all) {
expect(
RegExp(r'^[a-z][a-z0-9]*(_[a-z0-9]+)*$').hasMatch(e),
isTrue,
reason: '$e 不是 snake_case',
);
}
});
test('没有重名', () {
expect(all.toSet().length, all.length);
});
test('客户端事件表就是这 10 条', () {
// 多一条少一条都要先回到 13 §三的判据:这件事会不会产生一次后端请求?
expect(all.length, 10);
});
});
group('NoopAnalytics', () {
test('所有方法都不抛异常', () {
const Analytics analytics = NoopAnalytics();
expect(
() => analytics
..track(AnalyticsEvent.appColdStart)
..registerSuperProperties(<String, Object?>{'storeId': 1})
..identify('u1')
..reset(),
returnsNormally,
);
});
});
group('RecordingAnalytics', () {
test('记录事件与参数副本', () {
final RecordingAnalytics analytics = RecordingAnalytics();
final Map<String, Object?> params = <String, Object?>{AnalyticsParam.mode: 'vin'};
analytics.track(AnalyticsEvent.scanSucceeded, params);
params[AnalyticsParam.mode] = 'plate';
expect(analytics.events.single.event, AnalyticsEvent.scanSucceeded);
// 存的是副本,调用方后续修改不会污染已记录的事件。
expect(analytics.events.single.params[AnalyticsParam.mode], 'vin');
});
test('reset 同时清掉用户与超级属性', () {
final RecordingAnalytics analytics = RecordingAnalytics()
..identify('u1')
..registerSuperProperties(<String, Object?>{AnalyticsSuperProperty.storeId: 7})
..reset();
expect(analytics.currentUserId, isNull);
expect(analytics.superProperties, isEmpty);
});
});
}
+1
View File
@@ -0,0 +1 @@
include: ../../analysis_options.yaml
+13
View File
@@ -0,0 +1,13 @@
/// 会话与门店上下文。来源:conti-docs/11-store-context-and-session.md。
///
/// 依赖约束(01,编译期强制):本包**没有任何 `core_*` 出边**。
/// 11 的伪代码里那些跨包调用全部通过 `src/session_ports.dart` 的接口反转,
/// 由 app 层组装。`core_auth ↛ core_network` 尤其重要——否则 `AuthInterceptor`
/// 和刷新逻辑会互相纠缠成环,这也是 [TokenRefresher] 用裸 Dio 的原因。
library;
export 'src/models.dart';
export 'src/session_notifier.dart';
export 'src/session_ports.dart';
export 'src/token_refresher.dart';
export 'src/token_storage.dart';
+166
View File
@@ -0,0 +1,166 @@
/// 会话与上下文的数据模型。来源:conti-docs/11-store-context-and-session.md。
library;
import 'package:flutter/foundation.dart';
/// 用户上下文(PRD §6.4.1)。
///
/// 只放**跨门店恒定**的信息。菜单不在这里——同一个人在 A 店是店长、在 B 店是
/// 店员,菜单是门店维度的,见 [StoreContext.menus]。
@immutable
class UserContext {
/// 构造。
const UserContext({
required this.userId,
required this.employeeId,
required this.phone,
required this.roleCode,
required this.channel,
this.permissions = const <String>{},
});
/// 用户唯一标识。
final String userId;
/// 员工工号。
final String employeeId;
/// 手机号。**打日志前必须脱敏**(见 core_logging 的 `maskPhone`)。
final String phone;
/// 角色编码。
final String roleCode;
/// 渠道。
final String channel;
/// 权限集。
final Set<String> permissions;
}
/// 菜单项。路由映射见 04 的 `menuRouteMap`。
@immutable
class MenuItem {
/// 构造。
const MenuItem({required this.code, required this.name, this.children = const <MenuItem>[]});
/// 后端下发的稳定编码,客户端据此查本地路由表。
final String code;
/// 展示名。
final String name;
/// 子菜单。
final List<MenuItem> children;
}
/// 门店上下文(PRD §6.4.2)。
@immutable
class StoreContext {
/// 构造。
const StoreContext({
required this.storeId,
required this.storeCode,
required this.storeName,
required this.orgId,
this.parentStoreId,
this.menus = const <MenuItem>[],
});
/// 门店 ID。全 App 只能通过 `currentStoreIdProvider` 读它。
final int storeId;
/// 门店编码。
final String storeCode;
/// 门店名。
final String storeName;
/// 组织 ID。
final int orgId;
/// 所属总店;为空表示分店 / 独立店。
final String? parentStoreId;
/// 当前门店可访问的菜单。
final List<MenuItem> menus;
}
/// 登出原因。决定登录页展示什么提示文案,也是 `logout` 埋点的关键字段。
enum LogoutReason {
/// 用户主动点了退出。
userInitiated,
/// refresh token 失效(过期 / 被撤销 / 被判定重放)。
tokenExpired,
/// 在别处登录被踢。
sessionRevoked,
}
/// 会话状态。
///
/// **四个状态,不是一个 `bool isLoggedIn`**11 §会话状态模型):冷启动读本地态
/// 期间、以及"已登录但还没确定门店"这两种情况用布尔值表达不了,而后者需要跳到
/// 一个既不是登录页也不是首页的独立页面。
///
/// `sealed` 让 04 的路由 redirect 能穷举分支——漏一个状态编译器就报错。
sealed class AppSession {
/// 构造。
const AppSession();
}
/// 冷启动读本地态期间,UI 停在 splash。
final class SessionLoading extends AppSession {
/// 构造。
const SessionLoading();
}
/// 未登录。
final class SessionUnauthenticated extends AppSession {
/// 构造。
const SessionUnauthenticated({this.reason});
/// 为空表示从未登录过(首次安装),非空则是被登出的原因。
final LogoutReason? reason;
}
/// 已登录但还没确定门店:多门店用户需要选,或门店列表拉取失败需要重试。
final class SessionAwaitingStore extends AppSession {
/// 构造。
const SessionAwaitingStore({required this.user, this.candidates = const <StoreContext>[]});
/// 已确定的用户上下文。
final UserContext user;
/// 可选门店列表。拉取失败时为空,此时页面应展示重试而不是"无门店权限"。
final List<StoreContext> candidates;
}
/// 完整会话。只有这个状态下业务页面才允许渲染。
final class SessionActive extends AppSession {
/// 构造。
const SessionActive({required this.user, required this.store});
/// 用户上下文。
final UserContext user;
/// 当前门店上下文。
final StoreContext store;
}
/// 一对 token。
///
/// 后端采用**一次性 refresh token + 重放即全量撤销**,所以刷新拿到的新
/// refreshToken 必须立刻覆盖存储,旧的已经作废了(见 05 / backend 04)。
@immutable
class TokenPair {
/// 构造。
const TokenPair({required this.accessToken, required this.refreshToken});
/// 访问令牌。
final String accessToken;
/// 刷新令牌。**一次性**,用过即废。
final String refreshToken;
}
@@ -0,0 +1,301 @@
/// 会话唯一真相源。来源:conti-docs/11-store-context-and-session.md。
library;
import 'package:core_foundation/core_foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'models.dart';
import 'session_ports.dart';
import 'token_refresher.dart';
import 'token_storage.dart';
part 'session_notifier.g.dart';
/// 全 App 唯一的会话状态持有者。
@Riverpod(keepAlive: true)
class SessionNotifier extends _$SessionNotifier {
/// 冷启动恢复(11 §冷启动恢复)。
///
/// 三条约定:
/// - 读 secure storage 失败当作未登录([TokenStorage] 内部已兜底)。
/// - **网络失败时不要把用户踢到登录页**。门店里网络不稳是常态,本地有 token
/// 就先按已登录处理;真正无效的 token 会在第一个业务请求返回 401 时被发现。
/// - 门店列表拉取失败 → [SessionAwaitingStore],不是回登录页。
@override
Future<AppSession> build() async {
final String? token = await ref.read(tokenStorageProvider).readAccessToken();
if (token == null || token.isEmpty) {
return const SessionUnauthenticated();
}
final SessionRemote remote = ref.read(sessionRemoteProvider);
final UserContext user;
try {
user = await remote.fetchCurrentUser();
} on UnauthorizedException {
_notifyRestoreFailed('me');
await ref.read(tokenStorageProvider).clear();
return const SessionUnauthenticated(reason: LogoutReason.tokenExpired);
}
_notifyUser(user);
final List<StoreContext> stores;
try {
stores = await remote.fetchAccessibleStores();
} on Object {
_notifyRestoreFailed('stores');
// 拿不到门店列表就停在选店页并允许重试,而不是登出。
return SessionAwaitingStore(user: user);
}
return _resolveStore(user, stores);
}
/// 登录成功后调用:写 token → 拉门店 → 定上下文(11 §登录流程)。
///
/// 具体的账号密码 / 验证码请求由 `feature_auth` 发,这里只接手 token 之后的
/// 部分——**「登录成功后必须立即获取门店上下文」**(PRD §10.1)。
Future<void> onLoggedIn({required TokenPair tokens, required UserContext user}) async {
state = const AsyncLoading<AppSession>();
await ref.read(tokenStorageProvider).save(tokens);
_notifyUser(user);
state = await AsyncValue.guard(() async {
final List<StoreContext> stores = await ref
.read(sessionRemoteProvider)
.fetchAccessibleStores();
// 0 个门店必须登出,不能停在空白首页(PRD §10.1/§10.2 把「用户无门店权限」
// 列为登录异常流程)。
if (stores.isEmpty) {
await logout(reason: LogoutReason.userInitiated);
throw const BusinessException(-1, '您当前没有可访问的门店,请联系管理员');
}
return _resolveStore(user, stores);
});
}
/// 切换门店。
///
/// **下面每一步的顺序都是有意义的,改动前先读 11 §切换门店的那张表。**
Future<void> switchStore(int targetStoreId) async {
final AppSession? current = state.value;
if (current is! SessionActive && current is! SessionAwaitingStore) {
throw const PreconditionException('当前不在可切换门店的状态');
}
// ── 0. 前置:有未完成的写操作就拦住 ────────────────────────────────
// 默认阻止而不是静默取消:取消一个已经发出去的下单请求,客户端不知道
// 服务端到底成没成。本地判定,不编后端错误码(12)。
if (ref.read(pendingWritesProvider).isNotEmpty) {
throw const PreconditionException('有未完成的操作,请稍后再试');
}
final UserContext user = switch (current) {
SessionActive(:final UserContext user) => user,
SessionAwaitingStore(:final UserContext user) => user,
_ => throw const PreconditionException('当前不在可切换门店的状态'),
};
// ── 1. 先让 UI 进入切换中,挡住用户继续操作 ────────────────────────
state = const AsyncLoading<AppSession>();
// ── 2. 服务端切换。失败则整个流程中止,本地状态原样不动 ──────────────
// 反过来先清缓存再请求,一旦失败用户就停在"门店没变但数据全没了"的状态。
final StoreContext newStore;
try {
newStore = await ref.read(sessionRemoteProvider).switchStore(targetStoreId);
} on Object {
// 本地状态原样回滚。不用 AsyncError.copyWithPrevious——它在 Riverpod 3 里
// 是 internal;而且这里本来就该回到"什么都没发生",错误由 rethrow 交给
// 调用方(切店页)自己展示。
state = AsyncData<AppSession>(current!);
rethrow;
}
// ── 3 & 4. 关 H5 会话(不清 Cookie)+ 清本地业务缓存 ────────────────
// 必须在第 5 步之前:H5 页面里可能有在途请求,先停掉,否则旧门店的 H5
// 请求会带着新门店的票据回来;缓存清理和新上下文之间也不能有窗口期,
// 否则新数据可能刚写进去就被一起删掉。
for (final SessionScopedStore store in ref.read(sessionScopedStoresProvider)) {
await store.onStoreChanged();
}
// ── 5. 落新上下文 → 依赖 currentStoreId 的 provider 自动失效 ─────────
state = AsyncData<AppSession>(SessionActive(user: user, store: newStore));
// ── 6. 路由清栈回首页 ───────────────────────────────────────────────
// core_auth 不能依赖 core_router01)。反过来由 core_router 监听本
// provider 做 refresh + 清栈,见 core_router 的 goRouterProvider。
// ── 7 & 8. 记住这次选择 + 同步观测上下文 ────────────────────────────
// 第 8 步漏掉的表现是切店后的崩溃和埋点还挂在旧门店名下,很隐蔽。
for (final SessionObserver observer in ref.read(sessionObserversProvider)) {
observer.onStoreChanged(newStore);
}
}
/// 登出(11 §登出:清理清单)。
Future<void> logout({LogoutReason reason = LogoutReason.userInitiated}) async {
// ── 1. 通知服务端撤销 refresh token ─────────────────────────────────
// 尽力而为。网络不通时用户点登出必须能退出去,否则体验是"这个 App 退不出来"。
// 服务端 token 会自然过期,不撤销的代价可以接受。
if (reason == LogoutReason.userInitiated) {
try {
await ref.read(sessionRemoteProvider).revokeSession().timeout(const Duration(seconds: 3));
} on Object {
// 故意吞掉。
}
}
// ── 2 & 3. 清 H5 会话(含 Cookie)+ 清本地数据 ───────────────────────
// **必须等这两步完成再切状态**fire-and-forget 会出现"新用户已经进首页了,
// 上一个用户的缓存清理才刚跑完",然后把新用户的数据也删了。门店共用设备上
// 这不是理论问题。
for (final SessionScopedStore store in ref.read(sessionScopedStoresProvider)) {
try {
await store.onSessionEnded();
} on Object {
// 某一处清理失败不能卡住登出,继续清剩下的。
}
}
await ref.read(tokenStorageProvider).clear();
// ── 4. 状态置未登录 → 路由守卫自动跳登录页 ──────────────────────────
state = AsyncData<AppSession>(SessionUnauthenticated(reason: reason));
// ── 5. 断开观测/埋点的用户关联 ──────────────────────────────────────
// 门店设备是共用的,不断开会让下一个人的数据串到上一个人身上。
for (final SessionObserver observer in ref.read(sessionObserversProvider)) {
observer.onSessionEnded(reason);
}
// ── 6. 兜底 provider 清理 ───────────────────────────────────────────
// 不逐个 ref.invalidate 点名(一定会漏)。业务 provider 全部直接或间接
// watch 本 provider 或 currentStoreIdProvider,状态一变 autoDispose 的自然
// 重算;少数 keepAlive 的白名单由 app 层在 sessionScopedStores 里处理。
}
/// 门店列表拉取失败后的重试入口,给 [SessionAwaitingStore] 页面用。
Future<void> retryStoreLoad() async {
final AppSession? current = state.value;
if (current is! SessionAwaitingStore) return;
state = const AsyncLoading<AppSession>();
state = await AsyncValue.guard(() async {
final List<StoreContext> stores = await ref
.read(sessionRemoteProvider)
.fetchAccessibleStores();
return _resolveStore(current.user, stores);
});
}
/// 按 11 §登录流程的三分支决定落到哪个状态。
Future<AppSession> _resolveStore(UserContext user, List<StoreContext> stores) async {
if (stores.isEmpty) {
return SessionAwaitingStore(user: user);
}
// "上次门店"只是一个提示,不是权限依据:**必须先确认它在后端返回的可访问
// 列表里**,用户的门店权限可能已经被管理员回收了。
// 上次门店 ID 由 app 层通过 lastStoreIdProvider 从 prefs 读入(core_auth
// 不能依赖 core_storage)。
final int? lastStoreId = ref.read(lastStoreIdProvider);
final StoreContext? preselected = stores
.where((StoreContext s) => s.storeId == lastStoreId)
.firstOrNull;
final StoreContext? target = preselected ?? (stores.length == 1 ? stores.first : null);
if (target == null) {
// 多门店且没有可用的上次选择 → 选店页。
return SessionAwaitingStore(user: user, candidates: stores);
}
// 走一次服务端 switch 才能拿到菜单(列表接口不下发菜单)。
final StoreContext store = await ref.read(sessionRemoteProvider).switchStore(target.storeId);
for (final SessionObserver observer in ref.read(sessionObserversProvider)) {
observer.onStoreChanged(store);
}
return SessionActive(user: user, store: store);
}
void _notifyUser(UserContext user) {
for (final SessionObserver observer in ref.read(sessionObserversProvider)) {
observer.onUserIdentified(user);
}
}
void _notifyRestoreFailed(String stage) {
for (final SessionObserver observer in ref.read(sessionObserversProvider)) {
observer.onSessionRestoreFailed(stage);
}
}
}
/// [TokenStorage] 的注入点。测试里 override 成假实现。
@Riverpod(keepAlive: true)
TokenStorage tokenStorage(Ref ref) => TokenStorage();
/// [TokenRefresher] 的注入点。
///
/// **必须 keepAlive**:串行刷新靠的是实例内部那个共享的在途 Future,实例被
/// 回收重建就等于失去了串行保证,而并发刷新会触发后端的重放撤销(11)。
@Riverpod(keepAlive: true)
TokenRefresher tokenRefresher(Ref ref) => TokenRefresher(storage: ref.watch(tokenStorageProvider));
/// 上次选中的门店 ID,用于冷启动/登录时预选。
///
/// 值来自 `shared_preferences`(非敏感),由 app 层 override——core_auth 不能
/// 依赖 core_storage。默认 null 表示不预选。
final Provider<int?> lastStoreIdProvider = Provider<int?>((Ref ref) => null);
/// 未完成的写操作。切店的前置检查读它(11 §步骤 0)。
///
/// 由发起写操作的 feature 自己 add/remove,值是一个能在日志里认出来的标识。
@Riverpod(keepAlive: true)
class PendingWrites extends _$PendingWrites {
@override
Set<String> build() => const <String>{};
/// 登记一个在途写操作。
void add(String tag) => state = <String>{...state, tag};
/// 注销。**必须放在 finally 里**,否则一次失败的下单会永久挡住切店。
void remove(String tag) => state = <String>{...state}..remove(tag);
}
/// 全 App 读 storeId 的唯一入口。
///
/// **任何请求里带 storeId 的 provider 必须 `ref.watch` 它**,不能 `ref.read`
/// 也不能作为参数从上层传下来。这样切店时依赖图自动失效,不需要维护
/// "哪些 provider 要手动 invalidate"的清单——那份清单一定会漏(11 / 03)。
///
/// 非 [SessionActive] 时**抛异常而不是返回 0 或 null**:能读到这个 provider
/// 说明 UI 已经渲染到业务页面了,此时没有门店上下文是路由守卫的 bug,应该在
/// 开发期直接炸出来,而不是发一个 `storeId=0` 的请求让后端返回一堆空数据。
@riverpod
int currentStoreId(Ref ref) {
final int? storeId = ref.watch(currentStoreIdOrNullProvider);
if (storeId == null) {
throw StateError('在没有门店上下文时访问了 currentStoreId');
}
return storeId;
}
/// 可空版本。
///
/// **只给合法地在选店之前就要运行的基础设施用**——目前只有 core_network 的
/// `HeaderInterceptor`(登录、拉门店列表这些请求本身就发生在有门店之前)。
/// 业务代码一律用 [currentStoreIdProvider],用可空版会把"忘了选店"变成
/// 一个安静的空数据页。
@riverpod
int? currentStoreIdOrNull(Ref ref) {
final AppSession? session = ref.watch(sessionProvider).value;
return switch (session) {
SessionActive(:final StoreContext store) => store.storeId,
_ => null,
};
}
@@ -0,0 +1,105 @@
/// core_auth 的对外端口。
///
/// ---------------------------------------------------------------------------
/// 为什么需要这个文件
///
/// 11 的 `switchStore` / `logout` 伪代码直接 `ref.read` 了
/// `webViewSessionProvider`core_webview)、`appDatabaseProvider`core_storage)、
/// `prefsProvider`core_storage)、`goRouterProvider`core_router)、
/// `crashReporterProvider`core_logging)、`analyticsProvider`core_analytics)。
///
/// 但 01 的依赖约束里,`core_auth` 一条 `core_*` 出边都没有(连允许的三条例外
/// 都是别人指向它)。照抄伪代码会把 core_auth 变成整个 core 层的汇聚点,
/// 循环依赖立刻出现。
///
/// 裁决:**依赖反转**。core_auth 只声明"级联时需要有人做这些事",具体由谁做、
/// 怎么做,在 app 层组装时 override 进来。级联顺序(11 §切换门店的核心价值)
/// 仍然完整保留在 SessionNotifier 里。
///
/// 见根目录 SCAFFOLD-NOTES.md。
/// ---------------------------------------------------------------------------
library;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'models.dart';
/// 会话相关的服务端调用。
///
/// 实现放在 `feature_auth`(它同时依赖 core_auth 和 core_network),
/// 在 bootstrap 里 override 进来。core_auth 自己**不能**依赖 core_network。
abstract interface class SessionRemote {
/// 冷启动恢复:用本地 token 换用户上下文。token 无效时抛 `UnauthorizedException`。
Future<UserContext> fetchCurrentUser();
/// 拉可访问门店列表。
///
/// **失败不等于登录失败**(11 §登录流程):token 已经拿到了,应该进
/// [SessionAwaitingStore] 展示可重试的页面,而不是回登录页让用户重发验证码。
Future<List<StoreContext>> fetchAccessibleStores();
/// 服务端切店,返回带菜单的新门店上下文。
Future<StoreContext> switchStore(int storeId);
/// 撤销 refresh token。**尽力而为**,失败不阻断本地登出。
Future<void> revokeSession();
}
/// 会话结束 / 门店切换时需要被级联清理的一方。
///
/// 由持有用户态数据的包各自实现(core_webview 的 H5 会话、core_storage 的
/// 缓存与 prefs),在 app 层注册进 [sessionScopedStoresProvider]。
///
/// **新增一处用户态存储时,实现这个接口并注册**,就自动进入了级联清理清单——
/// 比维护一份"哪些东西要清"的文档清单可靠,那份清单一定会漏。
abstract interface class SessionScopedStore {
/// 出问题时能在日志里认出是谁。
String get debugName;
/// 切店:清门店维度的数据。
///
/// H5 会话在这里要 `invalidateAll(clearCookies: false)`——**不清 Cookie**
/// 用户还是同一个人,清了等于让 H5 重新登录一次(10 / 11)。
Future<void> onStoreChanged();
/// 登出:清全部用户数据。
///
/// H5 会话在这里必须 `clearCookies: true`。不清的话下一个人打开 H5 会直接
/// 落进上一个人的 F6 会话——门店设备是共用的,这是本项目最可能出现的
/// 真实安全事故(11)。
Future<void> onSessionEnded();
}
/// 会话变化时需要同步的观测 / 埋点侧。
///
/// 由 app 层用 core_logging 的 `CrashReporter` 和 core_analytics 的 `Analytics`
/// 实现。**漏了这一步的表现是切店后的崩溃和埋点还挂在旧门店名下**,按门店维度
/// 分析时数据是错的,而且错得很隐蔽(11 步骤 8)。
abstract interface class SessionObserver {
/// 门店上下文已更新。
void onStoreChanged(StoreContext store);
/// 用户已确定。
void onUserIdentified(UserContext user);
/// 已登出。实现里必须**先报事件再 reset**,顺序不能反(11 步骤 5)。
void onSessionEnded(LogoutReason reason);
/// 冷启动恢复失败。失败发生在读 secure storage 这一步时**完全不产生网络请求**,
/// 后端无从知晓,只能客户端报(13 / 11 §埋点)。
void onSessionRestoreFailed(String stage);
}
/// [SessionRemote] 的注入点。必须在 bootstrap 里 override。
final Provider<SessionRemote> sessionRemoteProvider = Provider<SessionRemote>(
(Ref ref) => throw UnimplementedError('sessionRemoteProvider 必须在 bootstrap() 里 override'),
);
/// 级联清理的注册表。默认空——app 层负责把各包的实现装进来。
final Provider<List<SessionScopedStore>> sessionScopedStoresProvider =
Provider<List<SessionScopedStore>>((Ref ref) => const <SessionScopedStore>[]);
/// 观测侧的注册表。默认空,测试里不用管。
final Provider<List<SessionObserver>> sessionObserversProvider = Provider<List<SessionObserver>>(
(Ref ref) => const <SessionObserver>[],
);
@@ -0,0 +1,87 @@
/// token 刷新。来源:conti-docs/05-networking.md §core_auth 侧的刷新实现。
library;
import 'dart:async';
import 'package:core_foundation/core_foundation.dart';
import 'package:dio/dio.dart';
import 'models.dart';
import 'token_storage.dart';
/// 刷新 access token。
///
/// 两条硬约束来自后端的**一次性 refresh token + 重放即全量撤销**机制
/// (见 11 §与 refresh token 轮换的配合):
///
/// 1. **刷新必须串行**。并发刷新会把同一个 refresh token 用两次,后端判定为
/// 重放,撤销该用户**所有设备**的会话——用户会在自己毫无操作的情况下被全端
/// 踢下线。这里用一个共享的 Future 保证同一时刻只有一个在途刷新,后来者
/// 等同一个结果。
/// 2. **失败立即登出,不重试**。失败意味着 refresh token 已失效,重试只会再
/// 触发一次重放判定。所以这里没有任何 retry 逻辑,这是有意的。
class TokenRefresher {
/// [dio] 仅供测试注入。
///
/// 生产走下面那个**裸 Dio**:不装任何拦截器。装了 AuthInterceptor 的话,
/// 刷新请求本身返回 401 会再触发一轮刷新,无限递归。
TokenRefresher({required this.storage, Dio? dio})
: _bare =
dio ??
Dio(
BaseOptions(
baseUrl: AppEnv.current.apiBaseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
),
);
/// token 存取。
final TokenStorage storage;
final Dio _bare;
Future<TokenPair>? _inFlight;
/// 刷新一次。并发调用共享同一个在途请求。
///
/// 成功时新 token **已经写回 secure storage** 才返回——写回必须在通知会话层
/// 之前完成(05)。
Future<TokenPair> refresh() {
return _inFlight ??= _refresh().whenComplete(() => _inFlight = null);
}
Future<TokenPair> _refresh() async {
final String? refreshToken = await storage.readRefreshToken();
if (refreshToken == null || refreshToken.isEmpty) {
throw const UnauthorizedException();
}
final Response<Map<String, dynamic>> res;
try {
res = await _bare.post<Map<String, dynamic>>(
'/api/v1/auth/refresh',
data: <String, String>{'refreshToken': refreshToken},
);
} on DioException catch (e) {
// 网络问题和 token 失效在这里不区分:两种情况都不能重试(见上),
// 上层一律按登出处理。
throw UnauthorizedException('登录已过期(${e.type.name}');
}
// 裸 Dio 没有 ApiResultInterceptor,得手动解包一层 data。
final Object? payload = res.data?['data'];
if (payload is! Map<String, dynamic>) {
throw const UnauthorizedException('刷新响应格式异常');
}
final Object? access = payload['accessToken'];
final Object? refresh = payload['refreshToken'];
if (access is! String || refresh is! String) {
throw const UnauthorizedException('刷新响应缺少 token');
}
final TokenPair pair = TokenPair(accessToken: access, refreshToken: refresh);
await storage.save(pair);
return pair;
}
}
@@ -0,0 +1,64 @@
/// token 的唯一存取点。来源:conti-docs/06-local-storage.md §secure storage。
library;
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'models.dart';
/// 包装 secure storage 的 token 读写。
///
/// **全仓库唯一直接持有 [FlutterSecureStorage] 实例的类**06 §secure storage
/// 使用示例)。其他包只能通过 core_auth 暴露的 provider 间接读写 token——
/// 这条约束是"token 到底存在哪、有几份拷贝"这个问题永远只有一个答案的前提。
class TokenStorage {
/// [storage] 仅供测试注入。
TokenStorage({FlutterSecureStorage? storage, this.onReadFailure})
: _storage = storage ?? const FlutterSecureStorage();
static const String _kAccessToken = 'access_token';
static const String _kRefreshToken = 'refresh_token';
final FlutterSecureStorage _storage;
/// 读失败时的回调。由 app 层接到 core_logging 上——core_auth 不依赖 core_logging。
final void Function(Object error, StackTrace stackTrace)? onReadFailure;
/// 写入一对 token。
///
/// 刷新拿到新 token 时也走这里:后端轮换机制下旧 refreshToken 立即作废,
/// **必须在通知会话层之前完成写回**,否则进程被杀后下次启动会用旧 token
/// 触发重放判定,导致全端被踢(05 / 11)。
Future<void> save(TokenPair pair) async {
await _storage.write(key: _kAccessToken, value: pair.accessToken);
await _storage.write(key: _kRefreshToken, value: pair.refreshToken);
}
/// 读 access token。
Future<String?> readAccessToken() => _read(_kAccessToken);
/// 读 refresh token。
Future<String?> readRefreshToken() => _read(_kRefreshToken);
/// 清空。
Future<void> clear() => _storage.deleteAll();
/// 读失败一律按未登录处理:清空 + 返回 null。
///
/// **绝对不能让 secure storage 的异常向上冒到启动流程**(06)——那会变成
/// "升级后一打开就白屏",比让用户重新登录一次严重得多。
/// `flutter_secure_storage 11.0.0` 改了 Android 侧的默认加密实现,旧版本
/// 写入的数据在升级后确实有读不出来的风险,这个兜底不是防御性编程。
Future<String?> _read(String key) async {
try {
return await _storage.read(key: key);
} on Object catch (e, st) {
onReadFailure?.call(e, st);
try {
await clear();
} on Object {
// 连清都清不掉就只能放弃,但绝不抛出去。
}
return null;
}
}
}
+31
View File
@@ -0,0 +1,31 @@
name: core_auth
description: 会话与门店上下文。token 存取、刷新串行化、登出/切店级联。
publish_to: none
version: 0.1.0
resolution: workspace
# ---------------------------------------------------------------------------
# 依赖约束(01 §依赖约束,硬规则):
# core_auth ↛ core_network —— 否则 AuthInterceptor 与刷新逻辑会互相纠缠成环
# core_auth ↛ core_storage —— 清缓存通过下面的 SessionScopedStore 接口反转依赖
# 因此本包里的 token 刷新用的是一个**不带任何拦截器的裸 Dio**。
# ---------------------------------------------------------------------------
environment:
sdk: ^3.12.0
dependencies:
core_foundation: ^0.1.0
dio: ^5.11.0
flutter:
sdk: flutter
flutter_riverpod: ^3.3.2
flutter_secure_storage: ^11.0.0
riverpod_annotation: ^4.0.3
dev_dependencies:
build_runner: ^2.4.13
flutter_lints: ^6.0.0
flutter_test:
sdk: flutter
mocktail: ^1.0.5
riverpod_generator: ^4.0.4
+231
View File
@@ -0,0 +1,231 @@
// core_auth 的两个高价值断言:
// 1. 刷新必须串行——并发刷新会触发后端的重放判定,把用户全端踢下线(11)。
// 2. 切店的级联顺序——服务端失败时本地必须原样不动(11 的那张表)。
// 其余都是数据搬运,不写用例。
import 'dart:convert';
import 'dart:typed_data';
import 'package:core_auth/core_auth.dart';
import 'package:core_foundation/core_foundation.dart';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
/// 内存版 secure storage。
class _FakeTokenStorage implements TokenStorage {
TokenPair? saved = const TokenPair(accessToken: 'a', refreshToken: 'r0');
int clearCount = 0;
@override
void Function(Object, StackTrace)? get onReadFailure => null;
@override
Future<String?> readAccessToken() async => saved?.accessToken;
@override
Future<String?> readRefreshToken() async => saved?.refreshToken;
@override
Future<void> save(TokenPair pair) async => saved = pair;
@override
Future<void> clear() async {
clearCount++;
saved = null;
}
}
class _FakeRemote implements SessionRemote {
_FakeRemote({this.stores = const <StoreContext>[]});
List<StoreContext> stores;
bool switchFails = false;
int switchCalls = 0;
@override
Future<UserContext> fetchCurrentUser() async => const UserContext(
userId: 'u1',
employeeId: 'e1',
phone: '13800001111',
roleCode: 'manager',
channel: 'app',
);
@override
Future<List<StoreContext>> fetchAccessibleStores() async => stores;
@override
Future<StoreContext> switchStore(int storeId) async {
switchCalls++;
if (switchFails) throw const NetworkException('网络不通');
return stores.firstWhere((StoreContext s) => s.storeId == storeId);
}
@override
Future<void> revokeSession() async {}
}
class _RecordingStore implements SessionScopedStore {
final List<String> calls = <String>[];
@override
String get debugName => 'recording';
@override
Future<void> onStoreChanged() async => calls.add('store');
@override
Future<void> onSessionEnded() async => calls.add('session');
}
StoreContext _store(int id) =>
StoreContext(storeId: id, storeCode: 'S$id', storeName: '门店$id', orgId: 1);
void main() {
group('TokenRefresher', () {
setUpAll(() {
AppEnv.resetForTest();
AppEnv.install(
const AppEnv(
flavor: AppFlavor.dev,
apiBaseUrl: 'https://example.test',
enableLog: false,
sentryDsn: '',
h5AllowedHosts: <String>{},
),
);
});
test('并发刷新只发一个请求——多发一个就会被后端判定为 refresh token 重放', () async {
int requests = 0;
final Dio dio = Dio()
..httpClientAdapter = _StubAdapter(() {
requests++;
return <String, dynamic>{
'data': <String, dynamic>{'accessToken': 'a1', 'refreshToken': 'r1'},
};
});
final _FakeTokenStorage storage = _FakeTokenStorage();
final TokenRefresher refresher = TokenRefresher(storage: storage, dio: dio);
final List<TokenPair> results = await Future.wait<TokenPair>(<Future<TokenPair>>[
refresher.refresh(),
refresher.refresh(),
refresher.refresh(),
]);
expect(requests, 1);
expect(results.every((TokenPair p) => p.accessToken == 'a1'), isTrue);
// 新 token 必须在返回前就写回,否则进程被杀会留下一个作废的 refreshToken。
expect(storage.saved?.refreshToken, 'r1');
});
test('没有 refresh token 时直接抛 UnauthorizedException,不发请求', () async {
final _FakeTokenStorage storage = _FakeTokenStorage()..saved = null;
final TokenRefresher refresher = TokenRefresher(storage: storage, dio: Dio());
await expectLater(refresher.refresh(), throwsA(isA<UnauthorizedException>()));
});
});
group('SessionNotifier.switchStore', () {
late _FakeTokenStorage storage;
late _FakeRemote remote;
late _RecordingStore scoped;
ProviderContainer makeContainer() {
storage = _FakeTokenStorage();
remote = _FakeRemote(stores: <StoreContext>[_store(1), _store(2)]);
scoped = _RecordingStore();
return ProviderContainer(
overrides: [
tokenStorageProvider.overrideWithValue(storage),
sessionRemoteProvider.overrideWithValue(remote),
sessionScopedStoresProvider.overrideWithValue(<SessionScopedStore>[scoped]),
lastStoreIdProvider.overrideWithValue(1),
],
);
}
test('服务端切换失败时本地状态原样不动,级联清理一次都不能跑', () async {
final ProviderContainer container = makeContainer();
addTearDown(container.dispose);
final AppSession initial = await container.read(sessionProvider.future);
expect(initial, isA<SessionActive>());
scoped.calls.clear();
remote.switchFails = true;
await expectLater(
container.read(sessionProvider.notifier).switchStore(2),
throwsA(isA<NetworkException>()),
);
// 先清缓存再请求的写法,会让用户停在"门店没变但数据全没了"的状态。
expect(scoped.calls, isEmpty);
expect((container.read(sessionProvider).value! as SessionActive).store.storeId, 1);
});
test('有未完成的写操作时拒绝切店', () async {
final ProviderContainer container = makeContainer();
addTearDown(container.dispose);
await container.read(sessionProvider.future);
container.read(pendingWritesProvider.notifier).add('submitOrder');
await expectLater(
container.read(sessionProvider.notifier).switchStore(2),
throwsA(isA<PreconditionException>()),
);
expect(remote.switchCalls, 1); // 只有冷启动那次
});
test('登出会清 token 并跑完整的级联清理', () async {
final ProviderContainer container = makeContainer();
addTearDown(container.dispose);
await container.read(sessionProvider.future);
scoped.calls.clear();
await container.read(sessionProvider.notifier).logout();
expect(scoped.calls, <String>['session']);
expect(storage.saved, isNull);
expect(
container.read(sessionProvider).value,
isA<SessionUnauthenticated>().having(
(SessionUnauthenticated s) => s.reason,
'reason',
LogoutReason.userInitiated,
),
);
});
});
}
/// 固定返回一份 JSON 的 adapter,省掉起 http server。
class _StubAdapter implements HttpClientAdapter {
_StubAdapter(this.body);
final Map<String, dynamic> Function() body;
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<Uint8List>? requestStream,
Future<void>? cancelFuture,
) async {
final Map<String, dynamic> payload = body();
return ResponseBody.fromString(
jsonEncode(payload),
200,
headers: <String, List<String>>{
Headers.contentTypeHeader: <String>[Headers.jsonContentType],
},
);
}
@override
void close({bool force = false}) {}
}
@@ -0,0 +1 @@
include: ../../analysis_options.yaml
@@ -0,0 +1,10 @@
/// 全仓库的依赖叶子包。
///
/// 这个包**不依赖仓库内任何其他包**,因此可以被所有 `core_*` / `feature_*`
/// 安全依赖而不产生循环。`native_*` 除外——它们按 01 的规定不依赖任何 core 包。
library;
export 'src/env/app_env.dart';
export 'src/error/api_code.dart';
export 'src/error/app_exception.dart';
export 'src/error/network_error_kind.dart';
+120
View File
@@ -0,0 +1,120 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 构建变体。与 `--flavor` 参数、Android productFlavors、iOS Scheme 一一对应。
///
/// 见 conti-docs/08-build-flavors.md。
enum AppFlavor {
dev,
uat,
prod;
static AppFlavor parse(String name) => switch (name) {
'dev' => AppFlavor.dev,
'uat' => AppFlavor.uat,
'prod' => AppFlavor.prod,
_ => throw ArgumentError.value(name, 'name', '未知 flavor,只允许 dev / uat / prod'),
};
}
/// 运行时环境配置。
///
/// **所有环境差异都收敛在这一个类里**,业务代码里不允许出现
/// `if (kDebugMode)` 或 `if (host.contains('uat'))` 这类判断。
///
/// 值来自 `--dart-define-from-file=env/{flavor}.json`(见 08)。构建时不传这个
/// 参数会得到空的 baseUrl[fromDartDefine] 会直接抛错而不是让 App 带着空配置跑起来——
/// 这种错误必须在启动瞬间暴露,而不是等第一个网络请求 404。
class AppEnv {
const AppEnv({
required this.flavor,
required this.apiBaseUrl,
required this.enableLog,
required this.sentryDsn,
required this.h5AllowedHosts,
});
/// 从 `--dart-define-from-file` 注入的值构造。
///
/// [flavor] 由各自的入口文件(main_dev.dart / main_uat.dart / main_prod.dart
/// 硬编码传入,而不是从 dart-define 读——这样"用 dev 的入口配了 prod 的 json"
/// 这种事故在代码里看得见。
factory AppEnv.fromDartDefine({required String flavor}) {
const apiBaseUrl = String.fromEnvironment('API_BASE_URL');
const enableLog = bool.fromEnvironment('ENABLE_LOG');
const sentryDsn = String.fromEnvironment('SENTRY_DSN');
const h5AllowedHosts = String.fromEnvironment('H5_ALLOWED_HOSTS');
if (apiBaseUrl.isEmpty) {
throw StateError(
'API_BASE_URL 为空。启动时必须带上 --dart-define-from-file=env/$flavor.json'
'见 README「怎么跑起来」。',
);
}
return AppEnv(
flavor: AppFlavor.parse(flavor),
apiBaseUrl: apiBaseUrl,
enableLog: enableLog,
sentryDsn: sentryDsn,
h5AllowedHosts: _parseHosts(h5AllowedHosts),
);
}
static Set<String> _parseHosts(String raw) => raw
.split(',')
.map((String e) => e.trim().toLowerCase())
.where((String e) => e.isNotEmpty)
.toSet();
final AppFlavor flavor;
/// 形如 `https://api.conti.com`**不带**结尾斜杠、**不带** `/api/v1` 前缀。
final String apiBaseUrl;
/// 是否输出网络日志和 debug 级日志。prod 必须为 false(见 13)。
final bool enableLog;
/// 为空表示不启用 Sentry(dev 默认不上报,避免把开发期噪音混进线上数据)。
final String sentryDsn;
/// H5 域名白名单。WebView 只允许加载这些域名及其子域,见 10。
final Set<String> h5AllowedHosts;
bool get isProd => flavor == AppFlavor.prod;
bool get flavorSuffixVisible => flavor != AppFlavor.prod;
/// 全局单例。
///
/// 优先用 [appEnvProvider] 注入(可测试、可 override)。这个静态入口只服务于
/// **拿不到 Ref 的地方**——目前唯一的使用点是 core_auth 的 TokenRefresher
/// 它必须用一个不带任何拦截器的裸 Dio,无法从 provider 树里取配置。
///
/// 声明成 `late`(而非 `late final`)是为了让 [resetForTest] 能真的重置;
/// 运行期的"只赋值一次"由 [install] 的 [_initialized] 标志保证。
static late AppEnv current;
static bool _initialized = false;
/// 由 bootstrap() 在 runApp 之前调用一次。重复调用会抛错。
static void install(AppEnv env) {
if (_initialized) {
throw StateError('AppEnv 已经初始化过了,不允许在运行期替换环境配置。');
}
_initialized = true;
current = env;
}
/// 仅供测试重置。
@visibleForTesting
static void resetForTest() => _initialized = false;
}
/// 环境配置的注入点。
///
/// 必须在 bootstrap() 的 ProviderScope 里 override,否则读取时直接抛错——
/// 给一个默认值会让"忘了注入"变成一个安静的线上事故。
final Provider<AppEnv> appEnvProvider = Provider<AppEnv>(
(Ref ref) => throw UnimplementedError('appEnvProvider 必须在 bootstrap() 里 overrideWithValue'),
);
@@ -0,0 +1,37 @@
/// 后端业务错误码常量。
///
/// **客户端不允许出现字面量数字**(见 12 §一):`if (e.code == ApiCode.forbidden)`
/// 而不是 `if (e.code == 10403)`。
///
/// ---------------------------------------------------------------------------
/// ⚠️ 完整码表尚未与后端对齐(12「待确认项」里的最高优先级项)。
///
/// 在码表定下来之前,客户端的策略是:**默认直接展示后端返回的 `message`**
/// 只对下面这一小组「需要特殊 UX 而不只是提示文案」的码做分支。这一组必须
/// 保持尽可能小——每加一个都意味着客户端和后端之间多一处硬编码耦合。
///
/// 分段约定(5 位,前 2 位是域段):
/// 0 成功
/// 10xxx 平台通用
/// 11xxx 认证与门店
/// 20xxx 采购 21xxx 库存
/// 3xxxx F6 / Mini 透传类
/// ---------------------------------------------------------------------------
abstract final class ApiCode {
static const int ok = 0;
/// → 表单内联报错,不弹 Toast
static const int invalidParam = 10001;
/// → 触发刷新 / 登出
static const int unauthorized = 10401;
/// → 权限变更,可能要重拉门店上下文
static const int forbidden = 10403;
/// → 展示 traceId
static const int internalError = 10500;
/// → 引导重选门店
static const int storeNotAccessible = 11001;
}
@@ -0,0 +1,145 @@
import 'network_error_kind.dart';
/// App 内部统一的异常体系(文档 12 §二)。
///
/// `sealed` 是有意的:UI 层的错误映射用 `switch` 穷举,将来新增一种异常类型,
/// 所有映射点会编译报错,逼着人去处理,而不是悄悄落进 `default` 变成"未知错误"。
///
/// ---------------------------------------------------------------------------
/// 与文档的偏差(见根目录 SCAFFOLD-NOTES.md §C / §E):
///
/// 1. 本体系文档里放在 `core_network`,但 `StorageException` 属于 core_storage、
/// `UnauthorizedException` 要被 core_auth 使用,而 01 明令禁止
/// `core_auth → core_network`。放在 core_network 无法同时满足依赖规则,
/// 因此下沉到叶子包 core_foundation。
/// 2. 文档里 `UnauthorizedException` / `RequestCancelledException` /
/// `StorageException` 都写成了空类体,但父类要求一个位置参数 `message`,
/// 照抄编译不过。这里补上带默认文案的 const 构造。
/// 3. `bridgeCode` 被 10 的 JSBridge 用到但从未定义,这里补上。
/// ---------------------------------------------------------------------------
sealed class AppException implements Exception {
const AppException(this.message, {this.traceId});
/// 可直接展示给用户的文案。不要往里塞堆栈或英文技术描述。
final String message;
/// 服务端链路 ID。只有 [ServerException] 会展示它,但所有异常都会把它写进日志。
final String? traceId;
/// 透传给 H5 的稳定字符串错误码(见 10 §JSBridge)。
///
/// **一旦发布就不能改**——H5 侧按它分支。新增能力时只能加新值。
String get bridgeCode;
@override
String toString() {
final String trace = traceId == null ? '' : ' traceId=$traceId';
return '$runtimeType($bridgeCode): $message$trace';
}
}
/// 网络不通、超时、DNS 失败——用户重试可能就好了。
final class NetworkException extends AppException {
const NetworkException(super.message, {this.kind});
final NetworkErrorKind? kind;
@override
String get bridgeCode => 'NETWORK_ERROR';
}
/// 后端返回了 `code != 0`[message] 可直接展示。
///
/// 构造签名按文档 12(位置参数),05 里那份具名参数的写法是笔误。
final class BusinessException extends AppException {
const BusinessException(this.code, super.message, {super.traceId});
final int code;
@override
String get bridgeCode => 'BUSINESS_ERROR';
}
/// 5xx、非 ApiResult 响应、解析失败——用户重试大概率也不好。
///
/// 文档 05 里叫 `HttpException`,与 `dart:io` 同名,统一用 12 的 `ServerException`。
final class ServerException extends AppException {
const ServerException(super.message, {this.statusCode, super.traceId});
final int? statusCode;
@override
String get bridgeCode => 'SERVER_ERROR';
}
/// token 失效且刷新失败,已触发登出。
///
/// UI 不展示它——登出流程本身会把用户送回登录页,再弹一个 Toast 是噪音。
final class UnauthorizedException extends AppException {
const UnauthorizedException([super.message = '登录已过期']);
@override
String get bridgeCode => 'UNAUTHORIZED';
}
/// 请求被 CancelToken 取消(页面销毁、用户主动退出)。
///
/// **必须被 UI 静默处理**(见 05 / 12)。用户返回上一页时在途请求被取消,
/// 弹一个"请求已取消"是纯粹的噪音。
final class RequestCancelledException extends AppException {
const RequestCancelledException([super.message = '请求已取消']);
@override
String get bridgeCode => 'CANCELLED';
}
/// 客户端本地判定的前置条件不满足(如切店时有未完成的写操作),[message] 可直接展示。
///
/// 不复用 [BusinessException]:后者的 code 来自后端错误码表,纯本地的判定
/// 没有、也不该编一个 code。
final class PreconditionException extends AppException {
const PreconditionException(super.message);
@override
String get bridgeCode => 'PRECONDITION_FAILED';
}
/// 本地存储 / 数据库错误。
final class StorageException extends AppException {
const StorageException([super.message = '本地数据异常']);
@override
String get bridgeCode => 'STORAGE_ERROR';
}
/// 原生能力错误(权限拒绝、设备不支持),见 07。
///
/// 注意 `native_*` 包**不能**抛这个类型——它们不允许依赖任何 core 包。
/// native 侧抛自己的包内异常,由调用方(feature / core_webview)转成这里的类型。
final class NativeException extends AppException {
const NativeException(this.code, super.message);
/// 取值见 [NativeErrorCode]。
final String code;
@override
String get bridgeCode => code;
}
/// [NativeException.code] 的取值。同时也是透传给 H5 的 bridge code。
abstract final class NativeErrorCode {
/// 用户拒绝了权限
static const String permissionDenied = 'PERMISSION_DENIED';
/// 设备没有这个硬件 / 系统不支持
static const String unavailable = 'UNAVAILABLE';
/// 用户主动取消(如扫码页返回)
static const String cancelled = 'CANCELLED';
/// 当前平台没有实现这个能力(如鸿蒙上的某些能力)
///
/// 文档 07 里单独有一个 `UnsupportedPlatformException`,收敛到这里,
/// 避免为一种情况多开一个异常类型。
static const String unsupportedPlatform = 'UNSUPPORTED_PLATFORM';
}
@@ -0,0 +1,17 @@
/// 网络层失败的细分原因。
///
/// 文档 12 的 `present()` 里用到了这个枚举,但 05 / 12 都没有声明它——
/// 这里补上。由 core_network 的 ErrorMappingInterceptor 负责从 DioExceptionType 映射。
enum NetworkErrorKind {
/// 连不上服务器(建连超时)
connectTimeout,
/// 连上了但服务器迟迟不返回
receiveTimeout,
/// 请求体发送超时(大文件上传常见)
sendTimeout,
/// 设备根本没有网络 / DNS 解析失败
noConnection,
}
+18
View File
@@ -0,0 +1,18 @@
name: core_foundation
description: 全仓库的依赖叶子包:运行环境(AppEnv)、错误契约(AppException 体系)、API 错误码常量。
publish_to: none
version: 0.1.0
resolution: workspace
environment:
sdk: ^3.12.0
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^3.3.2
dev_dependencies:
flutter_lints: ^6.0.0
flutter_test:
sdk: flutter
@@ -0,0 +1,55 @@
import 'package:core_foundation/core_foundation.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('AppException', () {
test('每个子类都有稳定且互不冲突的 bridgeCode', () {
const List<AppException> all = <AppException>[
NetworkException('x'),
BusinessException(1, 'x'),
ServerException('x'),
UnauthorizedException(),
RequestCancelledException(),
PreconditionException('x'),
StorageException(),
NativeException(NativeErrorCode.permissionDenied, 'x'),
];
final Set<String> codes = all.map((AppException e) => e.bridgeCode).toSet();
expect(codes.length, all.length, reason: 'bridgeCode 是 H5 的分支依据,不能有重复');
for (final String code in codes) {
expect(code, matches(RegExp(r'^[A-Z][A-Z_]+$')), reason: '必须是大写下划线常量风格');
}
});
test('toString 带上 traceId 但不带堆栈', () {
const AppException e = ServerException('系统繁忙', statusCode: 502, traceId: 'abc123');
expect(e.toString(), contains('abc123'));
expect(e.toString(), contains('系统繁忙'));
});
});
group('AppFlavor', () {
test('只接受三个已知 flavor', () {
expect(AppFlavor.parse('dev'), AppFlavor.dev);
expect(AppFlavor.parse('uat'), AppFlavor.uat);
expect(AppFlavor.parse('prod'), AppFlavor.prod);
expect(() => AppFlavor.parse('staging'), throwsArgumentError);
});
});
group('AppEnv', () {
test('isProd / flavorSuffixVisible 按 flavor 判定', () {
const AppEnv env = AppEnv(
flavor: AppFlavor.dev,
apiBaseUrl: 'https://api-dev.conti.com',
enableLog: true,
sentryDsn: '',
h5AllowedHosts: <String>{'f6.conti.com'},
);
expect(env.isProd, isFalse);
expect(env.flavorSuffixVisible, isTrue);
expect(env.h5AllowedHosts, contains('f6.conti.com'));
});
});
}
@@ -0,0 +1 @@
include: ../../analysis_options.yaml
@@ -0,0 +1,19 @@
/// 日志与崩溃上报的统一入口。来源:conti-docs/13-observability-analytics.md。
///
/// 边界约定:
/// - 业务代码只用 [AppLogger] / [CrashReporter] 两个接口,**不直接 import
/// `logger` 或 `sentry_flutter`**,也不用 `print` / `debugPrint`
/// - 全仓库只有本包的 `sentry_*.dart` 和 `app/bootstrap.dart` 可以见到
/// Sentry SDK。
library;
export 'src/app_logger.dart';
export 'src/crash_breadcrumb_observer.dart';
export 'src/crash_reporter.dart';
export 'src/log_buffer.dart';
export 'src/logger_app_logger.dart';
export 'src/providers.dart';
export 'src/scrubber.dart';
export 'src/sentry_crash_reporter.dart';
export 'src/sentry_scrubber.dart';
export 'src/test_exception.dart';
@@ -0,0 +1,42 @@
/// 日志门面。来源:13 §二。
library;
/// 全仓库唯一允许调用的日志入口。
///
/// **禁止直接用 `logger` 包、`print`、`debugPrint`**
/// - 换实现只改一处;
/// - `print` 在 release 下不会被剥离,是一条实打实的信息泄漏通道。
abstract interface class AppLogger {
/// 调试信息。只在 dev 输出。
void d(String message, {Map<String, Object?>? data});
/// 关键流程节点。uat 及以上输出。
void i(String message, {Map<String, Object?>? data});
/// 可恢复的异常状况。
void w(String message, {Object? error, StackTrace? stackTrace});
/// 错误。prod 下也会进环形缓冲并随崩溃上报。
void e(String message, {Object? error, StackTrace? stackTrace});
}
/// 测试和早期启动阶段用的空实现。
///
/// 启动最早期(`AppEnv` 还没装好)就可能有日志调用,那时不能因为
/// logger 未初始化而抛异常。
class NoopAppLogger implements AppLogger {
/// 创建一个什么都不做的 logger。
const NoopAppLogger();
@override
void d(String message, {Map<String, Object?>? data}) {}
@override
void i(String message, {Map<String, Object?>? data}) {}
@override
void w(String message, {Object? error, StackTrace? stackTrace}) {}
@override
void e(String message, {Object? error, StackTrace? stackTrace}) {}
}
@@ -0,0 +1,38 @@
/// 路由面包屑观察者。来源:13 §一「崩溃前的页面路径」。
library;
import 'package:flutter/widgets.dart';
import 'crash_reporter.dart';
/// 把路由变化写进 Sentry 面包屑。
///
/// **记的是路由名不是完整 URL**——`/webview?target=X&ticket=...` 里带着票据
/// (见 10 与本包的 `scrubber.dart`)。
///
/// 挂载位置:`app/` 在构造 GoRouter 时通过 `navigatorObserversProvider`
/// 注入。`core_router` 不依赖 `core_logging`(那不在 01 允许的三条 core 间
/// 依赖里),所以这个类住在这里而不是路由包。详见 SCAFFOLD-NOTES.md §G。
class CrashBreadcrumbObserver extends NavigatorObserver {
/// [reporter] 通常是 `SentryCrashReporter`。
CrashBreadcrumbObserver(this._reporter);
final CrashReporter _reporter;
@override
void didPush(Route<Object?> route, Route<Object?>? previousRoute) => _leave('push', route);
@override
void didPop(Route<Object?> route, Route<Object?>? previousRoute) => _leave('pop', route);
@override
void didReplace({Route<Object?>? newRoute, Route<Object?>? oldRoute}) {
if (newRoute != null) _leave('replace', newRoute);
}
void _leave(String action, Route<Object?> route) {
// name 为空时用 '<unnamed>',绝不回退到 route.settings.arguments 或
// 完整 location——那两个都可能带业务参数。
_reporter.leaveBreadcrumb('nav: $action ${route.settings.name ?? '<unnamed>'}');
}
}
@@ -0,0 +1,56 @@
/// 崩溃上报门面。来源:13 §一。
library;
/// 业务代码唯一可见的崩溃上报接口。
///
/// 这一层**不是**为了"将来可能换 Sentry"——是为了测试里能直接 mock 掉、
/// 不必真的初始化 SDK,以及让 `feature_*` 不多一条对三方 SDK 的直接依赖
/// (见 01 的依赖规则)。
abstract interface class CrashReporter {
/// 只传后端的 `userId`**不传手机号 / 姓名**(13 §一「用户与门店上下文」)。
void setUser(String userId);
/// 设置一个自定义维度。`storeId` 一定要带——门店设备型号和网络环境高度
/// 集中,很多崩溃是设备相关的,没有这个维度只能盲猜。
void setTag(String key, String value);
/// 清空用户与门店维度。登出时调用,否则共用设备上会串号。
void clearUser();
/// 面包屑。记路由名而不是完整 URL——URL 的 query 里带着票据。
void leaveBreadcrumb(String message);
/// 上报一个异常。
///
/// [extra] 用于带 `traceId` 这类能直接关联到后端 ELK 的字段。
void report(
Object error,
StackTrace? stack, {
Map<String, String> extra = const <String, String>{},
});
}
/// 测试与未接入环境用的空实现。
class NoopCrashReporter implements CrashReporter {
/// 创建一个什么都不做的上报器。
const NoopCrashReporter();
@override
void setUser(String userId) {}
@override
void setTag(String key, String value) {}
@override
void clearUser() {}
@override
void leaveBreadcrumb(String message) {}
@override
void report(
Object error,
StackTrace? stack, {
Map<String, String> extra = const <String, String>{},
}) {}
}
@@ -0,0 +1,57 @@
/// 内存环形缓冲——崩溃时的"黑匣子"。来源:13 §二「级别与环境」。
library;
/// 一条已经脱敏、可以直接外发的日志。
class LogRecord {
/// 构造一条日志记录。[message] 必须是脱敏之后的内容。
const LogRecord({required this.level, required this.message, required this.timestamp});
/// 级别缩写:`D` / `I` / `W` / `E`。
final String level;
/// 已脱敏的消息体。
final String message;
/// 记录时刻(本地时区)。
final DateTime timestamp;
@override
String toString() => '${timestamp.toIso8601String()} [$level] $message';
}
/// 固定容量的环形缓冲。
///
/// 只在内存里,**不落磁盘**——日志文件会成为新的泄漏面,且门店设备上
/// `logcat` 与外部存储都不是可信边界。App 退出即消失。
class LogBuffer {
/// [capacity] 默认 500,与 13 §二的表格一致。
LogBuffer({this.capacity = 500}) : assert(capacity > 0, 'capacity 必须为正');
/// 最多保留的条数。
final int capacity;
final List<LogRecord> _records = <LogRecord>[];
/// 追加一条,超出容量时丢弃最旧的。
void add(LogRecord record) {
_records.add(record);
if (_records.length > capacity) {
_records.removeRange(0, _records.length - capacity);
}
}
/// 取最近 [count] 条,按时间正序。
///
/// 崩溃上报时实际带走的是 30 条而不是全部 500 条——单个 Sentry 事件的
/// 体积有上限,超了整条事件会被丢弃,那比少带日志更糟。
List<LogRecord> recent([int count = 30]) {
if (_records.length <= count) return List<LogRecord>.unmodifiable(_records);
return List<LogRecord>.unmodifiable(_records.sublist(_records.length - count));
}
/// 当前条数。
int get length => _records.length;
/// 清空。登出时调用,避免上一个账号的日志被下一个账号的崩溃带走。
void clear() => _records.clear();
}
@@ -0,0 +1,124 @@
/// `AppLogger` 的 logger 包实现。来源:13 §二。
library;
import 'package:core_foundation/core_foundation.dart';
import 'package:logger/logger.dart';
import 'app_logger.dart';
import 'log_buffer.dart';
import 'scrubber.dart';
/// 日志级别,按严重程度递增。
enum LogSeverity {
/// 调试。
debug('D'),
/// 常规信息。
info('I'),
/// 警告。
warning('W'),
/// 错误。
error('E');
const LogSeverity(this.tag);
/// 出现在环形缓冲文本里的单字母标记。
final String tag;
}
/// 生产可用的 [AppLogger]。
///
/// 三条硬约束(13 §二):
/// 1. **prod 不输出到控制台**——Android 的 `logcat` 是全局可读的,门店设备上
/// 这不是理论风险;
/// 2. 所有内容进环形缓冲前**必须过一遍脱敏器**,因为缓冲会随崩溃外发;
/// 3. 级别按 flavor 分档:dev=debug / uat=info / prod=warning。
class LoggerAppLogger implements AppLogger {
/// 按 [env] 推导级别和控制台开关。
///
/// [buffer] 由调用方持有并同时交给 `CrashReporter`,两边必须是同一个实例,
/// 否则崩溃上报里带的是一份空日志。
LoggerAppLogger({required AppEnv env, required this.buffer, Logger? logger})
: _minSeverity = _severityFor(env.flavor),
// enableLog 是 dart-define 里的开关,prod 即使误开也不打控制台。
_console = env.enableLog && !env.isProd,
_logger =
logger ??
Logger(
printer: PrettyPrinter(
methodCount: 0,
errorMethodCount: 8,
colors: true,
printEmojis: false,
),
// 级别过滤由本类统一做,交给 logger 会多一层不一致的语义。
filter: ProductionFilter(),
level: Level.all,
);
static LogSeverity _severityFor(AppFlavor flavor) => switch (flavor) {
AppFlavor.dev => LogSeverity.debug,
AppFlavor.uat => LogSeverity.info,
AppFlavor.prod => LogSeverity.warning,
};
/// 崩溃时随事件外发的"黑匣子"。与 `beforeSend` 读的必须是同一个实例。
final LogBuffer buffer;
final LogSeverity _minSeverity;
final bool _console;
final Logger _logger;
@override
void d(String message, {Map<String, Object?>? data}) =>
_log(LogSeverity.debug, message, data: data);
@override
void i(String message, {Map<String, Object?>? data}) =>
_log(LogSeverity.info, message, data: data);
@override
void w(String message, {Object? error, StackTrace? stackTrace}) =>
_log(LogSeverity.warning, message, error: error, stackTrace: stackTrace);
@override
void e(String message, {Object? error, StackTrace? stackTrace}) =>
_log(LogSeverity.error, message, error: error, stackTrace: stackTrace);
void _log(
LogSeverity severity,
String message, {
Map<String, Object?>? data,
Object? error,
StackTrace? stackTrace,
}) {
if (severity.index < _minSeverity.index) return;
final String line = data == null || data.isEmpty ? message : '$message ${scrubMap(data)}';
buffer.add(
LogRecord(
level: severity.tag,
// error 的 toString 无法结构化脱敏,所以约定反过来:仓库内的异常类型
// (见 core_foundation 的 AppException)只带 code / traceId,不带任何
// 凭据或用户输入原文。三方异常同理,发现例外要在这里显式拦。
message: error == null ? line : '$line | error=$error',
timestamp: DateTime.now(),
),
);
if (!_console) return;
switch (severity) {
case LogSeverity.debug:
_logger.d(line);
case LogSeverity.info:
_logger.i(line);
case LogSeverity.warning:
_logger.w(line, error: error, stackTrace: stackTrace);
case LogSeverity.error:
_logger.e(line, error: error, stackTrace: stackTrace);
}
}
}
@@ -0,0 +1,28 @@
/// core_logging 的 Riverpod 接线。
library;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app_logger.dart';
import 'crash_reporter.dart';
import 'log_buffer.dart';
/// 环形缓冲的唯一实例。
///
/// `AppLogger` 往里写、`beforeSend` 从里读,两边必须是同一个对象,
/// 否则崩溃上报里带的是一份空日志。
final Provider<LogBuffer> logBufferProvider = Provider<LogBuffer>((Ref ref) => LogBuffer());
/// 全局日志入口。
///
/// 默认是 [NoopAppLogger]——真实实现需要 `AppEnv`,由 `app/` 的 `bootstrap()`
/// 在拿到环境配置后 override。这样保证:即使某个测试忘了 override,
/// 也只是没有日志,而不是启动就抛异常。
final Provider<AppLogger> appLoggerProvider = Provider<AppLogger>(
(Ref ref) => const NoopAppLogger(),
);
/// 全局崩溃上报入口。默认空实现,同 [appLoggerProvider]。
final Provider<CrashReporter> crashReporterProvider = Provider<CrashReporter>(
(Ref ref) => const NoopCrashReporter(),
);
@@ -0,0 +1,85 @@
/// 脱敏器。来源:13 §二「脱敏」、PRD §21.3。
///
/// 这个文件是全仓库唯一的脱敏实现,`AppLogger`、Sentry 的两个钩子、以及
/// 网络层的日志拦截器都必须走这里,不允许各写各的。
library;
/// 命中即整体替换为 [redacted] 的 key(大小写不敏感)。
///
/// `code` 在这里指短信验证码。业务错误码字段名统一叫 `bizCode` / `errorCode`
/// 不会被误伤——见 12 的 `ApiResult` 契约。
const Set<String> sensitiveKeys = <String>{
'token',
'accessToken',
'refreshToken',
'authorization',
'ticket',
'password',
'code',
'phone',
'mobile',
'idCard',
'bankCard',
};
/// 需要走掩码而不是整体抹掉的 key——保留首尾便于人工比对。
const Set<String> _maskedKeys = <String>{'phone', 'mobile'};
/// 统一的替换文案。出现在日志里时应当一眼看出是被脱敏了,而不是"值为空"。
const String redacted = '<redacted>';
/// 手机号掩码:`13812345678` → `138****5678`。
///
/// 长度不足 11 位时不做部分保留——短号段保留首三位仍可能足以定位到人。
String maskPhone(String v) => v.length >= 11 ? '${v.substring(0, 3)}****${v.substring(7)}' : '***';
/// 递归脱敏一个结构化日志载荷。
///
/// 只对 Map 的 **key** 做判断——裸字符串没有可靠的判据,不做猜测式匹配。
/// 因此约定:凭据只能以键值对的形式进日志,不允许拼进自由文本。
/// Map / List 之外的类型原样返回;调用方拿到的是新对象,原始 Map 不被修改。
Object? scrubValue(Object? value) {
if (value is Map) {
return <String, Object?>{
for (final MapEntry<Object?, Object?> e in value.entries)
'${e.key}': _scrubEntry('${e.key}', e.value),
};
}
if (value is List) {
return value.map<Object?>(scrubValue).toList();
}
return value;
}
/// [scrubValue] 的 Map 入口,返回类型收窄,供调用方直接塞进日志。
Map<String, Object?> scrubMap(Map<String, Object?> data) =>
scrubValue(data)! as Map<String, Object?>;
Object? _scrubEntry(String key, Object? value) {
final String lower = key.toLowerCase();
if (_maskedKeys.any((String k) => lower == k.toLowerCase())) {
return value is String ? maskPhone(value) : redacted;
}
if (sensitiveKeys.any((String k) => lower.contains(k.toLowerCase()))) {
return redacted;
}
return scrubValue(value);
}
/// 去掉 URL 的 query 和 fragment。
///
/// H5 的 URL 上带着 `ticket`(见 10),整条打出去等于打 token。
/// 解析失败时**整条抹掉**而不是原样返回——解析不出来的 URL 更可疑,不是更安全。
///
/// 实现注意:13 §二给的 `uri.replace(query: '')` 会留下一个空的 `?`(连同
/// `fragment: ''` 会得到 `...?#`),所以这里直接重建 Uri。
String scrubUrl(String url) {
final Uri? uri = Uri.tryParse(url);
if (uri == null) return redacted;
return Uri(
scheme: uri.hasScheme ? uri.scheme : null,
host: uri.host.isEmpty ? null : uri.host,
port: uri.hasPort ? uri.port : null,
path: uri.path,
).toString();
}
@@ -0,0 +1,71 @@
/// `CrashReporter` 的 Sentry 实现。来源:13 §一。
///
/// **本文件是全仓库唯一允许 `import 'package:sentry_flutter/...'` 的地方**
/// (另加 app/ 里的 `SentryFlutter.init`)。
library;
import 'dart:async';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'crash_reporter.dart';
/// 把 [CrashReporter] 转接到 Sentry SDK。
///
/// 所有方法都是"发射后不管":上报本身绝不能阻塞或影响业务流程,
/// 因此统一 `unawaited` 并吞掉自身异常。
class SentryCrashReporter implements CrashReporter {
/// 创建一个转接到全局 `Sentry` 的上报器。
const SentryCrashReporter();
@override
void setUser(String userId) => _configure((Scope scope) async {
// 只传 ID。手机号、姓名、IP 一律不带(sendDefaultPii 也已关闭)。
await scope.setUser(SentryUser(id: userId));
});
@override
void setTag(String key, String value) => _configure((Scope scope) => scope.setTag(key, value));
@override
void clearUser() => _configure((Scope scope) async {
await scope.setUser(null);
await scope.removeTag('storeId');
await scope.removeTag('roleCode');
});
@override
void leaveBreadcrumb(String message) {
unawaited(Sentry.addBreadcrumb(Breadcrumb(message: message)).catchError((_) {}));
}
@override
void report(
Object error,
StackTrace? stack, {
Map<String, String> extra = const <String, String>{},
}) {
unawaited(
Sentry.captureException(
error,
stackTrace: stack,
withScope: (Scope scope) async {
if (extra.isNotEmpty) {
// traceId 放这里——一条崩溃能直接关联到后端 ELK 里的那次请求。
await scope.setContexts('app_extra', extra);
}
},
).then<void>((_) {}).catchError((_) {}),
);
}
void _configure(FutureOr<void> Function(Scope) callback) {
unawaited(() async {
try {
await Sentry.configureScope(callback);
} on Object {
// 上报链路自身的异常不能外溢到业务流程。
}
}());
}
}
@@ -0,0 +1,43 @@
/// Sentry 的两个脱敏钩子。来源:13 §二「脱敏」。
///
/// 这两个钩子是 `SentryFlutter.init` 里 `beforeBreadcrumb` / `beforeSend`
/// 的实现,**一个都不能漏**:Sentry 默认会自动记录所有 HTTP 请求作为面包屑,
/// 我们在 05/10 里辛苦保证的"URL 不落日志"会被这条默认行为绕过去——
/// 它不走我们的 `AppLogger`。
library;
import 'package:sentry_flutter/sentry_flutter.dart';
import 'log_buffer.dart';
import 'scrubber.dart';
/// 面包屑脱敏:剥掉 URL 的 query(里面可能带 `ticket` / `token`)。
Breadcrumb? scrubBreadcrumb(Breadcrumb? crumb, Hint hint) {
if (crumb == null) return null;
final Object? url = crumb.data?['url'];
if (url is String) {
crumb.data?['url'] = scrubUrl(url);
}
return crumb;
}
/// 事件脱敏 + 挂载"黑匣子"。
///
/// 返回的 `beforeSend` 闭包做两件事:
/// 1. 把环形缓冲里**最近 30 条**日志作为 context 附上——不是全部 500 条,
/// 单个事件有体积上限,超了整条事件会被丢弃;
/// 2. 剥掉请求 URL 的 query。
BeforeSendCallback buildScrubEvent(LogBuffer buffer) {
return (SentryEvent event, Hint hint) {
final SentryRequest? request = event.request;
final String? url = request?.url;
if (request != null && url != null) {
request.url = scrubUrl(url);
}
return event
..contexts['app_logs'] = <String, Object?>{
'recent': buffer.recent().map((LogRecord r) => r.toString()).toList(growable: false),
};
};
}
@@ -0,0 +1,17 @@
/// 上报链路的自检入口。来源:13 §一「验证接入真的成功了」。
library;
import 'package:core_foundation/core_foundation.dart';
/// 崩溃上报最常见的失败模式是**静默不上报**——数据没传上来,但你以为
/// App 很稳定。所以每次发版前在 dev 上调一次这个函数,确认 Android 和 iOS
/// 都能在 Sentry 里看到,**并且堆栈是可读的 Dart 文件名行号而不是 `_x12`**。
///
/// 只在 dev flavor 下可调;uat/prod 调用会直接抛 [StateError]
/// 这样误留在代码里的调用会在测试环境就暴露,而不是污染线上崩溃率。
Never throwTestException(AppEnv env) {
if (env.flavor != AppFlavor.dev) {
throw StateError('throwTestException 只允许在 dev flavor 调用');
}
throw Exception('Sentry 接入自检:这是一条人为触发的测试异常');
}
+21
View File
@@ -0,0 +1,21 @@
name: core_logging
description: 日志与崩溃上报的统一入口(AppLogger / CrashReporter),含敏感信息脱敏。
publish_to: none
version: 0.1.0
resolution: workspace
environment:
sdk: ^3.12.0
dependencies:
core_foundation: ^0.1.0
flutter:
sdk: flutter
flutter_riverpod: ^3.3.2
logger: ^2.7.0
sentry_flutter: ^9.26.0
dev_dependencies:
flutter_lints: ^6.0.0
flutter_test:
sdk: flutter
@@ -0,0 +1,137 @@
import 'package:core_foundation/core_foundation.dart';
import 'package:core_logging/core_logging.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('scrubber', () {
test('敏感 key 整体抹掉', () {
final Map<String, Object?> out = scrubMap(<String, Object?>{
'accessToken': 'eyJhbGciOi...',
'Authorization': 'Bearer abc',
'ticket': 'T-123',
'path': '/api/v1/stores',
});
expect(out['accessToken'], redacted);
expect(out['Authorization'], redacted);
expect(out['ticket'], redacted);
expect(out['path'], '/api/v1/stores');
});
test('手机号走掩码而不是整体抹掉', () {
expect(scrubMap(<String, Object?>{'phone': '13812345678'})['phone'], '138****5678');
expect(maskPhone('123'), '***');
});
test('嵌套结构递归处理', () {
final Map<String, Object?> out = scrubMap(<String, Object?>{
'user': <String, Object?>{'userId': 'u1', 'password': 'p'},
'list': <Object?>[
<String, Object?>{'refreshToken': 'r'},
],
});
final Map<String, Object?> user = out['user']! as Map<String, Object?>;
expect(user['userId'], 'u1');
expect(user['password'], redacted);
final List<Object?> list = out['list']! as List<Object?>;
expect((list.first! as Map<String, Object?>)['refreshToken'], redacted);
});
test('原始 Map 不被修改', () {
final Map<String, Object?> src = <String, Object?>{'token': 'x'};
scrubMap(src);
expect(src['token'], 'x');
});
test('URL 去掉 query 和 fragment', () {
expect(scrubUrl('https://h5.example.com/p?ticket=abc&x=1#frag'), 'https://h5.example.com/p');
});
test('URL 解析失败时整条抹掉,而不是原样返回', () {
expect(scrubUrl('http://[bad'), redacted);
});
});
group('LogBuffer', () {
test('超出容量丢最旧的', () {
final LogBuffer buffer = LogBuffer(capacity: 3);
for (int i = 0; i < 5; i++) {
buffer.add(LogRecord(level: 'I', message: '$i', timestamp: DateTime(2026)));
}
expect(buffer.length, 3);
expect(buffer.recent().map((LogRecord r) => r.message), <String>['2', '3', '4']);
});
test('recent 默认只取 30 条', () {
final LogBuffer buffer = LogBuffer();
for (int i = 0; i < 500; i++) {
buffer.add(LogRecord(level: 'I', message: '$i', timestamp: DateTime(2026)));
}
expect(buffer.length, 500);
expect(buffer.recent().length, 30);
expect(buffer.recent().first.message, '470');
});
});
group('LoggerAppLogger', () {
AppEnv envOf(AppFlavor flavor) => AppEnv(
flavor: flavor,
apiBaseUrl: 'https://example.com',
enableLog: true,
sentryDsn: '',
h5AllowedHosts: const <String>{},
);
test('prod 只收 warning 及以上', () {
final LogBuffer buffer = LogBuffer();
final AppLogger log = LoggerAppLogger(env: envOf(AppFlavor.prod), buffer: buffer);
log
..d('debug')
..i('info')
..w('warn')
..e('error');
expect(buffer.recent().map((LogRecord r) => r.message), <String>['warn', 'error']);
});
test('dev 收全部级别', () {
final LogBuffer buffer = LogBuffer();
LoggerAppLogger(env: envOf(AppFlavor.dev), buffer: buffer)
..d('debug')
..i('info');
expect(buffer.length, 2);
});
test('结构化 data 进缓冲前已脱敏', () {
final LogBuffer buffer = LogBuffer();
LoggerAppLogger(
env: envOf(AppFlavor.uat),
buffer: buffer,
).i('login', data: <String, Object?>{'phone': '13812345678'});
expect(buffer.recent().single.message, contains('138****5678'));
expect(buffer.recent().single.message, isNot(contains('13812345678')));
});
});
group('throwTestException', () {
AppEnv envOf(AppFlavor flavor) => AppEnv(
flavor: flavor,
apiBaseUrl: 'https://example.com',
enableLog: false,
sentryDsn: '',
h5AllowedHosts: const <String>{},
);
test('dev 抛测试异常', () {
expect(() => throwTestException(envOf(AppFlavor.dev)), throwsA(isA<Exception>()));
});
test('prod 拒绝调用', () {
expect(() => throwTestException(envOf(AppFlavor.prod)), throwsA(isA<StateError>()));
});
});
}
@@ -0,0 +1 @@
include: ../../analysis_options.yaml
@@ -0,0 +1,14 @@
/// 网络层。来源:conti-docs/05-networking.md。
///
/// 对外只暴露 [ApiClient] 和 `apiClientProvider`——**repository 一律注入
/// ApiClient,不注入 Dio**05)。`dioProvider` 也导出,但只给需要直接持有
/// Dio 的极少数场景(目前没有)。
///
/// 依赖约束(01):`core_network → core_auth` 是允许的三条 core 互依例外之一;
/// 其余跨包需要(日志、设备信息)走 `src/ports.dart` 的接口反转。
library;
export 'src/api_client.dart';
export 'src/paging.dart';
export 'src/ports.dart';
export 'src/providers.dart';
@@ -0,0 +1,99 @@
/// 仓库唯一对外的 HTTP 出口。来源:conti-docs/05-networking.md。
library;
import 'dart:io';
import 'package:core_foundation/core_foundation.dart';
import 'package:dio/dio.dart';
import 'package:path/path.dart' as p;
/// 包在 [Dio] 外面的一层。
///
/// ---------------------------------------------------------------------------
/// 为什么需要它:**拦截器没有办法让 `dio.get()` 抛出 [AppException]**。
/// dio 的错误通道只认 [DioException],我们的 [AppException] 只能挂在它的
/// `error` 字段上。repository 直接调 `dio.get()` 的话,业务层写
///
/// try { ... } on UnauthorizedException { ... }
///
/// 永远进不来——实际抛出来的仍然是 [DioException]。
///
/// 所以在出口处把 `DioException.error` 拆出来重抛。
///
/// **规则:repository 一律注入 [ApiClient],不注入 [Dio]。** 全仓库只有
/// core_network 内部和 core_auth 的裸 Dio 会直接碰 [Dio] 类型。
/// ---------------------------------------------------------------------------
class ApiClient {
/// [dio] 由 `dioProvider` 提供。
ApiClient(this._dio);
final Dio _dio;
/// GET。
Future<T> get<T>(String path, {Map<String, dynamic>? query, CancelToken? cancelToken}) =>
_run(() => _dio.get<T>(path, queryParameters: query, cancelToken: cancelToken));
/// POST。
Future<T> post<T>(String path, {Object? data, CancelToken? cancelToken}) =>
_run(() => _dio.post<T>(path, data: data, cancelToken: cancelToken));
/// PUT。
Future<T> put<T>(String path, {Object? data, CancelToken? cancelToken}) =>
_run(() => _dio.put<T>(path, data: data, cancelToken: cancelToken));
/// DELETE。
Future<T> delete<T>(String path, {Object? data, CancelToken? cancelToken}) =>
_run(() => _dio.delete<T>(path, data: data, cancelToken: cancelToken));
/// 多文件上传。
///
/// 约定(05 §文件与图片上传):
/// - **上传前必须压缩**。门店员工直接拍的照片通常 3–8 MB,原图在门店 WiFi 下
/// 大概率超时。统一压到长边 1600px / JPEG 80,超 2 MB 再降一档。
/// - **进度必须可见**,否则用户会以为卡死反复点。
/// - **失败要能单张重传**,所以 UI 的上传状态按单张维护,别整批重来。
/// - [FormData] **不可重用**:它是流,重试必须重新构造,复用会报
/// stream already listened——所以这个方法每次调用都自己建一个。
Future<T> upload<T>(
String path, {
required List<File> files,
Map<String, dynamic>? fields,
void Function(int sent, int total)? onProgress,
CancelToken? cancelToken,
}) async {
final FormData formData = FormData.fromMap(<String, dynamic>{
...?fields,
'files': <MultipartFile>[
for (final File f in files)
await MultipartFile.fromFile(f.path, filename: p.basename(f.path)),
],
});
return _run(
() => _dio.post<T>(
path,
data: formData,
cancelToken: cancelToken,
onSendProgress: onProgress,
// 上传单独放宽:用全局的 30s 传几张原图会超。
options: Options(sendTimeout: const Duration(minutes: 3)),
),
);
}
Future<T> _run<T>(Future<Response<T>> Function() send) async {
try {
final Response<T> res = await send();
return res.data as T;
} on DioException catch (e, st) {
final Object? error = e.error;
// 拦截器已经归一化过,这里只负责把它从 DioException 的壳里拿出来重抛。
// Error.throwWithStackTrace 保留原始堆栈——否则上报到崩溃平台的堆栈会
// 全部指向下面这一行,等于没有堆栈。
if (error is AppException) {
Error.throwWithStackTrace(error, st);
}
Error.throwWithStackTrace(const NetworkException('网络异常,请稍后重试'), st);
}
}
}
@@ -0,0 +1,58 @@
/// 后端统一响应包装的解包。来源:conti-docs/05-networking.md §后端契约。
library;
import 'package:core_foundation/core_foundation.dart';
import 'package:dio/dio.dart';
import 'ports.dart';
/// 把 `ApiResult<T> { code, message, data, traceId }` 剥成里层的 `data`。
///
/// **解包只在这里做一次**repository 拿到的 `response.data` 已经是 `data` 本身。
class ApiResultInterceptor extends Interceptor {
/// [log] 见 [apiLogSinkProvider]。
ApiResultInterceptor(this._log);
final ApiLogSink _log;
@override
void onResponse(Response<dynamic> response, ResponseInterceptorHandler handler) {
final Object? body = response.data;
// 非 JSON 对象响应(如文件下载)不走解包。
if (body is! Map<String, dynamic> || !body.containsKey('code')) {
handler.next(response);
return;
}
// 用 num 而不是 int:万一后端某个字段序列化成了 JSON 浮点数,as int 会直接抛。
final int? code = (body['code'] as num?)?.toInt();
final String? traceId = body['traceId'] as String?;
// traceId 必须留存:用户报一个 traceId,后端就能在日志里定位这次请求
// backend 06/08)。成功失败都要打。URL 不带 query——H5 那类 URL 里有 ticket。
final Uri uri = response.requestOptions.uri;
_log('[api] ${uri.origin}${uri.path} code=$code traceId=$traceId');
if (code == ApiCode.ok) {
response.data = body['data'];
handler.next(response);
return;
}
// code != 0 一律转成业务异常,不让它以"成功响应"的形态流到业务层。
handler.reject(
DioException(
requestOptions: response.requestOptions,
response: response,
error: BusinessException(
code ?? -1,
(body['message'] as String?) ?? '请求失败',
traceId: traceId,
),
),
// callFollowingErrorInterceptor:让 ErrorMappingInterceptor 有机会放行它。
true,
);
}
}
@@ -0,0 +1,73 @@
/// 鉴权与 401 刷新。来源:conti-docs/05-networking.md §Token 刷新:必须串行,失败即登出。
library;
import 'package:core_auth/core_auth.dart';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'providers.dart';
/// 附加 token;401 时刷新一次并重放原请求。
///
/// 串行化本身**不在这里**——它在 core_auth 的 [TokenRefresher] 里(共享在途
/// Future)。这样即使将来多了一个走刷新的调用方,串行保证也只有一份实现。
///
/// 三条硬约束(后端 refresh token 一次性 + 重放即全量撤销):
/// 1. 绝不并发刷新,否则用户被全设备强制登出;
/// 2. 刷新失败不重试,直接登出;
/// 3. 刷新请求本身走裸 Dio,不经过本拦截器,否则无限递归。
class AuthInterceptor extends Interceptor {
/// [ref] 用来读 token 与会话。
AuthInterceptor(this._ref);
final Ref _ref;
/// 一次性重试标记。带着新 token 重放后又 401,说明不是 token 的问题,别再刷了。
static const String _retriedKey = 'x-retried';
@override
Future<void> onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
final String? token = await _ref.read(tokenStorageProvider).readAccessToken();
if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
}
@override
Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
if (err.response?.statusCode != 401) {
handler.next(err);
return;
}
if (err.requestOptions.extra[_retriedKey] == true) {
await _logout();
handler.next(err);
return;
}
final TokenPair refreshed;
try {
refreshed = await _ref.read(tokenRefresherProvider).refresh();
} on Object {
// 刷新失败 = refresh token 已失效。不重试——再试一次只会再触发一次
// 重放判定,把用户的其他设备也一起踢掉。
await _logout();
handler.next(err);
return;
}
try {
final RequestOptions options = err.requestOptions
..extra[_retriedKey] = true
..headers['Authorization'] = 'Bearer ${refreshed.accessToken}';
handler.resolve(await _ref.read(dioProvider).fetch<dynamic>(options));
} on DioException catch (e) {
handler.next(e);
}
}
Future<void> _logout() =>
_ref.read(sessionProvider.notifier).logout(reason: LogoutReason.tokenExpired);
}
@@ -0,0 +1,58 @@
/// 异常归一化。来源:conti-docs/05-networking.md §异常归一化。
library;
import 'package:core_foundation/core_foundation.dart';
import 'package:dio/dio.dart';
/// 把 [DioException] 统一转成 [AppException] 体系。
///
/// **必须排在拦截器链的最后**:它把所有还没被归一化的错误兜底成 [NetworkException]
/// 排在前面会把 [ApiResultInterceptor] 抛的 [BusinessException] 提前吃掉。
class ErrorMappingInterceptor extends Interceptor {
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
// 已经是 AppException 的直接放行,不要二次包装。
if (err.error is AppException) {
handler.next(err);
return;
}
final AppException mapped = switch (err.type) {
DioExceptionType.connectionTimeout => const NetworkException(
'网络超时,请检查网络后重试',
kind: NetworkErrorKind.connectTimeout,
),
DioExceptionType.sendTimeout => const NetworkException(
'网络超时,请检查网络后重试',
kind: NetworkErrorKind.sendTimeout,
),
DioExceptionType.receiveTimeout => const NetworkException(
'网络超时,请检查网络后重试',
kind: NetworkErrorKind.receiveTimeout,
),
DioExceptionType.connectionError => const NetworkException(
'网络不可用,请检查网络后重试',
kind: NetworkErrorKind.noConnection,
),
// 必须静默处理:用户返回上一页时在途请求被取消,弹提示是纯粹的噪音(12)。
DioExceptionType.cancel => const RequestCancelledException(),
// 401 走到这里说明 AuthInterceptor 已经刷新失败并登出了,UI 不再重复提示。
DioExceptionType.badResponse when err.response?.statusCode == 401 =>
const UnauthorizedException(),
DioExceptionType.badResponse => ServerException(
'服务异常(${err.response?.statusCode}',
statusCode: err.response?.statusCode,
),
_ => const NetworkException('网络异常,请稍后重试'),
};
handler.next(
DioException(
requestOptions: err.requestOptions,
response: err.response,
type: err.type,
error: mapped,
),
);
}
}
@@ -0,0 +1,46 @@
/// 统一请求头。来源:conti-docs/05-networking.md §统一请求头。
library;
import 'dart:io';
import 'package:core_auth/core_auth.dart';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:uuid/uuid.dart';
import 'ports.dart';
/// 给每个请求补上追踪、版本、平台、设备和门店头。
class HeaderInterceptor extends Interceptor {
/// [ref] 用来读环境和会话。
HeaderInterceptor(this._ref);
final Ref _ref;
static const Uuid _uuid = Uuid();
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
final ClientInfo info = _ref.read(clientInfoProvider);
options.headers.addAll(<String, String>{
// 客户端生成,便于端到端串联。
// **待与后端确认**backend 06 说 traceId 由后端入口 filter 生成,约定是
// 后端优先复用这个头、没有才自己生成,否则两边日志各用一套 ID 对不上。
'X-Trace-Id': _uuid.v4(),
'X-App-Version': info.appVersion,
'X-Platform': Platform.isIOS ? 'ios' : 'android',
'X-Device-Id': info.deviceId,
});
// 用可空版:登录、拉门店列表这些请求本身就发生在选店之前,
// 用会 throw 的 currentStoreIdProvider 会直接把登录流程打死。
final int? storeId = _ref.read(currentStoreIdOrNullProvider);
if (storeId != null) {
// 冗余信息:access token 的 claims 里已经有 storeId,后端以 token 为准。
// 带这个头只为排查时能一眼看出客户端当时认为自己在哪个门店——两者不一致
// 就说明切店后 token 没换,是个 bug 信号。
options.headers['X-Store-Id'] = '$storeId';
}
handler.next(options);
}
}
+62
View File
@@ -0,0 +1,62 @@
/// 分页契约。来源:conti-docs/02-layering.md。
library;
import 'package:flutter/foundation.dart';
/// 分页请求参数。
@immutable
class PageQuery {
/// [page] 从 1 开始。
const PageQuery({required this.page, this.size = 20});
/// 页码,从 1 开始。
final int page;
/// 每页条数。
final int size;
/// 转成 query 参数。**字段名待与后端对齐**(backend 06 的分页约定还没定)。
Map<String, dynamic> toQuery() => <String, dynamic>{'page': page, 'size': size};
}
/// 分页结果。
@immutable
class PageResult<T> {
/// 构造。
const PageResult({
required this.items,
required this.total,
required this.page,
required this.hasMore,
});
/// 从 `{items, total, page, hasMore}` 结构解析。
factory PageResult.fromJson(
Map<String, dynamic> json,
T Function(Map<String, dynamic>) itemFromJson,
) {
final List<dynamic> raw = (json['items'] as List<dynamic>?) ?? const <dynamic>[];
return PageResult<T>(
items: raw.map((dynamic e) => itemFromJson(e as Map<String, dynamic>)).toList(),
total: (json['total'] as num?)?.toInt() ?? 0,
page: (json['page'] as num?)?.toInt() ?? 1,
hasMore: json['hasMore'] as bool? ?? false,
);
}
/// 当前页数据。
final List<T> items;
/// 总条数。
final int total;
/// 当前页码。
final int page;
/// 是否还有下一页。
///
/// **用后端下发的字段,不在客户端算**。02 里那份
/// `items.length + (page - 1) * items.length < total` 的推算在最后一页
/// 条数不满时会算错;README 的已解决项里后端已确认下发这个字段。
final bool hasMore;
}
+45
View File
@@ -0,0 +1,45 @@
/// core_network 的对外端口。
///
/// 和 core_auth 的 `session_ports.dart` 是同一套思路:05 的伪代码里
/// `HeaderInterceptor` 读了 `deviceIdProvider`、`ApiResultInterceptor` 收了一个
/// `AppLogger`,但 01 只允许 `core_network → core_auth` 这一条 core 出边,
/// core_logging 不在其中。所以这里只声明"需要什么",由 app 层接上去。
library;
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 请求头里那几个和环境无关、只有运行时才知道的值。
@immutable
class ClientInfo {
/// 构造。
const ClientInfo({required this.appVersion, required this.deviceId});
/// 形如 `1.4.0+142`。
final String appVersion;
/// **安装级匿名 ID**:首次安装时生成的随机 UUID,存本地。
///
/// 绝不是 IMEI / IDFA / MAC / AndroidID——采集这些是合规红线(05 / 07 隐私清单)。
final String deviceId;
}
/// [ClientInfo] 的注入点。必须在 bootstrap 里 override。
///
/// 不给默认值:带着 `appVersion: 'unknown'` 上线,问题会表现为线上日志里
/// 版本分布全糊成一团,等发现时已经排查了很久。
final Provider<ClientInfo> clientInfoProvider = Provider<ClientInfo>(
(Ref ref) => throw UnimplementedError('clientInfoProvider 必须在 bootstrap() 里 override'),
);
/// API 日志出口。
typedef ApiLogSink = void Function(String message);
/// [ApiLogSink] 的注入点。默认丢弃——测试里不用管。
///
/// app 层把它接到 core_logging 的 `AppLogger.d` 上。**接的时候注意 message 已经
/// 是脱敏过的**:这里输出的只有 URL(不含 query)、code、traceId,不含请求体,
/// 也绝不含 `Authorization`13 §脱敏)。
final Provider<ApiLogSink> apiLogSinkProvider = Provider<ApiLogSink>(
(Ref ref) => (String message) {},
);
@@ -0,0 +1,62 @@
/// 拦截器链的组装。来源:conti-docs/05-networking.md §附录·拦截器链的组装。
library;
import 'package:core_foundation/core_foundation.dart';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'api_client.dart';
import 'api_result_interceptor.dart';
import 'auth_interceptor.dart';
import 'error_mapping_interceptor.dart';
import 'header_interceptor.dart';
import 'ports.dart';
/// 全 App 唯一的 [Dio] 实例。
///
/// ---------------------------------------------------------------------------
/// **拦截器顺序不能改。** dio 的三个时机(onRequest / onResponse / onError
/// 都是按注册顺序**正向**执行的,不是洋葱模型——这一点和很多人的直觉不同。
///
/// HeaderInterceptor 补 trace / 版本 / 平台 / 门店头
/// LogInterceptor 仅 enableLogprod 绝不能开(13
/// AuthInterceptor 必须在 ErrorMapping 之前,才能在 401 被归一化成
/// UnauthorizedException **之前**先尝试刷新
/// ApiResultInterceptor 必须在 ErrorMapping 之前,它抛的 BusinessException
/// 需要能被后者识别并放行
/// ErrorMappingInterceptor 兜底,必须最后
///
/// 顺序取自 05 的附录(正文那份和附录不一致,以附录为准,见 SCAFFOLD-NOTES)。
/// ---------------------------------------------------------------------------
final Provider<Dio> dioProvider = Provider<Dio>((Ref ref) {
final AppEnv env = ref.watch(appEnvProvider);
final Dio dio = Dio(
BaseOptions(
baseUrl: env.apiBaseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 15),
// 上传单独放宽到 3 分钟,见 ApiClient.upload。
sendTimeout: const Duration(seconds: 30),
),
);
dio.interceptors.addAll(<Interceptor>[
HeaderInterceptor(ref),
// responseBody: false —— 响应体里可能有手机号、地址这类个人信息,
// 而且日志只在 dev/uat 开。requestHeader 里的 Authorization 由
// LogInterceptor 原样打印,所以 prod 必须关掉整条(env.enableLog == false)。
if (env.enableLog) LogInterceptor(responseBody: false),
AuthInterceptor(ref),
ApiResultInterceptor(ref.watch(apiLogSinkProvider)),
ErrorMappingInterceptor(),
]);
ref.onDispose(dio.close);
return dio;
});
/// repository 唯一该注入的东西。
final Provider<ApiClient> apiClientProvider = Provider<ApiClient>(
(Ref ref) => ApiClient(ref.watch(dioProvider)),
);
+24
View File
@@ -0,0 +1,24 @@
name: core_network
description: dio 封装。拦截器链、ApiResult 解包、错误映射,以及唯一对外的 ApiClient。
publish_to: none
version: 0.1.0
resolution: workspace
environment:
sdk: ^3.12.0
dependencies:
core_auth: ^0.1.0
core_foundation: ^0.1.0
dio: ^5.11.0
flutter:
sdk: flutter
flutter_riverpod: ^3.3.2
path: ^1.9.1
uuid: ^4.5.1
dev_dependencies:
flutter_lints: ^6.0.0
flutter_test:
sdk: flutter
mocktail: ^1.0.5
@@ -0,0 +1,121 @@
// core_network 的高价值断言:
// 1. ApiClient 真的把 AppException 从 DioException 的壳里抛出来了——
// 这条不成立的话,业务层所有 `on BusinessException` 都是死代码。
// 2. code != 0 不会以"成功响应"的形态流到业务层。
import 'dart:convert';
import 'dart:typed_data';
import 'package:core_foundation/core_foundation.dart';
import 'package:core_network/core_network.dart';
import 'package:core_network/src/api_result_interceptor.dart';
import 'package:core_network/src/error_mapping_interceptor.dart';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
/// 直接吐一段 JSON 的 adapter,省掉起 http server。
class _StubAdapter implements HttpClientAdapter {
_StubAdapter(this.status, this.body);
int status;
Map<String, dynamic> body;
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<Uint8List>? requestStream,
Future<void>? cancelFuture,
) async => ResponseBody.fromString(
jsonEncode(body),
status,
headers: <String, List<String>>{
Headers.contentTypeHeader: <String>[Headers.jsonContentType],
},
);
@override
void close({bool force = false}) {}
}
void main() {
late _StubAdapter adapter;
late ApiClient api;
setUp(() {
adapter = _StubAdapter(200, <String, dynamic>{});
final Dio dio = Dio(BaseOptions(baseUrl: 'https://example.test'))
..httpClientAdapter = adapter
..interceptors.addAll(<Interceptor>[
ApiResultInterceptor((String _) {}),
ErrorMappingInterceptor(),
]);
api = ApiClient(dio);
});
test('code == 0 时外层包装被剥掉,repository 拿到的就是 data 本身', () async {
adapter.body = <String, dynamic>{
'code': 0,
'message': 'ok',
'traceId': 't-1',
'data': <String, dynamic>{'storeId': 7},
};
final Map<String, dynamic> data = await api.get<Map<String, dynamic>>('/x');
expect(data, <String, dynamic>{'storeId': 7});
});
test('code != 0 抛 BusinessException 而不是当成功返回,且 traceId 留存', () async {
adapter.body = <String, dynamic>{
'code': 40001,
'message': '门店不可访问',
'traceId': 't-2',
'data': null,
};
await expectLater(
api.get<Map<String, dynamic>>('/x'),
throwsA(
isA<BusinessException>()
.having((BusinessException e) => e.code, 'code', 40001)
.having((BusinessException e) => e.message, 'message', '门店不可访问')
// 用户报一个 traceId 后端就能定位这次请求,丢了就没法排查。
.having((BusinessException e) => e.traceId, 'traceId', 't-2'),
),
);
});
test('5xx 归一化成 ServerException——业务层只认 AppException 体系', () async {
adapter
..status = 500
..body = <String, dynamic>{'error': 'boom'};
await expectLater(
api.get<Map<String, dynamic>>('/x'),
throwsA(
isA<ServerException>().having((ServerException e) => e.statusCode, 'statusCode', 500),
),
);
});
test('取消抛 RequestCancelledExceptionUI 必须静默处理', () async {
final CancelToken token = CancelToken();
final Future<Map<String, dynamic>> future = api.get<Map<String, dynamic>>(
'/x',
cancelToken: token,
);
token.cancel();
await expectLater(future, throwsA(isA<RequestCancelledException>()));
});
test('非 ApiResult 结构(文件下载等)原样透传,不被误解包', () async {
adapter.body = <String, dynamic>{
'items': <int>[1, 2],
};
final Map<String, dynamic> data = await api.get<Map<String, dynamic>>('/x');
expect(data, <String, dynamic>{
'items': <int>[1, 2],
});
});
}
@@ -0,0 +1 @@
include: ../../analysis_options.yaml
+19
View File
@@ -0,0 +1,19 @@
/// 路由聚合层。来源:conti-docs/04-routing.md。
///
/// ---------------------------------------------------------------------------
/// **本包 re-export `go_router`**`feature_*` 一律 `import 'package:core_router/core_router.dart'`
/// 拿 `GoRoute` / `context.go` 等类型,pubspec 里**不写 go_router**。
///
/// 这样将来换路由库(或 go_router 出 breaking change)时,改动收敛在这一个包
/// 里,而不是 20 个 feature 的 import 语句。
/// ---------------------------------------------------------------------------
library;
export 'package:go_router/go_router.dart';
export 'src/app_router.dart';
export 'src/menu_route_map.dart';
export 'src/pages.dart';
export 'src/ports.dart';
export 'src/redirect.dart';
export 'src/route_paths.dart';
@@ -0,0 +1,90 @@
/// GoRouter 实例。来源:conti-docs/04-routing.md。
library;
import 'package:core_auth/core_auth.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'pages.dart';
import 'ports.dart';
import 'redirect.dart';
import 'route_paths.dart';
/// 全局 Navigator key。顶层 dialog / 无 context 跳转需要它。
final GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();
/// 全 App 唯一的 [GoRouter]。
///
/// ---------------------------------------------------------------------------
/// **`GoRouter` 实例不能因为登录态变化被重建。** 重建会丢掉整个导航栈——用户
/// 在三级页面上 token 刷新了一下,就被弹回首页。
///
/// 所以:
/// - `redirect` 里**只能 `ref.read`**,不能 `ref.watch`watch 会让这个
/// Provider 本身重建)。
/// - 订阅由外面的 `ref.listen` 负责,变化时调 `router.refresh()` 只重跑一次
/// `redirect`,导航栈保留。
/// - 用 `ref.listen` 而不是 `refreshListenable`:登录态本身是 Riverpod
/// provider,用 `refreshListenable` 还要额外包一层 `ChangeNotifier`。
/// - `ref.onDispose(router.dispose)` 不能漏:`GoRouter` 持有 `Listenable`
/// 不 dispose 在热重载和测试里会泄漏。
///
/// 路由表本身**不在这里写死**feature 路由由 `appRoutesProvider` 注入,
/// 否则 core_router 就得依赖每一个 feature_*,违反 01 的分层。
/// ---------------------------------------------------------------------------
final Provider<GoRouter> goRouterProvider = Provider<GoRouter>((Ref ref) {
final GoRouter router = GoRouter(
navigatorKey: rootNavigatorKey,
initialLocation: AppRoutes.splash,
observers: ref.read(navigatorObserversProvider),
redirect: (BuildContext context, GoRouterState state) =>
_redirect(ref, state.matchedLocation, state.uri),
errorBuilder: (BuildContext context, GoRouterState state) {
// 上报:能直接暴露出后端下发了 App 不支持的菜单。
ref.read(routeReporterProvider).onRouteNotFound(state.uri.toString());
return RouteNotFoundPage(location: state.uri.toString());
},
routes: <RouteBase>[
GoRoute(
path: AppRoutes.splash,
builder: (BuildContext context, GoRouterState state) => const SplashPage(),
),
...ref.read(appRoutesProvider),
],
);
AppSession? previous = ref.read(sessionProvider).value;
ref.listen<AsyncValue<AppSession>>(sessionProvider, (
AsyncValue<AppSession>? _,
AsyncValue<AppSession> next,
) {
final AppSession? before = previous;
final AppSession? after = next.value;
previous = after;
// 11 §切店级联的第 6 步:切店成功后清空导航栈回工作台。
//
// 这一步文档写在 SessionNotifier.switchStore 里,但 core_auth 不能依赖
// core_router(不是 01 允许的那三条边),所以反过来由这里监听落地。
// 理由见 04 §门店切换后的路由重置:用户在 A 门店的
// /purchase/orders/123 切到 B 门店,这个订单在 B 门店可能不存在,
// 或者更糟——存在但是另一张单。
if (before is SessionActive &&
after is SessionActive &&
before.store.storeId != after.store.storeId) {
// go 而不是 push:替换整个栈。
router.go(AppRoutes.home);
return;
}
router.refresh();
});
ref.onDispose(router.dispose);
return router;
});
String? _redirect(Ref ref, String matchedLocation, Uri uri) {
// read 不是 watch:这里只要当前值,订阅由上面的 listen 负责。
return resolveRedirect(ref.read(sessionProvider).value, matchedLocation, uri);
}
@@ -0,0 +1,35 @@
/// 后端动态菜单 code → 本地路由的映射。来源:conti-docs/04-routing.md。
library;
import 'route_paths.dart';
/// 菜单 code → 路由路径。
///
/// ---------------------------------------------------------------------------
/// PRD §22.2:工作台菜单由后端按角色权限下发,不是写死在 App 里的。但
/// **路由表必须是编译期写死的**(页面是 Dart 代码,不可能动态下发),所以中间
/// 需要这张表。
///
/// **`code` 一旦定义就不能改含义**——改了等于老版本 App 跳错页面。新增功能
/// 只能加新 code。这条要在后端接口评审时对齐。
///
/// TODO(backend): 目前只有 04 举例的三条,完整菜单 code 表待后端下发后补齐。
/// ---------------------------------------------------------------------------
const Map<String, String> menuRouteMap = <String, String>{
'PURCHASE_ORDER': '/purchase/orders',
'INVENTORY_CHECK': '/inventory/check',
// H5 承载的功能也走这张表,形态是 /webview?target=<CODE>,不是裸 URL。
'QUOTE_ORDER': '${AppRoutes.webview}?target=QUOTE_ORDER',
};
/// 解析菜单 code。未知 code 返回 null。
///
/// ---------------------------------------------------------------------------
/// **未知 code 的处理:隐藏该菜单项 + 上报 `menu_code_unsupported`(带 code 和
/// App 版本)。调用方负责这两件事**——本函数是纯的,没有上报通道。
///
/// 不选"提示升级":老版本 App 上会因为后端加了新菜单就弹升级提示,对完全
/// 不需要这个新功能的门店是骚扰。隐藏 + 上报能让我们从数据上看到"有多少用户
/// 因为版本旧看不到新功能",需要推升级时再针对性推。
/// ---------------------------------------------------------------------------
String? resolveMenuRoute(String code) => menuRouteMap[code];
+52
View File
@@ -0,0 +1,52 @@
/// 路由兜底页与启动页。来源:conti-docs/04-routing.md §errorBuilder 是必须的。
library;
import 'package:flutter/material.dart';
/// 未注册路径的兜底页。
///
/// ---------------------------------------------------------------------------
/// 不写 `errorBuilder`go_router 会显示一个英文的默认错误页——对门店一线员工
/// 来说等于崩溃。
///
/// 触发场景:深链接拼错、后端下发了 App 还不认识的菜单 code、H5 回跳的 URL
/// 有问题。所以文案指向"升级 App"而不是"稍后重试"。
/// ---------------------------------------------------------------------------
class RouteNotFoundPage extends StatelessWidget {
/// [location] 只用于开发期排查,不展示给用户。
const RouteNotFoundPage({required this.location, super.key});
/// 出问题的路径。
final String location;
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('页面不存在')),
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text('页面不存在', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Text('请检查是否需要升级 App', style: Theme.of(context).textTheme.bodySmall),
],
),
),
),
);
}
/// 会话恢复期间的占位页。
///
/// 冷启动要先读 token、拉用户、拉门店(11),这段时间还不知道该去登录页还是
/// 工作台。停在这里,由 `redirect` 在会话就绪后把用户送走。
class SplashPage extends StatelessWidget {
/// 构造。
const SplashPage({super.key});
@override
Widget build(BuildContext context) =>
const Scaffold(body: Center(child: CircularProgressIndicator()));
}
+51
View File
@@ -0,0 +1,51 @@
/// core_router 向外部索取的东西。
///
/// ---------------------------------------------------------------------------
/// 依赖反转(同 core_auth/src/session_ports.dart、core_network/src/ports.dart)。
///
/// 01 规定 `core_router` 只能依赖 `core_auth`。但它需要两样东西是别处的:
/// - **各 feature 的路由**(在 `feature_*` 里,core_* 不能依赖 feature_*
/// - **上报通道**(在 core_logging / core_analytics 里,不是允许的依赖边)
///
/// 所以在这里声明接口和 provider,由 `app/bootstrap.dart` 统一 override。
/// ---------------------------------------------------------------------------
library;
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
/// 全 App 的路由表。
///
/// core_router 自己只提供 `/splash``/login`、`/home` 等由各 feature 的
/// `buildXxxRoutes()` 产出,在 `app/` 里拼好后 override 进来。
///
/// **默认是空表**——不 override 的话除了启动页什么都打不开,会立刻在开发期
/// 暴露出来,比默默跑起来一个没有页面的 App 好。
final Provider<List<RouteBase>> appRoutesProvider = Provider<List<RouteBase>>(
(Ref ref) => const <RouteBase>[],
);
/// 导航监听器。`app/` 里注入面包屑上报的那个(13)。
final Provider<List<NavigatorObserver>> navigatorObserversProvider =
Provider<List<NavigatorObserver>>((Ref ref) => const <NavigatorObserver>[]);
/// 路由异常上报。
abstract interface class RouteReporter {
/// 命中 `errorBuilder`(未注册的路径)。
///
/// 这个上报很有价值:能直接暴露出后端下发了 App 不支持的菜单。
void onRouteNotFound(String location);
}
class _NoopRouteReporter implements RouteReporter {
const _NoopRouteReporter();
@override
void onRouteNotFound(String location) {}
}
/// 由 `app/` override 成真实实现。
final Provider<RouteReporter> routeReporterProvider = Provider<RouteReporter>(
(Ref ref) => const _NoopRouteReporter(),
);
@@ -0,0 +1,54 @@
/// 登录态 → 目标路径的纯函数。来源:conti-docs/04-routing.md。
///
/// 单独拎出来是为了能不启 [GoRouter]、不启 `ProviderContainer` 就测——
/// 这段分支是全 App 唯一决定"用户能不能进业务页"的地方,值得有直接的断言。
library;
import 'package:core_auth/core_auth.dart';
import 'route_paths.dart';
/// 返回需要强制跳转的路径;返回 null 表示放行当前路径。
///
/// [session] 为 null 表示 `sessionProvider` 还没产出第一个值(冷启动瞬间)。
String? resolveRedirect(AppSession? session, String matchedLocation, Uri uri) {
final bool atSplash = matchedLocation == AppRoutes.splash;
final bool atLogin = matchedLocation == AppRoutes.login;
final bool atStorePicker = matchedLocation == AppRoutes.storePicker;
if (session == null) {
return atSplash ? null : AppRoutes.splash;
}
switch (session) {
// 会话还没恢复完(冷启动读 token → 拉用户 → 拉门店):停在启动页。
// 这时候放行到任何页面都是错的——业务页面会立刻用一个还不存在的门店 ID
// 去发请求。
case SessionLoading():
return atSplash ? null : AppRoutes.splash;
case SessionUnauthenticated():
if (atLogin) {
return null;
}
// 带上原目标,登录成功后回跳。启动页不值得回跳。
return atSplash
? AppRoutes.login
: '${AppRoutes.login}?from=${Uri.encodeComponent(uri.toString())}';
// 已登录但还没选店。**不能放行到业务页**:没有门店上下文,
// currentStoreIdProvider 会直接 throw11)。
case SessionAwaitingStore():
return atStorePicker ? null : AppRoutes.storePicker;
case SessionActive():
if (atSplash || atStorePicker) {
return AppRoutes.home;
}
if (atLogin) {
final String? from = uri.queryParameters['from'];
return (from == null || from.isEmpty) ? AppRoutes.home : Uri.decodeComponent(from);
}
return null;
}
}
@@ -0,0 +1,41 @@
/// 全 App 的路由路径常量。来源:conti-docs/04-routing.md。
library;
/// 路由路径。
///
/// 集中定义的理由:`redirect` 在 core_router、页面在各 feature,两边都要引用
/// 同一批字符串。散着写字面量,改一个路径就会出现"跳转过去是 404"的活见鬼。
abstract final class AppRoutes {
/// 启动页。会话恢复(读 token → 拉用户 → 拉门店)期间停在这里。
static const String splash = '/splash';
/// 登录页。由 feature_auth 提供页面。
static const String login = '/login';
/// 选店页。由 feature_auth 提供页面。
static const String storePicker = '/store-picker';
/// 工作台。由 feature_home 提供页面。
static const String home = '/home';
/// H5 容器。
static const String webview = '/webview';
/// 拼一个 H5 路由。
///
/// ---------------------------------------------------------------------------
/// **只传 target,不传裸 URL**(04 §H5 页面的路由约定)。真实 URL 由
/// core_webview 拿 `target` 去后端换票得到。
///
/// 如果路由里能直接塞 URL,任何能构造深链接的地方(推送、H5 内跳转、剪贴板)
/// 都能让 App 打开任意网页——这是一个明确的安全洞。`target` 是白名单枚举,
/// 能打开哪些页面由后端和 App 共同决定。
/// ---------------------------------------------------------------------------
static String webviewFor(String target, {String? title}) {
final StringBuffer sb = StringBuffer('$webview?target=${Uri.encodeQueryComponent(target)}');
if (title != null && title.isNotEmpty) {
sb.write('&title=${Uri.encodeQueryComponent(title)}');
}
return sb.toString();
}
}
+24
View File
@@ -0,0 +1,24 @@
name: core_router
description: 路由聚合层。GoRouter 实例、登录态 redirect、菜单 code → 路由映射,并 re-export go_router 类型。
publish_to: none
version: 0.1.0
resolution: workspace
# core_router → core_auth 是 01 明确允许的三条 core 间依赖之一(redirect 需要登录态)。
# 注意:本包**不**依赖任何 feature_*。feature 路由由 app/ 通过 appRoutesProvider 注入,
# 详见 lib/src/app_router.dart 顶部注释与 SCAFFOLD-NOTES.md §G。
environment:
sdk: ^3.12.0
dependencies:
core_auth: ^0.1.0
core_foundation: ^0.1.0
flutter:
sdk: flutter
flutter_riverpod: ^3.3.2
go_router: ^17.5.0
dev_dependencies:
flutter_lints: ^6.0.0
flutter_test:
sdk: flutter
@@ -0,0 +1,102 @@
// core_router 的高价值断言:
// 1. 未选店时**绝不能**放行到业务页——那边 currentStoreIdProvider 会直接 throw。
// 2. 会话未就绪时停在启动页,不能提前放行。
// 3. H5 路由里只有 target,永远不出现裸 URL(安全)。
import 'package:core_auth/core_auth.dart';
import 'package:core_router/core_router.dart';
import 'package:flutter_test/flutter_test.dart';
const UserContext _user = UserContext(
userId: 'U1',
employeeId: 'E1',
phone: '13800000000',
roleCode: 'CLERK',
channel: 'APP',
);
const StoreContext _store = StoreContext(
storeId: 7,
storeCode: 'S007',
storeName: '测试门店',
orgId: 1,
);
void main() {
group('resolveRedirect', () {
test('会话未就绪时停在启动页,不提前放行到业务页', () {
expect(resolveRedirect(null, '/purchase/orders', Uri.parse('/purchase/orders')), '/splash');
expect(resolveRedirect(null, '/splash', Uri.parse('/splash')), isNull);
expect(resolveRedirect(const SessionLoading(), '/home', Uri.parse('/home')), '/splash');
});
test('未登录访问业务页 → 登录页并带上回跳目标', () {
final String? to = resolveRedirect(
const SessionUnauthenticated(),
'/purchase/orders',
Uri.parse('/purchase/orders?id=9'),
);
expect(to, startsWith('/login?from='));
expect(Uri.decodeComponent(Uri.parse(to!).queryParameters['from']!), '/purchase/orders?id=9');
});
test('未登录停在登录页时不再跳转(否则死循环)', () {
expect(
resolveRedirect(const SessionUnauthenticated(), '/login', Uri.parse('/login')),
isNull,
);
});
test('已登录但未选店时,任何业务页都被挡回选店页', () {
const AppSession awaiting = SessionAwaitingStore(user: _user, candidates: <StoreContext>[]);
// 这条是核心:放行过去 currentStoreIdProvider 会 throw11)。
expect(resolveRedirect(awaiting, '/home', Uri.parse('/home')), '/store-picker');
expect(resolveRedirect(awaiting, '/store-picker', Uri.parse('/store-picker')), isNull);
});
group('已登录且已选店', () {
const AppSession active = SessionActive(user: _user, store: _store);
test('停在启动页/选店页时进工作台', () {
expect(resolveRedirect(active, '/splash', Uri.parse('/splash')), '/home');
expect(resolveRedirect(active, '/store-picker', Uri.parse('/store-picker')), '/home');
});
test('登录页带 from 时回跳原目标,没有 from 时进工作台', () {
expect(
resolveRedirect(
active,
'/login',
Uri.parse('/login?from=${Uri.encodeComponent('/purchase/orders?id=9')}'),
),
'/purchase/orders?id=9',
);
expect(resolveRedirect(active, '/login', Uri.parse('/login')), '/home');
});
test('业务页放行', () {
expect(resolveRedirect(active, '/purchase/orders', Uri.parse('/purchase/orders')), isNull);
});
});
});
group('菜单与 H5 路由', () {
test('未知菜单 code 返回 null,由调用方隐藏并上报', () {
expect(resolveMenuRoute('PURCHASE_ORDER'), '/purchase/orders');
expect(resolveMenuRoute('SOMETHING_NEW_FROM_BACKEND'), isNull);
});
test('H5 路由只带 target,不出现裸 URL', () {
final String route = AppRoutes.webviewFor('QUOTE_ORDER', title: '报价开单');
expect(route, startsWith('/webview?target=QUOTE_ORDER'));
expect(route, isNot(contains('http')));
expect(Uri.parse(route).queryParameters['title'], '报价开单');
});
test('菜单表里的 H5 项也是 target 形态', () {
for (final String path in menuRouteMap.values) {
expect(path, isNot(contains('http')), reason: '路由里塞裸 URL 是明确的安全洞');
}
});
});
}
@@ -0,0 +1 @@
include: ../../analysis_options.yaml
@@ -0,0 +1,11 @@
/// 本地持久化。来源:conti-docs/06-local-storage.md。
///
/// 当前只有 KV 一档。**Drift 数据库暂缓**——骨架阶段没有任何业务表,
/// 见 pubspec.yaml 顶部的说明。
///
/// 边界约定:**token / refresh token 不在这里**——secure storage 归
/// `core_auth` 独占(06 §决策表下的说明)。这里存的都是非敏感数据。
library;
export 'src/prefs.dart';
export 'src/providers.dart';
+68
View File
@@ -0,0 +1,68 @@
/// 简单非敏感 KV 的统一封装。来源:06 §决策表第三档。
library;
import 'package:shared_preferences/shared_preferences.dart';
/// 键名常量。
///
/// **命名约定:用户级的键一律以 [Prefs.userScopedPrefix] 开头。**
/// 登出时只清这些,App 级偏好(主题、是否看过引导页)保留——换人登录不该
/// 把设备上的通用设置也重置掉。
abstract final class PrefKeys {
/// 是否看过引导页。App 级,登出保留。
static const String onboardingSeen = 'onboarding_seen';
/// 上次选中的门店 ID。用户级,登出必须清。
static const String lastStoreId = '${Prefs.userScopedPrefix}last_store_id';
/// 是否已同意隐私政策。App 级——同意是设备行为,且埋点/崩溃 SDK 的
/// 延迟初始化依赖它(见 13)。
static const String privacyPolicyAccepted = 'privacy_policy_accepted';
}
/// `shared_preferences` 的唯一入口。
///
/// 这里存的都是**非敏感**数据。token / refresh token 走 `core_auth` 的
/// secure storage,不允许出现在这里(06 §使用规则)。
class Prefs {
/// [store] 默认用 `SharedPreferencesAsync`,测试可注入替身。
Prefs([SharedPreferencesAsync? store]) : _store = store ?? SharedPreferencesAsync();
/// 用户级键的前缀。
static const String userScopedPrefix = 'u_';
final SharedPreferencesAsync _store;
/// 读一个字符串。
Future<String?> getString(String key) => _store.getString(key);
/// 写一个字符串。
Future<void> setString(String key, String value) => _store.setString(key, value);
/// 读一个布尔值,缺省 [defaultValue]。
Future<bool> getBool(String key, {bool defaultValue = false}) async =>
await _store.getBool(key) ?? defaultValue;
/// 写一个布尔值。
Future<void> setBool(String key, {required bool value}) => _store.setBool(key, value);
/// 读一个整数。
Future<int?> getInt(String key) => _store.getInt(key);
/// 写一个整数。
Future<void> setInt(String key, int value) => _store.setInt(key, value);
/// 删除一个键。
Future<void> remove(String key) => _store.remove(key);
/// 登出时调用:只清 [userScopedPrefix] 前缀的键,保留 App 级偏好。
///
/// 用前缀而不是维护一张手写的键名清单——新增用户级配置项时只要遵守命名
/// 约定就自动被清掉,手写清单一定会有人忘了加。
Future<void> clearUserScoped() async {
final Set<String> keys = await _store.getKeys();
for (final String key in keys.where((String k) => k.startsWith(userScopedPrefix))) {
await _store.remove(key);
}
}
}
@@ -0,0 +1,9 @@
/// core_storage 的 Riverpod 接线。
library;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'prefs.dart';
/// KV 存储的唯一入口。
final Provider<Prefs> prefsProvider = Provider<Prefs>((Ref ref) => Prefs());
+27
View File
@@ -0,0 +1,27 @@
name: core_storage
description: 本地持久化。当前只封装 SharedPreferencesKV),数据库层暂缓。
publish_to: none
version: 0.1.0
resolution: workspace
# 偏离 06 的说明:Drift 单库暂不落地。
# 骨架阶段还没有任何需要本地缓存的业务表,先建库只会留下一个没人用、
# 却要一直维护 schema 快照和迁移测试的空壳。06 §「所有业务缓存表必须带
# storeId」「登出/切店清理策略」「migration 必须被验证」这三条规则在真的
# 加第一张表时再一次性落地——那时才有东西可验证。
# 连带收益:去掉 drift_dev 后 melos 可以留在 8.xdrift_dev 2.34.0 依赖
# cli_util ^0.4.0,与 melos 8 的 ^0.5.0 互斥)。详见 SCAFFOLD-NOTES.md §A。
environment:
sdk: ^3.12.0
dependencies:
core_foundation: ^0.1.0
flutter:
sdk: flutter
flutter_riverpod: ^3.3.2
shared_preferences: ^2.5.5
dev_dependencies:
flutter_lints: ^6.0.0
flutter_test:
sdk: flutter
+1
View File
@@ -0,0 +1 @@
include: ../../analysis_options.yaml
+9
View File
@@ -0,0 +1,9 @@
/// 共享 UI 层。来源:conti-docs/12-error-and-api-contract.md §三、§四。
///
/// 这一层只放**与业务无关**的东西:主题、三态视图、错误文案映射。任何带
/// 业务语义的 Widget(订单卡片、门店选择器)都属于对应的 `feature_*`。
library;
export 'src/error/error_presenter.dart';
export 'src/error/error_view.dart';
export 'src/theme/app_theme.dart';
@@ -0,0 +1,97 @@
/// 异常 → 用户可见文案的唯一映射点。来源:conti-docs/12-error-and-api-contract.md §三。
library;
import 'package:core_foundation/core_foundation.dart';
/// [ErrorPresenter.present] 的返回值。
///
/// 用 record 而不是类:这东西只在 build 方法里活几行,没有身份也没有行为。
typedef ErrorDisplay = ({String title, String? detail, bool retryable, bool showTraceId});
/// 全 App 唯一的错误文案映射。
///
/// ---------------------------------------------------------------------------
/// **不要在 feature 里自己写 `if (e is XxxException)`。** 文案散在各处的结果是
/// 同一个错误在订单页叫"网络开小差"、在首页叫"加载失败",用户反馈时对不上。
///
/// [AppException] 是 `sealed` 的,下面的 switch 是穷尽的——将来加一种异常类型,
/// 这里会编译报错,逼着人补文案,而不是悄悄落进"未知错误"。
/// ---------------------------------------------------------------------------
abstract final class ErrorPresenter {
/// 把异常映射成一组展示参数。
static ErrorDisplay present(AppException e) => switch (e) {
NetworkException(kind: NetworkErrorKind.noConnection) => (
title: '网络未连接',
detail: '请检查网络后重试',
retryable: true,
showTraceId: false,
),
NetworkException() => (
title: '网络不太稳定',
detail: '请稍后重试',
retryable: true,
// 请求根本没到后端,traceId 在服务端日志里查不到,展示出来只会误导。
showTraceId: false,
),
ServerException() => (
title: '系统繁忙',
detail: '请稍后重试',
retryable: true,
// 这正是 traceId 存在的意义:把一次投诉定位到一条服务端日志。
showTraceId: true,
),
// retryable 必须是 false:库存不足、订单已支付这类错误重试没有意义,
// 给一个重试按钮只会让用户反复点。
BusinessException(:final String message) => (
title: message,
detail: null,
retryable: false,
showTraceId: false,
),
// 文档 12 的 switch 里漏了这一支,sealed 穷尽会直接编译不过。
// 本地前置条件(如切店时有未完成的写操作)的 message 本身就是给用户看的。
PreconditionException(:final String message) => (
title: message,
detail: null,
retryable: false,
showTraceId: false,
),
StorageException() => (
title: '本地数据异常',
detail: '请重启 App',
retryable: false,
showTraceId: false,
),
NativeException(code: NativeErrorCode.permissionDenied, :final String message) => (
title: message,
detail: '可在系统设置中开启',
retryable: false,
showTraceId: false,
),
NativeException(:final String message) => (
title: message,
detail: null,
retryable: false,
showTraceId: false,
),
// 不展示:登出流程本身会把用户送回登录页;取消是用户自己触发的。
UnauthorizedException() ||
RequestCancelledException() => (title: '', detail: null, retryable: false, showTraceId: false),
};
/// 这个错误是否应当**完全不出现在 UI 上**。
///
/// [RequestCancelledException]:用户返回上一页导致在途请求被取消,
/// 弹"请求已取消"是纯噪音。
/// [UnauthorizedException]:登出跳转已经是最强的反馈了。
static bool isSilent(Object error) =>
error is UnauthorizedException || error is RequestCancelledException;
/// 非 [AppException] 的兜底。
///
/// 正常情况下不该走到这里——网络层出口已经把一切归一化成 [AppException]。
/// 走到这里说明是一个 bug(空指针、类型转换失败),文案上不能暴露技术细节。
static ErrorDisplay presentUnknown(Object error) => error is AppException
? present(error)
: (title: '出了点问题', detail: '请稍后重试', retryable: true, showTraceId: false);
}
@@ -0,0 +1,239 @@
/// 三态视图与错误 Widget。来源:conti-docs/12-error-and-api-contract.md §三、§四。
library;
import 'package:core_foundation/core_foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'error_presenter.dart';
/// `AsyncValue` 的统一三态渲染。
///
/// ---------------------------------------------------------------------------
/// **每个 feature 自己写 `switch (asyncValue)` 是最常见的重复劳动,也是三态处理
/// 不一致的根源**(12 §三)。所有页面级异步数据都走这里。
///
/// 内部统一处理:
/// - loading → 居中转圈
/// - error → [ErrorPresenter.present] → 整页错误态 + 重试
/// - [RequestCancelledException] / [UnauthorizedException] → **静默**,退回 loading 态
/// - 空数据([isEmpty] 判定)→ 空态
/// - 刷新失败但有旧数据 → 继续渲染旧数据(不把用户已经看到的内容换成错误页)
/// ---------------------------------------------------------------------------
class AsyncValueView<T> extends StatelessWidget {
/// [data] 只在有数据时调用;[onRetry] 一般是 `() => ref.invalidate(xxxProvider)`。
const AsyncValueView({
required this.value,
required this.data,
this.onRetry,
this.isEmpty,
this.empty,
super.key,
});
/// 来自 `ref.watch(someProvider)`。
final AsyncValue<T> value;
/// 有数据时的渲染。
final Widget Function(T data) data;
/// 重试回调。为 null 时错误态不显示重试按钮。
final VoidCallback? onRetry;
/// 判定"有数据但是空的"。默认不判定(即永远不显示空态)。
final bool Function(T data)? isEmpty;
/// 空态。不传时用一段默认文案。
final Widget? empty;
@override
Widget build(BuildContext context) {
// 注意顺序:先看有没有数据。刷新失败时 AsyncError 也可能带着上一次的
// 数据(hasValue),这时候必须继续展示旧数据——把用户正在看的列表换成
// 一整页错误,比什么都不做更糟。
if (value.hasValue) {
final T current = value.value as T;
if (isEmpty?.call(current) ?? false) {
return empty ?? const _EmptyView();
}
return data(current);
}
if (value.hasError && !ErrorPresenter.isSilent(value.error!)) {
return ErrorView(error: value.error!, onRetry: onRetry);
}
// 静默错误也走这里:用户看到的是"还在加载",而不是一个他不需要理解的错误。
return const Center(child: CircularProgressIndicator());
}
}
/// 整页错误态。
class ErrorView extends StatelessWidget {
/// [error] 通常是 `AsyncValue.error`,非 [AppException] 会走兜底文案。
const ErrorView({required this.error, this.onRetry, super.key});
/// 原始错误对象。
final Object error;
/// 重试回调。
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) {
final ErrorDisplay display = ErrorPresenter.presentUnknown(error);
final String? traceId = error is AppException ? (error as AppException).traceId : null;
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(display.title, style: Theme.of(context).textTheme.titleMedium),
if (display.detail != null) ...<Widget>[
const SizedBox(height: 8),
Text(display.detail!, style: Theme.of(context).textTheme.bodySmall),
],
if (display.retryable && onRetry != null) ...<Widget>[
const SizedBox(height: 16),
FilledButton(onPressed: onRetry, child: const Text('重试')),
],
// traceId 不印在主文案里——用户看到一串乱码只会更慌。
// 折叠在「问题反馈」后面,客服话术是"把那串编号发给我"。
if (display.showTraceId && traceId != null) ...<Widget>[
const SizedBox(height: 12),
_TraceIdSection(traceId: traceId),
],
],
),
),
);
}
}
/// 局部(tile 级)错误态。
///
/// 首页某个区块失败时用这个,**尺寸自适应,不撑破布局**——它会被塞进一个
/// 高度有限的 tile 里,不能像 [ErrorView] 那样撑满。
class TileErrorView extends StatelessWidget {
/// 构造。
const TileErrorView({required this.error, this.onRetry, super.key});
/// 原始错误对象。
final Object error;
/// 重试回调。
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) {
final ErrorDisplay display = ErrorPresenter.presentUnknown(error);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: <Widget>[
Expanded(
child: Text(
display.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
),
),
if (display.retryable && onRetry != null)
TextButton(onPressed: onRetry, child: const Text('重试')),
],
),
);
}
}
/// 「展示的是缓存数据」的顶部提示条。
///
/// 门店里网络不稳是常态,网络失败但本地有缓存时展示缓存 + 这条提示,比展示
/// 一个错误页好得多。**但必须带时间戳**:展示旧数据却不告诉用户是旧的,
/// 比展示错误更危险——尤其是库存和价格(12 §四)。
class StaleDataBanner extends StatelessWidget {
/// [updatedAt] 是缓存写入时间,必须真实。
const StaleDataBanner({required this.updatedAt, this.onRefresh, super.key});
/// 缓存写入时间。
final DateTime updatedAt;
/// 刷新回调。
final VoidCallback? onRefresh;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
return Material(
color: theme.colorScheme.secondaryContainer,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: Row(
children: <Widget>[
Expanded(
child: Text(
'更新于 ${formatElapsed(updatedAt)}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSecondaryContainer,
),
),
),
if (onRefresh != null) TextButton(onPressed: onRefresh, child: const Text('刷新')),
],
),
),
);
}
}
/// 把时间点格式化成"10 分钟前"这类相对文案。
///
/// [now] 只给测试用;生产代码不要传。
String formatElapsed(DateTime updatedAt, {DateTime? now}) {
final Duration d = (now ?? DateTime.now()).difference(updatedAt);
if (d.inMinutes < 1) {
return '刚刚';
}
if (d.inMinutes < 60) {
return '${d.inMinutes} 分钟前';
}
if (d.inHours < 24) {
return '${d.inHours} 小时前';
}
return '${d.inDays} 天前';
}
class _TraceIdSection extends StatefulWidget {
const _TraceIdSection({required this.traceId});
final String traceId;
@override
State<_TraceIdSection> createState() => _TraceIdSectionState();
}
class _TraceIdSectionState extends State<_TraceIdSection> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
if (!_expanded) {
return TextButton(
onPressed: () => setState(() => _expanded = true),
child: const Text('问题反馈 '),
);
}
return SelectableText(widget.traceId, style: Theme.of(context).textTheme.bodySmall);
}
}
class _EmptyView extends StatelessWidget {
const _EmptyView();
@override
Widget build(BuildContext context) =>
Center(child: Text('暂无数据', style: Theme.of(context).textTheme.bodySmall));
}
@@ -0,0 +1,28 @@
/// 主题。
///
/// **这是一个占位实现。** conti-docs 的 `15-ui-design-system.md` 还没写,色板、
/// 字号阶梯、间距规范都未定。这里只把结构搭出来(一个集中定义点 + 一个
/// seed color),等设计规范落地后在这个文件里补,**不要在各 feature 里
/// 自己 `ThemeData(...)`**——那正是这个文件存在的目的。
library;
import 'package:flutter/material.dart';
/// App 主题。
abstract final class AppTheme {
/// TODO(design): 待 15-ui-design-system.md 确定品牌主色后替换。
static const Color _seed = Color(0xFFFF6A13);
/// 亮色主题。
static ThemeData get light => _build(Brightness.light);
/// 暗色主题。
///
/// 门店场景基本用不到,但 `MaterialApp` 需要一个,跟随系统即可。
static ThemeData get dark => _build(Brightness.dark);
static ThemeData _build(Brightness brightness) => ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(seedColor: _seed, brightness: brightness),
);
}
+19
View File
@@ -0,0 +1,19 @@
name: core_ui
description: 共享 UI。主题、三态视图(AsyncValueView)、错误展示映射。
publish_to: none
version: 0.1.0
resolution: workspace
environment:
sdk: ^3.12.0
dependencies:
core_foundation: ^0.1.0
flutter:
sdk: flutter
flutter_riverpod: ^3.3.2
dev_dependencies:
flutter_lints: ^6.0.0
flutter_test:
sdk: flutter
+133
View File
@@ -0,0 +1,133 @@
// core_ui 的高价值断言:
// 1. 取消/未授权必须静默——这两条一旦回归,用户每次返回上一页都会看到错误页。
// 2. 刷新失败时旧数据不能被错误页顶掉。
// 3. BusinessException 不给重试按钮。
import 'package:core_foundation/core_foundation.dart';
import 'package:core_ui/core_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
Widget _host(Widget child) => MaterialApp(home: Scaffold(body: child));
void main() {
group('ErrorPresenter', () {
test('BusinessException 不可重试——重试一个"库存不足"没有意义', () {
final ErrorDisplay d = ErrorPresenter.present(const BusinessException(40001, '库存不足'));
expect(d.title, '库存不足');
expect(d.retryable, isFalse);
expect(d.showTraceId, isFalse);
});
test('只有 ServerException 展示 traceId', () {
expect(ErrorPresenter.present(const ServerException('x')).showTraceId, isTrue);
// 请求没到后端,服务端日志里查不到这个 id。
expect(
ErrorPresenter.present(
const NetworkException('x', kind: NetworkErrorKind.noConnection),
).showTraceId,
isFalse,
);
});
test('取消与未授权必须静默', () {
expect(ErrorPresenter.isSilent(const RequestCancelledException()), isTrue);
expect(ErrorPresenter.isSilent(const UnauthorizedException()), isTrue);
expect(ErrorPresenter.isSilent(const ServerException('x')), isFalse);
});
test('非 AppException 走兜底,不泄露技术细节', () {
final ErrorDisplay d = ErrorPresenter.presentUnknown(StateError('null check'));
expect(d.title, '出了点问题');
});
});
group('AsyncValueView', () {
testWidgets('error 态渲染文案与重试按钮', (WidgetTester tester) async {
await tester.pumpWidget(
_host(
AsyncValueView<int>(
value: AsyncValue<int>.error(const ServerException('x'), StackTrace.empty),
onRetry: () {},
data: (int v) => Text('$v'),
),
),
);
expect(find.text('系统繁忙'), findsOneWidget);
expect(find.text('重试'), findsOneWidget);
});
testWidgets('取消错误不显示错误态', (WidgetTester tester) async {
await tester.pumpWidget(
_host(
AsyncValueView<int>(
value: AsyncValue<int>.error(const RequestCancelledException(), StackTrace.empty),
data: (int v) => Text('$v'),
),
),
);
expect(find.text('请求已取消'), findsNothing);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
testWidgets('刷新失败但有旧数据时继续渲染旧数据,而不是换成整页错误', (WidgetTester tester) async {
// riverpod 在重建时会把上一次的值带进新的 AsyncErrorhasValue 仍为 true)。
// AsyncValueView 必须先看 hasValue——把用户正在看的列表换成一整页错误,
// 比什么都不做更糟。
bool shouldFail = false;
final FutureProvider<int> provider = FutureProvider<int>((Ref ref) async {
if (shouldFail) {
throw const ServerException('x');
}
return 7;
});
final ProviderContainer container = ProviderContainer();
addTearDown(container.dispose);
// riverpod 3 默认 autoDispose,没有监听者的话读完就被回收了。
container.listen<AsyncValue<int>>(provider, (_, _) {});
// runAsynctestWidgets 默认跑在 fake async 区里,真实的 Future 永远不会
// 完成(会挂到 10 分钟超时)。碰真 provider 生命周期必须包这一层。
final AsyncValue<int> state = (await tester.runAsync(() async {
expect(await container.read(provider.future), 7);
shouldFail = true;
await expectLater(container.refresh(provider.future), throwsA(isA<ServerException>()));
return container.read(provider);
}))!;
expect(state.hasError, isTrue);
expect(state.hasValue, isTrue, reason: '上一次的数据必须被保留');
await tester.pumpWidget(
_host(AsyncValueView<int>(value: state, data: (int v) => Text('$v'))),
);
expect(find.text('7'), findsOneWidget);
expect(find.text('系统繁忙'), findsNothing);
});
testWidgets('isEmpty 判定为真时走空态', (WidgetTester tester) async {
await tester.pumpWidget(
_host(
AsyncValueView<List<int>>(
value: const AsyncValue<List<int>>.data(<int>[]),
isEmpty: (List<int> v) => v.isEmpty,
data: (List<int> v) => Text('${v.length}'),
),
),
);
expect(find.text('暂无数据'), findsOneWidget);
});
});
test('formatElapsed 给出人类可读的相对时间', () {
final DateTime now = DateTime(2026, 8, 17, 12);
expect(formatElapsed(now.subtract(const Duration(seconds: 30)), now: now), '刚刚');
expect(formatElapsed(now.subtract(const Duration(minutes: 10)), now: now), '10 分钟前');
expect(formatElapsed(now.subtract(const Duration(hours: 3)), now: now), '3 小时前');
expect(formatElapsed(now.subtract(const Duration(days: 2)), now: now), '2 天前');
});
}
@@ -0,0 +1 @@
include: ../../analysis_options.yaml
@@ -0,0 +1,22 @@
/// H5 容器。来源:conti-docs/10-webview-h5.md。
///
/// ---------------------------------------------------------------------------
/// **适用范围:Embedded H5 仅用于承载 F6 页面,不做通用外链容器**(PRD §7.1)。
/// 任何"能不能顺便用它打开某个网页"的需求,默认答案是不能。
///
/// 本包只负责**校验和承载**:换票(要发 HTTP)不在这里,`core_webview →
/// core_network` 不是 01 允许的依赖边——由调用方实现
/// [H5LaunchRepository] 后 override 进来。
///
/// TODO(10): 12 项 bridge 能力的具体 handler 尚未实现(依赖 native_media /
/// native_device,本次脚手架范围外)。[BridgeDispatcher.handlers] 现在是空表,
/// 任何调用都会得到 `UNSUPPORTED_METHOD`——这是预期行为,不是 bug。
/// ---------------------------------------------------------------------------
library;
export 'src/bridge.dart';
export 'src/bridge_shim.dart';
export 'src/h5_launch.dart';
export 'src/page_watchdog.dart';
export 'src/url_guard.dart';
export 'src/webview_session.dart';
+161
View File
@@ -0,0 +1,161 @@
/// JSBridge 的消息分发。来源:conti-docs/10-webview-h5.md §JSBridge 协议。
library;
import 'dart:async';
import 'dart:convert';
import 'package:core_foundation/core_foundation.dart';
import 'url_guard.dart';
/// 回给 H5 的错误。
///
/// `code` 是**稳定的字符串枚举**,不是数字,也不透传原生错误码——H5 侧按 code
/// 分支处理,`message` 只用于展示。取值见 [AppException.bridgeCode] 和
/// [BridgeErrorCode]。
class BridgeError {
/// 构造。
const BridgeError(this.code, this.message);
/// 稳定错误码。**一旦发布不能改**,H5 侧按它分支。
final String code;
/// 展示文案。
final String message;
}
/// [BridgeError.code] 里由 bridge 自己产生(而非来自 [AppException])的取值。
abstract final class BridgeErrorCode {
/// H5 调了一个当前 App 版本没有的能力。
///
/// 不静默忽略:H5 版本比 App 新时,明确告诉它"不支持"才能降级,
/// 否则 H5 侧的 Promise 永远 pending,页面卡死。
static const String unsupportedMethod = 'UNSUPPORTED_METHOD';
/// handler 抛了非 [AppException] 的异常,属于 bug。
static const String internalError = 'INTERNAL_ERROR';
}
/// 一项 bridge 能力的实现。
typedef BridgeHandler = Future<Object?> Function(Map<String, dynamic> params);
/// bridge 的日志出口。core_webview 不能依赖 core_logging(不是允许的依赖边)。
typedef BridgeLogSink = void Function(String message, {Object? error});
/// 把 `ContiBridge` 通道收到的原始字符串分发到各能力实现。
///
/// ---------------------------------------------------------------------------
/// 只开**一个** JavaScript Channel,所有能力走同一个通道分发。每个能力开一个
/// channel 会让来源校验、日志、错误处理各写一遍。
/// ---------------------------------------------------------------------------
class BridgeDispatcher {
/// [currentUrl] 通常是 `controller.currentUrl`[evaluateJavaScript] 通常是
/// `controller.runJavaScript`。注入而不是直接持有 `WebViewController`
/// 是为了这段安全逻辑能被单测覆盖。
BridgeDispatcher({
required this.urlGuard,
required this.handlers,
required this.currentUrl,
required this.evaluateJavaScript,
required this.log,
});
/// 白名单。
final UrlGuard urlGuard;
/// method → 实现。
final Map<String, BridgeHandler> handlers;
/// 当前主 frame 的 URL。
final Future<String?> Function() currentUrl;
/// 执行一段 JS(用于回包和推事件)。
final Future<void> Function(String js) evaluateJavaScript;
/// 日志出口。
final BridgeLogSink log;
/// 处理一条来自 H5 的原始消息。
Future<void> handle(String raw) async {
// ------------------------------------------------------------------
// 1. 来源校验。
//
// JavaScript Channel 会注入到 WebView 的**所有 frame,包括 iframe**。
// F6 页面里嵌的第三方 iframe 也能调 ContiBridge。
//
// 注意 currentUrl() 返回的是**主 frame** 的 URL:这一条能挡住"整页被导航
// 到恶意站点后调 bridge",挡不住"白名单页面内的恶意 iframe"。后者只能靠
// 协议层面约定 F6 不嵌不受信 iframe + 导航拦截限制 iframe 域名。
// ------------------------------------------------------------------
final String? current = await currentUrl();
if (!urlGuard.isAllowedUrl(current)) {
log('[bridge] 拒绝来自非白名单页面的调用: $current');
// 静默丢弃,不回包——不给探测者任何反馈。
return;
}
// 2. 解析必须容错:H5 传了畸形 JSON 不能让 App 崩。
final Map<String, dynamic> req;
try {
final Object? decoded = jsonDecode(raw);
if (decoded is! Map<String, dynamic>) {
log('[bridge] 消息不是对象');
return;
}
req = decoded;
} on FormatException catch (e) {
log('[bridge] 无法解析的消息', error: e);
return;
}
// id 由 H5 侧生成并原样回传,App 不生成——H5 的 Promise 映射表由它自己管。
final Object? id = req['id'];
final Object? method = req['method'];
if (id is! String || method is! String) {
log('[bridge] 缺少 id 或 method');
return;
}
final BridgeHandler? handler = handlers[method];
if (handler == null) {
await _reply(
id,
error: const BridgeError(BridgeErrorCode.unsupportedMethod, '当前 App 版本不支持该能力'),
);
return;
}
final Object? rawParams = req['params'];
final Map<String, dynamic> params = rawParams is Map<String, dynamic>
? rawParams
: const <String, dynamic>{};
try {
await _reply(id, data: await handler(params));
} on AppException catch (e) {
// 供应商/原生错误不透传,只给稳定 code + 可展示文案。
await _reply(id, error: BridgeError(e.bridgeCode, e.message));
} on Object catch (e) {
log('[bridge] $method 未预期异常', error: e);
await _reply(id, error: const BridgeError(BridgeErrorCode.internalError, '操作失败,请重试'));
}
}
/// 主动事件(App → H5,无 id)。如门店切换、上传进度。
Future<void> emit(String event, Map<String, dynamic> payload) {
final String json = jsonEncode(<String, dynamic>{'event': event, 'payload': payload});
return evaluateJavaScript('window.__contiBridgeEvent && window.__contiBridgeEvent($json);');
}
Future<void> _reply(String id, {Object? data, BridgeError? error}) {
final Map<String, dynamic> resp = <String, dynamic>{
'id': id,
'ok': error == null,
if (error == null) 'data': data,
if (error != null) 'error': <String, String>{'code': error.code, 'message': error.message},
};
return evaluateJavaScript(
'window.__contiBridgeCallback && window.__contiBridgeCallback(${jsonEncode(resp)});',
);
}
}
@@ -0,0 +1,44 @@
/// 注入给 H5 的 JS 胶水。来源:conti-docs/10-webview-h5.md §JS 侧胶水。
library;
/// `window.ContiBridge` 只是一个原始的 `postMessage` 通道,H5 侧直接用很难写。
/// 这段把它包成 Promise。
///
/// ---------------------------------------------------------------------------
/// **注入时机是 `onPageFinished`,不是 `onPageStarted`**——后者时 H5 的脚本
/// 可能还没执行完,会重复注入或时序错乱。
///
/// `__contiBridgeReady` 做幂等保护:SPA 内部路由变化可能触发多次回调。
///
/// H5 侧要处理"bridge 还没就绪"的情况,约定等待 `window.__contiBridgeReady`。
/// **这条要写进给 F6 的接入文档**(10 §与 F6 的接口对齐清单 第 1 条)。
/// ---------------------------------------------------------------------------
const String kBridgeShim = r'''
(function () {
if (window.__contiBridgeReady) return;
const pending = new Map();
window.__contiBridgeCallback = function (resp) {
const p = pending.get(resp.id);
if (!p) return;
pending.delete(resp.id);
resp.ok ? p.resolve(resp.data) : p.reject(resp.error);
};
window.__contiBridgeEvent = function (evt) {
window.dispatchEvent(new CustomEvent('conti:' + evt.event, { detail: evt.payload }));
};
const raw = window.ContiBridge;
window.ContiBridge = {
call: function (method, params) {
const id = String(Date.now()) + Math.random().toString(36).slice(2);
return new Promise(function (resolve, reject) {
pending.set(id, { resolve: resolve, reject: reject });
raw.postMessage(JSON.stringify({ id: id, method: method, params: params || {} }));
});
},
};
window.__contiBridgeReady = true;
})();
''';
/// JavaScript Channel 名。H5 侧按这个名字调用,**改名等于破坏所有 F6 页面**。
const String kBridgeChannelName = 'ContiBridge';
@@ -0,0 +1,49 @@
/// H5 启动信息与换票端口。来源:conti-docs/10-webview-h5.md §H5 启动流程。
library;
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// `/api/v1/h5/launch` 的返回。
@immutable
class H5LaunchInfo {
/// 构造。
const H5LaunchInfo({required this.url, required this.title, required this.ttl});
/// 已由**后端**拼好票据和上下文参数的完整 URL。
///
/// ---------------------------------------------------------------------------
/// **启动上下文参数(PRD §7.3)由 App Backend 拼进 URL,客户端不参与拼接。**
/// 客户端拼参数意味着 userId / storeId / roleCode 这些权限相关字段可以被本地
/// 篡改;后端拼接时这些值都从服务端会话上下文取,客户端只能说"我要开
/// QUOTE_ORDER"。
///
/// 客户端唯一负责传的是 traceId(请求头 `X-Trace-Id`),后端把它带进 H5 URL
/// 这样"用户在 H5 里遇到问题"能一路追到 App 侧的请求。
/// ---------------------------------------------------------------------------
final String url;
/// 导航栏标题。
final String title;
/// 票据有效期,用于判断是否需要换票。
final Duration ttl;
}
/// 换票端口。
///
/// ---------------------------------------------------------------------------
/// 实现**不在本包**:换票要发 HTTP,而 `core_webview → core_network` 不是 01
/// 允许的依赖边。由 `feature_*`(或 app/)实现后 override 进来。
///
/// 同 core_auth/src/session_ports.dart 的依赖反转套路。
/// ---------------------------------------------------------------------------
abstract interface class H5LaunchRepository {
/// 用 [target](白名单枚举,不是 URL)换一份可加载的 [H5LaunchInfo]。
Future<H5LaunchInfo> launch(String target);
}
/// 未 override 时直接报错,比默默打不开页面好。
final Provider<H5LaunchRepository> h5LaunchRepositoryProvider = Provider<H5LaunchRepository>(
(Ref ref) => throw UnimplementedError('h5LaunchRepositoryProvider 必须在 bootstrap 里 override'),
);
@@ -0,0 +1,43 @@
/// 白屏看门狗。来源:conti-docs/10-webview-h5.md §白屏、超时、网络失败兜底。
library;
import 'dart:async';
/// `onPageStarted` 后 15 秒还没 `onPageFinished` 就判超时。
///
/// ---------------------------------------------------------------------------
/// **WebView 在某些网络状况下既不成功也不报错**`onWebResourceError` 不会触发,
/// 用户看到的是一片空白且永远等下去。只有超时能兜住这种情况——这是 H5 容器
/// 体验最差的一类问题。
///
/// 超时后要展示错误态并**上报 `h5_failed` 埋点**(带 target、错误码、耗时、
/// traceId):这一类失败后端完全看不到(换票请求是成功的,加载失败发生在
/// WebView 内部),所以它必须由客户端报。这是 H5 链路健康度最重要的指标。
/// ---------------------------------------------------------------------------
class PageWatchdog {
/// [onTimeout] 里做展示错误态 + 埋点上报。
PageWatchdog({required this.onTimeout, this.timeout = const Duration(seconds: 15)});
/// 超时回调。
final void Function() onTimeout;
/// 超时时长。
final Duration timeout;
Timer? _timer;
/// `onPageStarted` 时调。重复调用会重置计时。
void start() {
_timer?.cancel();
_timer = Timer(timeout, onTimeout);
}
/// `onPageFinished` / 出错时调。
void cancel() {
_timer?.cancel();
_timer = null;
}
/// 是否正在计时。
bool get isRunning => _timer?.isActive ?? false;
}
@@ -0,0 +1,54 @@
/// 域名白名单。来源:conti-docs/10-webview-h5.md §域名白名单。
library;
import 'package:core_foundation/core_foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// H5 URL 的准入判定。
///
/// ---------------------------------------------------------------------------
/// 白名单在**三个位置**都要生效,缺一不可:
///
/// 1. 首次加载前——校验后端返回的 URL(防后端配置错误)。
/// 2. 导航拦截(`NavigationDelegate.onNavigationRequest`)——H5 内部跳到非白名单
/// 域名一律 `prevent`,并记一条埋点。
/// 3. JSBridge 每条消息进来时——校验当前页面的 host。
///
/// 少任何一处,前面的校验都会被绕过。
/// ---------------------------------------------------------------------------
class UrlGuard {
/// [allowedHosts] 来自 `env/{flavor}.json`,各环境不同。
const UrlGuard(this._allowedHosts);
final Set<String> _allowedHosts;
/// 是否允许加载。
bool isAllowed(Uri uri) {
// 只允许 HTTPSPRD §7.6)。dev 也不放开——一旦放开,dev 上写的
// http 地址会跟着代码活到 uat。
if (uri.scheme != 'https') {
return false;
}
final String host = uri.host.toLowerCase();
// ------------------------------------------------------------------
// 用 endsWith('.$allowed') 而不是 contains
// contains('example.com') 会让 f6.example.com.evil.com 通过校验。
// 这是白名单实现里最经典的一个洞,别改成 contains。
// ------------------------------------------------------------------
return _allowedHosts.any((String allowed) => host == allowed || host.endsWith('.$allowed'));
}
/// 字符串版,解析失败按不允许处理。
bool isAllowedUrl(String? url) {
if (url == null) {
return false;
}
final Uri? uri = Uri.tryParse(url);
return uri != null && isAllowed(uri);
}
}
/// 由 [AppEnv.h5AllowedHosts] 驱动。
final Provider<UrlGuard> urlGuardProvider = Provider<UrlGuard>(
(Ref ref) => UrlGuard(ref.watch(appEnvProvider).h5AllowedHosts),
);
@@ -0,0 +1,98 @@
/// H5 会话失效。来源:conti-docs/10-webview-h5.md §门店切换与登出时的会话失效。
library;
import 'package:core_auth/core_auth.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:webview_flutter/webview_flutter.dart';
/// 一个已打开的 H5 页面。
///
/// 抽象出来是为了 [WebViewSession] 的清理顺序能被单测覆盖——真的
/// `WebViewController` 在单测里起不来。
abstract interface class H5Surface {
/// 停掉当前页面(导航到 about:blank),防止在途请求继续。
Future<void> stop();
/// 清 LocalStorage 和 Cache。
Future<void> clearBrowsingData();
}
/// [H5Surface] 在真机上的实现。
class WebViewSurface implements H5Surface {
/// 构造。
const WebViewSurface(this._controller);
final WebViewController _controller;
@override
Future<void> stop() => _controller.loadRequest(Uri.parse('about:blank'));
@override
Future<void> clearBrowsingData() async {
await _controller.clearLocalStorage();
await _controller.clearCache();
}
}
/// 所有打开中的 H5 页面的登记处。
///
/// ---------------------------------------------------------------------------
/// PRD §7.5 的两条硬要求:
/// - **门店切换后,当前 H5 页面必须失效并提示用户重新进入**,但**不清 Cookie**
/// (用户还是同一个人,清了会导致 F6 侧重新走一遍登录)。
/// - **用户退出登录后,所有 H5 会话必须同步失效**,并**清 Cookie / LocalStorage
/// / Cache**。不清的话下一个登录的人可能直接进到上一个人的 F6 会话——
/// 同一台门店共用设备上这是真实会发生的。
///
/// 本类实现 [SessionScopedStore],由 `SessionNotifier` 的级联统一调用
/// (11),不散在各处手动调。清理**必须 await 完成**再让新用户登录,
/// 不能 fire-and-forget。
/// ---------------------------------------------------------------------------
class WebViewSession implements SessionScopedStore {
/// [clearCookies] 只给测试替换;生产用默认的 [WebViewCookieManager]。
WebViewSession({Future<void> Function()? clearCookies})
: _clearCookies = clearCookies ?? _defaultClearCookies;
static Future<void> _defaultClearCookies() => WebViewCookieManager().clearCookies();
final Future<void> Function() _clearCookies;
final List<H5Surface> _open = <H5Surface>[];
/// 打开 H5 页时登记。
void register(H5Surface surface) => _open.add(surface);
/// 关闭 H5 页时注销。
void unregister(H5Surface surface) => _open.remove(surface);
/// 当前打开中的页面数。给测试和诊断用。
int get openCount => _open.length;
@override
String get debugName => 'WebViewSession';
@override
Future<void> onStoreChanged() => invalidateAll(clearCookies: false);
@override
Future<void> onSessionEnded() => invalidateAll(clearCookies: true);
/// 失效所有 H5 会话。
Future<void> invalidateAll({required bool clearCookies}) async {
// 先停掉页面,再清数据——反过来的话在途请求可能把刚清掉的东西又写回去。
for (final H5Surface surface in _open) {
await surface.stop();
}
if (clearCookies) {
await _clearCookies();
for (final H5Surface surface in _open) {
await surface.clearBrowsingData();
}
}
_open.clear();
}
}
/// 全 App 唯一的会话登记处。
final Provider<WebViewSession> webViewSessionProvider = Provider<WebViewSession>(
(Ref ref) => WebViewSession(),
);
+24
View File
@@ -0,0 +1,24 @@
name: core_webview
description: H5 容器。域名白名单、JSBridge、WebView 会话管理。
publish_to: none
version: 0.1.0
resolution: workspace
# core_webview → core_auth 是 01 允许的三条 core 间依赖之一(bridge 要读会话上下文)。
# 「用 target 换真实 URL」的接口调用**不在本包**——那需要 core_network,不是允许的边。
# 换票由调用方(feature)完成后把 URL 传进来,本包只负责校验和承载。
environment:
sdk: ^3.12.0
dependencies:
core_auth: ^0.1.0
core_foundation: ^0.1.0
flutter:
sdk: flutter
flutter_riverpod: ^3.3.2
webview_flutter: ^4.14.1
dev_dependencies:
flutter_lints: ^6.0.0
flutter_test:
sdk: flutter
@@ -0,0 +1,163 @@
// core_webview 的高价值断言——这几条全是安全相关,回归了不会有人察觉:
// 1. 白名单不能被 f6.example.com.evil.com 绕过。
// 2. 非白名单页面调 bridge 必须静默丢弃,连错误都不回(不给探测者反馈)。
// 3. 畸形 JSON 不能让 App 崩。
// 4. 未知 method 必须明确回 UNSUPPORTED_METHOD,不能静默(否则 H5 侧 Promise 永远 pending)。
// 5. 登出清 Cookie,切店不清。
import 'dart:convert';
import 'package:core_foundation/core_foundation.dart';
import 'package:core_webview/core_webview.dart';
import 'package:flutter_test/flutter_test.dart';
class _FakeSurface implements H5Surface {
bool stopped = false;
bool cleared = false;
@override
Future<void> stop() async => stopped = true;
@override
Future<void> clearBrowsingData() async => cleared = true;
}
void main() {
group('UrlGuard', () {
const UrlGuard guard = UrlGuard(<String>{'example.com'});
test('放行白名单域名及其子域', () {
expect(guard.isAllowed(Uri.parse('https://example.com/a')), isTrue);
expect(guard.isAllowed(Uri.parse('https://f6.example.com/a')), isTrue);
expect(guard.isAllowed(Uri.parse('https://F6.EXAMPLE.COM/a')), isTrue);
});
test('挡住后缀伪装——这是白名单实现最经典的一个洞', () {
// 用 contains 实现的话这一条会通过。
expect(guard.isAllowed(Uri.parse('https://f6.example.com.evil.com/a')), isFalse);
expect(guard.isAllowed(Uri.parse('https://notexample.com/a')), isFalse);
});
test('只允许 HTTPSdev 也不放开', () {
expect(guard.isAllowed(Uri.parse('http://example.com/a')), isFalse);
expect(guard.isAllowedUrl('about:blank'), isFalse);
expect(guard.isAllowedUrl(null), isFalse);
});
});
group('BridgeDispatcher', () {
late List<String> evaluated;
late String currentUrl;
BridgeDispatcher build(Map<String, BridgeHandler> handlers) => BridgeDispatcher(
urlGuard: const UrlGuard(<String>{'example.com'}),
handlers: handlers,
currentUrl: () async => currentUrl,
evaluateJavaScript: (String js) async => evaluated.add(js),
log: (String message, {Object? error}) {},
);
setUp(() {
evaluated = <String>[];
currentUrl = 'https://f6.example.com/quote';
});
test('非白名单页面的调用静默丢弃,不回包', () async {
currentUrl = 'https://evil.com/x';
await build(<String, BridgeHandler>{
'scan': (Map<String, dynamic> _) async => 'never',
}).handle('{"id":"1","method":"scan"}');
expect(evaluated, isEmpty, reason: '回任何东西都是在给探测者反馈');
});
test('畸形 JSON 不崩也不回包', () async {
await build(const <String, BridgeHandler>{}).handle('{not json');
expect(evaluated, isEmpty);
});
test('未知 method 明确回 UNSUPPORTED_METHOD,不静默', () async {
await build(const <String, BridgeHandler>{}).handle('{"id":"1","method":"teleport"}');
expect(evaluated, hasLength(1));
final Map<String, dynamic> resp = _decodeReply(evaluated.single);
expect(resp['ok'], isFalse);
expect((resp['error']! as Map<String, dynamic>)['code'], 'UNSUPPORTED_METHOD');
});
test('成功调用原样回传 H5 生成的 id', () async {
await build(<String, BridgeHandler>{
'getStoreContext': (Map<String, dynamic> _) async => <String, dynamic>{'storeId': 7},
}).handle('{"id":"c8f1","method":"getStoreContext"}');
final Map<String, dynamic> resp = _decodeReply(evaluated.single);
expect(resp['id'], 'c8f1');
expect(resp['ok'], isTrue);
expect(resp['data'], <String, dynamic>{'storeId': 7});
});
test('AppException 转成稳定的 bridgeCode,不透传原始错误', () async {
await build(<String, BridgeHandler>{
'scan': (Map<String, dynamic> _) async =>
throw const NativeException(NativeErrorCode.permissionDenied, '未授予相机权限'),
}).handle('{"id":"1","method":"scan"}');
final Map<String, dynamic> error =
_decodeReply(evaluated.single)['error']! as Map<String, dynamic>;
expect(error['code'], 'PERMISSION_DENIED');
expect(error['message'], '未授予相机权限');
});
test('非 AppException 归一化成 INTERNAL_ERROR,不泄露技术细节', () async {
await build(<String, BridgeHandler>{
'scan': (Map<String, dynamic> _) async => throw StateError('null check on FooBar'),
}).handle('{"id":"1","method":"scan"}');
final Map<String, dynamic> error =
_decodeReply(evaluated.single)['error']! as Map<String, dynamic>;
expect(error['code'], 'INTERNAL_ERROR');
expect(error['message'], isNot(contains('FooBar')));
});
});
group('WebViewSession', () {
test('切店:停页面但不清 Cookie——用户还是同一个人', () async {
bool cookiesCleared = false;
final WebViewSession session = WebViewSession(
clearCookies: () async => cookiesCleared = true,
);
final _FakeSurface surface = _FakeSurface();
session.register(surface);
await session.onStoreChanged();
expect(surface.stopped, isTrue);
expect(cookiesCleared, isFalse);
expect(surface.cleared, isFalse);
expect(session.openCount, 0);
});
test('登出:必须清 Cookie——门店共用设备上会串号', () async {
bool cookiesCleared = false;
final WebViewSession session = WebViewSession(
clearCookies: () async => cookiesCleared = true,
);
final _FakeSurface surface = _FakeSurface();
session.register(surface);
await session.onSessionEnded();
expect(surface.stopped, isTrue);
expect(cookiesCleared, isTrue);
expect(surface.cleared, isTrue);
expect(session.openCount, 0);
});
});
}
/// 从 `window.__contiBridgeCallback({...});` 里把 JSON 抠出来。
Map<String, dynamic> _decodeReply(String js) {
final int start = js.indexOf('({') + 1;
final int end = js.lastIndexOf('})') + 1;
return jsonDecode(js.substring(start, end)) as Map<String, dynamic>;
}
@@ -0,0 +1 @@
include: ../../analysis_options.yaml
@@ -0,0 +1,11 @@
/// 登录、登出、门店选择。
///
/// 对外只暴露三样东西:[buildAuthRoutes](给 app/ 拼路由表)、
/// [authRepositoryProvider](给 app/ override `sessionRemoteProvider`)、
/// 以及登录动作的 [loginControllerProvider]。页面本身不导出——别的包没有
/// 直接构造它们的正当理由。
library;
export 'src/data/auth_repository.dart' show AuthRepository, LoginResult, authRepositoryProvider;
export 'src/presentation/login_controller.dart';
export 'src/routes.dart';
@@ -0,0 +1,125 @@
/// 登录与会话相关的服务端调用。
///
/// 02 §Repository 接口的位置规则:本 feature 没有 `domain` 层(登录是直白的
/// 请求-响应,没有跨 repository 协调),所以接口直接声明在 `data/repository/`
/// presentation 只依赖接口,不依赖 [AuthRepositoryImpl]。
library;
import 'package:core_auth/core_auth.dart';
import 'package:core_foundation/core_foundation.dart';
import 'package:core_network/core_network.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 登录成功后拿到的东西。
typedef LoginResult = ({TokenPair tokens, UserContext user});
/// ---------------------------------------------------------------------------
/// 它同时实现 core_auth 的 [SessionRemote]core_auth 不能依赖 core_network
/// (01 的硬约束),所以那边只声明接口,实现落在这里,由 `app/bootstrap.dart`
/// 把 `sessionRemoteProvider` override 成本实现。
/// ---------------------------------------------------------------------------
abstract interface class AuthRepository implements SessionRemote {
/// 账号密码登录。
Future<LoginResult> login({required String username, required String password});
}
/// [AuthRepository] 的实现。
///
/// TODO(backend): 下面所有路径和字段名都是按 05 / 11 的示例推的,
/// 待后端接口契约确认后校准。
class AuthRepositoryImpl implements AuthRepository {
/// repository 一律注入 [ApiClient],不注入 Dio05)。
const AuthRepositoryImpl(this._api);
final ApiClient _api;
@override
Future<LoginResult> login({required String username, required String password}) async {
final Map<String, dynamic> data = await _api.post<Map<String, dynamic>>(
'/api/v1/auth/login',
data: <String, String>{'username': username, 'password': password},
);
return (
tokens: TokenPair(
accessToken: _requireString(data, 'accessToken'),
refreshToken: _requireString(data, 'refreshToken'),
),
user: _parseUser(_requireMap(data, 'user')),
);
}
@override
Future<UserContext> fetchCurrentUser() async =>
_parseUser(await _api.get<Map<String, dynamic>>('/api/v1/auth/me'));
@override
Future<List<StoreContext>> fetchAccessibleStores() async {
final List<dynamic> raw = await _api.get<List<dynamic>>('/api/v1/stores/accessible');
return raw.map((dynamic e) => _parseStore(e as Map<String, dynamic>)).toList();
}
@override
Future<StoreContext> switchStore(int storeId) async => _parseStore(
await _api.post<Map<String, dynamic>>(
'/api/v1/stores/switch',
data: <String, int>{'storeId': storeId},
),
);
@override
Future<void> revokeSession() => _api.post<void>('/api/v1/auth/logout');
static UserContext _parseUser(Map<String, dynamic> json) => UserContext(
userId: _requireString(json, 'userId'),
employeeId: _requireString(json, 'employeeId'),
phone: _requireString(json, 'phone'),
roleCode: _requireString(json, 'roleCode'),
channel: _requireString(json, 'channel'),
permissions: <String>{
...?(json['permissions'] as List<dynamic>?)?.map((dynamic e) => e as String),
},
);
static StoreContext _parseStore(Map<String, dynamic> json) => StoreContext(
storeId: (json['storeId'] as num).toInt(),
storeCode: _requireString(json, 'storeCode'),
storeName: _requireString(json, 'storeName'),
orgId: (json['orgId'] as num).toInt(),
parentStoreId: json['parentStoreId'] as String?,
menus: _parseMenus(json['menus'] as List<dynamic>?),
);
static List<MenuItem> _parseMenus(List<dynamic>? raw) => raw == null
? const <MenuItem>[]
: raw.map((dynamic e) {
final Map<String, dynamic> m = e as Map<String, dynamic>;
return MenuItem(
code: _requireString(m, 'code'),
name: _requireString(m, 'name'),
children: _parseMenus(m['children'] as List<dynamic>?),
);
}).toList();
// 缺字段直接当服务异常,不给默认值——一个 userId 为 '' 的会话会在后面
// 十个地方以更难懂的方式炸掉。
static String _requireString(Map<String, dynamic> json, String key) {
final Object? v = json[key];
if (v is! String) {
throw ServerException('响应缺少字段 $key');
}
return v;
}
static Map<String, dynamic> _requireMap(Map<String, dynamic> json, String key) {
final Object? v = json[key];
if (v is! Map<String, dynamic>) {
throw ServerException('响应缺少字段 $key');
}
return v;
}
}
/// presentation 通过它拿接口类型。
final Provider<AuthRepository> authRepositoryProvider = Provider<AuthRepository>(
(Ref ref) => AuthRepositoryImpl(ref.watch(apiClientProvider)),
);
@@ -0,0 +1,35 @@
/// 登录页的状态。
library;
import 'dart:async';
import 'package:core_auth/core_auth.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../data/auth_repository.dart';
part 'login_controller.g.dart';
/// 登录动作的三态。
///
/// 用 `AsyncNotifier<void>` 而不是自定义 state 类:登录只有"进行中/失败/成功"
/// 三种形态,成功后页面由路由 redirect 接管(会话变成 SessionActive
/// goRouterProvider 会把用户送走),不需要在这里保留成功数据。
@riverpod
class LoginController extends _$LoginController {
@override
FutureOr<void> build() {}
/// 提交登录。
Future<void> submit({required String username, required String password}) async {
state = const AsyncLoading<void>();
// AsyncValue.guard:异常留在 state 里由页面展示,不往外抛——
// 抛出去会变成未捕获异常上报,而"密码错了"不是崩溃。
state = await AsyncValue.guard(() async {
final LoginResult result = await ref
.read(authRepositoryProvider)
.login(username: username, password: password);
await ref.read(sessionProvider.notifier).onLoggedIn(tokens: result.tokens, user: result.user);
});
}
}
@@ -0,0 +1,95 @@
/// 登录页。
library;
import 'dart:async';
import 'package:core_ui/core_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'login_controller.dart';
/// 账号密码登录。
///
/// 这是一个**骨架**:字段、找回密码、验证码、记住账号等都待 PRD 细化。
/// Key 是 integration test 的约定(09),改名会让端到端用例失效。
class LoginPage extends ConsumerStatefulWidget {
/// 构造。
const LoginPage({super.key});
@override
ConsumerState<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends ConsumerState<LoginPage> {
final TextEditingController _username = TextEditingController();
final TextEditingController _password = TextEditingController();
@override
void dispose() {
_username.dispose();
_password.dispose();
super.dispose();
}
void _submit() {
// 结果通过 state 回到 UI,这里不需要 await——但也不能裸调,
// discarded_futures 会拦。
unawaited(
ref
.read(loginControllerProvider.notifier)
.submit(username: _username.text.trim(), password: _password.text),
);
}
@override
Widget build(BuildContext context) {
final AsyncValue<void> state = ref.watch(loginControllerProvider);
final bool busy = state.isLoading;
return Scaffold(
appBar: AppBar(title: const Text('登录')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
TextField(
key: const Key('login_username'),
controller: _username,
enabled: !busy,
decoration: const InputDecoration(labelText: '账号'),
),
const SizedBox(height: 12),
TextField(
key: const Key('login_password'),
controller: _password,
enabled: !busy,
obscureText: true,
decoration: const InputDecoration(labelText: '密码'),
),
const SizedBox(height: 24),
if (state.hasError && !ErrorPresenter.isSilent(state.error!)) ...<Widget>[
Text(
ErrorPresenter.presentUnknown(state.error!).title,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
const SizedBox(height: 12),
],
FilledButton(
key: const Key('login_submit'),
// busy 时置灰而不是靠节流:门店网络慢,用户会反复点。
onPressed: busy ? null : _submit,
child: busy
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('登录'),
),
],
),
),
);
}
}
@@ -0,0 +1,100 @@
/// 选店页。来源:conti-docs/11-store-context-and-session.md。
library;
import 'dart:async';
import 'package:core_auth/core_auth.dart';
import 'package:core_ui/core_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 登录后选择门店;也用于登录后切换门店。
///
/// ---------------------------------------------------------------------------
/// 用户停在这里时会话是 [SessionAwaitingStore]——**没有门店上下文**
/// `currentStoreIdProvider` 会直接 throw。所以这个页面不能读任何门店维度的
/// provider,路由 redirect 也不放行去别的页面。
/// ---------------------------------------------------------------------------
class StorePickerPage extends ConsumerStatefulWidget {
/// 构造。
const StorePickerPage({super.key});
@override
ConsumerState<StorePickerPage> createState() => _StorePickerPageState();
}
class _StorePickerPageState extends ConsumerState<StorePickerPage> {
Object? _error;
bool _switching = false;
Future<void> _pick(int storeId) async {
setState(() {
_switching = true;
_error = null;
});
try {
// 切店的 8 步级联全在 SessionNotifier 里,页面只负责触发和展示失败。
await ref.read(sessionProvider.notifier).switchStore(storeId);
} on Object catch (e) {
if (mounted) {
setState(() => _error = e);
}
} finally {
if (mounted) {
setState(() => _switching = false);
}
}
}
@override
Widget build(BuildContext context) {
final AppSession? session = ref.watch(sessionProvider).value;
final List<StoreContext> stores = switch (session) {
SessionAwaitingStore(:final List<StoreContext> candidates) => candidates,
SessionActive(:final StoreContext store) => <StoreContext>[store],
_ => const <StoreContext>[],
};
return Scaffold(
appBar: AppBar(title: const Text('选择门店')),
body: Column(
children: <Widget>[
if (_error != null) TileErrorView(error: _error!),
if (_switching) const LinearProgressIndicator(),
Expanded(
child: stores.isEmpty
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
const Text('没有可访问的门店'),
const SizedBox(height: 12),
// 拉门店失败和"确实一家都没有"在 UI 上无法区分,
// 所以两种情况都给重试入口(11 §冷启动恢复)。
FilledButton(
onPressed: () =>
unawaited(ref.read(sessionProvider.notifier).retryStoreLoad()),
child: const Text('重试'),
),
],
),
)
: ListView.builder(
itemCount: stores.length,
itemBuilder: (BuildContext context, int index) {
final StoreContext store = stores[index];
return ListTile(
// 09 的端到端用例按 index 取,改 key 名会让用例失效。
key: Key('store_item_$index'),
title: Text(store.storeName),
subtitle: Text(store.storeCode),
onTap: _switching ? null : () => unawaited(_pick(store.storeId)),
);
},
),
),
],
),
);
}
}
+28
View File
@@ -0,0 +1,28 @@
/// 本 feature 对外暴露的路由。来源:conti-docs/04-routing.md。
library;
import 'package:core_router/core_router.dart';
import 'package:flutter/widgets.dart';
import 'presentation/login_page.dart';
import 'presentation/store_picker_page.dart';
/// 登录相关路由。
///
/// ---------------------------------------------------------------------------
/// feature 只**导出**自己的路由,不知道别人的存在,也不持有 GoRouter。
/// 拼装在 `app/` 的 `appRoutesProvider` 里完成——这样 core_router 不必依赖
/// 任何 feature01 的分层),feature 之间也不会互相 import。
///
/// `GoRoute` 类型来自 core_router 的 re-export,本包 pubspec 里没有 go_router。
/// ---------------------------------------------------------------------------
List<RouteBase> buildAuthRoutes() => <RouteBase>[
GoRoute(
path: AppRoutes.login,
builder: (BuildContext context, GoRouterState state) => const LoginPage(),
),
GoRoute(
path: AppRoutes.storePicker,
builder: (BuildContext context, GoRouterState state) => const StorePickerPage(),
),
];
+30
View File
@@ -0,0 +1,30 @@
name: feature_auth
description: 登录、登出、门店选择。
publish_to: none
version: 0.1.0
resolution: workspace
# feature_* 的 pubspec 是包边界的**执行现场**:
# - 这里不出现任何另一个 feature_*(feature 间禁止互相依赖,见 01)
# - 这里不直接出现 go_router(路由类型由 core_router re-export,见 04
environment:
sdk: ^3.12.0
dependencies:
core_auth: ^0.1.0
core_foundation: ^0.1.0
core_network: ^0.1.0
core_router: ^0.1.0
core_ui: ^0.1.0
flutter:
sdk: flutter
flutter_riverpod: ^3.3.2
riverpod_annotation: ^4.0.3
dev_dependencies:
build_runner: ^2.4.13
flutter_lints: ^6.0.0
flutter_test:
sdk: flutter
mocktail: ^1.0.5
riverpod_generator: ^4.0.4
@@ -0,0 +1,89 @@
// feature_auth 的高价值断言:
// 1. 登录失败不能变成未捕获异常("密码错了"不是崩溃),要留在 state 里展示。
// 2. 提交中按钮必须置灰——门店网络慢,用户会反复点。
import 'dart:async';
import 'package:core_auth/core_auth.dart';
import 'package:core_foundation/core_foundation.dart';
import 'package:feature_auth/feature_auth.dart';
// LoginPage 不对外导出(别的包没有直接构造它的正当理由),测试走 src。
import 'package:feature_auth/src/presentation/login_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
class _FailingRepo implements AuthRepository {
/// 非 null 时 login 挂在这个 completer 上,用来观察"请求在途"这一帧。
Completer<LoginResult>? gate;
int calls = 0;
String? lastUsername;
@override
Future<LoginResult> login({required String username, required String password}) {
calls++;
lastUsername = username;
return gate?.future ?? Future<LoginResult>.error(const BusinessException(40101, '账号或密码错误'));
}
@override
Future<UserContext> fetchCurrentUser() => throw UnimplementedError();
@override
Future<List<StoreContext>> fetchAccessibleStores() => throw UnimplementedError();
@override
Future<StoreContext> switchStore(int storeId) => throw UnimplementedError();
@override
Future<void> revokeSession() => throw UnimplementedError();
}
void main() {
testWidgets('登录失败展示业务文案,不抛出未捕获异常', (WidgetTester tester) async {
final _FailingRepo repo = _FailingRepo();
await tester.pumpWidget(
ProviderScope(
overrides: [authRepositoryProvider.overrideWithValue(repo)],
child: const MaterialApp(home: LoginPage()),
),
);
// 前后空格是扫码枪/手动输入的常见污染,必须在提交前 trim。
await tester.enterText(find.byKey(const Key('login_username')), ' clerk01 ');
await tester.enterText(find.byKey(const Key('login_password')), 'pwd');
await tester.tap(find.byKey(const Key('login_submit')));
await tester.pump();
expect(repo.lastUsername, 'clerk01');
await tester.pumpAndSettle();
expect(find.text('账号或密码错误'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('提交中按钮置灰,重复点击不会重复发请求', (WidgetTester tester) async {
final _FailingRepo repo = _FailingRepo()..gate = Completer<LoginResult>();
await tester.pumpWidget(
ProviderScope(
overrides: [authRepositoryProvider.overrideWithValue(repo)],
child: const MaterialApp(home: LoginPage()),
),
);
await tester.tap(find.byKey(const Key('login_submit')));
await tester.pump(); // 请求还挂在 gate 上,这一帧就是"提交中"
final FilledButton button = tester.widget(find.byKey(const Key('login_submit')));
expect(button.onPressed, isNull, reason: '门店网络慢,用户会反复点');
await tester.tap(find.byKey(const Key('login_submit')), warnIfMissed: false);
await tester.pump();
expect(repo.calls, 1);
repo.gate!.completeError(const BusinessException(40101, '账号或密码错误'));
await tester.pumpAndSettle();
});
}
@@ -0,0 +1 @@
include: ../../analysis_options.yaml
@@ -0,0 +1,9 @@
/// 工作台(首页)。
///
/// 对外只暴露 [buildHomeRoutes](给 app/ 拼路由表)和 [homeRepositoryProvider]
/// (给测试/后续替换实现用)。页面本身不导出。
library;
export 'src/data/home_models.dart';
export 'src/data/home_repository.dart' show HomeRepository, homeRepositoryProvider;
export 'src/routes.dart';
@@ -0,0 +1,36 @@
/// 工作台的数据模型。
///
/// 字段按 PRD 的工作台描述反推,**待与后端接口对齐**——这里只保证结构和
/// 降级逻辑成立,字段名后面照着真接口改即可。
library;
import 'package:flutter/foundation.dart';
/// 待办条目。
@immutable
class TodoItem {
/// 构造。
const TodoItem({required this.code, required this.title, required this.count});
/// 业务编码,和菜单 `code` 同一套字典——工作台的角标靠它对上菜单。
final String code;
/// 展示名。
final String title;
/// 待处理数量。
final int count;
}
/// 预警条目。
@immutable
class AlertItem {
/// 构造。
const AlertItem({required this.title, required this.detail});
/// 标题。
final String title;
/// 描述。
final String detail;
}
@@ -0,0 +1,73 @@
/// 工作台的服务端调用。
///
/// 02 §Repository 接口的位置规则:本 feature 没有 `domain` 层,接口直接声明在
/// `data/repository/`presentation 只依赖接口。
library;
import 'package:core_foundation/core_foundation.dart';
import 'package:core_network/core_network.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'home_models.dart';
/// 工作台数据。
///
/// ---------------------------------------------------------------------------
/// **两个方法,两个请求,不合并。** 合并成一个 `fetchHome()` 会把降级粒度也
/// 一起合并掉:待办挂了就连预警一起看不见(12 §四)。接口层面就分开,
/// presentation 才有分开降级的可能。
/// ---------------------------------------------------------------------------
abstract interface class HomeRepository {
/// 待办列表。
Future<List<TodoItem>> fetchTodos();
/// 预警列表。
Future<List<AlertItem>> fetchAlerts();
}
/// [HomeRepository] 的实现。
///
/// TODO(backend): 路径和字段名按 PRD 工作台描述推的,待接口契约确认后校准。
class HomeRepositoryImpl implements HomeRepository {
/// repository 一律注入 [ApiClient],不注入 Dio05)。
const HomeRepositoryImpl(this._api);
final ApiClient _api;
@override
Future<List<TodoItem>> fetchTodos() async {
final List<dynamic> raw = await _api.get<List<dynamic>>('/api/v1/home/todos');
return raw.map((dynamic e) {
final Map<String, dynamic> m = e as Map<String, dynamic>;
return TodoItem(
code: _requireString(m, 'code'),
title: _requireString(m, 'title'),
count: (m['count'] as num?)?.toInt() ?? 0,
);
}).toList();
}
@override
Future<List<AlertItem>> fetchAlerts() async {
final List<dynamic> raw = await _api.get<List<dynamic>>('/api/v1/home/alerts');
return raw.map((dynamic e) {
final Map<String, dynamic> m = e as Map<String, dynamic>;
return AlertItem(title: _requireString(m, 'title'), detail: _requireString(m, 'detail'));
}).toList();
}
// 缺字段直接当服务异常,不给默认值——理由同 feature_auth。
// count 是例外:角标缺失降级为不显示,不值得整个待办区块挂掉。
static String _requireString(Map<String, dynamic> json, String key) {
final Object? v = json[key];
if (v is! String) {
throw ServerException('响应缺少字段 $key');
}
return v;
}
}
/// presentation 通过它拿接口类型。
final Provider<HomeRepository> homeRepositoryProvider = Provider<HomeRepository>(
(Ref ref) => HomeRepositoryImpl(ref.watch(apiClientProvider)),
);
@@ -0,0 +1,235 @@
/// 工作台(首页)。降级粒度来源:conti-docs/12-error-and-api-contract.md §四。
library;
import 'dart:async';
import 'package:core_auth/core_auth.dart';
import 'package:core_router/core_router.dart';
import 'package:core_ui/core_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/home_models.dart';
import 'home_providers.dart';
/// 工作台。
///
/// ---------------------------------------------------------------------------
/// 三档降级在这个文件里的对应关系(12 §四):
/// 1. 门店上下文、菜单挂了 → [HomePage] 整页 [ErrorView] + 重试
/// 2. 待办、预警挂了 → 各自的 [_SectionAsync] 显示 [TileErrorView],其余照常
/// 3. 菜单 tile 的角标挂了 → [_MenuSection] 里不显示角标,**不显示任何错误 UI**
///
/// 每一档都由"谁 watch 谁"决定,不是由 try-catch 决定——所以这三个 section
/// 必须各 watch 各的 provider,不能在上层合并(见 `home_providers.dart`)。
/// ---------------------------------------------------------------------------
class HomePage extends ConsumerWidget {
/// 构造。
const HomePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final AsyncValue<AppSession> session = ref.watch(sessionProvider);
final AppSession? value = session.value;
final StoreContext? store = value is SessionActive ? value.store : null;
return Scaffold(
appBar: AppBar(
title: Text(store?.storeName ?? '工作台'),
actions: <Widget>[
IconButton(
key: const Key('home_store_switch'),
icon: const Icon(Icons.store_outlined),
tooltip: '切换门店',
onPressed: () => context.push(AppRoutes.storePicker),
),
],
),
body: store == null
// 第 1 档:没有门店上下文,首页整体没有意义。
? AsyncValueView<AppSession>(
value: session,
onRetry: () => unawaited(ref.read(sessionProvider.notifier).retryStoreLoad()),
data: (AppSession _) => const Center(child: CircularProgressIndicator()),
)
: ListView(
padding: const EdgeInsets.all(16),
children: const <Widget>[_MenuSection(), _TodoSection(), _AlertSection()],
),
);
}
}
class _MenuSection extends ConsumerWidget {
const _MenuSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final List<MenuEntry> entries = ref.watch(homeMenuEntriesProvider);
// 第 3 档降级:角标是锦上添花,拿不到就不显示。
// 这里**故意**只取 .value 而不处理 error——待办接口挂了,菜单入口照常
// 能点,用户仍然能进去干活。给角标加一个错误 UI 只会挡住入口。
final Map<String, int> badges = <String, int>{
for (final TodoItem todo in ref.watch(homeTodosProvider).value ?? const <TodoItem>[])
todo.code: todo.count,
};
if (entries.isEmpty) {
return const _Section(title: '常用功能', child: Text('当前门店没有可用功能'));
}
return _Section(
title: '常用功能',
child: GridView.count(
crossAxisCount: 3,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: <Widget>[
for (final MenuEntry entry in entries) _MenuTile(entry: entry, badge: badges[entry.code]),
],
),
);
}
}
class _MenuTile extends StatelessWidget {
const _MenuTile({required this.entry, this.badge});
final MenuEntry entry;
final int? badge;
@override
Widget build(BuildContext context) {
return InkWell(
key: Key('home_menu_${entry.code}'),
// 路由来自 menuRouteMap,绝不会是后端下发的 URL(04 的安全约定)。
onTap: () => context.push(entry.route),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Badge(
isLabelVisible: badge != null && badge! > 0,
label: Text('$badge'),
child: const Icon(Icons.widgets_outlined, size: 32),
),
const SizedBox(height: 8),
Text(entry.name, textAlign: TextAlign.center),
],
),
);
}
}
class _TodoSection extends ConsumerWidget {
const _TodoSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
return _Section(
title: '待办',
child: _SectionAsync<List<TodoItem>>(
value: ref.watch(homeTodosProvider),
onRetry: () => ref.invalidate(homeTodosProvider),
empty: '暂无待办',
data: (List<TodoItem> todos) => Column(
children: <Widget>[
for (final TodoItem todo in todos)
ListTile(
key: Key('home_todo_${todo.code}'),
title: Text(todo.title),
trailing: Text('${todo.count}'),
onTap: () {
final String? route = resolveMenuRoute(todo.code);
if (route != null) {
unawaited(context.push(route));
}
},
),
],
),
),
);
}
}
class _AlertSection extends ConsumerWidget {
const _AlertSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
return _Section(
title: '预警',
child: _SectionAsync<List<AlertItem>>(
value: ref.watch(homeAlertsProvider),
onRetry: () => ref.invalidate(homeAlertsProvider),
empty: '暂无预警',
data: (List<AlertItem> alerts) => Column(
children: <Widget>[
for (final AlertItem alert in alerts)
ListTile(title: Text(alert.title), subtitle: Text(alert.detail)),
],
),
),
);
}
}
/// 区块级三态。
///
/// 和 core_ui 的 [AsyncValueView] 唯一的区别:错误态用 [TileErrorView] 而不是
/// 整页 [ErrorView]——这就是第 2 档降级。判定顺序保持一致(先看有没有数据,
/// 刷新失败时继续渲染旧数据)。
class _SectionAsync<T> extends StatelessWidget {
const _SectionAsync({
required this.value,
required this.data,
required this.onRetry,
required this.empty,
});
final AsyncValue<T> value;
final Widget Function(T data) data;
final VoidCallback onRetry;
final String empty;
@override
Widget build(BuildContext context) {
if (value.hasValue) {
final T current = value.value as T;
if (current is List<Object?> && current.isEmpty) {
return Text(empty);
}
return data(current);
}
if (value.hasError && !ErrorPresenter.isSilent(value.error!)) {
return TileErrorView(error: value.error!, onRetry: onRetry);
}
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
}
class _Section extends StatelessWidget {
const _Section({required this.title, required this.child});
final String title;
final Widget child;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
child,
],
),
);
}
}
@@ -0,0 +1,84 @@
/// 工作台的 provider。来源:conti-docs/12-error-and-api-contract.md §四。
library;
import 'package:core_analytics/core_analytics.dart';
import 'package:core_auth/core_auth.dart';
import 'package:core_router/core_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../data/home_models.dart';
import '../data/home_repository.dart';
part 'home_providers.g.dart';
/// 一个已经解析到本端路由的菜单入口。
typedef MenuEntry = ({String code, String name, String route});
/// 待办。
///
/// ---------------------------------------------------------------------------
/// **为什么每个区块一个 provider,而不是一个 `Future.wait` 拉全部?**
///
/// `Future.wait` 的语义是"全部成功才算成功"——四个请求里任意一个挂了,整个
/// 工作台就是错误态。但门店现场最常见的情况恰恰是某一个下游服务抖动,
/// 这时把已经拿到的待办、菜单一起藏起来,用户就什么都干不了了。
///
/// 12 §四把降级粒度定死成三档:
/// 1. 门店上下文、菜单 → 整页错误态 + 重试(没有它首页无意义)
/// 2. 待办、预警、公告、促销位 → 该区块局部错误态,其余正常
/// 3. tile 上的数字/角标 → 降级为不显示角标,不显示错误 UI
///
/// 只有"一个区块一个 provider、各自 watch 各自的"这种写法能表达第 2 档。
/// ---------------------------------------------------------------------------
///
/// `ref.watch(currentStoreIdProvider)` 不是为了用返回值,而是为了**订阅门店**:
/// 切店后本 provider 自动失效重拉(11 §切店级联的第 4 步)。漏了这一句,
/// 切完店首页还显示上一家店的待办。
@riverpod
Future<List<TodoItem>> homeTodos(Ref ref) {
ref.watch(currentStoreIdProvider);
return ref.watch(homeRepositoryProvider).fetchTodos();
}
/// 预警。失败时只影响预警区块,见 [homeTodos] 的说明。
@riverpod
Future<List<AlertItem>> homeAlerts(Ref ref) {
ref.watch(currentStoreIdProvider);
return ref.watch(homeRepositoryProvider).fetchAlerts();
}
/// 当前门店的菜单,已按本端路由表过滤。
///
/// ---------------------------------------------------------------------------
/// 后端下发了本端没有的编码时:**隐藏该入口 + 上报**(04)。不能弹错、不能
/// 留一个点了没反应的格子——灰度期后端先配菜单、App 后发版是常态。
///
/// 解析和上报放在 provider 里而不是 `build()` 里:`build()` 每帧都可能重跑,
/// 埋点会被刷爆;provider 只在门店(菜单随门店下发)变化时重算一次。
/// ---------------------------------------------------------------------------
@riverpod
List<MenuEntry> homeMenuEntries(Ref ref) {
final AppSession? session = ref.watch(sessionProvider).value;
final List<MenuItem> menus = session is SessionActive ? session.store.menus : const <MenuItem>[];
final List<MenuEntry> entries = <MenuEntry>[];
final List<String> unsupported = <String>[];
for (final MenuItem item in menus) {
final String? route = resolveMenuRoute(item.code);
if (route == null) {
unsupported.add(item.code);
continue;
}
entries.add((code: item.code, name: item.name, route: route));
}
if (unsupported.isNotEmpty) {
final Analytics analytics = ref.read(analyticsProvider);
for (final String code in unsupported) {
analytics.track(AnalyticsEvent.menuCodeUnsupported, <String, Object?>{
AnalyticsParam.code: code,
});
}
}
return entries;
}
+19
View File
@@ -0,0 +1,19 @@
/// 本 feature 对外暴露的路由。来源:conti-docs/04-routing.md。
library;
import 'package:core_router/core_router.dart';
import 'package:flutter/widgets.dart';
import 'presentation/home_page.dart';
/// 工作台路由。
///
/// 拼装在 `app/` 的 `appRoutesProvider` 里完成——core_router 不依赖任何
/// featurefeature 之间也不互相 import。`GoRoute` 来自 core_router 的
/// re-export,本包 pubspec 里没有 go_router。
List<RouteBase> buildHomeRoutes() => <RouteBase>[
GoRoute(
path: AppRoutes.home,
builder: (BuildContext context, GoRouterState state) => const HomePage(),
),
];
+32
View File
@@ -0,0 +1,32 @@
name: feature_home
description: 工作台(首页)。
publish_to: none
version: 0.1.0
resolution: workspace
# feature_* 的 pubspec 是包边界的**执行现场**:
# - 这里不出现 feature_authfeature 间禁止互相依赖,见 01)——工作台看起来
# "需要" 登录信息,但它拿的是 core_auth 的会话状态,不是 feature_auth 的页面
# - 这里不直接出现 go_router(路由类型由 core_router re-export,见 04
environment:
sdk: ^3.12.0
dependencies:
core_analytics: ^0.1.0
core_auth: ^0.1.0
core_foundation: ^0.1.0
core_network: ^0.1.0
core_router: ^0.1.0
core_ui: ^0.1.0
flutter:
sdk: flutter
flutter_riverpod: ^3.3.2
riverpod_annotation: ^4.0.3
dev_dependencies:
build_runner: ^2.4.13
flutter_lints: ^6.0.0
flutter_test:
sdk: flutter
mocktail: ^1.0.5
riverpod_generator: ^4.0.4

Some files were not shown because too many files have changed in this diff Show More