app scaffold
This commit is contained in:
@@ -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(),
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
include: ../../analysis_options.yaml
|
||||
@@ -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';
|
||||
@@ -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_router(01)。反过来由 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
@@ -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 接入自检:这是一条人为触发的测试异常');
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 仅 enableLog;prod 绝不能开(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)),
|
||||
);
|
||||
@@ -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('取消抛 RequestCancelledException,UI 必须静默处理', () 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
|
||||
@@ -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];
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -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 会直接 throw(11)。
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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 会 throw(11)。
|
||||
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';
|
||||
@@ -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());
|
||||
@@ -0,0 +1,27 @@
|
||||
name: core_storage
|
||||
description: 本地持久化。当前只封装 SharedPreferences(KV),数据库层暂缓。
|
||||
publish_to: none
|
||||
version: 0.1.0
|
||||
resolution: workspace
|
||||
|
||||
# 偏离 06 的说明:Drift 单库暂不落地。
|
||||
# 骨架阶段还没有任何需要本地缓存的业务表,先建库只会留下一个没人用、
|
||||
# 却要一直维护 schema 快照和迁移测试的空壳。06 §「所有业务缓存表必须带
|
||||
# storeId」「登出/切店清理策略」「migration 必须被验证」这三条规则在真的
|
||||
# 加第一张表时再一次性落地——那时才有东西可验证。
|
||||
# 连带收益:去掉 drift_dev 后 melos 可以留在 8.x(drift_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
|
||||
@@ -0,0 +1 @@
|
||||
include: ../../analysis_options.yaml
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 在重建时会把上一次的值带进新的 AsyncError(hasValue 仍为 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, (_, _) {});
|
||||
|
||||
// runAsync:testWidgets 默认跑在 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';
|
||||
@@ -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) {
|
||||
// 只允许 HTTPS(PRD §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(),
|
||||
);
|
||||
@@ -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('只允许 HTTPS,dev 也不放开', () {
|
||||
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],不注入 Dio(05)。
|
||||
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)),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 不必依赖
|
||||
/// 任何 feature(01 的分层),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(),
|
||||
),
|
||||
];
|
||||
@@ -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],不注入 Dio(05)。
|
||||
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;
|
||||
}
|
||||
@@ -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 不依赖任何
|
||||
/// feature,feature 之间也不互相 import。`GoRoute` 来自 core_router 的
|
||||
/// re-export,本包 pubspec 里没有 go_router。
|
||||
List<RouteBase> buildHomeRoutes() => <RouteBase>[
|
||||
GoRoute(
|
||||
path: AppRoutes.home,
|
||||
builder: (BuildContext context, GoRouterState state) => const HomePage(),
|
||||
),
|
||||
];
|
||||
@@ -0,0 +1,32 @@
|
||||
name: feature_home
|
||||
description: 工作台(首页)。
|
||||
publish_to: none
|
||||
version: 0.1.0
|
||||
resolution: workspace
|
||||
|
||||
# feature_* 的 pubspec 是包边界的**执行现场**:
|
||||
# - 这里不出现 feature_auth(feature 间禁止互相依赖,见 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
Reference in New Issue
Block a user