app scaffold

This commit is contained in:
Guangfei.Zhao
2026-08-17 15:29:55 +08:00
commit 681688dfae
301 changed files with 18414 additions and 0 deletions
@@ -0,0 +1,14 @@
/// 网络层。来源:conti-docs/05-networking.md。
///
/// 对外只暴露 [ApiClient] 和 `apiClientProvider`——**repository 一律注入
/// ApiClient,不注入 Dio**05)。`dioProvider` 也导出,但只给需要直接持有
/// Dio 的极少数场景(目前没有)。
///
/// 依赖约束(01):`core_network → core_auth` 是允许的三条 core 互依例外之一;
/// 其余跨包需要(日志、设备信息)走 `src/ports.dart` 的接口反转。
library;
export 'src/api_client.dart';
export 'src/paging.dart';
export 'src/ports.dart';
export 'src/providers.dart';
@@ -0,0 +1,99 @@
/// 仓库唯一对外的 HTTP 出口。来源:conti-docs/05-networking.md。
library;
import 'dart:io';
import 'package:core_foundation/core_foundation.dart';
import 'package:dio/dio.dart';
import 'package:path/path.dart' as p;
/// 包在 [Dio] 外面的一层。
///
/// ---------------------------------------------------------------------------
/// 为什么需要它:**拦截器没有办法让 `dio.get()` 抛出 [AppException]**。
/// dio 的错误通道只认 [DioException],我们的 [AppException] 只能挂在它的
/// `error` 字段上。repository 直接调 `dio.get()` 的话,业务层写
///
/// try { ... } on UnauthorizedException { ... }
///
/// 永远进不来——实际抛出来的仍然是 [DioException]。
///
/// 所以在出口处把 `DioException.error` 拆出来重抛。
///
/// **规则:repository 一律注入 [ApiClient],不注入 [Dio]。** 全仓库只有
/// core_network 内部和 core_auth 的裸 Dio 会直接碰 [Dio] 类型。
/// ---------------------------------------------------------------------------
class ApiClient {
/// [dio] 由 `dioProvider` 提供。
ApiClient(this._dio);
final Dio _dio;
/// GET。
Future<T> get<T>(String path, {Map<String, dynamic>? query, CancelToken? cancelToken}) =>
_run(() => _dio.get<T>(path, queryParameters: query, cancelToken: cancelToken));
/// POST。
Future<T> post<T>(String path, {Object? data, CancelToken? cancelToken}) =>
_run(() => _dio.post<T>(path, data: data, cancelToken: cancelToken));
/// PUT。
Future<T> put<T>(String path, {Object? data, CancelToken? cancelToken}) =>
_run(() => _dio.put<T>(path, data: data, cancelToken: cancelToken));
/// DELETE。
Future<T> delete<T>(String path, {Object? data, CancelToken? cancelToken}) =>
_run(() => _dio.delete<T>(path, data: data, cancelToken: cancelToken));
/// 多文件上传。
///
/// 约定(05 §文件与图片上传):
/// - **上传前必须压缩**。门店员工直接拍的照片通常 3–8 MB,原图在门店 WiFi 下
/// 大概率超时。统一压到长边 1600px / JPEG 80,超 2 MB 再降一档。
/// - **进度必须可见**,否则用户会以为卡死反复点。
/// - **失败要能单张重传**,所以 UI 的上传状态按单张维护,别整批重来。
/// - [FormData] **不可重用**:它是流,重试必须重新构造,复用会报
/// stream already listened——所以这个方法每次调用都自己建一个。
Future<T> upload<T>(
String path, {
required List<File> files,
Map<String, dynamic>? fields,
void Function(int sent, int total)? onProgress,
CancelToken? cancelToken,
}) async {
final FormData formData = FormData.fromMap(<String, dynamic>{
...?fields,
'files': <MultipartFile>[
for (final File f in files)
await MultipartFile.fromFile(f.path, filename: p.basename(f.path)),
],
});
return _run(
() => _dio.post<T>(
path,
data: formData,
cancelToken: cancelToken,
onSendProgress: onProgress,
// 上传单独放宽:用全局的 30s 传几张原图会超。
options: Options(sendTimeout: const Duration(minutes: 3)),
),
);
}
Future<T> _run<T>(Future<Response<T>> Function() send) async {
try {
final Response<T> res = await send();
return res.data as T;
} on DioException catch (e, st) {
final Object? error = e.error;
// 拦截器已经归一化过,这里只负责把它从 DioException 的壳里拿出来重抛。
// Error.throwWithStackTrace 保留原始堆栈——否则上报到崩溃平台的堆栈会
// 全部指向下面这一行,等于没有堆栈。
if (error is AppException) {
Error.throwWithStackTrace(error, st);
}
Error.throwWithStackTrace(const NetworkException('网络异常,请稍后重试'), st);
}
}
}
@@ -0,0 +1,58 @@
/// 后端统一响应包装的解包。来源:conti-docs/05-networking.md §后端契约。
library;
import 'package:core_foundation/core_foundation.dart';
import 'package:dio/dio.dart';
import 'ports.dart';
/// 把 `ApiResult<T> { code, message, data, traceId }` 剥成里层的 `data`。
///
/// **解包只在这里做一次**repository 拿到的 `response.data` 已经是 `data` 本身。
class ApiResultInterceptor extends Interceptor {
/// [log] 见 [apiLogSinkProvider]。
ApiResultInterceptor(this._log);
final ApiLogSink _log;
@override
void onResponse(Response<dynamic> response, ResponseInterceptorHandler handler) {
final Object? body = response.data;
// 非 JSON 对象响应(如文件下载)不走解包。
if (body is! Map<String, dynamic> || !body.containsKey('code')) {
handler.next(response);
return;
}
// 用 num 而不是 int:万一后端某个字段序列化成了 JSON 浮点数,as int 会直接抛。
final int? code = (body['code'] as num?)?.toInt();
final String? traceId = body['traceId'] as String?;
// traceId 必须留存:用户报一个 traceId,后端就能在日志里定位这次请求
// backend 06/08)。成功失败都要打。URL 不带 query——H5 那类 URL 里有 ticket。
final Uri uri = response.requestOptions.uri;
_log('[api] ${uri.origin}${uri.path} code=$code traceId=$traceId');
if (code == ApiCode.ok) {
response.data = body['data'];
handler.next(response);
return;
}
// code != 0 一律转成业务异常,不让它以"成功响应"的形态流到业务层。
handler.reject(
DioException(
requestOptions: response.requestOptions,
response: response,
error: BusinessException(
code ?? -1,
(body['message'] as String?) ?? '请求失败',
traceId: traceId,
),
),
// callFollowingErrorInterceptor:让 ErrorMappingInterceptor 有机会放行它。
true,
);
}
}
@@ -0,0 +1,73 @@
/// 鉴权与 401 刷新。来源:conti-docs/05-networking.md §Token 刷新:必须串行,失败即登出。
library;
import 'package:core_auth/core_auth.dart';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'providers.dart';
/// 附加 token;401 时刷新一次并重放原请求。
///
/// 串行化本身**不在这里**——它在 core_auth 的 [TokenRefresher] 里(共享在途
/// Future)。这样即使将来多了一个走刷新的调用方,串行保证也只有一份实现。
///
/// 三条硬约束(后端 refresh token 一次性 + 重放即全量撤销):
/// 1. 绝不并发刷新,否则用户被全设备强制登出;
/// 2. 刷新失败不重试,直接登出;
/// 3. 刷新请求本身走裸 Dio,不经过本拦截器,否则无限递归。
class AuthInterceptor extends Interceptor {
/// [ref] 用来读 token 与会话。
AuthInterceptor(this._ref);
final Ref _ref;
/// 一次性重试标记。带着新 token 重放后又 401,说明不是 token 的问题,别再刷了。
static const String _retriedKey = 'x-retried';
@override
Future<void> onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
final String? token = await _ref.read(tokenStorageProvider).readAccessToken();
if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
}
@override
Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
if (err.response?.statusCode != 401) {
handler.next(err);
return;
}
if (err.requestOptions.extra[_retriedKey] == true) {
await _logout();
handler.next(err);
return;
}
final TokenPair refreshed;
try {
refreshed = await _ref.read(tokenRefresherProvider).refresh();
} on Object {
// 刷新失败 = refresh token 已失效。不重试——再试一次只会再触发一次
// 重放判定,把用户的其他设备也一起踢掉。
await _logout();
handler.next(err);
return;
}
try {
final RequestOptions options = err.requestOptions
..extra[_retriedKey] = true
..headers['Authorization'] = 'Bearer ${refreshed.accessToken}';
handler.resolve(await _ref.read(dioProvider).fetch<dynamic>(options));
} on DioException catch (e) {
handler.next(e);
}
}
Future<void> _logout() =>
_ref.read(sessionProvider.notifier).logout(reason: LogoutReason.tokenExpired);
}
@@ -0,0 +1,58 @@
/// 异常归一化。来源:conti-docs/05-networking.md §异常归一化。
library;
import 'package:core_foundation/core_foundation.dart';
import 'package:dio/dio.dart';
/// 把 [DioException] 统一转成 [AppException] 体系。
///
/// **必须排在拦截器链的最后**:它把所有还没被归一化的错误兜底成 [NetworkException]
/// 排在前面会把 [ApiResultInterceptor] 抛的 [BusinessException] 提前吃掉。
class ErrorMappingInterceptor extends Interceptor {
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
// 已经是 AppException 的直接放行,不要二次包装。
if (err.error is AppException) {
handler.next(err);
return;
}
final AppException mapped = switch (err.type) {
DioExceptionType.connectionTimeout => const NetworkException(
'网络超时,请检查网络后重试',
kind: NetworkErrorKind.connectTimeout,
),
DioExceptionType.sendTimeout => const NetworkException(
'网络超时,请检查网络后重试',
kind: NetworkErrorKind.sendTimeout,
),
DioExceptionType.receiveTimeout => const NetworkException(
'网络超时,请检查网络后重试',
kind: NetworkErrorKind.receiveTimeout,
),
DioExceptionType.connectionError => const NetworkException(
'网络不可用,请检查网络后重试',
kind: NetworkErrorKind.noConnection,
),
// 必须静默处理:用户返回上一页时在途请求被取消,弹提示是纯粹的噪音(12)。
DioExceptionType.cancel => const RequestCancelledException(),
// 401 走到这里说明 AuthInterceptor 已经刷新失败并登出了,UI 不再重复提示。
DioExceptionType.badResponse when err.response?.statusCode == 401 =>
const UnauthorizedException(),
DioExceptionType.badResponse => ServerException(
'服务异常(${err.response?.statusCode}',
statusCode: err.response?.statusCode,
),
_ => const NetworkException('网络异常,请稍后重试'),
};
handler.next(
DioException(
requestOptions: err.requestOptions,
response: err.response,
type: err.type,
error: mapped,
),
);
}
}
@@ -0,0 +1,46 @@
/// 统一请求头。来源:conti-docs/05-networking.md §统一请求头。
library;
import 'dart:io';
import 'package:core_auth/core_auth.dart';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:uuid/uuid.dart';
import 'ports.dart';
/// 给每个请求补上追踪、版本、平台、设备和门店头。
class HeaderInterceptor extends Interceptor {
/// [ref] 用来读环境和会话。
HeaderInterceptor(this._ref);
final Ref _ref;
static const Uuid _uuid = Uuid();
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
final ClientInfo info = _ref.read(clientInfoProvider);
options.headers.addAll(<String, String>{
// 客户端生成,便于端到端串联。
// **待与后端确认**backend 06 说 traceId 由后端入口 filter 生成,约定是
// 后端优先复用这个头、没有才自己生成,否则两边日志各用一套 ID 对不上。
'X-Trace-Id': _uuid.v4(),
'X-App-Version': info.appVersion,
'X-Platform': Platform.isIOS ? 'ios' : 'android',
'X-Device-Id': info.deviceId,
});
// 用可空版:登录、拉门店列表这些请求本身就发生在选店之前,
// 用会 throw 的 currentStoreIdProvider 会直接把登录流程打死。
final int? storeId = _ref.read(currentStoreIdOrNullProvider);
if (storeId != null) {
// 冗余信息:access token 的 claims 里已经有 storeId,后端以 token 为准。
// 带这个头只为排查时能一眼看出客户端当时认为自己在哪个门店——两者不一致
// 就说明切店后 token 没换,是个 bug 信号。
options.headers['X-Store-Id'] = '$storeId';
}
handler.next(options);
}
}
+62
View File
@@ -0,0 +1,62 @@
/// 分页契约。来源:conti-docs/02-layering.md。
library;
import 'package:flutter/foundation.dart';
/// 分页请求参数。
@immutable
class PageQuery {
/// [page] 从 1 开始。
const PageQuery({required this.page, this.size = 20});
/// 页码,从 1 开始。
final int page;
/// 每页条数。
final int size;
/// 转成 query 参数。**字段名待与后端对齐**(backend 06 的分页约定还没定)。
Map<String, dynamic> toQuery() => <String, dynamic>{'page': page, 'size': size};
}
/// 分页结果。
@immutable
class PageResult<T> {
/// 构造。
const PageResult({
required this.items,
required this.total,
required this.page,
required this.hasMore,
});
/// 从 `{items, total, page, hasMore}` 结构解析。
factory PageResult.fromJson(
Map<String, dynamic> json,
T Function(Map<String, dynamic>) itemFromJson,
) {
final List<dynamic> raw = (json['items'] as List<dynamic>?) ?? const <dynamic>[];
return PageResult<T>(
items: raw.map((dynamic e) => itemFromJson(e as Map<String, dynamic>)).toList(),
total: (json['total'] as num?)?.toInt() ?? 0,
page: (json['page'] as num?)?.toInt() ?? 1,
hasMore: json['hasMore'] as bool? ?? false,
);
}
/// 当前页数据。
final List<T> items;
/// 总条数。
final int total;
/// 当前页码。
final int page;
/// 是否还有下一页。
///
/// **用后端下发的字段,不在客户端算**。02 里那份
/// `items.length + (page - 1) * items.length < total` 的推算在最后一页
/// 条数不满时会算错;README 的已解决项里后端已确认下发这个字段。
final bool hasMore;
}
+45
View File
@@ -0,0 +1,45 @@
/// core_network 的对外端口。
///
/// 和 core_auth 的 `session_ports.dart` 是同一套思路:05 的伪代码里
/// `HeaderInterceptor` 读了 `deviceIdProvider`、`ApiResultInterceptor` 收了一个
/// `AppLogger`,但 01 只允许 `core_network → core_auth` 这一条 core 出边,
/// core_logging 不在其中。所以这里只声明"需要什么",由 app 层接上去。
library;
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 请求头里那几个和环境无关、只有运行时才知道的值。
@immutable
class ClientInfo {
/// 构造。
const ClientInfo({required this.appVersion, required this.deviceId});
/// 形如 `1.4.0+142`。
final String appVersion;
/// **安装级匿名 ID**:首次安装时生成的随机 UUID,存本地。
///
/// 绝不是 IMEI / IDFA / MAC / AndroidID——采集这些是合规红线(05 / 07 隐私清单)。
final String deviceId;
}
/// [ClientInfo] 的注入点。必须在 bootstrap 里 override。
///
/// 不给默认值:带着 `appVersion: 'unknown'` 上线,问题会表现为线上日志里
/// 版本分布全糊成一团,等发现时已经排查了很久。
final Provider<ClientInfo> clientInfoProvider = Provider<ClientInfo>(
(Ref ref) => throw UnimplementedError('clientInfoProvider 必须在 bootstrap() 里 override'),
);
/// API 日志出口。
typedef ApiLogSink = void Function(String message);
/// [ApiLogSink] 的注入点。默认丢弃——测试里不用管。
///
/// app 层把它接到 core_logging 的 `AppLogger.d` 上。**接的时候注意 message 已经
/// 是脱敏过的**:这里输出的只有 URL(不含 query)、code、traceId,不含请求体,
/// 也绝不含 `Authorization`13 §脱敏)。
final Provider<ApiLogSink> apiLogSinkProvider = Provider<ApiLogSink>(
(Ref ref) => (String message) {},
);
@@ -0,0 +1,62 @@
/// 拦截器链的组装。来源:conti-docs/05-networking.md §附录·拦截器链的组装。
library;
import 'package:core_foundation/core_foundation.dart';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'api_client.dart';
import 'api_result_interceptor.dart';
import 'auth_interceptor.dart';
import 'error_mapping_interceptor.dart';
import 'header_interceptor.dart';
import 'ports.dart';
/// 全 App 唯一的 [Dio] 实例。
///
/// ---------------------------------------------------------------------------
/// **拦截器顺序不能改。** dio 的三个时机(onRequest / onResponse / onError
/// 都是按注册顺序**正向**执行的,不是洋葱模型——这一点和很多人的直觉不同。
///
/// HeaderInterceptor 补 trace / 版本 / 平台 / 门店头
/// LogInterceptor 仅 enableLogprod 绝不能开(13
/// AuthInterceptor 必须在 ErrorMapping 之前,才能在 401 被归一化成
/// UnauthorizedException **之前**先尝试刷新
/// ApiResultInterceptor 必须在 ErrorMapping 之前,它抛的 BusinessException
/// 需要能被后者识别并放行
/// ErrorMappingInterceptor 兜底,必须最后
///
/// 顺序取自 05 的附录(正文那份和附录不一致,以附录为准,见 SCAFFOLD-NOTES)。
/// ---------------------------------------------------------------------------
final Provider<Dio> dioProvider = Provider<Dio>((Ref ref) {
final AppEnv env = ref.watch(appEnvProvider);
final Dio dio = Dio(
BaseOptions(
baseUrl: env.apiBaseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 15),
// 上传单独放宽到 3 分钟,见 ApiClient.upload。
sendTimeout: const Duration(seconds: 30),
),
);
dio.interceptors.addAll(<Interceptor>[
HeaderInterceptor(ref),
// responseBody: false —— 响应体里可能有手机号、地址这类个人信息,
// 而且日志只在 dev/uat 开。requestHeader 里的 Authorization 由
// LogInterceptor 原样打印,所以 prod 必须关掉整条(env.enableLog == false)。
if (env.enableLog) LogInterceptor(responseBody: false),
AuthInterceptor(ref),
ApiResultInterceptor(ref.watch(apiLogSinkProvider)),
ErrorMappingInterceptor(),
]);
ref.onDispose(dio.close);
return dio;
});
/// repository 唯一该注入的东西。
final Provider<ApiClient> apiClientProvider = Provider<ApiClient>(
(Ref ref) => ApiClient(ref.watch(dioProvider)),
);