diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 85cd0d2..489cc98 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -96,8 +96,6 @@ coverage-gate: - > flutter build apk --flavor $FLAVOR - -t lib/main_$FLAVOR.dart - --dart-define-from-file=env/$FLAVOR.json $EXTRA_ARGS artifacts: paths: @@ -122,12 +120,8 @@ android-prod: <<: *android-build variables: FLAVOR: prod - # release 包必须混淆 + 拆符号表,否则 Sentry 上的堆栈是一堆十六进制地址。 - # --split-debug-info 的产物要留着,sentry_dart_plugin 靠它还原堆栈。 + # release 包必须混淆 + 拆符号表,符号表保留供后续排障使用。 EXTRA_ARGS: --release --obfuscate --split-debug-info=build/symbols - after_script: - # SENTRY_AUTH_TOKEN 只从 CI 变量读,仓库里任何文件都不许出现它。 - - cd app && dart run sentry_dart_plugin artifacts: paths: - app/build/app/outputs/flutter-apk/*.apk diff --git a/README.md b/README.md index 739c96d..2d5ee49 100644 --- a/README.md +++ b/README.md @@ -25,16 +25,12 @@ melos run gen # 3. 跑起来 cd app -flutter run --flavor dev -t lib/main_dev.dart --dart-define-from-file=env/dev.json +flutter run --flavor dev ``` -`--flavor` / `-t` / `--dart-define-from-file` **三个参数缺一不可**: - -- 少了 `--dart-define-from-file`,`AppEnv.fromDartDefine` 会在启动瞬间抛错。 - 这是故意的——带着空 baseUrl 跑起来,问题会在第一个请求 404 时才暴露。 -- 少了 `-t`,Flutter 会去找 `lib/main.dart`,本仓库没有这个文件。 - -VS Code / Android Studio 用户建议把三条 flavor 配进 `launch.json`。 +项目只有一个 Dart 入口 `lib/main.dart`。`--flavor dev|uat|prod` 仍用于选择 +Android/iOS 的原生包变体,同时 Flutter 会通过 `appFlavor` 选择对应的内置环境 +配置。未传 flavor 时默认使用 dev,因此 DevEco 的普通 HAP 构建无需额外入口配置。 ## 环境与工具链版本 @@ -100,10 +96,10 @@ melos run test # flutter test --coverage ```powershell . .\enable-ohos.ps1 cd app -flutter build hap --debug -t lib\main_dev.dart +flutter build hap --debug ``` -构建产物为未签名 debug HAP:`app/ohos/entry/build/default/outputs/default/entry-default-unsigned.hap`。这不会创建新的 Dart `main` 文件;现有环境入口仍是 `lib/main_dev.dart`、`lib/main_uat.dart` 和 `lib/main_prod.dart`。HAP 编译通过不代表 ArkTS 原生桥接、扫码或真机安装已经完成,边界见 [docs/07-native-integration.md](docs/07-native-integration.md)。 +构建产物为未签名 debug HAP:`app/ohos/entry/build/default/outputs/default/entry-default-unsigned.hap`。Hvigor 默认使用现有的 `lib/main.dart`,并加载 dev 环境配置。HAP 编译通过不代表 ArkTS 原生桥接、扫码或真机安装已经完成,边界见 [docs/07-native-integration.md](docs/07-native-integration.md)。 `analyze` 带 `--fatal-infos`。不加等于没加 lint——绝大多数 riverpod_lint 规则 报的是 info 级。 @@ -118,7 +114,7 @@ flutter build hap --debug -t lib\main_dev.dart app/ 壳工程。唯一知道所有包的地方:环境注入、启动编排、路由聚合 packages/ core_foundation/ AppEnv / AppException 体系 / ApiCode ← 叶子包,谁都能依赖 - core_logging/ AppLogger / CrashReporter / 脱敏 + core_logging/ AppLogger / 脱敏 core_analytics/ Analytics 接口 + 事件常量表 core_storage/ Prefs(KV)。Drift 暂缓,见 SCAFFOLD-NOTES core_auth/ AppSession 四态 / TokenStorage / SessionNotifier @@ -179,5 +175,5 @@ flutter analyze → info: depend_on_referenced_packages 模板。现在调 `startScan` 会抛 `MissingPluginException`,属预期。 - **神策 SDK 未采购**:`analyticsProvider` 是 `NoopAnalytics`。接入时注意必须在 用户同意隐私政策之后再初始化。 -- **env/*.json 里全是占位域名**,`SENTRY_DSN` 全空,待运维确认。 +- **env/*.json 里全是占位域名**,待运维确认。 - **CI 镜像名未定**,`.gitlab-ci.yml` 里标了 TODO(ops)。 diff --git a/app/env/dev.json b/app/env/dev.json index 03b85cb..a91ed1f 100644 --- a/app/env/dev.json +++ b/app/env/dev.json @@ -1,7 +1,6 @@ { - "_comment": "dev 环境。构建时必须带 --dart-define-from-file=env/dev.json,否则 AppEnv.fromDartDefine 会直接抛错。所有地址都是占位符,TODO(ops) 待运维确认真实域名。", + "_comment": "dev 环境。未指定 flavor 的启动(包括 DevEco 默认 HAP 构建)使用此配置。所有地址都是占位符,TODO(ops) 待运维确认真实域名。", "API_BASE_URL": "https://api-dev.example.com", "ENABLE_LOG": true, - "SENTRY_DSN": "", "H5_ALLOWED_HOSTS": "h5-dev.example.com" } diff --git a/app/env/prod.json b/app/env/prod.json index 169e015..d32362b 100644 --- a/app/env/prod.json +++ b/app/env/prod.json @@ -1,7 +1,6 @@ { - "_comment": "prod 环境。ENABLE_LOG 必须为 false(13:生产不写控制台、不落日志文件)。TODO(ops) 待运维确认真实域名与 Sentry DSN。", + "_comment": "prod 环境。ENABLE_LOG 必须为 false,生产不写控制台日志。TODO(ops) 待运维确认真实域名。", "API_BASE_URL": "https://api.example.com", "ENABLE_LOG": false, - "SENTRY_DSN": "", "H5_ALLOWED_HOSTS": "h5.example.com" } diff --git a/app/env/uat.json b/app/env/uat.json index cc13586..7cb06d0 100644 --- a/app/env/uat.json +++ b/app/env/uat.json @@ -2,6 +2,5 @@ "_comment": "uat 环境。TODO(ops) 待运维确认真实域名与 Sentry DSN。", "API_BASE_URL": "https://api-uat.example.com", "ENABLE_LOG": true, - "SENTRY_DSN": "", "H5_ALLOWED_HOSTS": "h5-uat.example.com" } diff --git a/app/integration_test/app_test.dart b/app/integration_test/app_test.dart index bb52132..3de479d 100644 --- a/app/integration_test/app_test.dart +++ b/app/integration_test/app_test.dart @@ -7,7 +7,7 @@ // // 跑法: // flutter test integration_test/app_test.dart \ -// --flavor dev -t lib/main_dev.dart --dart-define-from-file=env/dev.json +// --flavor dev // --------------------------------------------------------------------------- import 'package:app/bootstrap.dart'; @@ -20,7 +20,16 @@ void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); testWidgets('冷启动后停在登录页', (WidgetTester tester) async { - await bootstrap(AppEnv.fromDartDefine(flavor: 'dev')); + await bootstrap( + AppEnv.fromConfig( + flavor: 'dev', + values: { + 'API_BASE_URL': 'https://api-dev.example.com', + 'ENABLE_LOG': true, + 'H5_ALLOWED_HOSTS': 'h5-dev.example.com', + }, + ), + ); await tester.pumpAndSettle(); // 没有 token → SessionUnauthenticated → redirect 到 /login(04)。 diff --git a/app/ios/FLAVORS.md b/app/ios/FLAVORS.md index 57264c4..7d20d6c 100644 --- a/app/ios/FLAVORS.md +++ b/app/ios/FLAVORS.md @@ -62,7 +62,7 @@ Xcode 自己维护的二进制风格文本,手写会在下一次 Xcode 打开 6. 验证: ```bash cd app - flutter build ios --flavor dev -t lib/main_dev.dart --dart-define-from-file=env/dev.json + flutter build ios --flavor dev ``` 三个 flavor 各跑一次,确认桌面上能同时装下三个图标、名字不同。 @@ -71,6 +71,5 @@ Xcode 自己维护的二进制风格文本,手写会在下一次 Xcode 打开 - **CocoaPods 和 flavor 无关**:`Podfile` 不需要改,pod 是按 target 装的, 不是按 configuration。但新增 configuration 后要跑一次 `pod install`,否则 会报 `Unable to find a configuration named 'Debug-dev'`。 -- **`--flavor` 和 `-t` 必须同时给**。只给 `--flavor dev` 会用默认的 - `lib/main.dart`——这个文件在本仓库里**不存在**(入口是 `main_dev.dart`), - 报错信息是找不到文件,和 flavor 看不出关系。 +- 项目固定使用 `lib/main.dart`。`--flavor` 除了选择 Scheme,还会让启动代码 + 选择同名的内置环境配置;不要再额外传 `-t` 或 `--dart-define-from-file`。 diff --git a/app/lib/bootstrap.dart b/app/lib/bootstrap.dart index 950c22f..7c26e2c 100644 --- a/app/lib/bootstrap.dart +++ b/app/lib/bootstrap.dart @@ -1,6 +1,6 @@ -/// 三个入口共用的启动编排。 +/// 唯一入口共用的启动编排。 /// -/// 来源:08(多环境)、13(Sentry / 日志)、12(全局错误)、03(Riverpod 装配)。 +/// 来源:08(多环境)、12(全局错误)、03(Riverpod 装配)。 library; import 'package:app/src/app_widget.dart'; @@ -20,12 +20,11 @@ import 'package:feature_auth/feature_auth.dart'; import 'package:feature_home/feature_home.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; /// TODO(app): 换成 package_info_plus 读真实版本,现在写死会在灰度期骗人。 const String _appVersion = '1.0.0+1'; -/// 全部三个 main_*.dart 都只调这一个函数。 +/// 唯一 `main.dart` 调用的启动函数。 /// /// --------------------------------------------------------------------------- /// 这个函数是**整个仓库唯一一处知道所有包的地方**。各个 core_* 只声明自己需要 @@ -40,14 +39,7 @@ Future bootstrap(AppEnv env) async { // 装一次全局单例,供拿不到 Ref 的 TokenRefresher 用;重复调用会抛错。 AppEnv.install(env); - final LogBuffer buffer = LogBuffer(); - final AppLogger logger = LoggerAppLogger(env: env, buffer: buffer); - - // dsn 为空(dev 默认)时连 SDK 都不初始化:开发期的噪音不该混进线上数据。 - final bool sentryEnabled = env.sentryDsn.isNotEmpty; - final CrashReporter reporter = sentryEnabled - ? const SentryCrashReporter() - : const NoopCrashReporter(); + final AppLogger logger = LoggerAppLogger(env: env); final Prefs prefs = Prefs(); final ClientInfo clientInfo = ClientInfo( @@ -62,13 +54,11 @@ Future bootstrap(AppEnv env) async { Widget buildApp() { return ProviderScope( - observers: [ErrorObserver(logger: logger, reporter: reporter)], + observers: [ErrorObserver(logger: logger)], overrides: [ // --- 基础设施:值已经在上面造好了,直接注入 ----------------------- appEnvProvider.overrideWithValue(env), // core_foundation appLoggerProvider.overrideWithValue(logger), // core_logging - logBufferProvider.overrideWithValue(buffer), // core_logging(和 beforeSend 同一实例) - crashReporterProvider.overrideWithValue(reporter), // core_logging prefsProvider.overrideWithValue(prefs), // core_storage clientInfoProvider.overrideWithValue(clientInfo), // core_network 端口 // --- 端口:接口在 core_*,实现在能依赖 core_network 的这一层 -------- @@ -87,10 +77,7 @@ Future bootstrap(AppEnv env) async { ), sessionObserversProvider.overrideWith( (Ref ref) => [ - AppSessionObserver( - analytics: ref.watch(analyticsProvider), - reporter: ref.watch(crashReporterProvider), - ), + AppSessionObserver(analytics: ref.watch(analyticsProvider)), ], ), @@ -98,16 +85,8 @@ Future bootstrap(AppEnv env) async { appRoutesProvider.overrideWith( (Ref ref) => [...buildAuthRoutes(), ...buildHomeRoutes()], ), - navigatorObserversProvider.overrideWith( - (Ref ref) => [ - CrashBreadcrumbObserver(ref.watch(crashReporterProvider)), - ], - ), routeReporterProvider.overrideWith( - (Ref ref) => AppRouteReporter( - logger: ref.watch(appLoggerProvider), - reporter: ref.watch(crashReporterProvider), - ), + (Ref ref) => AppRouteReporter(logger: ref.watch(appLoggerProvider)), ), // --- 日志出口:core_network 只知道"往这里写字符串" ------------------- @@ -123,26 +102,5 @@ Future bootstrap(AppEnv env) async { ); } - if (!sentryEnabled) { - runApp(buildApp()); - return; - } - - await SentryFlutter.init( - (SentryFlutterOptions options) { - options.dsn = env.sentryDsn; - options.environment = env.flavor.name; - options.release = 'conti-retail-app@$_appVersion'; - options.tracesSampleRate = env.isProd ? 0.1 : 1.0; - // 合规红线:不自动带用户 IP / 请求头 / cookie。 - options.sendDefaultPii = false; - options.beforeBreadcrumb = scrubBreadcrumb; - options.beforeSend = buildScrubEvent(buffer); - options.debug = false; - }, - // 用 appRunner 而不是自己写 FlutterError.onError:SentryFlutter 已经在 - // 里面装好了 Flutter / PlatformDispatcher / Zone 三层钩子,再手写一遍 - // 会**每个异常上报两次**(13)。 - appRunner: () => runApp(buildApp()), - ); + runApp(buildApp()); } diff --git a/app/lib/main.dart b/app/lib/main.dart new file mode 100644 index 0000000..7920b65 --- /dev/null +++ b/app/lib/main.dart @@ -0,0 +1,15 @@ +/// 应用唯一 Dart 入口。 +/// +/// Android、iOS 与 Web 用 `--flavor dev|uat|prod` 选择环境;未提供 flavor +/// 的平台(例如 DevEco 的默认 HAP 构建)统一使用 dev。 +library; + +import 'package:app/bootstrap.dart'; +import 'package:app/src/bundled_app_env.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show appFlavor; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + await bootstrap(await loadBundledAppEnv(flavor: appFlavor)); +} diff --git a/app/lib/main_dev.dart b/app/lib/main_dev.dart deleted file mode 100644 index 9040d16..0000000 --- a/app/lib/main_dev.dart +++ /dev/null @@ -1,14 +0,0 @@ -/// dev 环境入口。 -/// -/// 跑法(08): -/// ``` -/// flutter run --flavor dev -t lib/main_dev.dart --dart-define-from-file=env/dev.json -/// ``` -/// flavor 名写死在这里而不是从 dart-define 读——"用 dev 的入口配了别的环境的 -/// json"这种事故必须在代码里看得见。 -library; - -import 'package:app/bootstrap.dart'; -import 'package:core_foundation/core_foundation.dart'; - -Future main() => bootstrap(AppEnv.fromDartDefine(flavor: 'dev')); diff --git a/app/lib/main_prod.dart b/app/lib/main_prod.dart deleted file mode 100644 index 6f3b7b4..0000000 --- a/app/lib/main_prod.dart +++ /dev/null @@ -1,14 +0,0 @@ -/// prod 环境入口。 -/// -/// 跑法(08): -/// ``` -/// flutter run --flavor prod -t lib/main_prod.dart --dart-define-from-file=env/prod.json -/// ``` -/// flavor 名写死在这里而不是从 dart-define 读——"用 prod 的入口配了别的环境的 -/// json"这种事故必须在代码里看得见。 -library; - -import 'package:app/bootstrap.dart'; -import 'package:core_foundation/core_foundation.dart'; - -Future main() => bootstrap(AppEnv.fromDartDefine(flavor: 'prod')); diff --git a/app/lib/main_uat.dart b/app/lib/main_uat.dart deleted file mode 100644 index c08e309..0000000 --- a/app/lib/main_uat.dart +++ /dev/null @@ -1,14 +0,0 @@ -/// uat 环境入口。 -/// -/// 跑法(08): -/// ``` -/// flutter run --flavor uat -t lib/main_uat.dart --dart-define-from-file=env/uat.json -/// ``` -/// flavor 名写死在这里而不是从 dart-define 读——"用 uat 的入口配了别的环境的 -/// json"这种事故必须在代码里看得见。 -library; - -import 'package:app/bootstrap.dart'; -import 'package:core_foundation/core_foundation.dart'; - -Future main() => bootstrap(AppEnv.fromDartDefine(flavor: 'uat')); diff --git a/app/lib/src/bundled_app_env.dart b/app/lib/src/bundled_app_env.dart new file mode 100644 index 0000000..83f2041 --- /dev/null +++ b/app/lib/src/bundled_app_env.dart @@ -0,0 +1,29 @@ +/// 随 App 打包的环境配置加载器。 +library; + +import 'dart:convert'; + +import 'package:core_foundation/core_foundation.dart'; +import 'package:flutter/foundation.dart' show FlutterError; +import 'package:flutter/services.dart'; + +/// 根据 Flutter 的 flavor 加载对应环境配置;未指定时默认开发环境。 +Future loadBundledAppEnv({String? flavor}) async { + final String selectedFlavor = flavor ?? AppFlavor.dev.name; + final String assetPath = 'env/$selectedFlavor.json'; + + try { + final String jsonObj = await rootBundle.loadString(assetPath); + final decoded = jsonDecode(jsonObj); + if (decoded is! Map) { + throw const FormatException('根节点必须是 JSON 对象'); + } + return AppEnv.fromConfig(flavor: selectedFlavor, values: decoded.cast()); + } on StateError { + rethrow; + } on FormatException catch (error) { + throw StateError('环境配置 $assetPath 格式错误:${error.message}'); + } on FlutterError catch (error) { + throw StateError('无法加载环境配置 $assetPath:${error.message}'); + } +} diff --git a/app/lib/src/error_observer.dart b/app/lib/src/error_observer.dart index de026dd..8b9ecab 100644 --- a/app/lib/src/error_observer.dart +++ b/app/lib/src/error_observer.dart @@ -10,7 +10,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; /// /// --------------------------------------------------------------------------- /// 它是**兜底**,不是主路径:UI 该显示的错误由 `AsyncValueView` 负责,这里只 -/// 负责"这个异常有没有人处理过"之外的另一件事——落日志和上报。 +/// 负责"这个异常有没有人处理过"之外的另一件事——落日志。 /// /// 三类要**主动排除**,否则线上告警会被噪音淹没: /// - [BusinessException]:后端明确告诉我们"这个操作不允许",是预期内的流程 @@ -20,14 +20,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; /// --------------------------------------------------------------------------- final class ErrorObserver extends ProviderObserver { /// 构造。 - ErrorObserver({required this.logger, required this.reporter}); + ErrorObserver({required this.logger}); /// 日志出口。 final AppLogger logger; - /// 崩溃上报出口。 - final CrashReporter reporter; - @override void providerDidFail(ProviderObserverContext context, Object error, StackTrace stackTrace) { final String name = context.provider.name ?? context.provider.runtimeType.toString(); @@ -42,6 +39,5 @@ final class ErrorObserver extends ProviderObserver { } logger.e('provider 失败 $name', error: error, stackTrace: stackTrace); - reporter.report(error, stackTrace, extra: {'provider': name}); } } diff --git a/app/lib/src/session_observer.dart b/app/lib/src/session_observer.dart index a65d2c8..a0614c6 100644 --- a/app/lib/src/session_observer.dart +++ b/app/lib/src/session_observer.dart @@ -1,4 +1,4 @@ -/// 会话事件的旁路接线:埋点身份、崩溃上报的用户上下文、路由错误上报。 +/// 会话事件的旁路接线:埋点身份与路由错误日志。 /// /// 这些都是 core_auth 声明的端口(`session_ports.dart` / `core_router/ports.dart`) /// 的实现——core_auth 不能依赖 core_analytics / core_logging,所以实现落在这里。 @@ -9,26 +9,20 @@ import 'package:core_auth/core_auth.dart'; import 'package:core_logging/core_logging.dart'; import 'package:core_router/core_router.dart'; -/// 把会话变化广播给埋点和崩溃上报。 +/// 把会话变化广播给埋点。 class AppSessionObserver implements SessionObserver { /// 构造。 - const AppSessionObserver({required this.analytics, required this.reporter}); + const AppSessionObserver({required this.analytics}); /// 埋点。 final Analytics analytics; - /// 崩溃上报。 - final CrashReporter reporter; - @override void onUserIdentified(UserContext user) { analytics.identify(user.userId); analytics.registerSuperProperties({ AnalyticsSuperProperty.roleCode: user.roleCode, }); - // 只传 userId,不传手机号——Sentry 侧 sendDefaultPii = false 的前提就是 - // 我们自己也不往里塞 PII。 - reporter.setUser(user.userId); } @override @@ -37,7 +31,6 @@ class AppSessionObserver implements SessionObserver { analytics.registerSuperProperties({ AnalyticsSuperProperty.storeId: store.storeId, }); - reporter.setTag('storeId', '${store.storeId}'); } @override @@ -46,7 +39,6 @@ class AppSessionObserver implements SessionObserver { analytics.track(AnalyticsEvent.logout, {AnalyticsParam.reason: reason.name}); // 门店设备是共用的:不 reset,下一个人的数据会串到上一个人身上。 analytics.reset(); - reporter.clearUser(); } @override @@ -57,26 +49,18 @@ class AppSessionObserver implements SessionObserver { } } -/// 路由未命中时上报。 +/// 路由未命中时记录日志。 class AppRouteReporter implements RouteReporter { /// 构造。 - const AppRouteReporter({required this.logger, required this.reporter}); + const AppRouteReporter({required this.logger}); /// 日志。 final AppLogger logger; - /// 崩溃上报。 - final CrashReporter reporter; - @override void onRouteNotFound(String location) { // 记路径不记 query——H5 相关路径的 query 里带票据(13 §脱敏)。 final String path = Uri.tryParse(location)?.path ?? location; logger.w('路由未命中: $path'); - reporter.report( - StateError('route not found'), - StackTrace.current, - extra: {AnalyticsParam.path: path}, - ); } } diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 9b28735..861af41 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -26,7 +26,6 @@ dependencies: flutter_riverpod: 3.2.1 intl: any native_scan: ^0.1.0 - sentry_flutter: ^9.26.0 dev_dependencies: flutter_lints: ^6.0.0 @@ -34,19 +33,13 @@ dev_dependencies: sdk: flutter integration_test: sdk: flutter - sentry_dart_plugin: ^3.4.0 # 只为测试拿 InMemorySharedPreferencesAsync,不在 lib/ 里出现。 shared_preferences_platform_interface: ^2.4.2 flutter: uses-material-design: true generate: true - -# release 构建后由 CI 调用 `dart run sentry_dart_plugin` 上传符号表。 -# 见 08 §release 构建 和 13 §崩溃上报。 -sentry: - upload_debug_symbols: true - upload_source_maps: false - project: conti-retail-app - org: continental - # auth_token 只从 CI 变量 SENTRY_AUTH_TOKEN 读,绝不写进仓库 + assets: + - env/dev.json + - env/uat.json + - env/prod.json diff --git a/docs/04-routing.md b/docs/04-routing.md index 6d1a151..244570d 100644 --- a/docs/04-routing.md +++ b/docs/04-routing.md @@ -37,7 +37,7 @@ final goRouterProvider = Provider((ref) { final router = GoRouter( navigatorKey: rootNavigatorKey, // 全局 dialog / 顶层跳转需要它 initialLocation: '/home', - observers: [NavigationObserver(ref.read(crashReporterProvider))], // 崩溃前的页面路径,见 13 + observers: const [], // 路由观测由 app 层按需注入 redirect: (context, state) { // read 不是 watch:这里只要当前值,订阅由下面的 listen 负责 final auth = ref.read(authStateProvider); diff --git a/docs/07-native-integration.md b/docs/07-native-integration.md index e664601..81b6fd8 100644 --- a/docs/07-native-integration.md +++ b/docs/07-native-integration.md @@ -238,7 +238,7 @@ app/ohos/entry/src/main/ets/ **延后抽取独立 OHOS plugin。** 只有出现第二个 OHOS App 的复用需求、扫描实现和契约已稳定、Flutter_OH plugin 注册/模板已成熟并经验证,或 SDK 化收益明确超过维护成本时,才评审将 ArkTS 从 `app/ohos/entry` 抽到 `packages/native_scan/ohos`。在此之前,不添加 `ohos:` plugin 声明或额外 `ohos/` 目录。 -**SDK 收敛条件。** 当前 Android/iOS 使用 Flutter 3.44.9,OHOS 使用 Flutter_OH 3.41.10-ohos-1.0.0;Flutter_OH 上游文档计划在 **2026 年 9 月**提供 Flutter 3.44.x / Dart 3.12.x 对齐版本。实际 Tag/Commit 发布并验证可用后,可评审统一基线。升级前必须以该候选 SDK 完成依赖解析、Pigeon 与 Riverpod 生成物核验、`flutter build hap --debug -t lib\main_dev.dart` 和真机原生能力回归;不能仅因计划日期或版本号相同就替换工具链。 +**SDK 收敛条件。** 当前 Android/iOS 使用 Flutter 3.44.9,OHOS 使用 Flutter_OH 3.41.10-ohos-1.0.0;Flutter_OH 上游文档计划在 **2026 年 9 月**提供 Flutter 3.44.x / Dart 3.12.x 对齐版本。实际 Tag/Commit 发布并验证可用后,可评审统一基线。升级前必须以该候选 SDK 完成依赖解析、Pigeon 与 Riverpod 生成物核验、`flutter build hap --debug` 和真机原生能力回归;不能仅因计划日期或版本号相同就替换工具链。 ## 使用规则 diff --git a/docs/08-build-flavors.md b/docs/08-build-flavors.md index 2714bc4..f943f62 100644 --- a/docs/08-build-flavors.md +++ b/docs/08-build-flavors.md @@ -17,11 +17,11 @@ App 侧维护 **3 个 flavor:`dev` / `uat` / `prod`**,环境划分与现有 ## 使用规则 -- 每个 flavor 对应一个独立的 Dart 入口文件(`main_dev.dart`/`main_uat.dart`/`main_prod.dart`),三者都只是设置好环境标识后调用同一个共享的 `bootstrap()` 启动函数,不允许在入口文件里写业务逻辑分支。 -- 环境相关的可变配置(API base URL、是否开启日志等,见 [05-networking.md](./05-networking.md) 的 `appEnvProvider`)通过 `--dart-define-from-file=env/{flavor}.json` 注入,不写死在代码里、也不用 `if (flavor == 'dev')` 这种运行时字符串判断来分支配置。 +- 全项目只保留 `lib/main.dart` 一个 Dart 入口。Flutter 的 `appFlavor` 自动对应 `--flavor`,启动代码据此加载打包的 `env/{flavor}.json`;未传 flavor 时默认 dev,DevEco 不需要额外入口配置。 +- 环境配置只包含可公开随包发布的 API 地址、日志开关与 H5 host;不在 Dart 代码中写死,也不要求开发者为入口文件额外传 `-t` 或 `--dart-define-from-file`。 - `env/*.json` 只包含非敏感配置(API 地址等);密钥类配置(如第三方 SDK App Key)通过 CI 变量在构建时注入,不提交进仓库。 - Android 侧用 Gradle `productFlavors` 区分 `applicationIdSuffix`/图标/`versionNameSuffix`;iOS 侧用对应的 xcconfig + Scheme 区分 `Bundle Identifier`/图标;两端 flavor 名称必须完全一致(`dev`/`uat`/`prod`),不允许两端用不同命名。 -- CI 流水线阶段固定为:`melos run analyze` → `melos run test` → 按 flavor `flutter build apk/ipa --flavor {flavor} --dart-define-from-file=env/{flavor}.json` → 上传对应分发渠道。`prod` flavor 的构建触发条件是打 tag,不是每次 push 都触发(避免误发生产包)。 +- CI 流水线阶段固定为:`melos run analyze` → `melos run test` → 按 flavor `flutter build apk/ipa --flavor {flavor}` → 上传对应分发渠道。`prod` flavor 的构建触发条件是打 tag,不是每次 push 都触发(避免误发生产包)。 ## 用 `applicationIdSuffix` 而不是覆盖 `applicationId` @@ -41,15 +41,13 @@ productFlavors { ```bash fvm flutter build appbundle \ - --flavor prod --target lib/main_prod.dart \ - --dart-define-from-file=env/prod.json \ + --flavor prod \ --obfuscate --split-debug-info=build/symbols/$CI_COMMIT_TAG ``` - `--obfuscate` 混淆 Dart 符号名,`--split-debug-info` 把调试符号剥离到单独目录(同时显著减小包体)。 - **两个参数必须一起用**,只写 `--obfuscate` 不写 `--split-debug-info` 会被工具链拒绝。 -- **符号表必须归档,并且构建完立刻上传到 Sentry**:混淆后崩溃堆栈是不可读的乱码。CI 在 build 之后紧跟一条 `fvm dart run sentry_dart_plugin`,把 Dart 符号表、Android mapping、iOS dSYM 一起传上去(见 [13-observability-analytics.md](./13-observability-analytics.md))。**上传时的 `release` 必须和 App 里 `options.release` 严格一致**,对不上的表现是"传了但堆栈还是混淆的",且后台不报错。 -- 同时把 `build/symbols/` 按 `版本号+构建号` 归档为 CI artifact 保留至少 1 年,作为 Sentry 侧数据过期或服务不可用时的兜底。**丢了符号表 = 那个版本的所有线上崩溃永远无法定位**,这是个不可逆的失误。 +- 符号表必须按 `版本号+构建号` 归档为 CI artifact 保留至少 1 年,供后续排障或未来选择的崩溃平台使用。 - 符号表目录按版本号区分(用 tag 或 `versionName+versionCode`),不能所有版本堆一个目录。 ## 版本号规则 @@ -113,8 +111,7 @@ build_ios_uat: stage: build tags: [macos] # 只有 mac runner 有这个 tag script: - - fvm flutter build ipa --flavor uat --target lib/main_uat.dart \ - --dart-define-from-file=env/uat.json --export-options-plist=ios/ExportOptions-uat.plist + - fvm flutter build ipa --flavor uat --export-options-plist=ios/ExportOptions-uat.plist ``` ## 内测分发渠道:Firebase 在国内可达性存疑 @@ -154,13 +151,11 @@ Android 的 flavor 源集目录(`src/{flavor}/`)会自动生效;iOS 没有 - **iOS 构建 runner 方案(A/B/C 选哪个)**——阻塞 iOS 发版,优先级最高。 - 内测分发渠道的最终选型与托管位置。 -- 是否引入 Firebase(影响崩溃上报选型,见 [13-observability-analytics.md](./13-observability-analytics.md))。 - Android 上架渠道清单(华为/小米/OPPO/vivo 各家应用市场是否都要上,各家的加固/隐私合规要求不同)。 ## 参考链接 - [Flutter 官方 Flavors 文档](https://docs.flutter.dev/deployment/flavors) -- [--dart-define-from-file 官方说明](https://docs.flutter.dev/deployment/flavors#configuration-approaches) - [Flutter: 混淆 Dart 代码](https://docs.flutter.dev/deployment/obfuscate) - [Android: 从命令行构建并签名](https://developer.android.com/build/building-cmdline) - [fastlane match(证书管理)](https://docs.fastlane.tools/actions/match/) @@ -179,8 +174,7 @@ Android 的 flavor 源集目录(`src/{flavor}/`)会自动生效;iOS 没有 1. **Android `productFlavors`**:在 `android/app/build.gradle` 里声明多套 `applicationId`/`versionNameSuffix`/资源目录,编译时用 `--flavor` 选择其中一套。 2. **iOS Scheme + xcconfig**:iOS 没有 Gradle 那样的单文件配置,而是通过 Xcode 里多个 Build Configuration(对应不同的 `.xcconfig` 文件设置 Bundle ID 等)+ 多个 Scheme 组合实现同样的效果,`flutter build ipa --flavor xxx` 背后就是选中同名 Scheme。 -3. **Dart 入口文件(`main_xxx.dart`)**:flavor 决定的是"编译出什么样的原生外壳",Dart 代码本身默认只有一个 `main.dart` 入口——项目约定用多个入口文件对应各 flavor,让每个 flavor 能设置不同的启动参数(比如传给 `bootstrap()` 一个环境枚举)。 -4. **`--dart-define-from-file`**:flavor 解决的是原生层面的差异(图标、包名),但 API 地址这类 Dart 侧读取的配置,用编译期注入的 JSON 文件解决,避免打包进一个写死 `http://dev-api...` 的字符串常量。 +3. **Dart 入口与环境配置**:Dart 固定从 `main.dart` 启动,Flutter 自动把 `--flavor` 暴露为 `appFlavor`;启动代码按其加载内置 JSON 配置。未提供 flavor 时用 dev,适配 DevEco 的默认构建。 ### 使用示例 @@ -225,30 +219,21 @@ android { } ``` -共享启动入口 + 各 flavor 的 Dart 入口文件: +唯一 Dart 入口: ```dart -// lib/bootstrap.dart —— 三个 flavor 共用的启动逻辑 -Future bootstrap(AppEnv env) async { - runApp(ProviderScope( - overrides: [appEnvProvider.overrideWithValue(env)], - child: const App(), - )); +// lib/main.dart +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + await bootstrap(await loadBundledAppEnv(flavor: appFlavor)); } ``` -```dart -// lib/main_dev.dart -void main() => bootstrap(AppEnv.fromDartDefine(name: 'dev')); -``` - 构建命令: ```bash fvm flutter build apk \ - --flavor dev \ - --target lib/main_dev.dart \ - --dart-define-from-file=env/dev.json + --flavor dev ``` GitLab CI 片段(衔接现有 Runner;注意 Android 和 iOS 必须落到不同的 runner 上): @@ -266,7 +251,7 @@ build_android_dev: script: - melos run analyze - melos run test - - flutter build apk --flavor dev --target lib/main_dev.dart --dart-define-from-file=env/dev.json + - flutter build apk --flavor dev artifacts: paths: [build/app/outputs/flutter-apk/app-dev-release.apk] rules: @@ -276,8 +261,7 @@ build_android_prod: <<: *flutter_base stage: build script: - - flutter build appbundle --flavor prod --target lib/main_prod.dart - --dart-define-from-file=env/prod.json + - flutter build appbundle --flavor prod --build-name=${CI_COMMIT_TAG#v} --build-number=$CI_PIPELINE_ID --obfuscate --split-debug-info=build/symbols/$CI_COMMIT_TAG artifacts: @@ -292,8 +276,7 @@ build_ios_prod: stage: build tags: [macos] # 必须是 mac runner,Linux runner 跑不了,见上文「iOS 构建链路」 script: - - fvm flutter build ipa --flavor prod --target lib/main_prod.dart - --dart-define-from-file=env/prod.json + - fvm flutter build ipa --flavor prod --build-name=${CI_COMMIT_TAG#v} --build-number=$CI_PIPELINE_ID --obfuscate --split-debug-info=build/symbols/$CI_COMMIT_TAG rules: diff --git a/docs/11-store-context-and-session.md b/docs/11-store-context-and-session.md index 441b760..14f6b12 100644 --- a/docs/11-store-context-and-session.md +++ b/docs/11-store-context-and-session.md @@ -145,8 +145,7 @@ Future switchStore(int targetStoreId) async { // ── 7. 记住这次选择,供下次冷启动预选 ──────────────── await ref.read(prefsProvider).setInt('last_store_id', newStore.storeId); - // ── 8. 同步观测上下文(见 13)──────────────────────── - ref.read(crashReporterProvider).setTag('storeId', '${newStore.storeId}'); + // ── 8. 同步埋点上下文 ────────────────────────────────── ref.read(analyticsProvider).registerSuperProperties({'storeId': newStore.storeId}); // 切店事件本身由后端从 /stores/{id}/switch 的接口日志出,客户端不重复上报,见 13 } @@ -158,7 +157,7 @@ Future switchStore(int targetStoreId) async { | 3 在 5 之前 | H5 页面里可能有在途请求。先 `about:blank` 停掉,再换上下文,否则旧门店的 H5 请求会带着新门店的票据回来 | | 4 在 5 之前 | 缓存表带 `storeId`(见 06),但**清理和新上下文之间不能有窗口期**:如果先落新上下文,provider 立刻失效并重新请求,可能在清理完成前就把新数据写进去,然后被 `clearBusinessCache()` 一起删掉 | | 6 在 5 之后 | 清栈时目标页面(首页)要用新上下文渲染 | -| 8 在 5 之后 | 观测上下文要和业务上下文保持一致;漏了这一步的表现是**切店后的崩溃和埋点还挂在旧门店名下**,按门店维度分析时数据是错的,而且错得很隐蔽 | +| 8 在 5 之后 | 埋点上下文要和业务上下文保持一致;漏了这一步会让切店后的埋点还挂在旧门店名下,按门店维度分析时数据会悄悄出错 | **关于步骤 0(未完成写操作)**:切店时用户可能正在提交订单或上传图片。默认策略是**阻止切换并提示**,而不是静默取消——取消一个已经发出去的下单请求,客户端不知道服务端到底成没成。`pendingWriteProvider` 由发起写操作的 feature 自己注册/注销。 @@ -190,7 +189,6 @@ Future logout({LogoutReason reason = LogoutReason.userInitiated}) async { ref.read(analyticsProvider) ..track(AnalyticsEvent.logout, {'reason': reason.name}) // 报完再 reset,顺序不能反 ..reset(); - ref.read(crashReporterProvider).setUser(''); // 6. 兜底:清掉所有 provider 缓存 ref.invalidate(...); // 或在 ProviderScope 层重建,见下文 diff --git a/docs/13-observability-analytics.md b/docs/13-observability-analytics.md index 497b1b7..098906a 100644 --- a/docs/13-observability-analytics.md +++ b/docs/13-observability-analytics.md @@ -1,442 +1,22 @@ -# 13. 可观测性与埋点 - -## 为什么单独一篇 - -PRD §21.4 和 §22.1 有明确要求(主链路 Trace ID、H5 打开/关闭/失败事件、关键业务审计日志、9 类埋点事件),但 01-12 里完全没有落点。同时,**App 侧的可观测性是排查线上问题唯一的手段**——后端有 ELK 可以查日志,App 装在几百家门店的员工手机上,没有上报就等于全盲。 - -这一篇定三件事:**崩溃上报**、**日志规范**、**埋点规范**。 +# 13. 日志与埋点 ## 决策 -| 项 | 决策 | -|---|---| -| 崩溃上报 | **[sentry_flutter](https://pub.dev/packages/sentry_flutter) `^9.26.0`**(官方 verified publisher),配套 [sentry_dart_plugin](https://pub.dev/packages/sentry_dart_plugin) `^3.4.0` 上传符号表 | -| Sentry 部署形态 | **自建优先**(`sentry.io` SaaS 是跨境上报),**待确认** | -| 本地日志 | **[logger](https://pub.dev/packages/logger) `^2.7.0`**,封装在 `core_logging` 的 `AppLogger` 后面 | -| 客户端埋点 | **[神策 `sensors_analytics_flutter_plugin`](https://pub.dev/packages/sensors_analytics_flutter_plugin) `^4.2.3`**(官方 verified publisher `sensorsdata.cn`;团队过往项目用过,本项目待正式确认) | -| 业务埋点 | **以后端为主**,客户端只补后端看不到的那部分 | -| 链路关联 | 客户端生成 `X-Trace-Id`(见 05),写入本地日志并作为崩溃上报的自定义字段 | +首期不接入任何崩溃上报 SDK,也不向第三方传输异常、用户或门店上下文。业务代码通过 +`core_logging` 输出经过脱敏的本地日志;行为分析继续通过 `core_analytics` 的抽象接口接入。 -## 一、崩溃上报:Sentry +## 日志规则 -### 为什么不是 Bugly +- `AppLogger` 是唯一日志入口;请求日志和业务日志都必须经 `scrubber.dart` 脱敏。 +- dev 输出 debug 级别日志,uat 输出 info 及以上,prod 不输出控制台日志。 +- 日志不持久化、不上传;不保留无消费者的崩溃黑匣子缓存。 +- Provider 失败和未知路由仅记录脱敏日志。业务拒绝、登录失效和主动取消属于预期流程, + 不作为错误日志噪音处理。 -Bugly 是团队过往项目用过的方案,国内可达性没问题,本来是很自然的默认选项。**否掉它的理由只有一条,但这一条是决定性的:Bugly 没有上传 Dart 符号表的能力。** +## 埋点 -- Flutter App 的**绝大多数异常是 Dart 异常**(`setState` 期间抛错、null check、JSON 解析失败),不是原生崩溃。Bugly 只能把它们当"自定义异常"收下,存成一段字符串堆栈。 -- [08-build-flavors.md](./08-build-flavors.md) 要求 release 必须 `--obfuscate --split-debug-info`。两者相加的结果是:**线上占比最大的那一半崩溃,在 Bugly 后台是一串读不出来的混淆符号**,只能人工把堆栈拷出来跑 `flutter symbolize` 还原。 +`Analytics` 仍由 app 层注入。会话建立时设置用户与角色维度,切店时更新门店维度,登出时 +先记录登出事件再 reset,避免共用门店设备上的用户上下文串联。 -Bugly 在原生侧(Java/Kotlin 异常、SIGSEGV、ANR、iOS crash)确实做得好,能自动符号化。但它强的正好是我们占比小的那一半。 - -其余差别一并记录在此,作为决策存档: - -| | 腾讯 Bugly | Sentry | -|---|---|---| -| **Dart 异常堆栈还原** | **做不到** | **做得到**(`sentry_dart_plugin` 自动上传) | -| Flutter 官方 SDK | **没有**,只有 Android/iOS 原生 SDK,pub.dev 上只有 `flutter_bugly` 1.1.1、`bugly_pro_flutter` 0.4.21 两个 unverified 社区插件 | **有**,官方维护、13 天前刚发版 | -| 原生崩溃 | 强项,自动符号化 | 支持,mapping/dSYM 由同一个插件上传 | -| 接入成本 | 要自己写 `native_crash`(Pigeon + 几十行 Kotlin/Swift) | `pubspec.yaml` 加两行 | -| 国内可达性 | 无问题 | **自建无问题;SaaS 是跨境上报**,见下 | -| 运维成本 | 无 | 自建的话有(存储、升级、告警) | -| 账号/合同 | 本项目**没有**现成的,要新申请 | 同样要新建 | - -代价是运维:Sentry 这条路把"接入成本"换成了"部署成本"。这是这次选型唯一真正付出的东西。 - -注意最后一行:**本项目在两边都没有既有账号或合同**,所以"沿用现成的"这个通常最有分量的理由,在这次选型里不成立——两条路的启动成本都要从零算。 - -**连带影响:不再需要 `native_crash` 这个包。** [07-native-integration.md](./07-native-integration.md) 里的 Pigeon 包只剩 `native_scan` / `native_media`。 - -### 唯一还没定的:自建还是 SaaS - -这一条**必须在开工前定**,它决定的不只是可达性,还有合规: - -- **自建(推荐)**:崩溃数据不出境,门店网络下上报可靠,长期成本可控。代价是要一套内网 K8s/VM 资源和运维承接方。 -- **`sentry.io` SaaS**:零运维,但崩溃报告里带着 `userId`、`storeId`、面包屑和日志片段,属于**数据出境**,要走合规评估;同时门店网络访问境外服务的丢报率无法预估。 - -需要在讨论时明确的:有没有可用的内网资源、谁运维、以及法务对崩溃数据出境的口径。**在结论出来之前,`SENTRY_DSN` 走 `--dart-define-from-file`(见 08),代码里不写死任何地址——换 DSN 不需要改一行代码。** - -### 依赖与初始化 - -```yaml -dependencies: - sentry_flutter: ^9.26.0 - -dev_dependencies: - sentry_dart_plugin: ^3.4.0 -``` - -```dart -// main.dart —— 崩溃上报必须在最早期初始化,晚一步就漏掉启动期崩溃 -await SentryFlutter.init( - (options) { - options.dsn = env.sentryDsn; // 来自 --dart-define-from-file,见 08 - options.environment = env.flavorName; // dev / uat / prod 分开看,否则测试数据污染线上崩溃率 - options.release = '${env.appVersion}+${env.buildNumber}'; // 必须和符号表归档对得上 - options.tracesSampleRate = 0.0; // 首版不开性能追踪,见下 - options.sendDefaultPii = false; // 关键:默认不采集 IP / 请求头 / 用户信息 - options.beforeBreadcrumb = scrubBreadcrumb; // 见「脱敏」 - options.beforeSend = scrubEvent; - }, - appRunner: () => runApp(ProviderScope( - retry: (_, __) => null, - observers: [ErrorObserver()], // 见 12 - child: const ContiApp(), - )), -); -``` - -**`appRunner` 不是可选写法。** 传了它,Sentry 会自己接管 `FlutterError.onError` 和 `PlatformDispatcher.instance.onError` 并把 `runApp` 放进受保护的 error zone;**这时候再手写一遍这两个回调,结果是同一个异常上报两次**,线上崩溃数直接翻倍,是这个 SDK 最常见的接入错误。 - -业务代码仍然只依赖 `core_logging` 暴露的 `CrashReporter` 接口,不直接 import `sentry_flutter`: - -```dart -// packages/core_logging/lib/src/crash_reporter.dart -abstract interface class CrashReporter { - void setUser(String userId); - void setTag(String key, String value); - void leaveBreadcrumb(String message); - void report(Object error, StackTrace? stack, {Map extra = const {}}); -} -``` - -这一层不是为了"将来可能换 Sentry"——**是为了测试里能直接 mock 掉,不必真的初始化 SDK**,以及让 `feature_*` 不多一条对三方 SDK 的直接依赖(见 [01-project-structure.md](./01-project-structure.md) 的依赖规则)。 - -### 符号表:唯一必须打通的一步 - -`sentry_dart_plugin` 包装 `sentry-cli`,构建后一条命令把 Dart 符号表、Android mapping、iOS dSYM 一起传上去: - -```yaml -# pubspec.yaml -sentry: - upload_debug_symbols: true - upload_source_maps: false # 不做 Web - project: conti-retail-app - org: continental - # auth_token 走 CI 环境变量 SENTRY_AUTH_TOKEN,不写进仓库 -``` - -```bash -# CI:build 之后立刻跑,见 08 -fvm flutter build appbundle --flavor prod --target lib/main_prod.dart \ - --dart-define-from-file=env/prod.json \ - --obfuscate --split-debug-info=build/symbols/$CI_COMMIT_TAG -fvm dart run sentry_dart_plugin -``` - -三条硬约束: - -- **`options.release` 必须和上传符号表时的 release 严格一致**,对不上的表现是"符号表传上去了,堆栈还是混淆的"——这是接入 Sentry 最常见的坑,且后台不会报错。统一由 `versionName+versionCode` 生成(见 08 的版本号规则)。 -- **上传步骤必须在 CI 里、紧跟 build**,不能靠人工。漏传一次,那个版本的崩溃就永久读不出来。 -- **本地符号表归档照旧保留**(08 要求 ≥1 年)。Sentry 能自动还原之后它不再是唯一手段,但仍是 Sentry 服务出问题/数据过期时的兜底。 - -### 必须关掉的默认行为 - -Sentry 的默认配置面向公网 C 端产品,有几项在门店场景下不能开: - -| 项 | 结论 | -|---|---| -| `sendDefaultPii` | **false**。开了会自动带上 IP、请求头(含 `Authorization`)、用户信息 | -| Session Replay / 截图(`attachScreenshot`) | **关闭**。收银、经营分析页面上有金额和客户信息 | -| `attachViewHierarchy` | 关闭。控件树里会出现输入框内容 | -| `tracesSampleRate` | **0.0**,首版不开性能追踪。接口耗时后端已有(见 backend/08),开了只是多一份跨境流量 | -| HTTP 面包屑里的 URL | **必须脱敏**:H5 URL 的 query 里带着 `ticket`,原样进面包屑等于把 token 发出去,见「脱敏」一节 | - -### 用户与门店上下文 - -会话状态变化时同步(见 [11-store-context-and-session.md](./11-store-context-and-session.md)): - -```dart -CrashReporter.instance - ..setUser(session.user.userId) // 只传 ID,不传手机号/姓名 - ..setTag('storeId', '${session.store.storeId}') - ..setTag('roleCode', session.user.roleCode) - ..setTag('flavor', env.flavorName); -``` - -**`storeId` 一定要带。** 它能直接回答"这个崩溃是不是只发生在某几家门店"——门店设备型号和网络环境高度集中,很多崩溃是设备相关的,没有这个维度只能盲猜。 - -`traceId` 在网络相关的错误上报时作为自定义字段带上,这样一条崩溃能直接关联到后端 ELK 里的那次请求(见 [backend/08-observability.md](../../conti-backend/docs/08-observability.md))。 - -### 崩溃前的页面路径 - -崩溃报告里最有用的上下文之一是"崩之前用户在哪几个页面"。go_router 的 `observers` 挂一个 `NavigationObserver`(见 [04-routing.md](./04-routing.md)),把最近的路由变化写进环形缓冲,随崩溃一起上报: - -```dart -class NavigationObserver extends NavigatorObserver { - NavigationObserver(this._reporter); - final CrashReporter _reporter; - - @override - void didPush(Route route, Route? previous) => - _reporter.leaveBreadcrumb('nav: ${route.settings.name}'); -} -``` - -注意**记的是路由名不是完整 URL**——`/webview?target=X&ticket=...` 里带着票据(见脱敏一节)。同理再补三处业务关键节点:H5 启动/失败、门店切换、扫码。这三条链路最长、最容易出问题。 - -### 上报什么、不上报什么 - -- **上报**:未捕获的 Dart 异常、原生崩溃、ANR、Riverpod provider 抛出的异常(通过 `ErrorObserver`,即使 UI 已经优雅处理了——"用户看到了漂亮的错误页"和"不需要知道有多少人看到"是两回事,见 12)。 -- **不上报**:`RequestCancelledException`(用户正常退出页面)、`UnauthorizedException`(正常的登出流程)。这两类是业务流程的一部分,上报只会把真正的崩溃淹掉。 - -### 验证接入真的成功了 - -崩溃上报最常见的失败模式是**静默不上报**——数据没传上来,但你以为 App 很稳定。所以: - -- `core_logging` 暴露一个 `throwTestException()`,**只在 dev flavor 下可调**,每次发版前在 dev 上验证一遍 Android 和 iOS 都能在 Sentry 里看到。 -- **同时验证堆栈是不是可读的**——这一步比"能收到"更容易漏。用 `--obfuscate` 打一个 release 包、跑一遍 `sentry_dart_plugin`、再触发一次异常,确认后台显示的是 Dart 文件名行号而不是 `_x12`。`release` 对不上的话就是这个表现,见上文。 -- uat 环境跑一个迭代后,对一下"Sentry 上的错误数"和"埋点里的 `api_failed` 数量级",如果差得离谱说明有一侧漏了。 - -## 二、日志规范 - -### `AppLogger` - -```dart -// packages/core_logging/lib/src/app_logger.dart -abstract interface class AppLogger { - void d(String message, {Map? data}); - void i(String message, {Map? data}); - void w(String message, {Object? error, StackTrace? stackTrace}); - void e(String message, {Object? error, StackTrace? stackTrace}); -} -``` - -各包**不直接用 `logger` 包,也不用 `print`/`debugPrint`**,统一注入 `AppLogger`。理由:将来换日志实现只改一处;同时 `print` 在 release 下不会被剥离,是一条实打实的信息泄漏通道。 - -### 级别与环境 - -| 环境 | 级别 | 输出 | -|---|---|---| -| dev | `debug` | 控制台,带颜色和调用栈 | -| uat | `info` | 控制台 + 内存环形缓冲(最近 500 条) | -| prod | `warning` | **不输出到控制台**,只进内存环形缓冲 + 随崩溃上报 | - -**prod 不打控制台日志**:Android 上 `logcat` 是全局可读的,任何装了 adb 或第三方日志 App 的人都能看到。门店设备上这不是理论风险。 - -**内存环形缓冲**的作用是:崩溃时把最近 N 条日志一起传上去,相当于一个"黑匣子"。不落磁盘,App 退出即消失,避免日志文件成为新的泄漏面。实现上挂在 `beforeSend` 里作为 `contexts` 附加,单个事件体积有上限,所以实际带的是**最近 30 条**,不是全部 500 条。 - -### 脱敏(PRD §21.3「敏感字段脱敏」) - -```dart -// packages/core_logging/lib/src/scrubber.dart -const _sensitiveKeys = { - 'token', 'accessToken', 'refreshToken', 'ticket', 'password', - 'code', // 短信验证码 - 'phone', 'mobile', 'idCard', 'bankCard', -}; - -String maskPhone(String v) => v.length >= 11 ? '${v.substring(0, 3)}****${v.substring(7)}' : '***'; -``` - -规则: - -- **请求/响应体不整体打日志**。只打 method、path、状态码、耗时、`code`、`traceId`。真要看 body 只在 dev 下开,且过一遍脱敏器。 -- **`Authorization` 头永远不打**,一个字符都不打——打前 8 位也不行,那既足够辅助暴力破解,也足够在日志里认出是谁的 token。 -- **H5 URL 打日志前必须去掉 query**:URL 里带着 `ticket`,整条打出去等于打 token。 -- 崩溃上报前再做一遍同样的脱敏——环形缓冲里的日志会随崩溃一起传上去。 - -**同一个脱敏器要挂到 Sentry 的两个钩子上**,这是上文 `SentryFlutter.init` 里 `beforeBreadcrumb` / `beforeSend` 的实现: - -```dart -// packages/core_logging/lib/src/sentry_scrubber.dart -Breadcrumb? scrubBreadcrumb(Breadcrumb? crumb, Hint hint) { - if (crumb == null) return null; - // SDK 自动记录的 HTTP 面包屑里 url 是完整的,query 里可能带 ticket / token - final url = crumb.data?['url']; - if (url is String) { - final u = Uri.tryParse(url); - crumb.data?['url'] = u == null ? '' : u.replace(query: '').toString(); - } - return crumb; -} -``` - -**`beforeBreadcrumb` 不能漏。** Sentry 默认会自动记录所有 HTTP 请求作为面包屑,我们在 05/10 里辛苦保证的"URL 不落日志",会被这条默认行为绕过去——它不走我们的 `AppLogger`。 - -### 不采集什么 - -出于合规(个人信息保护法「最小必要」原则)和 PRD §21.3: - -| 项 | 结论 | -|---|---| -| 崩溃截图 / Session Replay / View Hierarchy | **关闭**。收银、经营分析页面上有金额和客户信息 | -| 精确位置 | 不采集。App 没有需要精确位置的功能 | -| IMEI / IDFA / MAC / AndroidID | **不采集**(见 05,`X-Device-Id` 用的是匿名安装 UUID)。**神策原生 SDK 默认会采集设备标识来生成 `distinct_id`,必须在初始化时逐项关掉**;Sentry 侧靠 `sendDefaultPii = false` | -| 通讯录、短信 | 不申请权限 | -| 用户输入的原文 | 不打日志(包括搜索关键词里可能出现的车牌、手机号) | - -这份清单要和 App 的隐私政策(PRD §10.3,由 App Backend 下发)**逐条对齐**——隐私政策里没写的,代码里就不能采。**第三方 SDK 的默认采集行为是最容易在合规审查时出问题的地方**:神策和 Sentry 的隐私说明都要单独过一遍,并且要在**用户同意隐私政策之前不初始化**(两个 SDK 都支持延迟初始化),否则「同意前不采集」这条硬要求就破了。 - -## 三、埋点:以后端为主 - -**大部分业务埋点由后端从自己的请求日志和审计日志里出,客户端不重复做一遍。** - -理由很直接:任何一个业务动作(登录、切店、下单、入库、打开 H5)都会打到 App Backend 的接口上,后端已经有 `traceId`、用户上下文、门店上下文和 `@Audited` 审计通道(见 [backend/08-observability.md](../../conti-backend/docs/08-observability.md))。客户端再报一遍,得到的是同一件事的两份数据——而且客户端那份还更不可靠(可能丢、可能延迟、可能被篡改)。 - -**这两份数据最好落到同一个地方。** 神策有服务端 SDK / 数据导入接口,后端把业务事件写进同一个神策项目的话,运营就能做「扫码失败的门店,后续下单转化率是不是更低」这种跨端漏斗;分成两套系统也能跑,但每次跨端分析都要人工对数。**这一条要和后端确认**,见待确认项。 - -### Metabase 不是神策的替代品 - -后端侧提到过 Metabase。**它和神策不冲突,也不是二选一**——两者根本不在一层: - -| | 神策 | Metabase | -|---|---|---| -| 客户端采集 SDK | **有** | **没有**,它不采集任何数据 | -| 数据来源 | 自己的 SDK / 服务端导入 | 接已有的数据库、数仓 | -| 定位 | 采集 + 管道 + 分析平台 | BI / 看板层 | - -Metabase 官网自己把 Mixpanel、PostHog、Amplitude 列为**上游集成**——由那些工具负责采集,Metabase 在导出的数据上出图。这就说明了它的位置。 - -所以合理的分工是:**神策收客户端事件;后端的业务埋点本来就在自己库里,Metabase 接上去出报表。** 后端如果已经在用 Metabase,那是个好消息而不是冲突信号——它意味着上面「两份数据落到一个地方」这条有了第二种解法:不把业务数据推进神策,而是反过来把神策的客户端事件导出到同一个库,用 Metabase 统一出图,还能直接和订单、门店主数据 join。哪一种更合适取决于后端的数仓现状,一并列进待确认项。 - -### 分工 - -| PRD §22.1 事件 | 谁来出 | 说明 | -|---|---|---| -| 登录成功/失败 | **后端** | 登录本身就是接口调用 | -| 首页曝光 | **后端** | 首页聚合接口的调用即曝光 | -| 门店切换 | **后端** | 切换接口 | -| 采购下单 | **后端** | | -| 入库成功 | **后端** | | -| 待办点击 | **后端** | 点击后会请求详情接口 | -| **扫码成功/失败** | **客户端** | 扫码是 App 原生实现(见 07),**不产生任何请求**,后端完全看不到 | -| **H5 关闭 / 异常** | **客户端** | 「打开」有 `/h5/launch` 请求后端能看到;**关闭、白屏、超时、加载失败后端看不到** | -| 客服点击 | **客户端** | 拨号、企微二维码是纯客户端行为 | - -**客户端埋点的判据只有一条:这件事会不会产生一次后端请求?不会,才由客户端上报。** - -### 客户端事件表 - -按上面的判据筛下来,客户端只需要这几个: - -| 事件 | 触发 | 关键参数 | -|---|---|---| -| `scan_succeeded` / `scan_failed` | 扫码结果 | `mode`(barcode/vin/plate)、`durationMs`、`failReason` | -| `h5_closed` | H5 页关闭 | `target`、`stayDurationMs` | -| `h5_failed` | 白屏 / 超时 / 加载失败 | `target`、`errorCode`、`elapsedMs`、`traceId` | -| `h5_first_paint` | H5 首屏完成 | `target`、`ticketMs`(换票耗时)、`loadMs`(页面加载耗时) | -| `support_clicked` | 客服入口点击 | `channel`(hotline/dealer/o2o) | -| `api_failed` | 请求失败 | `path`、`code`、`httpStatus`、`traceId` | -| `app_cold_start` | 冷启动完成 | `durationMs` | -| `logout` | 登出 | `reason`(userInitiated / tokenExpired / sessionRevoked)。**被动登出没有对应的接口调用**,见 [11](./11-store-context-and-session.md) | -| `session_restore_failed` | 冷启动恢复会话失败 | 失败阶段(读 storage / me / stores)。卡在读 secure storage 时不产生任何网络请求 | - -两条说明: - -- **`h5_first_paint` 必须把耗时拆成 `ticketMs` 和 `loadMs` 两段**。合成一个数字的话,慢了不知道该找 App Backend / F6 / 还是网络——这是这个 App 里最长的一条跨系统链路,也是最容易互相甩锅的地方。 -- **`api_failed` 客户端也要报**,虽然后端也能看到失败。因为**后端看不到"请求根本没发出去"和"响应没收到"**:超时、连接失败、DNS 失败、运营商劫持,这些在后端日志里要么完全没有记录,要么表现为一次正常的成功响应。门店网络不稳时这类失败占大头。 - -### 实现约定:神策 SDK,外面包一层 - -客户端埋点走**神策 `sensors_analytics_flutter_plugin`**:官方 verified publisher `sensorsdata.cn`,`4.2.3` 一个多月前发布,是当前维护中的官方插件——这在 pub.dev 上的国内三方 SDK 里不多见(对比 Bugly 那两个 unverified 社区插件)。**团队过往项目用过,事件模型和数据接入的坑踩过一遍**,这是选它最实在的理由。 - -#### 神策不是"开箱即用",这些活一样要干 - -先把预期摆正,否则排期一定会低估。**接了神策之后,下面这些工作量和自建一套上报是完全一样的**: - -- **事件方案设计**——事件名、属性、口径对齐。这才是埋点的大头,跟用什么 SDK 无关。 -- **`core_analytics` 的接口封装**(见下)。 -- **接入点的编排**——超级属性什么时候注册、切店后重注册、登录/登出的 ID 关联。神策给了 API,但在哪调是我们的事(见 [11-store-context-and-session.md](./11-store-context-and-session.md) 的级联清单)。 -- **私有化部署的运维**(如果走私有化)。 - -**神策真正替我们省掉的只有一件具体的事:客户端的可靠投递。** 原生 SDK 自带本地缓存、批量上报、弱网重传、后台 flush 和进程被杀后的补发。门店网络不稳,这个模块不能省,自己写的话是**容易写得看起来对、实际在丢数据**的那一类——丢了还不会有人发现。这一条就是选现成 SDK 的全部收益,其余都要照做。 - -#### 依赖与封装 - -```yaml -dependencies: - sensors_analytics_flutter_plugin: ^4.2.3 -``` - -业务代码仍然只见 `core_analytics` 的接口,不直接 import 神策: - -```dart -// packages/core_analytics/lib/src/analytics.dart -abstract interface class Analytics { - void track(String event, [Map params = const {}]); - void registerSuperProperties(Map props); // 公共属性,注册一次全局附加 - void identify(String userId); // 登录成功后调 - void reset(); // 登出时调 -} -``` - -理由和 `CrashReporter` 一样:测试里能 mock,`feature_*` 不多一条对三方 SDK 的直接依赖。**另外它也是采购未落地时的缓冲**——接口先定、事件方案先做,实现类换成一个最小的 `POST /api/v1/events/batch` 也只改一个文件(代价就是上面那条可靠投递要自己补)。 - -接入约定: - -- **公共属性用「超级属性」注册一次,不在每个调用点手写**:`storeId`、`roleCode`、`flavor`、`appVersion`、`buildNumber`。`storeId` 尤其重要——运营侧几乎所有分析都按门店维度看,靠每个调用点自己传一定会漏。**门店切换后必须重新注册**(见 [11-store-context-and-session.md](./11-store-context-and-session.md) 的级联清单)。 -- **登录/登出走 `login()` / `logout()`**:登录成功后用后端的 `userId` 关联匿名 ID,登出时断开,否则同一台设备上换人登录的数据会串到一起(门店设备是共用的,这个场景一定会发生)。 -- **埋点失败绝不能影响业务**:`track()` 内部 try-catch 兜住,任何异常只记日志不外抛。 -- **dev/uat 与 prod 必须分开**——独立项目,或至少用不同的数据接收地址。共用一个项目的话,测试数据会直接污染运营报表,且事后无法剔除。 - -#### 全埋点(AutoTrack):只开启动/退出,其余关掉 - -神策的全埋点支持 `APP_START` / `APP_END` / `APP_CLICK` / `APP_VIEW_SCREEN` 四类。我们的结论: - -| 类型 | 结论 | -|---|---| -| `APP_START` / `APP_END` | **开**。启动次数、使用时长是零成本拿到的基础指标 | -| `APP_CLICK` | **关**。Flutter 的控件树没有原生 `id`/`resource-name`,采上来的元素标识基本不可读,是纯噪音 | -| `APP_VIEW_SCREEN` | **关**。改用我们自己的 `NavigationObserver` 上报路由名——既更准,也**避免把 `/webview?target=X&ticket=...` 整条 URL 采上去**(见脱敏一节) | - -“少采一点”在这里不是保守,是因为**采上来读不懂的数据比没有更糟**:它会让报表看起来有数据,实际没法用。 - -#### H5 内部的埋点不归我们 - -F6 的 H5 页面是外部系统,页面内部的行为埋点由 F6 自己负责。**客户端只报容器级事件**(打开/关闭/失败/首屏耗时),不往 WebView 里注入神策的 JS SDK——注进去就等于我们要为别人页面里的数据质量负责,而且 JSBridge 的能力清单([10-webview-h5.md](./10-webview-h5.md) 的 12 项)里也没有埋点这一项。 - -### 命名约定 - -`snake_case`,`对象_动作` 或 `对象_动作_结果`。结果类用过去式(`succeeded`/`failed`),动作类用现在式(`clicked`)。 - -事件名和参数名一旦上线**不再改**——改名意味着历史数据断裂,运营报表要重做。要加维度就加参数。事件名统一定义为常量(`AnalyticsEvent.scanSucceeded`),不允许在调用处写字符串字面量:拼写错误编译期发现不了,在报表里表现为"这个事件怎么没数据"。 - -## 四、性能指标 - -| 指标 | 怎么测 | 目标 | -|---|---|---| -| 冷启动到首帧 | `WidgetsBinding.instance.addTimingsCallback` | < 2s | -| 冷启动到首页可用 | `main()` → 首页数据渲染完成 | < 3s | -| H5 打开耗时 | `h5_first_paint` 的 `ticketMs + loadMs` | < 3s(PRD §21.1 要求有超时策略) | -| 接口耗时 | 后端侧统计即可,客户端不重复报 | P95 < 1s | -| 帧率 | 先不做自动采集,用 DevTools 人工测关键页面 | — | - -**接口耗时不由客户端报**:后端有完整的请求日志和 Micrometer 指标(见 backend/08)。客户端唯一能补充的是"客户端观测到的耗时 - 服务端处理耗时 = 网络耗时",这个差值有价值但不是首版必须,先不做。 - -## 五、和后端审计日志的分工 - -`backend/08-observability.md` 已经有 `@Audited` 审计日志通道。**审计以服务端为准,客户端不做审计**——客户端日志可被篡改,不能作为审计依据。 - -| | 客户端埋点 | 服务端日志/审计 | -|---|---|---| -| 目的 | 补齐后端看不到的行为 | 业务分析、合规追溯 | -| 可信度 | 参考 | 权威 | -| 覆盖 | 纯客户端行为、请求失败 | 所有到达服务端的操作 | - -## 待确认项 - -- **Sentry 是自建还是用 SaaS**——这是本篇最硬的阻塞项,决定可达性和数据出境合规口径。需要明确内网资源、运维承接方、法务意见。见上文「唯一还没定的」。 -- Sentry 的 org/project 划分:dev/uat/prod 是三个 project 还是靠 `environment` 区分(建议 prod 单独一个 project,避免测试数据污染线上崩溃率告警)。 -- **公司有没有在用的神策服务?** 本项目没有现成账号。有的话拿数据接收地址即可;**没有的话开通神策是采购流程,不是配置项**,周期可能比开发长。这一项是埋点唯一的外部依赖——**但它不阻塞开工**:事件方案设计和 `core_analytics` 接口先做,这两块工作量与最终用什么 SDK 无关。真的走不通,实现类换成最小的 `POST /api/v1/events/batch`,代价是可靠投递要自己补。 -- 若确认用神策:数据接收地址是私有化部署还是神策云,以及 dev/uat/prod 的项目划分。地址走 `--dart-define-from-file`,代码里不写死。 -- **客户端事件和后端业务数据怎么汇到一起**:是后端用神策服务端 SDK 写进同一个神策项目,还是把神策的客户端事件导出到后端数仓、统一用 Metabase 出图。取决于后端数仓现状和 Metabase 的实际使用情况,要和后端一起定。 -- 神策原生 SDK 的默认设备信息采集项(`distinct_id` 的生成方式、是否取 AndroidID/IDFA),需逐项关闭并与隐私政策对齐(法务侧)。 -- **后端的业务埋点写不写进同一个神策项目**(用神策服务端 SDK / 数据导入),还是留在自己的 ELK 里出报表。影响的是能不能做跨端漏斗分析。 -- 后端从请求日志出业务埋点的具体口径(哪个接口对应哪个事件),需要和后端一起把 PRD §22.1 的 9 类事件逐条落到接口上。 -- 神策和 Sentry 都要在**用户同意隐私政策之后**才初始化,具体的延迟初始化时机要和 `feature_auth` 的协议弹窗流程对齐。 -- 性能指标目标值需在真机(门店常用的中低端 Android)实测后校准,上表是初始预期值。 - -## 参考链接 - -- [sentry_flutter | Dart package](https://pub.dev/packages/sentry_flutter) -- [sentry_dart_plugin | Dart package](https://pub.dev/packages/sentry_dart_plugin)(上传 Dart 符号表 / mapping / dSYM) -- [Sentry: Flutter Debug Symbols](https://docs.sentry.io/platforms/dart/guides/flutter/debug-symbols/) -- [Sentry: 自建(self-hosted)](https://develop.sentry.dev/self-hosted/) -- [sensors_analytics_flutter_plugin | Dart package](https://pub.dev/packages/sensors_analytics_flutter_plugin) -- [神策:Flutter 插件集成文档](https://manual.sensorsdata.cn/sa/docs/tech_sdk_client_flutter_plugin/v0300) -- [神策:Flutter 全埋点](https://manual.sensorsdata.cn/sa/docs/tech_sdk_client_flutter_auto_track/v0205) -- [Metabase](https://www.metabase.com/)(BI 层,非采集方案;官网把 Mixpanel/PostHog/Amplitude 列为上游采集集成) -- [Flutter: 混淆与 `flutter symbolize`](https://docs.flutter.dev/deployment/obfuscate) -- [logger | Dart package](https://pub.dev/packages/logger) -- [backend/08-observability.md:traceId 与审计日志](../../conti-backend/docs/08-observability.md) -- [PRD §21.4 可观测性 / §22.1 埋点](../../conti-docs/Architecture-Diagram/202606-Continental-Retail-APP-PRD.md) +如未来需要崩溃上报,须单独完成合规、数据字段、脱敏、符号表保存和 SDK 选型评审;不得 +直接恢复本项目已删除的第三方 SDK 配置。 diff --git a/packages/core_analytics/lib/src/analytics.dart b/packages/core_analytics/lib/src/analytics.dart index b87f3e9..6f14684 100644 --- a/packages/core_analytics/lib/src/analytics.dart +++ b/packages/core_analytics/lib/src/analytics.dart @@ -3,7 +3,7 @@ library; /// 业务代码唯一可见的埋点接口。 /// -/// 理由和 `CrashReporter` 一样:测试里能 mock,`feature_*` 不多一条对三方 +/// 这样测试里能 mock,`feature_*` 不多一条对三方 /// SDK 的直接依赖。**另外它也是采购未落地时的缓冲**——接口先定、事件方案 /// 先做,实现类换成一个最小的 `POST /api/v1/events/batch` 也只改一个文件。 /// diff --git a/packages/core_auth/lib/src/session_ports.dart b/packages/core_auth/lib/src/session_ports.dart index dd0515a..fe57c04 100644 --- a/packages/core_auth/lib/src/session_ports.dart +++ b/packages/core_auth/lib/src/session_ports.dart @@ -6,7 +6,7 @@ /// 11 的 `switchStore` / `logout` 伪代码直接 `ref.read` 了 /// `webViewSessionProvider`(core_webview)、`appDatabaseProvider`(core_storage)、 /// `prefsProvider`(core_storage)、`goRouterProvider`(core_router)、 -/// `crashReporterProvider`(core_logging)、`analyticsProvider`(core_analytics)。 +/// `analyticsProvider`(core_analytics)。 /// /// 但 01 的依赖约束里,`core_auth` 一条 `core_*` 出边都没有(连允许的三条例外 /// 都是别人指向它)。照抄伪代码会把 core_auth 变成整个 core 层的汇聚点, @@ -72,9 +72,8 @@ abstract interface class SessionScopedStore { /// 会话变化时需要同步的观测 / 埋点侧。 /// -/// 由 app 层用 core_logging 的 `CrashReporter` 和 core_analytics 的 `Analytics` -/// 实现。**漏了这一步的表现是切店后的崩溃和埋点还挂在旧门店名下**,按门店维度 -/// 分析时数据是错的,而且错得很隐蔽(11 步骤 8)。 +/// 由 app 层用 core_analytics 的 `Analytics` 实现。漏了这一步会让切店后的埋点 +/// 仍挂在旧门店名下,按门店维度分析时数据会悄悄出错。 abstract interface class SessionObserver { /// 门店上下文已更新。 void onStoreChanged(StoreContext store); diff --git a/packages/core_auth/test/core_auth_test.dart b/packages/core_auth/test/core_auth_test.dart index dd03a5f..b183b59 100644 --- a/packages/core_auth/test/core_auth_test.dart +++ b/packages/core_auth/test/core_auth_test.dart @@ -91,7 +91,6 @@ void main() { flavor: AppFlavor.dev, apiBaseUrl: 'https://example.test', enableLog: false, - sentryDsn: '', h5AllowedHosts: {}, ), ); diff --git a/packages/core_foundation/lib/src/env/app_env.dart b/packages/core_foundation/lib/src/env/app_env.dart index b4efcae..6591bb3 100644 --- a/packages/core_foundation/lib/src/env/app_env.dart +++ b/packages/core_foundation/lib/src/env/app_env.dart @@ -22,41 +22,36 @@ enum AppFlavor { /// **所有环境差异都收敛在这一个类里**,业务代码里不允许出现 /// `if (kDebugMode)` 或 `if (host.contains('uat'))` 这类判断。 /// -/// 值来自 `--dart-define-from-file=env/{flavor}.json`(见 08)。构建时不传这个 -/// 参数会得到空的 baseUrl,[fromDartDefine] 会直接抛错而不是让 App 带着空配置跑起来—— -/// 这种错误必须在启动瞬间暴露,而不是等第一个网络请求 404。 +/// 值来自随 App 打包的 `env/{flavor}.json`。配置无效时会在启动瞬间暴露, +/// 而不是等第一个网络请求 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'); + /// 从已解析的环境 JSON 构造,并在启动期验证所有必填字段。 + factory AppEnv.fromConfig({required String flavor, required Map values}) { + final Object? apiBaseUrl = values['API_BASE_URL']; + final Object? enableLog = values['ENABLE_LOG']; + final Object? h5AllowedHosts = values['H5_ALLOWED_HOSTS']; - if (apiBaseUrl.isEmpty) { - throw StateError( - 'API_BASE_URL 为空。启动时必须带上 --dart-define-from-file=env/$flavor.json,' - '见 README「怎么跑起来」。', - ); + if (apiBaseUrl is! String || apiBaseUrl.trim().isEmpty) { + throw StateError('环境 $flavor 的 API_BASE_URL 必须是非空字符串。'); + } + if (enableLog is! bool) { + throw StateError('环境 $flavor 的 ENABLE_LOG 必须是布尔值。'); + } + if (h5AllowedHosts is! String) { + throw StateError('环境 $flavor 的 H5_ALLOWED_HOSTS 必须是字符串。'); } return AppEnv( flavor: AppFlavor.parse(flavor), apiBaseUrl: apiBaseUrl, enableLog: enableLog, - sentryDsn: sentryDsn, h5AllowedHosts: _parseHosts(h5AllowedHosts), ); } @@ -75,9 +70,6 @@ class AppEnv { /// 是否输出网络日志和 debug 级日志。prod 必须为 false(见 13)。 final bool enableLog; - /// 为空表示不启用 Sentry(dev 默认不上报,避免把开发期噪音混进线上数据)。 - final String sentryDsn; - /// H5 域名白名单。WebView 只允许加载这些域名及其子域,见 10。 final Set h5AllowedHosts; diff --git a/packages/core_foundation/test/app_exception_test.dart b/packages/core_foundation/test/app_exception_test.dart index d2e4f4d..e762428 100644 --- a/packages/core_foundation/test/app_exception_test.dart +++ b/packages/core_foundation/test/app_exception_test.dart @@ -44,12 +44,48 @@ void main() { flavor: AppFlavor.dev, apiBaseUrl: 'https://api-dev.conti.com', enableLog: true, - sentryDsn: '', h5AllowedHosts: {'f6.conti.com'}, ); expect(env.isProd, isFalse); expect(env.flavorSuffixVisible, isTrue); expect(env.h5AllowedHosts, contains('f6.conti.com')); }); + + test('从内置环境配置读取字段并规范化 H5 域名', () { + final AppEnv env = AppEnv.fromConfig( + flavor: 'uat', + values: { + 'API_BASE_URL': 'https://api-uat.conti.com', + 'ENABLE_LOG': true, + 'H5_ALLOWED_HOSTS': ' H5.Conti.com, shop.conti.com ', + }, + ); + + expect(env.flavor, AppFlavor.uat); + expect(env.apiBaseUrl, 'https://api-uat.conti.com'); + expect(env.enableLog, isTrue); + expect(env.h5AllowedHosts, {'h5.conti.com', 'shop.conti.com'}); + }); + + test('缺失或类型错误的内置配置在启动期失败', () { + expect( + () => AppEnv.fromConfig( + flavor: 'dev', + values: {'API_BASE_URL': '', 'ENABLE_LOG': true, 'H5_ALLOWED_HOSTS': ''}, + ), + throwsStateError, + ); + expect( + () => AppEnv.fromConfig( + flavor: 'dev', + values: { + 'API_BASE_URL': 'https://api-dev.conti.com', + 'ENABLE_LOG': 'true', + 'H5_ALLOWED_HOSTS': '', + }, + ), + throwsStateError, + ); + }); }); } diff --git a/packages/core_logging/lib/core_logging.dart b/packages/core_logging/lib/core_logging.dart index 1e4053d..093df1f 100644 --- a/packages/core_logging/lib/core_logging.dart +++ b/packages/core_logging/lib/core_logging.dart @@ -1,19 +1,11 @@ -/// 日志与崩溃上报的统一入口。来源:conti-docs/13-observability-analytics.md。 +/// 日志与敏感信息脱敏的统一入口。 /// /// 边界约定: -/// - 业务代码只用 [AppLogger] / [CrashReporter] 两个接口,**不直接 import -/// `logger` 或 `sentry_flutter`**,也不用 `print` / `debugPrint`; -/// - 全仓库只有本包的 `sentry_*.dart` 和 `app/bootstrap.dart` 可以见到 -/// Sentry SDK。 +/// - 业务代码只用 [AppLogger],不直接 import `logger`,也不用 `print` / +/// `debugPrint`。 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'; diff --git a/packages/core_logging/lib/src/crash_breadcrumb_observer.dart b/packages/core_logging/lib/src/crash_breadcrumb_observer.dart deleted file mode 100644 index df849c6..0000000 --- a/packages/core_logging/lib/src/crash_breadcrumb_observer.dart +++ /dev/null @@ -1,38 +0,0 @@ -/// 路由面包屑观察者。来源: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 route, Route? previousRoute) => _leave('push', route); - - @override - void didPop(Route route, Route? previousRoute) => _leave('pop', route); - - @override - void didReplace({Route? newRoute, Route? oldRoute}) { - if (newRoute != null) _leave('replace', newRoute); - } - - void _leave(String action, Route route) { - // name 为空时用 '',绝不回退到 route.settings.arguments 或 - // 完整 location——那两个都可能带业务参数。 - _reporter.leaveBreadcrumb('nav: $action ${route.settings.name ?? ''}'); - } -} diff --git a/packages/core_logging/lib/src/crash_reporter.dart b/packages/core_logging/lib/src/crash_reporter.dart deleted file mode 100644 index 495eec4..0000000 --- a/packages/core_logging/lib/src/crash_reporter.dart +++ /dev/null @@ -1,56 +0,0 @@ -/// 崩溃上报门面。来源: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 extra = const {}, - }); -} - -/// 测试与未接入环境用的空实现。 -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 extra = const {}, - }) {} -} diff --git a/packages/core_logging/lib/src/log_buffer.dart b/packages/core_logging/lib/src/log_buffer.dart deleted file mode 100644 index 3ac4c22..0000000 --- a/packages/core_logging/lib/src/log_buffer.dart +++ /dev/null @@ -1,57 +0,0 @@ -/// 内存环形缓冲——崩溃时的"黑匣子"。来源: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 _records = []; - - /// 追加一条,超出容量时丢弃最旧的。 - void add(LogRecord record) { - _records.add(record); - if (_records.length > capacity) { - _records.removeRange(0, _records.length - capacity); - } - } - - /// 取最近 [count] 条,按时间正序。 - /// - /// 崩溃上报时实际带走的是 30 条而不是全部 500 条——单个 Sentry 事件的 - /// 体积有上限,超了整条事件会被丢弃,那比少带日志更糟。 - List recent([int count = 30]) { - if (_records.length <= count) return List.unmodifiable(_records); - return List.unmodifiable(_records.sublist(_records.length - count)); - } - - /// 当前条数。 - int get length => _records.length; - - /// 清空。登出时调用,避免上一个账号的日志被下一个账号的崩溃带走。 - void clear() => _records.clear(); -} diff --git a/packages/core_logging/lib/src/logger_app_logger.dart b/packages/core_logging/lib/src/logger_app_logger.dart index 3fab85e..0250628 100644 --- a/packages/core_logging/lib/src/logger_app_logger.dart +++ b/packages/core_logging/lib/src/logger_app_logger.dart @@ -5,7 +5,6 @@ import 'package:core_foundation/core_foundation.dart'; import 'package:logger/logger.dart'; import 'app_logger.dart'; -import 'log_buffer.dart'; import 'scrubber.dart'; /// 日志级别,按严重程度递增。 @@ -33,14 +32,12 @@ enum LogSeverity { /// 三条硬约束(13 §二): /// 1. **prod 不输出到控制台**——Android 的 `logcat` 是全局可读的,门店设备上 /// 这不是理论风险; -/// 2. 所有内容进环形缓冲前**必须过一遍脱敏器**,因为缓冲会随崩溃外发; +/// 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}) + LoggerAppLogger({required AppEnv env, Logger? logger}) : _minSeverity = _severityFor(env.flavor), // enableLog 是 dart-define 里的开关,prod 即使误开也不打控制台。 _console = env.enableLog && !env.isProd, @@ -64,9 +61,6 @@ class LoggerAppLogger implements AppLogger { AppFlavor.prod => LogSeverity.warning, }; - /// 崩溃时随事件外发的"黑匣子"。与 `beforeSend` 读的必须是同一个实例。 - final LogBuffer buffer; - final LogSeverity _minSeverity; final bool _console; final Logger _logger; @@ -98,17 +92,6 @@ class LoggerAppLogger implements AppLogger { 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: diff --git a/packages/core_logging/lib/src/providers.dart b/packages/core_logging/lib/src/providers.dart index 88810c0..8382f6f 100644 --- a/packages/core_logging/lib/src/providers.dart +++ b/packages/core_logging/lib/src/providers.dart @@ -4,14 +4,6 @@ library; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'app_logger.dart'; -import 'crash_reporter.dart'; -import 'log_buffer.dart'; - -/// 环形缓冲的唯一实例。 -/// -/// `AppLogger` 往里写、`beforeSend` 从里读,两边必须是同一个对象, -/// 否则崩溃上报里带的是一份空日志。 -final Provider logBufferProvider = Provider((Ref ref) => LogBuffer()); /// 全局日志入口。 /// @@ -21,8 +13,3 @@ final Provider logBufferProvider = Provider((Ref ref) => L final Provider appLoggerProvider = Provider( (Ref ref) => const NoopAppLogger(), ); - -/// 全局崩溃上报入口。默认空实现,同 [appLoggerProvider]。 -final Provider crashReporterProvider = Provider( - (Ref ref) => const NoopCrashReporter(), -); diff --git a/packages/core_logging/lib/src/scrubber.dart b/packages/core_logging/lib/src/scrubber.dart index 206f56c..c576bb3 100644 --- a/packages/core_logging/lib/src/scrubber.dart +++ b/packages/core_logging/lib/src/scrubber.dart @@ -1,7 +1,7 @@ -/// 脱敏器。来源:13 §二「脱敏」、PRD §21.3。 +/// 脱敏器。来源:PRD §21.3。 /// -/// 这个文件是全仓库唯一的脱敏实现,`AppLogger`、Sentry 的两个钩子、以及 -/// 网络层的日志拦截器都必须走这里,不允许各写各的。 +/// 这个文件是全仓库唯一的脱敏实现,`AppLogger` 与网络层的日志拦截器都必须 +/// 走这里,不允许各写各的。 library; /// 命中即整体替换为 [redacted] 的 key(大小写不敏感)。 diff --git a/packages/core_logging/lib/src/sentry_crash_reporter.dart b/packages/core_logging/lib/src/sentry_crash_reporter.dart deleted file mode 100644 index 706f464..0000000 --- a/packages/core_logging/lib/src/sentry_crash_reporter.dart +++ /dev/null @@ -1,71 +0,0 @@ -/// `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 extra = const {}, - }) { - unawaited( - Sentry.captureException( - error, - stackTrace: stack, - withScope: (Scope scope) async { - if (extra.isNotEmpty) { - // traceId 放这里——一条崩溃能直接关联到后端 ELK 里的那次请求。 - await scope.setContexts('app_extra', extra); - } - }, - ).then((_) {}).catchError((_) {}), - ); - } - - void _configure(FutureOr Function(Scope) callback) { - unawaited(() async { - try { - await Sentry.configureScope(callback); - } on Object { - // 上报链路自身的异常不能外溢到业务流程。 - } - }()); - } -} diff --git a/packages/core_logging/lib/src/sentry_scrubber.dart b/packages/core_logging/lib/src/sentry_scrubber.dart deleted file mode 100644 index f42eae7..0000000 --- a/packages/core_logging/lib/src/sentry_scrubber.dart +++ /dev/null @@ -1,43 +0,0 @@ -/// 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'] = { - 'recent': buffer.recent().map((LogRecord r) => r.toString()).toList(growable: false), - }; - }; -} diff --git a/packages/core_logging/lib/src/test_exception.dart b/packages/core_logging/lib/src/test_exception.dart deleted file mode 100644 index d54e2fa..0000000 --- a/packages/core_logging/lib/src/test_exception.dart +++ /dev/null @@ -1,17 +0,0 @@ -/// 上报链路的自检入口。来源: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 接入自检:这是一条人为触发的测试异常'); -} diff --git a/packages/core_logging/pubspec.yaml b/packages/core_logging/pubspec.yaml index c60bd1c..8cbea6d 100644 --- a/packages/core_logging/pubspec.yaml +++ b/packages/core_logging/pubspec.yaml @@ -1,5 +1,5 @@ name: core_logging -description: 日志与崩溃上报的统一入口(AppLogger / CrashReporter),含敏感信息脱敏。 +description: 应用日志与敏感信息脱敏。 publish_to: none version: 0.1.0 resolution: workspace @@ -13,7 +13,6 @@ dependencies: sdk: flutter flutter_riverpod: 3.2.1 logger: ^2.7.0 - sentry_flutter: ^9.26.0 dev_dependencies: flutter_lints: ^6.0.0 diff --git a/packages/core_logging/test/core_logging_test.dart b/packages/core_logging/test/core_logging_test.dart index eeef0df..9392808 100644 --- a/packages/core_logging/test/core_logging_test.dart +++ b/packages/core_logging/test/core_logging_test.dart @@ -53,85 +53,19 @@ void main() { }); }); - 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), ['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 {}, ); - 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), ['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: {'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 {}, - ); - - test('dev 抛测试异常', () { - expect(() => throwTestException(envOf(AppFlavor.dev)), throwsA(isA())); - }); - - test('prod 拒绝调用', () { - expect(() => throwTestException(envOf(AppFlavor.prod)), throwsA(isA())); + test('各环境均可创建日志器', () { + for (final AppFlavor flavor in AppFlavor.values) { + final AppLogger log = LoggerAppLogger(env: envOf(flavor)); + log.i('login', data: {'phone': '13812345678'}); + } }); }); } diff --git a/pubspec.lock b/pubspec.lock index 60029c2..f478771 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -33,14 +33,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.2+1" - ansicolor: - dependency: transitive - description: - name: ansicolor - sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f" - url: "https://pub.dev" - source: hosted - version: "2.0.3" args: dependency: transitive description: @@ -407,14 +399,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" - globbing: - dependency: transitive - description: - name: globbing - sha256: "4f89cfaf6fa74c9c1740a96259da06bd45411ede56744e28017cc534a12b6e2d" - url: "https://pub.dev" - source: hosted - version: "1.0.0" go_router: dependency: transitive description: @@ -463,14 +447,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" - injector: - dependency: transitive - description: - name: injector - sha256: ed389bed5b48a699d5b9561c985023d0d5cc88dd5ff2237aadcce5a5ab433e4e - url: "https://pub.dev" - source: hosted - version: "3.0.0" integration_test: dependency: transitive description: flutter @@ -492,14 +468,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" - jni: - dependency: transitive - description: - name: jni - sha256: d2c361082d554d4593c3012e26f6b188f902acd291330f13d6427641a92b3da1 - url: "https://pub.dev" - source: hosted - version: "0.14.2" json_annotation: dependency: transitive description: @@ -644,22 +612,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" - package_info_plus: - dependency: transitive - description: - name: package_info_plus - sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481" - url: "https://pub.dev" - source: hosted - version: "10.2.1" - package_info_plus_platform_interface: - dependency: transitive - description: - name: package_info_plus_platform_interface - sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4 - url: "https://pub.dev" - source: hosted - version: "4.1.0" path: dependency: transitive description: @@ -772,14 +724,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" - properties: - dependency: transitive - description: - name: properties - sha256: "333f427dd4ed07bdbe8c75b9ff864a1e70b5d7a8426a2e8bdd457b65ae5ac598" - url: "https://pub.dev" - source: hosted - version: "2.1.1" pub_semver: dependency: transitive description: @@ -844,30 +788,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.3" - sentry: - dependency: transitive - description: - name: sentry - sha256: c2ecd8abe82e63cdcb6947f71320612ced56e10ba94db7529d85cc02be47cb3b - url: "https://pub.dev" - source: hosted - version: "9.27.0" - sentry_dart_plugin: - dependency: transitive - description: - name: sentry_dart_plugin - sha256: da9c1d0b3c87a251bfc36301f16af090a88c2d59128fe9a6f908f5ac20340c97 - url: "https://pub.dev" - source: hosted - version: "3.4.0" - sentry_flutter: - dependency: transitive - description: - name: sentry_flutter - sha256: ec89cc6ba939ca19155ea83900d9740a36544f50b3b6baf265518e3348fb0f50 - url: "https://pub.dev" - source: hosted - version: "9.27.0" shared_preferences: dependency: transitive description: @@ -1041,14 +961,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.1" - system_info2: - dependency: transitive - description: - name: system_info2 - sha256: b937736ecfa63c45b10dde1ceb6bb30e5c0c340e14c441df024150679d65ac43 - url: "https://pub.dev" - source: hosted - version: "4.1.0" term_glyph: dependency: transitive description: