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,10 @@
/// 全仓库的依赖叶子包。
///
/// 这个包**不依赖仓库内任何其他包**,因此可以被所有 `core_*` / `feature_*`
/// 安全依赖而不产生循环。`native_*` 除外——它们按 01 的规定不依赖任何 core 包。
library;
export 'src/env/app_env.dart';
export 'src/error/api_code.dart';
export 'src/error/app_exception.dart';
export 'src/error/network_error_kind.dart';
+120
View File
@@ -0,0 +1,120 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 构建变体。与 `--flavor` 参数、Android productFlavors、iOS Scheme 一一对应。
///
/// 见 conti-docs/08-build-flavors.md。
enum AppFlavor {
dev,
uat,
prod;
static AppFlavor parse(String name) => switch (name) {
'dev' => AppFlavor.dev,
'uat' => AppFlavor.uat,
'prod' => AppFlavor.prod,
_ => throw ArgumentError.value(name, 'name', '未知 flavor,只允许 dev / uat / prod'),
};
}
/// 运行时环境配置。
///
/// **所有环境差异都收敛在这一个类里**,业务代码里不允许出现
/// `if (kDebugMode)` 或 `if (host.contains('uat'))` 这类判断。
///
/// 值来自 `--dart-define-from-file=env/{flavor}.json`(见 08)。构建时不传这个
/// 参数会得到空的 baseUrl[fromDartDefine] 会直接抛错而不是让 App 带着空配置跑起来——
/// 这种错误必须在启动瞬间暴露,而不是等第一个网络请求 404。
class AppEnv {
const AppEnv({
required this.flavor,
required this.apiBaseUrl,
required this.enableLog,
required this.sentryDsn,
required this.h5AllowedHosts,
});
/// 从 `--dart-define-from-file` 注入的值构造。
///
/// [flavor] 由各自的入口文件(main_dev.dart / main_uat.dart / main_prod.dart
/// 硬编码传入,而不是从 dart-define 读——这样"用 dev 的入口配了 prod 的 json"
/// 这种事故在代码里看得见。
factory AppEnv.fromDartDefine({required String flavor}) {
const apiBaseUrl = String.fromEnvironment('API_BASE_URL');
const enableLog = bool.fromEnvironment('ENABLE_LOG');
const sentryDsn = String.fromEnvironment('SENTRY_DSN');
const h5AllowedHosts = String.fromEnvironment('H5_ALLOWED_HOSTS');
if (apiBaseUrl.isEmpty) {
throw StateError(
'API_BASE_URL 为空。启动时必须带上 --dart-define-from-file=env/$flavor.json'
'见 README「怎么跑起来」。',
);
}
return AppEnv(
flavor: AppFlavor.parse(flavor),
apiBaseUrl: apiBaseUrl,
enableLog: enableLog,
sentryDsn: sentryDsn,
h5AllowedHosts: _parseHosts(h5AllowedHosts),
);
}
static Set<String> _parseHosts(String raw) => raw
.split(',')
.map((String e) => e.trim().toLowerCase())
.where((String e) => e.isNotEmpty)
.toSet();
final AppFlavor flavor;
/// 形如 `https://api.conti.com`**不带**结尾斜杠、**不带** `/api/v1` 前缀。
final String apiBaseUrl;
/// 是否输出网络日志和 debug 级日志。prod 必须为 false(见 13)。
final bool enableLog;
/// 为空表示不启用 Sentry(dev 默认不上报,避免把开发期噪音混进线上数据)。
final String sentryDsn;
/// H5 域名白名单。WebView 只允许加载这些域名及其子域,见 10。
final Set<String> h5AllowedHosts;
bool get isProd => flavor == AppFlavor.prod;
bool get flavorSuffixVisible => flavor != AppFlavor.prod;
/// 全局单例。
///
/// 优先用 [appEnvProvider] 注入(可测试、可 override)。这个静态入口只服务于
/// **拿不到 Ref 的地方**——目前唯一的使用点是 core_auth 的 TokenRefresher
/// 它必须用一个不带任何拦截器的裸 Dio,无法从 provider 树里取配置。
///
/// 声明成 `late`(而非 `late final`)是为了让 [resetForTest] 能真的重置;
/// 运行期的"只赋值一次"由 [install] 的 [_initialized] 标志保证。
static late AppEnv current;
static bool _initialized = false;
/// 由 bootstrap() 在 runApp 之前调用一次。重复调用会抛错。
static void install(AppEnv env) {
if (_initialized) {
throw StateError('AppEnv 已经初始化过了,不允许在运行期替换环境配置。');
}
_initialized = true;
current = env;
}
/// 仅供测试重置。
@visibleForTesting
static void resetForTest() => _initialized = false;
}
/// 环境配置的注入点。
///
/// 必须在 bootstrap() 的 ProviderScope 里 override,否则读取时直接抛错——
/// 给一个默认值会让"忘了注入"变成一个安静的线上事故。
final Provider<AppEnv> appEnvProvider = Provider<AppEnv>(
(Ref ref) => throw UnimplementedError('appEnvProvider 必须在 bootstrap() 里 overrideWithValue'),
);
@@ -0,0 +1,37 @@
/// 后端业务错误码常量。
///
/// **客户端不允许出现字面量数字**(见 12 §一):`if (e.code == ApiCode.forbidden)`
/// 而不是 `if (e.code == 10403)`。
///
/// ---------------------------------------------------------------------------
/// ⚠️ 完整码表尚未与后端对齐(12「待确认项」里的最高优先级项)。
///
/// 在码表定下来之前,客户端的策略是:**默认直接展示后端返回的 `message`**
/// 只对下面这一小组「需要特殊 UX 而不只是提示文案」的码做分支。这一组必须
/// 保持尽可能小——每加一个都意味着客户端和后端之间多一处硬编码耦合。
///
/// 分段约定(5 位,前 2 位是域段):
/// 0 成功
/// 10xxx 平台通用
/// 11xxx 认证与门店
/// 20xxx 采购 21xxx 库存
/// 3xxxx F6 / Mini 透传类
/// ---------------------------------------------------------------------------
abstract final class ApiCode {
static const int ok = 0;
/// → 表单内联报错,不弹 Toast
static const int invalidParam = 10001;
/// → 触发刷新 / 登出
static const int unauthorized = 10401;
/// → 权限变更,可能要重拉门店上下文
static const int forbidden = 10403;
/// → 展示 traceId
static const int internalError = 10500;
/// → 引导重选门店
static const int storeNotAccessible = 11001;
}
@@ -0,0 +1,145 @@
import 'network_error_kind.dart';
/// App 内部统一的异常体系(文档 12 §二)。
///
/// `sealed` 是有意的:UI 层的错误映射用 `switch` 穷举,将来新增一种异常类型,
/// 所有映射点会编译报错,逼着人去处理,而不是悄悄落进 `default` 变成"未知错误"。
///
/// ---------------------------------------------------------------------------
/// 与文档的偏差(见根目录 SCAFFOLD-NOTES.md §C / §E):
///
/// 1. 本体系文档里放在 `core_network`,但 `StorageException` 属于 core_storage、
/// `UnauthorizedException` 要被 core_auth 使用,而 01 明令禁止
/// `core_auth → core_network`。放在 core_network 无法同时满足依赖规则,
/// 因此下沉到叶子包 core_foundation。
/// 2. 文档里 `UnauthorizedException` / `RequestCancelledException` /
/// `StorageException` 都写成了空类体,但父类要求一个位置参数 `message`,
/// 照抄编译不过。这里补上带默认文案的 const 构造。
/// 3. `bridgeCode` 被 10 的 JSBridge 用到但从未定义,这里补上。
/// ---------------------------------------------------------------------------
sealed class AppException implements Exception {
const AppException(this.message, {this.traceId});
/// 可直接展示给用户的文案。不要往里塞堆栈或英文技术描述。
final String message;
/// 服务端链路 ID。只有 [ServerException] 会展示它,但所有异常都会把它写进日志。
final String? traceId;
/// 透传给 H5 的稳定字符串错误码(见 10 §JSBridge)。
///
/// **一旦发布就不能改**——H5 侧按它分支。新增能力时只能加新值。
String get bridgeCode;
@override
String toString() {
final String trace = traceId == null ? '' : ' traceId=$traceId';
return '$runtimeType($bridgeCode): $message$trace';
}
}
/// 网络不通、超时、DNS 失败——用户重试可能就好了。
final class NetworkException extends AppException {
const NetworkException(super.message, {this.kind});
final NetworkErrorKind? kind;
@override
String get bridgeCode => 'NETWORK_ERROR';
}
/// 后端返回了 `code != 0`[message] 可直接展示。
///
/// 构造签名按文档 12(位置参数),05 里那份具名参数的写法是笔误。
final class BusinessException extends AppException {
const BusinessException(this.code, super.message, {super.traceId});
final int code;
@override
String get bridgeCode => 'BUSINESS_ERROR';
}
/// 5xx、非 ApiResult 响应、解析失败——用户重试大概率也不好。
///
/// 文档 05 里叫 `HttpException`,与 `dart:io` 同名,统一用 12 的 `ServerException`。
final class ServerException extends AppException {
const ServerException(super.message, {this.statusCode, super.traceId});
final int? statusCode;
@override
String get bridgeCode => 'SERVER_ERROR';
}
/// token 失效且刷新失败,已触发登出。
///
/// UI 不展示它——登出流程本身会把用户送回登录页,再弹一个 Toast 是噪音。
final class UnauthorizedException extends AppException {
const UnauthorizedException([super.message = '登录已过期']);
@override
String get bridgeCode => 'UNAUTHORIZED';
}
/// 请求被 CancelToken 取消(页面销毁、用户主动退出)。
///
/// **必须被 UI 静默处理**(见 05 / 12)。用户返回上一页时在途请求被取消,
/// 弹一个"请求已取消"是纯粹的噪音。
final class RequestCancelledException extends AppException {
const RequestCancelledException([super.message = '请求已取消']);
@override
String get bridgeCode => 'CANCELLED';
}
/// 客户端本地判定的前置条件不满足(如切店时有未完成的写操作),[message] 可直接展示。
///
/// 不复用 [BusinessException]:后者的 code 来自后端错误码表,纯本地的判定
/// 没有、也不该编一个 code。
final class PreconditionException extends AppException {
const PreconditionException(super.message);
@override
String get bridgeCode => 'PRECONDITION_FAILED';
}
/// 本地存储 / 数据库错误。
final class StorageException extends AppException {
const StorageException([super.message = '本地数据异常']);
@override
String get bridgeCode => 'STORAGE_ERROR';
}
/// 原生能力错误(权限拒绝、设备不支持),见 07。
///
/// 注意 `native_*` 包**不能**抛这个类型——它们不允许依赖任何 core 包。
/// native 侧抛自己的包内异常,由调用方(feature / core_webview)转成这里的类型。
final class NativeException extends AppException {
const NativeException(this.code, super.message);
/// 取值见 [NativeErrorCode]。
final String code;
@override
String get bridgeCode => code;
}
/// [NativeException.code] 的取值。同时也是透传给 H5 的 bridge code。
abstract final class NativeErrorCode {
/// 用户拒绝了权限
static const String permissionDenied = 'PERMISSION_DENIED';
/// 设备没有这个硬件 / 系统不支持
static const String unavailable = 'UNAVAILABLE';
/// 用户主动取消(如扫码页返回)
static const String cancelled = 'CANCELLED';
/// 当前平台没有实现这个能力(如鸿蒙上的某些能力)
///
/// 文档 07 里单独有一个 `UnsupportedPlatformException`,收敛到这里,
/// 避免为一种情况多开一个异常类型。
static const String unsupportedPlatform = 'UNSUPPORTED_PLATFORM';
}
@@ -0,0 +1,17 @@
/// 网络层失败的细分原因。
///
/// 文档 12 的 `present()` 里用到了这个枚举,但 05 / 12 都没有声明它——
/// 这里补上。由 core_network 的 ErrorMappingInterceptor 负责从 DioExceptionType 映射。
enum NetworkErrorKind {
/// 连不上服务器(建连超时)
connectTimeout,
/// 连上了但服务器迟迟不返回
receiveTimeout,
/// 请求体发送超时(大文件上传常见)
sendTimeout,
/// 设备根本没有网络 / DNS 解析失败
noConnection,
}