app scaffold
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
/// 三个入口共用的启动编排。
|
||||
///
|
||||
/// 来源:08(多环境)、13(Sentry / 日志)、12(全局错误)、03(Riverpod 装配)。
|
||||
library;
|
||||
|
||||
import 'package:app/src/app_widget.dart';
|
||||
import 'package:app/src/device_id.dart';
|
||||
import 'package:app/src/error_observer.dart';
|
||||
import 'package:app/src/h5_launch_repository.dart';
|
||||
import 'package:app/src/session_observer.dart';
|
||||
import 'package:core_analytics/core_analytics.dart';
|
||||
import 'package:core_auth/core_auth.dart';
|
||||
import 'package:core_foundation/core_foundation.dart';
|
||||
import 'package:core_logging/core_logging.dart';
|
||||
import 'package:core_network/core_network.dart';
|
||||
import 'package:core_router/core_router.dart';
|
||||
import 'package:core_storage/core_storage.dart';
|
||||
import 'package:core_webview/core_webview.dart';
|
||||
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 都只调这一个函数。
|
||||
///
|
||||
/// ---------------------------------------------------------------------------
|
||||
/// 这个函数是**整个仓库唯一一处知道所有包的地方**。各个 core_* 只声明自己需要
|
||||
/// 什么(端口 + provider),谁来满足它在这里决定——这就是那些
|
||||
/// `throw UnimplementedError('必须在 bootstrap 里 override')` 的兑现点。
|
||||
///
|
||||
/// 每加一个 override 就等于在编译期之外多了一个"忘了接就炸"的风险,所以下面
|
||||
/// 每一条都标注了它兑现的是哪个端口。
|
||||
/// ---------------------------------------------------------------------------
|
||||
Future<void> bootstrap(AppEnv env) async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
// 装一次全局单例,供拿不到 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 Prefs prefs = Prefs();
|
||||
final ClientInfo clientInfo = ClientInfo(
|
||||
appVersion: _appVersion,
|
||||
deviceId: await loadOrCreateDeviceId(prefs),
|
||||
);
|
||||
|
||||
// 12 §五:默认的红屏在 release 里是白屏加一行英文,用户只会以为 App 坏了。
|
||||
ErrorWidget.builder = (FlutterErrorDetails details) => env.isProd
|
||||
? const Material(child: Center(child: Text('页面出了点问题,请退出重试')))
|
||||
: ErrorWidget(details.exception);
|
||||
|
||||
Widget buildApp() {
|
||||
return ProviderScope(
|
||||
observers: <ProviderObserver>[ErrorObserver(logger: logger, reporter: reporter)],
|
||||
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 的这一层 --------
|
||||
sessionRemoteProvider.overrideWith(
|
||||
(Ref ref) => ref.watch(authRepositoryProvider), // core_auth ← feature_auth
|
||||
),
|
||||
h5LaunchRepositoryProvider.overrideWith(
|
||||
(Ref ref) => ApiH5LaunchRepository(ref.watch(apiClientProvider)), // core_webview ← app
|
||||
),
|
||||
|
||||
// --- 会话级联的参与者 ---------------------------------------------
|
||||
// 登出/切店时要被清掉的东西在这里登记。**漏登记 = 上一个用户的数据
|
||||
// 留在设备上**,而门店设备是共用的(11)。
|
||||
sessionScopedStoresProvider.overrideWith(
|
||||
(Ref ref) => <SessionScopedStore>[ref.watch(webViewSessionProvider)],
|
||||
),
|
||||
sessionObserversProvider.overrideWith(
|
||||
(Ref ref) => <SessionObserver>[
|
||||
AppSessionObserver(
|
||||
analytics: ref.watch(analyticsProvider),
|
||||
reporter: ref.watch(crashReporterProvider),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// --- 路由聚合:core_router 不认识任何 feature,在这里拼 -------------
|
||||
appRoutesProvider.overrideWith(
|
||||
(Ref ref) => <RouteBase>[...buildAuthRoutes(), ...buildHomeRoutes()],
|
||||
),
|
||||
navigatorObserversProvider.overrideWith(
|
||||
(Ref ref) => <NavigatorObserver>[
|
||||
CrashBreadcrumbObserver(ref.watch(crashReporterProvider)),
|
||||
],
|
||||
),
|
||||
routeReporterProvider.overrideWith(
|
||||
(Ref ref) => AppRouteReporter(
|
||||
logger: ref.watch(appLoggerProvider),
|
||||
reporter: ref.watch(crashReporterProvider),
|
||||
),
|
||||
),
|
||||
|
||||
// --- 日志出口:core_network 只知道"往这里写字符串" -------------------
|
||||
apiLogSinkProvider.overrideWith((Ref ref) {
|
||||
final AppLogger sink = ref.watch(appLoggerProvider);
|
||||
return (String message) => sink.d(message);
|
||||
}),
|
||||
|
||||
// TODO(analytics): 神策采购未落地,暂用 NoopAnalytics(core_analytics 的默认值)。
|
||||
// 接入时在这里 override,且必须在**用户同意隐私政策之后**才初始化 SDK(13)。
|
||||
],
|
||||
child: const ContiApp(),
|
||||
);
|
||||
}
|
||||
|
||||
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()),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
|
||||
import 'app_localizations_zh.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
|
||||
/// Callers can lookup localized strings with an instance of AppLocalizations
|
||||
/// returned by `AppLocalizations.of(context)`.
|
||||
///
|
||||
/// Applications need to include `AppLocalizations.delegate()` in their app's
|
||||
/// `localizationDelegates` list, and the locales they support in the app's
|
||||
/// `supportedLocales` list. For example:
|
||||
///
|
||||
/// ```dart
|
||||
/// import 'l10n/app_localizations.dart';
|
||||
///
|
||||
/// return MaterialApp(
|
||||
/// localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
/// supportedLocales: AppLocalizations.supportedLocales,
|
||||
/// home: MyApplicationHome(),
|
||||
/// );
|
||||
/// ```
|
||||
///
|
||||
/// ## Update pubspec.yaml
|
||||
///
|
||||
/// Please make sure to update your pubspec.yaml to include the following
|
||||
/// packages:
|
||||
///
|
||||
/// ```yaml
|
||||
/// dependencies:
|
||||
/// # Internationalization support.
|
||||
/// flutter_localizations:
|
||||
/// sdk: flutter
|
||||
/// intl: any # Use the pinned version from flutter_localizations
|
||||
///
|
||||
/// # Rest of dependencies
|
||||
/// ```
|
||||
///
|
||||
/// ## iOS Applications
|
||||
///
|
||||
/// iOS applications define key application metadata, including supported
|
||||
/// locales, in an Info.plist file that is built into the application bundle.
|
||||
/// To configure the locales supported by your app, you’ll need to edit this
|
||||
/// file.
|
||||
///
|
||||
/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file.
|
||||
/// Then, in the Project Navigator, open the Info.plist file under the Runner
|
||||
/// project’s Runner folder.
|
||||
///
|
||||
/// Next, select the Information Property List item, select Add Item from the
|
||||
/// Editor menu, then select Localizations from the pop-up menu.
|
||||
///
|
||||
/// Select and expand the newly-created Localizations item then, for each
|
||||
/// locale your application supports, add a new item and select the locale
|
||||
/// you wish to add from the pop-up menu in the Value field. This list should
|
||||
/// be consistent with the languages listed in the AppLocalizations.supportedLocales
|
||||
/// property.
|
||||
abstract class AppLocalizations {
|
||||
AppLocalizations(String locale) : localeName = intl.Intl.canonicalizedLocale(locale.toString());
|
||||
|
||||
final String localeName;
|
||||
|
||||
static AppLocalizations? of(BuildContext context) {
|
||||
return Localizations.of<AppLocalizations>(context, AppLocalizations);
|
||||
}
|
||||
|
||||
static const LocalizationsDelegate<AppLocalizations> delegate = _AppLocalizationsDelegate();
|
||||
|
||||
/// A list of this localizations delegate along with the default localizations
|
||||
/// delegates.
|
||||
///
|
||||
/// Returns a list of localizations delegates containing this delegate along with
|
||||
/// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
|
||||
/// and GlobalWidgetsLocalizations.delegate.
|
||||
///
|
||||
/// Additional delegates can be added by appending to this list in
|
||||
/// MaterialApp. This list does not have to be used at all if a custom list
|
||||
/// of delegates is preferred or required.
|
||||
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates =
|
||||
<LocalizationsDelegate<dynamic>>[
|
||||
delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
];
|
||||
|
||||
/// A list of this localizations delegate's supported locales.
|
||||
static const List<Locale> supportedLocales = <Locale>[Locale('zh')];
|
||||
|
||||
/// App 名称。目前只有这一条——其余文案等 16-i18n.md 定了方案再统一迁入。
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'大陆马门店'**
|
||||
String get appTitle;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations> {
|
||||
const _AppLocalizationsDelegate();
|
||||
|
||||
@override
|
||||
Future<AppLocalizations> load(Locale locale) {
|
||||
return SynchronousFuture<AppLocalizations>(lookupAppLocalizations(locale));
|
||||
}
|
||||
|
||||
@override
|
||||
bool isSupported(Locale locale) => <String>['zh'].contains(locale.languageCode);
|
||||
|
||||
@override
|
||||
bool shouldReload(_AppLocalizationsDelegate old) => false;
|
||||
}
|
||||
|
||||
AppLocalizations lookupAppLocalizations(Locale locale) {
|
||||
// Lookup logic when only language code is specified.
|
||||
switch (locale.languageCode) {
|
||||
case 'zh':
|
||||
return AppLocalizationsZh();
|
||||
}
|
||||
|
||||
throw FlutterError(
|
||||
'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely '
|
||||
'an issue with the localizations generation tool. Please file an issue '
|
||||
'on GitHub with a reproducible sample app and the gen-l10n configuration '
|
||||
'that was used.',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// ignore: unused_import
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
import 'app_localizations.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
|
||||
/// The translations for Chinese (`zh`).
|
||||
class AppLocalizationsZh extends AppLocalizations {
|
||||
AppLocalizationsZh([String locale = 'zh']) : super(locale);
|
||||
|
||||
@override
|
||||
String get appTitle => '大陆马门店';
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"@@locale": "zh",
|
||||
"appTitle": "大陆马门店",
|
||||
"@appTitle": {
|
||||
"description": "App 名称。目前只有这一条——其余文案等 16-i18n.md 定了方案再统一迁入。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/// 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<void> main() => bootstrap(AppEnv.fromDartDefine(flavor: 'dev'));
|
||||
@@ -0,0 +1,14 @@
|
||||
/// 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<void> main() => bootstrap(AppEnv.fromDartDefine(flavor: 'prod'));
|
||||
@@ -0,0 +1,14 @@
|
||||
/// 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<void> main() => bootstrap(AppEnv.fromDartDefine(flavor: 'uat'));
|
||||
@@ -0,0 +1,58 @@
|
||||
/// 根 Widget。
|
||||
library;
|
||||
|
||||
import 'package:core_auth/core_auth.dart';
|
||||
import 'package:core_router/core_router.dart';
|
||||
import 'package:core_ui/core_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// 应用根。
|
||||
///
|
||||
/// 壳工程只做组装:路由来自 core_router,主题来自 core_ui,页面来自 feature_*。
|
||||
/// **这里不应该出现任何业务逻辑**——一旦出现,它就没有能承载它的包了。
|
||||
class ContiApp extends ConsumerWidget {
|
||||
/// 构造。
|
||||
const ContiApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final GoRouter router = ref.watch(goRouterProvider);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 会话指纹:用户 + 门店。11 §切店级联的最后一步——把整棵页面子树按这个
|
||||
// key 重建,扔掉所有 StatefulWidget 里攒着的门店维度状态。
|
||||
//
|
||||
// 光 invalidate provider 是不够的:翻页页码、已勾选的行、输入框里半截的
|
||||
// 单号都活在 State 里,provider 层看不见它们。切完店留着上一家店的选中
|
||||
// 状态,会直接变成"给 A 店的单据提交到 B 店"。
|
||||
// ------------------------------------------------------------------
|
||||
final String sessionKey = ref.watch(
|
||||
sessionProvider.select(
|
||||
(AsyncValue<AppSession> value) => switch (value.value) {
|
||||
SessionActive(:final UserContext user, :final StoreContext store) =>
|
||||
'u${user.userId}-s${store.storeId}',
|
||||
_ => 'anonymous',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return MaterialApp.router(
|
||||
title: '大陆马门店',
|
||||
theme: AppTheme.light,
|
||||
darkTheme: AppTheme.dark,
|
||||
routerConfig: router,
|
||||
// 16-i18n.md 还没写,但结构先留着:首版之后再补代价高得多。
|
||||
// 文案暂时直接写在 Widget 里,等 arb 方案定了统一迁移(见 lib/l10n/)。
|
||||
localizationsDelegates: const <LocalizationsDelegate<Object>>[
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: const <Locale>[Locale('zh', 'CN')],
|
||||
builder: (BuildContext context, Widget? child) =>
|
||||
KeyedSubtree(key: ValueKey<String>(sessionKey), child: child ?? const SizedBox.shrink()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/// 安装级匿名设备 ID。
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:core_storage/core_storage.dart';
|
||||
|
||||
/// 首次安装时生成、之后一直复用的随机 ID。
|
||||
///
|
||||
/// ---------------------------------------------------------------------------
|
||||
/// **绝不是 IMEI / IDFA / MAC / AndroidID**。这几个是设备唯一标识,采集它们是
|
||||
/// 合规红线(05 / 07 的隐私清单),而且 Android 10+ / iOS 早就限制了读取。
|
||||
///
|
||||
/// 这里的语义是"这次安装":卸载重装换一个新 ID 是**预期行为**,不需要跨安装
|
||||
/// 追踪——它的用途只有一个,把同一台设备的日志串起来排查问题。
|
||||
/// ---------------------------------------------------------------------------
|
||||
Future<String> loadOrCreateDeviceId(Prefs prefs) async {
|
||||
const String key = 'device_id';
|
||||
final String? existing = await prefs.getString(key);
|
||||
if (existing != null && existing.isNotEmpty) {
|
||||
return existing;
|
||||
}
|
||||
final Random random = Random.secure();
|
||||
final List<int> bytes = List<int>.generate(16, (int _) => random.nextInt(256));
|
||||
final String created = base64Url.encode(bytes).replaceAll('=', '');
|
||||
await prefs.setString(key, created);
|
||||
return created;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/// Provider 层的全局错误出口。来源:conti-docs/12-error-and-api-contract.md §五。
|
||||
library;
|
||||
|
||||
import 'package:core_foundation/core_foundation.dart';
|
||||
import 'package:core_logging/core_logging.dart';
|
||||
import 'package:core_ui/core_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// 所有 provider 抛出的异常都会经过这里。
|
||||
///
|
||||
/// ---------------------------------------------------------------------------
|
||||
/// 它是**兜底**,不是主路径:UI 该显示的错误由 `AsyncValueView` 负责,这里只
|
||||
/// 负责"这个异常有没有人处理过"之外的另一件事——落日志和上报。
|
||||
///
|
||||
/// 三类要**主动排除**,否则线上告警会被噪音淹没:
|
||||
/// - [BusinessException]:后端明确告诉我们"这个操作不允许",是预期内的流程
|
||||
/// 分支(余额不足、单据已关闭),不是缺陷;
|
||||
/// - [UnauthorizedException]:登录过期,SessionNotifier 已经在处理了;
|
||||
/// - [RequestCancelledException]:用户切走了页面,请求被主动取消。
|
||||
/// ---------------------------------------------------------------------------
|
||||
final class ErrorObserver extends ProviderObserver {
|
||||
/// 构造。
|
||||
ErrorObserver({required this.logger, required this.reporter});
|
||||
|
||||
/// 日志出口。
|
||||
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();
|
||||
|
||||
if (ErrorPresenter.isSilent(error)) {
|
||||
return;
|
||||
}
|
||||
if (error is BusinessException) {
|
||||
// 记一条 info 就够:需要它来复盘"用户为什么走不下去",但它不是缺陷。
|
||||
logger.i('业务拒绝 $name: ${error.code} ${error.message}');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.e('provider 失败 $name', error: error, stackTrace: stackTrace);
|
||||
reporter.report(error, stackTrace, extra: <String, String>{'provider': name});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/// `/api/v1/h5/launch` 的实现。
|
||||
///
|
||||
/// 接口声明在 core_webview(`h5_launch.dart`),实现必须落在能依赖
|
||||
/// core_network 的地方——core_webview 不允许依赖 core_network(01)。
|
||||
library;
|
||||
|
||||
import 'package:core_foundation/core_foundation.dart';
|
||||
import 'package:core_network/core_network.dart';
|
||||
import 'package:core_webview/core_webview.dart';
|
||||
|
||||
/// 用 target 编码换一份带票据的 H5 URL。
|
||||
class ApiH5LaunchRepository implements H5LaunchRepository {
|
||||
/// 构造。
|
||||
const ApiH5LaunchRepository(this._api);
|
||||
|
||||
final ApiClient _api;
|
||||
|
||||
@override
|
||||
Future<H5LaunchInfo> launch(String target) async {
|
||||
// 只传 target 编码,不传 URL:URL 由后端从服务端会话上下文拼(见 10)。
|
||||
final Map<String, dynamic> data = await _api.post<Map<String, dynamic>>(
|
||||
'/api/v1/h5/launch',
|
||||
data: <String, String>{'target': target},
|
||||
);
|
||||
final Object? url = data['url'];
|
||||
final Object? title = data['title'];
|
||||
if (url is! String || title is! String) {
|
||||
throw const ServerException('H5 启动信息不完整');
|
||||
}
|
||||
return H5LaunchInfo(
|
||||
url: url,
|
||||
title: title,
|
||||
ttl: Duration(seconds: (data['ttlSeconds'] as num?)?.toInt() ?? 300),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/// 会话事件的旁路接线:埋点身份、崩溃上报的用户上下文、路由错误上报。
|
||||
///
|
||||
/// 这些都是 core_auth 声明的端口(`session_ports.dart` / `core_router/ports.dart`)
|
||||
/// 的实现——core_auth 不能依赖 core_analytics / core_logging,所以实现落在这里。
|
||||
library;
|
||||
|
||||
import 'package:core_analytics/core_analytics.dart';
|
||||
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});
|
||||
|
||||
/// 埋点。
|
||||
final Analytics analytics;
|
||||
|
||||
/// 崩溃上报。
|
||||
final CrashReporter reporter;
|
||||
|
||||
@override
|
||||
void onUserIdentified(UserContext user) {
|
||||
analytics.identify(user.userId);
|
||||
analytics.registerSuperProperties(<String, Object?>{
|
||||
AnalyticsSuperProperty.roleCode: user.roleCode,
|
||||
});
|
||||
// 只传 userId,不传手机号——Sentry 侧 sendDefaultPii = false 的前提就是
|
||||
// 我们自己也不往里塞 PII。
|
||||
reporter.setUser(user.userId);
|
||||
}
|
||||
|
||||
@override
|
||||
void onStoreChanged(StoreContext store) {
|
||||
// 运营侧几乎所有分析都按门店维度看,靠每个调用点自己传一定会漏。
|
||||
analytics.registerSuperProperties(<String, Object?>{
|
||||
AnalyticsSuperProperty.storeId: store.storeId,
|
||||
});
|
||||
reporter.setTag('storeId', '${store.storeId}');
|
||||
}
|
||||
|
||||
@override
|
||||
void onSessionEnded(LogoutReason reason) {
|
||||
// 被动登出没有对应的接口调用,后端看不见,必须客户端报(13)。
|
||||
analytics.track(AnalyticsEvent.logout, <String, Object?>{AnalyticsParam.reason: reason.name});
|
||||
// 门店设备是共用的:不 reset,下一个人的数据会串到上一个人身上。
|
||||
analytics.reset();
|
||||
reporter.clearUser();
|
||||
}
|
||||
|
||||
@override
|
||||
void onSessionRestoreFailed(String stage) {
|
||||
analytics.track(AnalyticsEvent.sessionRestoreFailed, <String, Object?>{
|
||||
AnalyticsParam.stage: stage,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 路由未命中时上报。
|
||||
class AppRouteReporter implements RouteReporter {
|
||||
/// 构造。
|
||||
const AppRouteReporter({required this.logger, required this.reporter});
|
||||
|
||||
/// 日志。
|
||||
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: <String, String>{AnalyticsParam.path: path},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user