1590 lines
56 KiB
Dart
1590 lines
56 KiB
Dart
import 'dart:async';
|
||
import 'dart:convert';
|
||
|
||
export 'widget/analytics_debug_banner.dart';
|
||
export 'entity/analytics_tab.dart';
|
||
export 'entity/ad_sdk_events.dart';
|
||
export 'entity/app_page_item_event.dart';
|
||
export 'entity/app_lifecycle_event.dart';
|
||
export 'entity/app_session_summary_event.dart';
|
||
export 'entity/network_state_change_event.dart';
|
||
export 'extension/tab_analytics_extension.dart';
|
||
export 'enum/page_route_enum.dart';
|
||
|
||
import 'package:analytics_sdk/config/api_config.dart';
|
||
import 'package:analytics_sdk/config/sdk_config.dart';
|
||
import 'package:analytics_sdk/const/event_type.dart';
|
||
import 'package:analytics_sdk/entity/ad_sdk_events.dart';
|
||
import 'package:analytics_sdk/entity/ad_impression_event.dart';
|
||
import 'package:analytics_sdk/entity/app_lifecycle_event.dart';
|
||
import 'package:analytics_sdk/entity/app_session_summary_event.dart';
|
||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||
import 'package:analytics_sdk/enum/user_type_enum.dart';
|
||
import 'package:analytics_sdk/entity/sdk_init_event.dart';
|
||
import 'package:analytics_sdk/manager/ad_impression_manager.dart';
|
||
import 'package:analytics_sdk/manager/event_processor.dart';
|
||
import 'package:analytics_sdk/manager/user_manager.dart';
|
||
import 'package:analytics_sdk/manager/domain_manager.dart';
|
||
import 'package:analytics_sdk/manager/event_type_config_manager.dart';
|
||
import 'package:analytics_sdk/manager/session_manager.dart';
|
||
import 'package:analytics_sdk/manager/sdk_activation_store.dart';
|
||
import 'package:analytics_sdk/manager/sdk_init_payload_builder.dart';
|
||
import 'package:analytics_sdk/manager/sdk_init_platform_fields.dart';
|
||
import 'package:analytics_sdk/manager/session_behavior_manager.dart';
|
||
import 'package:analytics_sdk/manager/session_behavior_monitor.dart';
|
||
import 'package:analytics_sdk/manager/session_metrics_manager.dart';
|
||
import 'package:analytics_sdk/manager/session_sensor_sampler.dart';
|
||
import 'package:analytics_sdk/observer/app_lifecycle_observer.dart';
|
||
import 'package:analytics_sdk/observer/app_lifecycle_web_unload_stub.dart'
|
||
if (dart.library.html) 'package:analytics_sdk/observer/app_lifecycle_web_unload_web.dart';
|
||
import 'package:analytics_sdk/entity/app_page_view_event.dart';
|
||
import 'package:analytics_sdk/entity/navigation_event.dart';
|
||
import 'package:analytics_sdk/manager/page_name_manager.dart';
|
||
import 'package:analytics_sdk/manager/page_observer_registration_guard.dart';
|
||
import 'package:analytics_sdk/observer/page_lifecycle_observer.dart';
|
||
import 'package:analytics_sdk/utils/aes_gcm_util.dart';
|
||
import 'package:analytics_sdk/utils/device_id_util.dart';
|
||
import 'package:analytics_sdk/utils/device_fingerprint_util.dart';
|
||
import 'package:analytics_sdk/utils/device_info_util.dart';
|
||
import 'package:analytics_sdk/utils/environment_info_util.dart';
|
||
import 'package:analytics_sdk/utils/event_validator.dart';
|
||
import 'package:analytics_sdk/utils/logger.dart';
|
||
import 'package:analytics_sdk/utils/widget_bridge.dart';
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:flutter/material.dart';
|
||
|
||
class AnalyticsSdk {
|
||
static final AnalyticsSdk instance = AnalyticsSdk._internal();
|
||
|
||
factory AnalyticsSdk() => instance;
|
||
|
||
AnalyticsSdk._internal();
|
||
|
||
final EventProcessor _eventProcessor = EventProcessor();
|
||
|
||
/// 单调递增的导航序号,用于快速连续切换时的去重。
|
||
/// 每次 [trackNavigation] 调用自增;回调触发时序号不匹配则丢弃。
|
||
/// 不在 init() 重置:计数器单调递增,旧回调 generation 永远小于新调用,天然隔离。
|
||
int _navigationGeneration = 0;
|
||
|
||
/// 最近一次成功上报的导航 pageKey,用于拦截同 Tab 重复点击。
|
||
String _lastReportedNavigationKey = '';
|
||
|
||
PageLifecycleObserver? _pageObserver;
|
||
PageLifecycleObserver? _sessionObserver;
|
||
String Function(String? actualRoute)? _realRouteResolver;
|
||
final PageObserverRegistrationGuard _pageObserverRegistrationGuard =
|
||
PageObserverRegistrationGuard();
|
||
|
||
PageLifecycleObserver _createPageObserver({
|
||
required bool autoTrackPageViews,
|
||
}) {
|
||
return PageLifecycleObserver(
|
||
track: track,
|
||
getUserType: () => AnalyticsSdk.userTypeProvider(),
|
||
onPageExit: (pageKey) => AdImpressionManager.instance.clearPage(pageKey),
|
||
resolveRealRoute: _realRouteResolver,
|
||
autoTrackPageViews: autoTrackPageViews,
|
||
);
|
||
}
|
||
|
||
static bool _enableDebugBanner = false;
|
||
|
||
/// 是否启用调试条(供 AnalyticsDebugBanner 读取)
|
||
static bool get enableDebugBanner => _enableDebugBanner;
|
||
|
||
/// 防止 init() 并发调用(异步挂起期间重复调用时直接跳过)
|
||
bool _isInitializing = false;
|
||
|
||
/// 当前进程内 sdk_init 只上报一次,匹配冷启动/Web 首次加载语义。
|
||
bool _sdkInitReported = false;
|
||
|
||
String _lastSessionSummarySid = '';
|
||
|
||
AppLifecycleWebUnloadDisposer? _webLifecycleUnloadDisposer;
|
||
|
||
// ==================== 初始化 ====================
|
||
|
||
/// 初始化 SDK。
|
||
///
|
||
/// 本方法永远不会抛出异常,即使初始化失败也不影响应用运行。
|
||
///
|
||
/// **参数说明**
|
||
///
|
||
/// - [appId]:应用 ID(必填)
|
||
/// - [channel]:渠道标识,可在初始化后通过 [setChannel] 更新
|
||
/// - [uid]:用户 ID,未登录时可传空,登录后通过 [setUserIdAndType] 或 [setUid] 更新
|
||
/// - [deviceId]:已废弃,SDK 不再信任外部传入 device_id,会内部生成
|
||
/// - [appVersion]:应用版本号,格式 `1.0.0`,由接入方传入,可不传
|
||
/// - [encryptedConfig]:加密配置字符串(Base64 编码 AES 密文),解密后为
|
||
/// `{"domainList":["https://..."],"eventList":["event_type",...]}` 格式,可选
|
||
///
|
||
/// device、deviceBrand、deviceModel、userAgent、systemName、systemVersion
|
||
/// 由 SDK 内部通过 device_info_plus 自动检测,无需外部传入。
|
||
Future<void> init({
|
||
required String appId,
|
||
required String? encryptedConfig,
|
||
String? channel,
|
||
String? uid,
|
||
@Deprecated('device_id 由 SDK 内部生成,外部传入值会被忽略') String? deviceId,
|
||
String? appVersion,
|
||
bool enableDebugBanner = false,
|
||
}) async {
|
||
if (_isInitializing) {
|
||
Logger.analyticsSdk('init() 正在进行中,跳过重复调用');
|
||
return;
|
||
}
|
||
final initStartedAt = DateTime.now();
|
||
_isInitializing = true;
|
||
_pageObserverRegistrationGuard.reset();
|
||
try {
|
||
_enableDebugBanner = !kReleaseMode && enableDebugBanner;
|
||
WidgetBridge.register(
|
||
track: track,
|
||
getUserType: userTypeProvider,
|
||
enableDebugBanner: _enableDebugBanner,
|
||
getDebugSteps: getDebugSteps,
|
||
);
|
||
|
||
_eventProcessor.stopAutoUploadTimer();
|
||
_eventProcessor.resetState();
|
||
_lastSessionSummarySid = '';
|
||
SessionMetricsManager.instance.reset();
|
||
SessionBehaviorManager.instance.reset();
|
||
SessionSensorSampler.instance.reset();
|
||
SessionBehaviorMonitor.instance.start();
|
||
|
||
await DeviceInfoUtil.initialize();
|
||
await EnvironmentInfoUtil.initialize();
|
||
|
||
final device = DeviceInfoUtil.deviceType;
|
||
final deviceBrand = DeviceInfoUtil.deviceBrand;
|
||
final deviceModel = DeviceInfoUtil.deviceModel;
|
||
final userAgent = DeviceInfoUtil.userAgent;
|
||
final systemName = DeviceInfoUtil.systemName;
|
||
final systemVersion = DeviceInfoUtil.systemVersion;
|
||
final sdkDeviceId = await DeviceIdUtil.getDeviceId();
|
||
|
||
SdkContext.configure(
|
||
appId: appId,
|
||
channel: channel,
|
||
uid: uid,
|
||
appVersion: appVersion,
|
||
device: device,
|
||
deviceId: sdkDeviceId,
|
||
deviceBrand: deviceBrand,
|
||
deviceModel: deviceModel,
|
||
userAgent: userAgent,
|
||
systemName: systemName,
|
||
systemVersion: systemVersion,
|
||
environmentFields: EnvironmentInfoUtil.fields,
|
||
);
|
||
EnvironmentInfoUtil.startMonitoring(track: track);
|
||
|
||
Logger.analyticsSdk('响应解密已启用(使用内置密钥)');
|
||
|
||
// re-init 时先重置会话,确保新一轮初始化生成新 session
|
||
SessionManager.instance.reset();
|
||
_lastReportedNavigationKey = '';
|
||
// 注入新 session 回调:新 session 开始时清空广告去重状态
|
||
SessionManager.instance.onNewSession = () {
|
||
AdImpressionManager.instance.clear();
|
||
SessionMetricsManager.instance.reset(
|
||
sid: SessionManager.instance.currentSessionId ?? '',
|
||
);
|
||
final pageLifecycleObserver = _pageObserver ?? _sessionObserver;
|
||
final sessionStartedAt = SessionManager.instance.sessionStartTime;
|
||
if (pageLifecycleObserver != null && sessionStartedAt != null) {
|
||
pageLifecycleObserver.startCurrentPageStayForNewSession(
|
||
sessionStartedAt,
|
||
);
|
||
}
|
||
SessionBehaviorManager.instance.reset(
|
||
sid: SessionManager.instance.currentSessionId ?? '',
|
||
);
|
||
SessionSensorSampler.instance.reset(
|
||
sid: SessionManager.instance.currentSessionId ?? '',
|
||
);
|
||
};
|
||
SessionManager.instance.onSessionEnding =
|
||
(reason) => _trackSessionSummary(sessionEndReason: reason);
|
||
SessionManager.instance.initialize();
|
||
// 注入 SID 提供者,避免 entity 层直接依赖 SessionManager
|
||
SdkContext.sidProvider = () {
|
||
return SessionManager.instance.getSessionId();
|
||
};
|
||
AppLifecycleObserver.instance.initialize(track: track);
|
||
|
||
// 初始化设备指纹
|
||
_prepareDeviceFingerprint();
|
||
|
||
await _applyEncryptedConfig(
|
||
encryptedConfig: encryptedConfig,
|
||
source: 'init',
|
||
);
|
||
|
||
try {
|
||
await _eventProcessor.initPersistence();
|
||
} catch (e) {
|
||
Logger.analyticsSdk('持久化初始化失败,继续运行: $e');
|
||
}
|
||
|
||
try {
|
||
await _eventProcessor.loadCachedEvents();
|
||
} catch (e) {
|
||
Logger.analyticsSdk('缓存加载失败,继续运行: $e');
|
||
}
|
||
|
||
await _trackSdkInit(initStartedAt: initStartedAt);
|
||
|
||
_eventProcessor.startAutoUploadTimer();
|
||
_schedulePageObserverRegistrationCheck();
|
||
} catch (e) {
|
||
Logger.analyticsSdk('初始化异常: $e');
|
||
} finally {
|
||
_isInitializing = false;
|
||
}
|
||
}
|
||
|
||
void _schedulePageObserverRegistrationCheck() {
|
||
try {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
_pageObserverRegistrationGuard.check(
|
||
pageObserverAttached: _pageObserver?.navigator != null,
|
||
sessionObserverAttached: _sessionObserver?.navigator != null,
|
||
);
|
||
});
|
||
// init() 发生在首帧之后时,主动请求一帧,避免接入检查长期不执行。
|
||
WidgetsBinding.instance.ensureVisualUpdate();
|
||
} catch (e) {
|
||
Logger.analyticsSdk('页面 Observer 接入检查注册失败: $e');
|
||
}
|
||
}
|
||
|
||
// ==================== UID / Channel 设置 ====================
|
||
|
||
/// 更新用户 ID 和用户类型(登录、切换账号时调用)。
|
||
///
|
||
/// [userId] 为空时表示未登录状态。
|
||
/// 本方法永远不会抛出异常。
|
||
static void setUserIdAndType(
|
||
{String userId = '', UserTypeEnum? userTypeEnum}) {
|
||
try {
|
||
SdkContext.setUid(userId);
|
||
UserManager.instance.updateUserType(userTypeEnum == null
|
||
? UserTypeEnum.normal.label
|
||
: userTypeEnum.label);
|
||
Logger.analyticsSdk('用户ID已更新为: $userId');
|
||
} catch (e) {
|
||
Logger.analyticsSdk('设置用户信息失败: $e');
|
||
}
|
||
}
|
||
|
||
/// 单独更新用户 ID(不影响用户类型)。
|
||
///
|
||
/// 适用于已知 userType 无变化、只需更新 uid 的场景(如静默登录)。
|
||
/// 本方法永远不会抛出异常。
|
||
static void setUid(String uid) {
|
||
try {
|
||
SdkContext.setUid(uid);
|
||
Logger.analyticsSdk('uid 已更新为: $uid');
|
||
} catch (e) {
|
||
Logger.analyticsSdk('setUid 失败: $e');
|
||
}
|
||
}
|
||
|
||
/// 更新渠道标识(渠道变更时调用,后续事件自动带上最新值)。
|
||
///
|
||
/// 本方法永远不会抛出异常。
|
||
static void setChannel(String channel) {
|
||
try {
|
||
SdkContext.setChannel(channel);
|
||
Logger.analyticsSdk('channel 已更新为: $channel');
|
||
} catch (e) {
|
||
Logger.analyticsSdk('setChannel 失败: $e');
|
||
}
|
||
}
|
||
|
||
/// 登出:清空 uid 和 userType。
|
||
///
|
||
/// 本方法永远不会抛出异常。
|
||
static void logoutUser() {
|
||
try {
|
||
SdkContext.setUid('');
|
||
UserManager.instance.logout();
|
||
Logger.analyticsSdk('用户已登出');
|
||
} catch (e) {
|
||
Logger.analyticsSdk('用户登出失败: $e');
|
||
}
|
||
}
|
||
|
||
// ==================== 设备公共字段 ====================
|
||
|
||
/// 获取当前设备公共字段(供产品方在服务端上报事件时复用)。
|
||
///
|
||
/// 返回字段:sid、device、device_id、device_brand、device_model、
|
||
/// user_agent、system_name、system_version、device_fingerprint、fp_version。
|
||
/// 未配置的字段返回空字符串。
|
||
///
|
||
/// `sid` 为客户端 SDK 自动生成的会话 ID;服务端上报事件时应将其一并回传。
|
||
///
|
||
/// 本方法永远不会抛出异常。
|
||
static Map<String, String> getDeviceCommonFields() {
|
||
try {
|
||
return SdkContext.getDeviceCommonFields();
|
||
} catch (e) {
|
||
Logger.analyticsSdk('getDeviceCommonFields 失败: $e');
|
||
return {};
|
||
}
|
||
}
|
||
|
||
/// 获取 SDK 当前全部公共参数,或按 [keys] 指定只返回部分参数。
|
||
///
|
||
/// - 不传 [keys](或传 null):返回全部参数
|
||
/// - 传入 key 列表:只返回命中的 key,不存在的 key 会被忽略
|
||
///
|
||
/// 可用 key:`app_id` `channel` `uid` `user_type` `sid`
|
||
/// `device` `device_id` `device_brand` `device_model`
|
||
/// `user_agent` `system_name` `system_version`
|
||
/// `device_fingerprint` `fp_version` `sdk_version` `app_version`
|
||
///
|
||
/// `sid` 为客户端 SDK 自动生成的会话ID,服务端上报事件时需将其一并回传,
|
||
/// 以确保服务端事件与客户端事件归属同一会话。
|
||
///
|
||
/// 本方法永远不会抛出异常。
|
||
static Map<String, dynamic> getParams([List<String>? keys]) {
|
||
try {
|
||
final all = <String, dynamic>{
|
||
'app_id': SdkContext.appId ?? '',
|
||
'channel': SdkContext.channel ?? '',
|
||
'uid': SdkContext.uid ?? '',
|
||
'user_type': UserManager.instance.userType,
|
||
'sid': SessionManager.instance.currentSessionId ?? '',
|
||
'sdk_version': SdkContext.kSdkVersion,
|
||
'app_version': SdkContext.appVersion ?? SdkContext.kDefaultAppVersion,
|
||
...SdkContext.getDeviceCommonFields(),
|
||
};
|
||
if (keys == null || keys.isEmpty) return all;
|
||
return {
|
||
for (final k in keys)
|
||
if (all.containsKey(k)) k: all[k]
|
||
};
|
||
} catch (e) {
|
||
Logger.analyticsSdk('getParams 失败: $e');
|
||
return {};
|
||
}
|
||
}
|
||
|
||
// ==================== 上报 ====================
|
||
|
||
/// 自动页面曝光模式的生命周期观察者。
|
||
///
|
||
/// 注册后既记录 Session 页面停留,也自动追踪所有命名路由的
|
||
/// [AppPageViewEvent]。与 [sessionObserver] 必须二选一,不能同时注册。
|
||
///
|
||
/// ```dart
|
||
/// MaterialApp(
|
||
/// navigatorObservers: [AnalyticsSdk.instance.pageObserver],
|
||
/// )
|
||
/// ```
|
||
NavigatorObserver get pageObserver =>
|
||
_pageObserver ??= _createPageObserver(autoTrackPageViews: true);
|
||
|
||
/// 手动页面曝光模式的生命周期观察者。
|
||
///
|
||
/// 只记录 Session 页面停留,不自动上报 [AppPageViewEvent]。如果业务已手动
|
||
/// 上报页面曝光,应在 [MaterialApp.navigatorObservers] 中注册此 observer。
|
||
/// 与 [pageObserver] 必须二选一,不能同时注册。
|
||
///
|
||
/// ```dart
|
||
/// MaterialApp(
|
||
/// navigatorObservers: [AnalyticsSdk.instance.sessionObserver],
|
||
/// )
|
||
/// ```
|
||
NavigatorObserver get sessionObserver =>
|
||
_sessionObserver ??= _createPageObserver(autoTrackPageViews: false);
|
||
|
||
/// 注入路由解析回调,将实际路径(如 `/video/123`)转换为模板路径(`/video/:id`)。
|
||
///
|
||
/// 与 [PageNameMapper.addMappings] 配合使用,实现带参数路由的稳定 pageKey 归因。
|
||
/// GoRouter 场景通常不需要注入(`settings.name` 即为完整路径);
|
||
/// GetX 等路由框架可能需要注入以获取模板路径。
|
||
///
|
||
/// 传入 `null` 可清除已注入的回调,恢复默认行为(直接使用 `settings.name`)。
|
||
void setRealRouteResolver(String Function(String? actualRoute)? resolver) {
|
||
_realRouteResolver = resolver;
|
||
_pageObserver?.setRealRouteResolver(resolver);
|
||
_sessionObserver?.setRealRouteResolver(resolver);
|
||
}
|
||
|
||
/// 手动上报导航事件,在用户点击导航菜单时调用。
|
||
///
|
||
/// **适用场景**:底部 Tab、顶部 Tab、侧边抽屉等导航菜单点击。
|
||
/// 内部同时调用 [PageLifecycleObserver.recordNavigation],更新全局 currentPageKey
|
||
/// 及栈顶 effectivePageKey,确保后续 push 的 referrer 和 page_click 归属均正确。
|
||
///
|
||
/// **已接入 [BottomNavigationBar.withAnalytics] / [TabBar.withAnalytics] 时无需调用**,
|
||
/// 包装器内部已处理;仅在完全手动管理导航时使用。
|
||
///
|
||
/// 同时上报 [AppPageViewEvent] 和 [NavigationEvent],通过
|
||
/// [WidgetsBinding.addPostFrameCallback] 在渲染完成后触发;
|
||
/// [pageLoadTime] 反映从调用到渲染完成的实际耗时。
|
||
/// 快速连续切换时只上报最后一次目标,中间状态自动丢弃。
|
||
///
|
||
/// - [pageKey]:导航目标的唯一标识,建议使用 [AppPageEnum](如 `AppPageEnum.video.key`),
|
||
/// 也可传入自定义字符串;与 app_page_view 的 page_key 保持一致
|
||
/// - [pageName]:导航目标的展示名称,建议使用 [AppPageEnum](如 `AppPageEnum.video.name`)
|
||
///
|
||
/// 本方法永远不会抛出异常。
|
||
void trackNavigation({
|
||
required String pageKey,
|
||
required String pageName,
|
||
}) {
|
||
try {
|
||
PageNameMapper.addMapping(pageKey, pageName);
|
||
final referrerKey = PageLifecycleObserver.currentPageKey;
|
||
final referrerName = PageNameMapper.getPageName(referrerKey);
|
||
final tapTime = DateTime.now();
|
||
PageLifecycleObserver.recordNavigation(pageKey);
|
||
final generation = ++_navigationGeneration;
|
||
|
||
bool callbackExecuted = false;
|
||
Timer? loadTimer;
|
||
|
||
void fireEvents() {
|
||
if (callbackExecuted) return;
|
||
callbackExecuted = true;
|
||
loadTimer?.cancel();
|
||
if (generation != _navigationGeneration) return;
|
||
if (pageKey == _lastReportedNavigationKey) return;
|
||
_lastReportedNavigationKey = pageKey;
|
||
try {
|
||
final pageLoadTime =
|
||
DateTime.now().difference(tapTime).inMilliseconds;
|
||
track(AppPageViewEvent(
|
||
userType: AnalyticsSdk.userTypeProvider(),
|
||
pageKey: pageKey,
|
||
pageName: pageName,
|
||
referrerPageKey: referrerKey,
|
||
referrerPageName: referrerName,
|
||
currentPageKey: pageKey,
|
||
currentPageName: pageName,
|
||
pageLoadTime: pageLoadTime,
|
||
pageEnterTs: tapTime.millisecondsSinceEpoch ~/ 1000,
|
||
));
|
||
track(NavigationEvent(
|
||
navigationKey: pageKey,
|
||
navigationName: pageName,
|
||
));
|
||
Logger.analyticsSdk(
|
||
'trackNavigation() 上报成功: pageKey=$pageKey, referrer=$referrerKey, pageLoadTime=${pageLoadTime}ms',
|
||
);
|
||
} catch (e) {
|
||
Logger.analyticsSdk('trackNavigation() post-frame 上报异常,已安全处理: $e');
|
||
}
|
||
}
|
||
|
||
try {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) => fireEvents());
|
||
loadTimer = Timer(SdkConfig.pageLoadTimeout, fireEvents);
|
||
} catch (e) {
|
||
Logger.analyticsSdk('trackNavigation() WidgetsBinding 未初始化,降级同步上报: $e');
|
||
fireEvents();
|
||
}
|
||
} catch (e) {
|
||
Logger.analyticsSdk('trackNavigation() 异常,已安全处理: $e');
|
||
}
|
||
}
|
||
|
||
/// 手动同步当前页面状态,专用于 PageView + AutomaticKeepAliveClientMixin 场景。
|
||
///
|
||
/// [PageView] 内 Tab 切换不经过路由系统,[PageLifecycleObserver] 无法感知;
|
||
/// 在 `PageView.onPageChanged` 中调用此方法,确保后续 page_click 的 `page_key`
|
||
/// 归属到实际可见的子页面,而非父路由 key。
|
||
///
|
||
/// 内部调用 [PageLifecycleObserver.recordNavigation],同时更新
|
||
/// [PageLifecycleObserver.currentPageKey] 和栈顶的 effectivePageKey;
|
||
/// pop 返回时会自动恢复到最后一次调用的 pageKey。
|
||
///
|
||
/// - [pageKey]:当前可见子页面的唯一标识,建议使用 [AppPageEnum](如 `AppPageEnum.videoList.key`),
|
||
/// 也可传入自定义字符串
|
||
/// - [pageName]:当前可见子页面的展示名称(仅用于日志,不上报事件)
|
||
///
|
||
/// 本方法永远不会抛出异常。
|
||
void updateCurrentPage({
|
||
required String pageKey,
|
||
required String pageName,
|
||
}) {
|
||
try {
|
||
PageLifecycleObserver.recordNavigation(pageKey);
|
||
Logger.analyticsSdk(
|
||
'updateCurrentPage: pageKey=$pageKey, pageName=$pageName');
|
||
} catch (e) {
|
||
Logger.analyticsSdk('updateCurrentPage() 异常,已安全处理: $e');
|
||
}
|
||
}
|
||
|
||
/// 手动上报任意事件(事件对象必须有 toJson() 方法)。
|
||
///
|
||
/// 本方法永远不会抛出异常,即使事件无效也不影响应用运行。
|
||
void track(dynamic event) {
|
||
try {
|
||
unawaited(EnvironmentInfoUtil.refreshBatteryLevelIfNeeded());
|
||
if (event == null) {
|
||
Logger.analyticsSdk('事件为 null,跳过');
|
||
return;
|
||
}
|
||
|
||
final adResult = _TrackPipeline.deduplicateAdImpression(event);
|
||
if (adResult == null) return;
|
||
event = adResult.processedEvent;
|
||
|
||
final json = _TrackPipeline.serializeEvent(event);
|
||
if (json == null) return;
|
||
|
||
final validated = _TrackPipeline.validateEvent(json);
|
||
if (validated == null) return;
|
||
|
||
if (!_TrackPipeline.checkEventSize(validated)) return;
|
||
if (!_TrackPipeline.isEventTypeEnabled(validated)) return;
|
||
|
||
final accepted = _eventProcessor.enqueueEvent(validated);
|
||
|
||
if (accepted &&
|
||
adResult.adsToMark != null &&
|
||
adResult.adsToMark!.isNotEmpty) {
|
||
try {
|
||
AdImpressionManager.instance.markAsReportedBatch(adResult.adsToMark!,
|
||
pageKey: adResult.pageKey);
|
||
} catch (e) {
|
||
Logger.analyticsSdk('标记广告ID为已上报失败: $e');
|
||
}
|
||
}
|
||
if (accepted &&
|
||
adResult.sdkTraceToMark != null &&
|
||
adResult.sdkTraceToMark!.isNotEmpty) {
|
||
try {
|
||
AdImpressionManager.instance
|
||
.markSdkTraceReported(adResult.sdkTraceToMark!);
|
||
} catch (e) {
|
||
Logger.analyticsSdk('标记广告 SDK 曝光 trace_id 失败: $e');
|
||
}
|
||
}
|
||
} catch (e) {
|
||
Logger.analyticsSdk('track() 发生异常,已安全处理: $e');
|
||
}
|
||
}
|
||
|
||
/// 使用事件名和业务 payload 上报事件。
|
||
///
|
||
/// 公共字段与 event_id 由埋点 SDK 统一生成,调用方无需构造事件实体。
|
||
/// 本方法永远不会抛出异常,即使参数无效也不影响应用运行。
|
||
void trackEvent({
|
||
required String eventName,
|
||
required Map<String, dynamic> payload,
|
||
}) {
|
||
try {
|
||
final normalizedEventName = eventName.trim();
|
||
if (normalizedEventName.isEmpty) {
|
||
Logger.analyticsSdk('trackEvent() eventName 为空,跳过');
|
||
return;
|
||
}
|
||
final payloadCopy = Map<String, dynamic>.from(payload);
|
||
if (normalizedEventName == EventType.adImpressionSdk.event) {
|
||
track(AdImpressionSdkEvent(
|
||
traceId: payloadCopy['trace_id']?.toString() ?? '',
|
||
requestId: payloadCopy['request_id']?.toString() ?? '',
|
||
adSlotKey: payloadCopy['ad_slot_key']?.toString() ?? '',
|
||
adId: payloadCopy['ad_id']?.toString() ?? '',
|
||
adType: payloadCopy['ad_type']?.toString() ?? '',
|
||
materialSize: payloadCopy['material_size']?.toString() ?? '',
|
||
));
|
||
return;
|
||
}
|
||
final commonFields = SdkContext.generateCommonFields(normalizedEventName);
|
||
final sortedPayloadKeys = payloadCopy.keys.toList()..sort();
|
||
final eventId = SdkContext.generateEventIdFromCommonFields(
|
||
commonFields,
|
||
[
|
||
jsonEncode({
|
||
for (final key in sortedPayloadKeys) key: payloadCopy[key],
|
||
}),
|
||
],
|
||
);
|
||
track({
|
||
...commonFields,
|
||
'event_id': eventId,
|
||
'payload': payloadCopy,
|
||
});
|
||
} catch (e) {
|
||
Logger.analyticsSdk('trackEvent() 发生异常,已安全处理: $e');
|
||
}
|
||
}
|
||
|
||
Future<void> _trackSdkInit({required DateTime initStartedAt}) async {
|
||
if (_sdkInitReported) {
|
||
Logger.analyticsSdk('sdk_init 当前进程已上报,跳过重复启动快照');
|
||
return;
|
||
}
|
||
try {
|
||
final platformFields = {
|
||
...await collectSdkInitPlatformFields(),
|
||
'activation_time': await SdkActivationStore.getOrCreateActivationTime(),
|
||
};
|
||
_applySdkInitEnvironmentOverrides(platformFields);
|
||
final commonFields =
|
||
SdkContext.generateCommonFields(EventType.sdkInit.event);
|
||
final sdkInitSnapshotFields = {
|
||
...commonFields,
|
||
...SdkContext.getEnvironmentSnapshotFields(),
|
||
};
|
||
final payload = SdkInitPayloadBuilder.build(
|
||
commonFields: sdkInitSnapshotFields,
|
||
platformFields: platformFields,
|
||
isWeb: kIsWeb,
|
||
);
|
||
final fingerprint =
|
||
DeviceFingerprintUtil.generateV3FromSdkInitPayload(payload);
|
||
if (fingerprint.isNotEmpty) {
|
||
SdkContext.configureGeneratedDeviceFingerprint(fingerprint);
|
||
}
|
||
final sdkInitEvent = SdkInitEvent(payload: payload);
|
||
track(sdkInitEvent);
|
||
_sdkInitReported = true;
|
||
_trackInitialAppLifecycle(
|
||
initStartedAt: initStartedAt,
|
||
platformFields: platformFields,
|
||
);
|
||
_registerWebLifecycleUnload(initStartedAt);
|
||
Logger.analyticsSdk('sdk_init 上报已入队');
|
||
} catch (e) {
|
||
Logger.analyticsSdk('sdk_init 上报失败,已安全处理: $e');
|
||
}
|
||
}
|
||
|
||
void _trackInitialAppLifecycle({
|
||
required DateTime initStartedAt,
|
||
required Map<String, dynamic> platformFields,
|
||
}) {
|
||
try {
|
||
final durationMs =
|
||
DateTime.now().difference(initStartedAt).inMilliseconds;
|
||
if (kIsWeb) {
|
||
track(AppLifecycleEvent.webLoad(
|
||
pageUrl: platformFields['current_url']?.toString() ?? '',
|
||
pageLoadTimeMs: durationMs,
|
||
viewportWidth: _intField(platformFields['viewport_width'], -1),
|
||
viewportHeight: _intField(platformFields['viewport_height'], -1),
|
||
));
|
||
} else {
|
||
track(AppLifecycleEvent.coldStart(
|
||
coldStartDurationMs: durationMs,
|
||
lastExitType:
|
||
platformFields['last_exit_type']?.toString() ?? 'unknown',
|
||
bootTime: _intField(platformFields['boot_time'], 0),
|
||
));
|
||
}
|
||
} catch (e) {
|
||
Logger.analyticsSdk('app_lifecycle 初始事件上报失败,已安全处理: $e');
|
||
track(AppLifecycleEvent.unknown(unknownReason: 'collect_error'));
|
||
}
|
||
}
|
||
|
||
void _registerWebLifecycleUnload(DateTime pageEnterTime) {
|
||
try {
|
||
_webLifecycleUnloadDisposer?.call();
|
||
_webLifecycleUnloadDisposer = registerAppLifecycleWebUnloadListener(
|
||
track: track,
|
||
pageEnterTime: pageEnterTime,
|
||
);
|
||
} catch (e) {
|
||
Logger.analyticsSdk('Web 生命周期卸载监听注册失败,已安全处理: $e');
|
||
}
|
||
}
|
||
|
||
int _intField(dynamic value, int fallback) {
|
||
if (value is int) return value;
|
||
if (value is num) return value.round();
|
||
return int.tryParse(value?.toString() ?? '') ?? fallback;
|
||
}
|
||
|
||
void _trackSessionSummary({required String sessionEndReason}) {
|
||
final currentSid = SessionManager.instance.currentSessionId ?? '';
|
||
if (currentSid.isEmpty || _lastSessionSummarySid == currentSid) return;
|
||
|
||
final previousSidProvider = SdkContext.sidProvider;
|
||
try {
|
||
SdkContext.sidProvider = () => currentSid;
|
||
final summaryEvent = AppSessionSummaryEvent(
|
||
sessionEndReason: sessionEndReason,
|
||
endedAt: SessionManager.instance.sessionEndTimeForNotification ??
|
||
DateTime.now(),
|
||
);
|
||
final serialized = _TrackPipeline.serializeEvent(summaryEvent);
|
||
if (serialized == null) return;
|
||
|
||
final validated = _TrackPipeline.validateEvent(serialized);
|
||
if (validated == null) return;
|
||
|
||
final summaryJson = validated;
|
||
if (!_TrackPipeline.checkEventSize(summaryJson)) return;
|
||
if (!_TrackPipeline.isEventTypeEnabled(summaryJson)) return;
|
||
|
||
final accepted = _eventProcessor.enqueueEvent(summaryJson);
|
||
if (accepted) {
|
||
_lastSessionSummarySid = currentSid;
|
||
}
|
||
} catch (e) {
|
||
Logger.analyticsSdk('app_session_summary 上报失败,已安全处理: $e');
|
||
} finally {
|
||
SdkContext.sidProvider = previousSidProvider;
|
||
}
|
||
}
|
||
|
||
/// Debug/QA only: 模拟旧 sid idle 超时,触发 app_session_summary 后创建新 sid。
|
||
///
|
||
/// Release 包中返回 false 且不做任何事。
|
||
Future<bool> debugTriggerIdleSessionSummaryForQa({
|
||
Duration idleFor = const Duration(minutes: 31),
|
||
}) async {
|
||
if (kReleaseMode) return false;
|
||
try {
|
||
final oldSid = SessionManager.instance.getSessionId();
|
||
final armed =
|
||
SessionManager.instance.debugSetBackgroundElapsedForQa(idleFor);
|
||
if (!armed) return false;
|
||
SessionManager.instance.onAppForeground();
|
||
await flush();
|
||
return SessionManager.instance.currentSessionId != oldSid;
|
||
} catch (e) {
|
||
Logger.analyticsSdk('QA 触发 app_session_summary 失败: $e');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
void _applySdkInitEnvironmentOverrides(Map<String, dynamic> platformFields) {
|
||
const keys = <String>{
|
||
'timezone',
|
||
'package_name',
|
||
'carrier',
|
||
'network_type',
|
||
'screen_width',
|
||
'screen_height',
|
||
'screen_dpi',
|
||
'device_pixel_ratio',
|
||
'orientation',
|
||
'screen_brightness',
|
||
'volume',
|
||
'is_screen_on',
|
||
'battery_level',
|
||
'is_charging',
|
||
'battery_state',
|
||
'cpu_cores',
|
||
'device_memory_gb',
|
||
'touch_support',
|
||
'max_touch_points',
|
||
};
|
||
final overrides = <String, dynamic>{};
|
||
for (final key in keys) {
|
||
final value = platformFields[key];
|
||
if (_isUsefulSdkInitEnvironmentValue(value)) {
|
||
overrides[key] = value;
|
||
}
|
||
}
|
||
SdkContext.updateEnvironmentFields(overrides);
|
||
}
|
||
|
||
bool _isUsefulSdkInitEnvironmentValue(dynamic value) {
|
||
if (value == null) return false;
|
||
if (value is num) return value >= 0;
|
||
final text = value.toString().trim();
|
||
return text.isNotEmpty && text != 'unknown';
|
||
}
|
||
|
||
/// 强制立即上报。
|
||
///
|
||
/// 本方法永远不会抛出异常。
|
||
Future<void> flush() async {
|
||
try {
|
||
await _eventProcessor.flush();
|
||
} catch (e) {
|
||
Logger.analyticsSdk('flush() 发生异常,已安全处理: $e');
|
||
}
|
||
}
|
||
|
||
/// 清理 SDK 资源(先尝试上报剩余事件,再释放资源)。
|
||
///
|
||
/// 本方法永远不会抛出异常。
|
||
Future<void> dispose() async {
|
||
try {
|
||
_trackSessionSummary(sessionEndReason: 'app_exit');
|
||
|
||
try {
|
||
await flush();
|
||
} catch (e) {
|
||
Logger.analyticsSdk('dispose() 时上报失败: $e');
|
||
}
|
||
|
||
await _eventProcessor.disposeAsync();
|
||
|
||
try {
|
||
DomainManager.instance.dispose();
|
||
} catch (e) {
|
||
Logger.analyticsSdk('销毁域名管理器失败: $e');
|
||
}
|
||
|
||
try {
|
||
EventTypeConfigManager.instance.dispose();
|
||
} catch (e) {
|
||
Logger.analyticsSdk('销毁事件类型配置管理器失败: $e');
|
||
}
|
||
|
||
try {
|
||
_webLifecycleUnloadDisposer?.call();
|
||
_webLifecycleUnloadDisposer = null;
|
||
} catch (e) {
|
||
Logger.analyticsSdk('销毁 Web 生命周期监听失败: $e');
|
||
}
|
||
|
||
try {
|
||
AppLifecycleObserver.instance.dispose();
|
||
} catch (e) {
|
||
Logger.analyticsSdk('销毁应用生命周期观察者失败: $e');
|
||
}
|
||
|
||
try {
|
||
await EnvironmentInfoUtil.dispose();
|
||
} catch (e) {
|
||
Logger.analyticsSdk('销毁环境字段监听失败: $e');
|
||
}
|
||
|
||
try {
|
||
await SessionSensorSampler.instance.dispose();
|
||
} catch (e) {
|
||
Logger.analyticsSdk('销毁传感器采样器失败: $e');
|
||
}
|
||
|
||
try {
|
||
SessionBehaviorMonitor.instance.dispose();
|
||
} catch (e) {
|
||
Logger.analyticsSdk('销毁行为监听器失败: $e');
|
||
}
|
||
|
||
try {
|
||
_pageObserver?.dispose();
|
||
_sessionObserver?.dispose();
|
||
_pageObserver = null;
|
||
_sessionObserver = null;
|
||
} catch (e) {
|
||
Logger.analyticsSdk('销毁页面生命周期观察者失败: $e');
|
||
}
|
||
} catch (e) {
|
||
Logger.analyticsSdk('dispose() 发生异常: $e');
|
||
}
|
||
}
|
||
|
||
// ==================== 调试 ====================
|
||
|
||
/// 更新用户类型(登录、升级会员、切换账号时调用)。
|
||
///
|
||
/// 本方法永远不会抛出异常。
|
||
void updateUserType(String newType) {
|
||
try {
|
||
UserManager.instance.updateUserType(newType);
|
||
} catch (e) {
|
||
Logger.analyticsSdk('更新用户类型失败: $e');
|
||
}
|
||
}
|
||
|
||
/// 在 init() 之后、需要更新配置时调用。
|
||
///
|
||
/// [encryptedConfig] 格式与 init() 中的同名参数一致。
|
||
/// 本方法永远不会抛出异常。
|
||
Future<void> refreshDomainConfig({
|
||
String? encryptedConfig,
|
||
}) async {
|
||
try {
|
||
if (encryptedConfig == null || encryptedConfig.isEmpty) {
|
||
Logger.analyticsSdk('refreshDomainConfig encryptedConfig 为空,忽略本次刷新',
|
||
level: LogLevel.warn);
|
||
return;
|
||
}
|
||
await _applyEncryptedConfig(
|
||
encryptedConfig: encryptedConfig,
|
||
source: 'refreshDomainConfig',
|
||
);
|
||
} catch (e) {
|
||
Logger.analyticsSdk('refreshDomainConfig 异常: $e', level: LogLevel.warn);
|
||
}
|
||
}
|
||
|
||
/// 测试包用:获取埋点调试信息
|
||
Map<String, String> getDebugInfo() {
|
||
try {
|
||
final steps = getDebugSteps();
|
||
final lastStep = steps.isNotEmpty ? steps.last : <String, String>{};
|
||
return {
|
||
'inited': steps.isNotEmpty && steps.length >= 2
|
||
? steps[1]['status'] ?? '未知'
|
||
: '未知',
|
||
'queueLength': '${_eventProcessor.queueLength}',
|
||
'reportUrl': _eventProcessor.debugReportUrl != null
|
||
? (_eventProcessor.debugReportUrl!.length > 40
|
||
? '${_eventProcessor.debugReportUrl!.substring(0, 40)}...'
|
||
: _eventProcessor.debugReportUrl!)
|
||
: '未配置',
|
||
'debugReportEventLogPath':
|
||
_eventProcessor.debugReportEventLogPath ?? '不可用',
|
||
'lastStep': lastStep['name'] ?? '',
|
||
'lastStatus': lastStep['status'] ?? '',
|
||
};
|
||
} catch (e) {
|
||
Logger.analyticsSdk('getDebugInfo 异常,已安全处理: $e', level: LogLevel.warn);
|
||
return {};
|
||
}
|
||
}
|
||
|
||
/// 测试包用:获取埋点流程步骤
|
||
List<Map<String, String>> getDebugSteps() {
|
||
try {
|
||
final dm = DomainManager.instance;
|
||
final ep = _eventProcessor;
|
||
final steps = <Map<String, String>>[];
|
||
|
||
final hasDomains = dm.reportDomains.isNotEmpty;
|
||
steps.add({
|
||
'name': '①域名配置',
|
||
'status': hasDomains ? '✓已配置' : '✗未配置',
|
||
'detail': hasDomains ? '由业务方传入' : '未传入 encryptedConfig',
|
||
});
|
||
|
||
final appIdOk = SdkContext.appId != null && SdkContext.appId!.isNotEmpty;
|
||
steps.add({
|
||
'name': '②SDK初始化',
|
||
'status': appIdOk ? '✓已初始化' : '✗未初始化',
|
||
'detail': appIdOk ? 'appId已设置' : '-',
|
||
});
|
||
|
||
final domains = dm.reportDomains;
|
||
steps.add({
|
||
'name': '③域名列表',
|
||
'status': domains.isEmpty
|
||
? (appIdOk ? '✗未配置或测速中' : '-')
|
||
: '✓已配置(${domains.length}个)',
|
||
'detail': domains.isEmpty ? '-' : domains.join(','),
|
||
});
|
||
|
||
final fastest = dm.fastestDomain;
|
||
String speedStatus;
|
||
if (fastest != null && fastest.isNotEmpty) {
|
||
speedStatus = '✓已选';
|
||
} else if (domains.isNotEmpty) {
|
||
speedStatus = '✗测速失败或进行中';
|
||
} else {
|
||
speedStatus = '-';
|
||
}
|
||
steps.add({
|
||
'name': '④域名测速',
|
||
'status': speedStatus,
|
||
'detail': fastest ?? '-',
|
||
});
|
||
|
||
final hasUrl = ep.debugReportUrl != null && ep.debugReportUrl!.isNotEmpty;
|
||
String urlDetail = '-';
|
||
if (hasUrl && ep.debugReportUrl != null) {
|
||
try {
|
||
final uri = Uri.parse(ep.debugReportUrl!);
|
||
urlDetail = '${uri.host}${uri.path}';
|
||
} catch (_) {
|
||
urlDetail = '已配置';
|
||
}
|
||
}
|
||
steps.add({
|
||
'name': '⑤上报地址',
|
||
'status': hasUrl ? '✓已就绪' : '✗未就绪',
|
||
'detail': urlDetail,
|
||
});
|
||
|
||
steps.add({
|
||
'name': '⑥待上报队列',
|
||
'status': '${ep.queueLength}条',
|
||
'detail': ep.queueLength > 0 ? '有事件待上报' : '空',
|
||
});
|
||
|
||
String uploadStatus;
|
||
String uploadDetail = '-';
|
||
if (ep.lastUploadTime == null) {
|
||
uploadStatus = '未尝试';
|
||
} else if (ep.lastUploadSuccess == true) {
|
||
uploadStatus = '✓成功';
|
||
uploadDetail = _DebugInspector.formatTime(ep.lastUploadTime);
|
||
} else {
|
||
uploadStatus = '✗失败';
|
||
uploadDetail = ep.lastUploadError ?? '未知';
|
||
}
|
||
steps.add({
|
||
'name': '⑦最近上报',
|
||
'status': uploadStatus,
|
||
'detail': uploadDetail,
|
||
});
|
||
|
||
return steps;
|
||
} catch (e) {
|
||
Logger.analyticsSdk('getDebugSteps 异常,已安全处理: $e', level: LogLevel.warn);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/// 验证加密配置字符串格式(仅 debug 包可用)
|
||
///
|
||
/// 支持以下解密后格式:
|
||
/// - JSON 数组:`["https://api.example.com", ...]`(域名列表或事件类型列表)
|
||
/// - JSON 对象含 `domainList`:`{"domainList":[...]}`
|
||
/// - JSON 对象含 `eventList`:`{"eventList":[...]}`
|
||
/// - JSON 对象含 `enabled_event_types`:`{"enabled_event_types":[...]}`
|
||
Map<String, dynamic> validateEncryptedConfig(String ciphertext) {
|
||
return _DebugInspector.validateEncryptedConfig(ciphertext);
|
||
}
|
||
|
||
// ==================== 私有方法 ====================
|
||
|
||
/// 用户类型提供者
|
||
static String Function() userTypeProvider =
|
||
() => UserManager.instance.userType;
|
||
|
||
/// 准备设备指纹状态。
|
||
///
|
||
/// v3 指纹必须在 sdk_init payload 采集完整后计算,这里只清理旧缓存并
|
||
/// 提前写入版本号,避免 re-init 复用旧设备参数。
|
||
void _prepareDeviceFingerprint() {
|
||
try {
|
||
DeviceFingerprintUtil.clearCache();
|
||
SdkContext.resetGeneratedDeviceFingerprint();
|
||
Logger.analyticsSdk('设备指纹状态已准备,规则版本: ${DeviceFingerprintUtil.kVersion}');
|
||
} catch (e) {
|
||
Logger.analyticsSdk('设备指纹状态准备失败,device_fingerprint 将为空: $e');
|
||
}
|
||
}
|
||
|
||
/// 解密并应用域名配置与事件类型配置(供 init() 和 refreshDomainConfig() 复用)
|
||
///
|
||
/// [encryptedConfig] 解密后格式:`{"domainList":[...],"eventList":[...]}`
|
||
Future<void> _applyEncryptedConfig({
|
||
String? encryptedConfig,
|
||
required String source,
|
||
}) async {
|
||
final appId = SdkContext.appId ?? '';
|
||
|
||
if (encryptedConfig != null && encryptedConfig.isNotEmpty) {
|
||
Logger.analyticsSdk(
|
||
'$source 收到 encryptedConfig: length=${encryptedConfig.length}');
|
||
DomainManager.instance.reset();
|
||
|
||
// ── 阶段 1:域名配置(独立 try-catch,异常不影响阶段 2)──────────────
|
||
List<String>? parsedEventList;
|
||
try {
|
||
final plaintext = AesGcmUtil.decryptResponseAuto(encryptedConfig);
|
||
Logger.analyticsSdkDeveloperLog(
|
||
'$source encryptedConfig 解密成功,明文(敏感字段已脱敏): ${_redactEncryptedConfigForLog(plaintext)}',
|
||
);
|
||
final raw = jsonDecode(plaintext);
|
||
|
||
// 纯 JSON 数组:直接作为域名列表处理,不含 eventList
|
||
if (raw is List) {
|
||
final domains = raw
|
||
.map<String>((e) => e.toString().trim())
|
||
.where((e) => e.isNotEmpty)
|
||
.toList();
|
||
if (domains.isNotEmpty) {
|
||
_startSpeedTest(domains, appId);
|
||
Logger.analyticsSdk(
|
||
'$source encryptedConfig 解析成功: type=List, domainList=${domains.length}, eventList=0');
|
||
} else {
|
||
Logger.analyticsSdk('$source encryptedConfig 解析结果为空数组,尝试加载本地缓存',
|
||
level: LogLevel.warn);
|
||
await _applyDomainCache(appId);
|
||
}
|
||
// 纯数组格式不含 eventList,parsedEventList 保持 null(走缓存)
|
||
} else if (raw is! Map<String, dynamic>) {
|
||
Logger.analyticsSdk(
|
||
'$source encryptedConfig 解析格式异常:期望 Map,实际 ${raw.runtimeType},尝试加载本地缓存',
|
||
level: LogLevel.warn);
|
||
await _applyDomainCache(appId);
|
||
// parsedEventList 保持 null,走缓存逻辑(阶段 2 处理)
|
||
} else {
|
||
final decoded = raw;
|
||
|
||
// 解析 domainList
|
||
final rawDomains = decoded['domainList'];
|
||
final domains = rawDomains is List
|
||
? rawDomains
|
||
.map<String>((e) => e.toString().trim())
|
||
.where((e) => e.isNotEmpty)
|
||
.toList()
|
||
: <String>[];
|
||
|
||
if (domains.isNotEmpty) {
|
||
_startSpeedTest(domains, appId);
|
||
} else {
|
||
Logger.analyticsSdk('domainList 解析结果为空,尝试加载本地缓存',
|
||
level: LogLevel.warn);
|
||
await _applyDomainCache(appId);
|
||
}
|
||
|
||
// 捕获 eventList 供阶段 2 使用
|
||
final rawEvents =
|
||
decoded['eventList'] ?? decoded['enabled_event_types'];
|
||
if (rawEvents is List && rawEvents.isNotEmpty) {
|
||
parsedEventList = rawEvents.map((e) => e.toString()).toList();
|
||
}
|
||
|
||
_applyEventSignatureConfig(decoded);
|
||
Logger.analyticsSdk(
|
||
'$source encryptedConfig 解析成功: type=Map, domainList=${domains.length}, eventList=${parsedEventList?.length ?? 0}, eventSignature=${_hasEventSignatureConfig(decoded)}');
|
||
}
|
||
} catch (e) {
|
||
Logger.analyticsSdk('$source encryptedConfig 解密/解析失败: $e,尝试加载本地缓存',
|
||
level: LogLevel.warn);
|
||
await _applyDomainCache(appId);
|
||
}
|
||
|
||
// ── 阶段 2:事件类型配置(独立 try-catch,异常不触发第二轮测速)──────
|
||
try {
|
||
if (parsedEventList != null) {
|
||
await EventTypeConfigManager.instance
|
||
.initWithConfig(jsonEncode(parsedEventList));
|
||
} else {
|
||
Logger.analyticsSdk('eventList 解析结果为空,尝试加载本地缓存',
|
||
level: LogLevel.warn);
|
||
await EventTypeConfigManager.instance.loadCachedConfig();
|
||
}
|
||
} catch (e) {
|
||
Logger.analyticsSdk('eventList 初始化失败: $e,尝试加载本地缓存',
|
||
level: LogLevel.warn);
|
||
await EventTypeConfigManager.instance.loadCachedConfig();
|
||
}
|
||
} else {
|
||
Logger.analyticsSdk('$source encryptedConfig 未传入,尝试加载本地缓存',
|
||
level: LogLevel.warn);
|
||
await _applyDomainCache(appId);
|
||
await EventTypeConfigManager.instance.loadCachedConfig();
|
||
}
|
||
}
|
||
|
||
String _redactEncryptedConfigForLog(String plaintext) {
|
||
try {
|
||
final decoded = jsonDecode(plaintext);
|
||
return jsonEncode(_redactSensitiveConfigValue(decoded));
|
||
} catch (_) {
|
||
return plaintext;
|
||
}
|
||
}
|
||
|
||
dynamic _redactSensitiveConfigValue(dynamic value) {
|
||
const sensitiveKeys = {
|
||
'signKey',
|
||
'eventSignKey',
|
||
'event_signature_key',
|
||
'event_sig_key',
|
||
'hmacKey',
|
||
'key',
|
||
};
|
||
if (value is Map) {
|
||
return value.map((key, child) {
|
||
final keyText = key.toString();
|
||
if (sensitiveKeys.contains(keyText)) {
|
||
return MapEntry(keyText, '***');
|
||
}
|
||
return MapEntry(keyText, _redactSensitiveConfigValue(child));
|
||
});
|
||
}
|
||
if (value is List) {
|
||
return value.map(_redactSensitiveConfigValue).toList();
|
||
}
|
||
return value;
|
||
}
|
||
|
||
bool _hasEventSignatureConfig(Map<String, dynamic> decoded) {
|
||
if (_firstConfigString(decoded, const [
|
||
'signKey',
|
||
'eventSignKey',
|
||
'event_signature_key',
|
||
'event_sig_key',
|
||
]) !=
|
||
null) {
|
||
return true;
|
||
}
|
||
final rawEventSignature = decoded['eventSignature'];
|
||
if (rawEventSignature is Map) {
|
||
final eventSignature = Map<String, dynamic>.from(rawEventSignature);
|
||
return _firstConfigString(eventSignature, const [
|
||
'signKey',
|
||
'key',
|
||
'hmacKey',
|
||
]) !=
|
||
null;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
void _applyEventSignatureConfig(Map<String, dynamic> decoded) {
|
||
try {
|
||
final directSignKey = _firstConfigString(decoded, const [
|
||
'signKey',
|
||
'eventSignKey',
|
||
'event_signature_key',
|
||
'event_sig_key',
|
||
]);
|
||
final directVersion = _firstConfigString(decoded, const [
|
||
'signVersion',
|
||
'eventSigVersion',
|
||
'event_sig_version',
|
||
]);
|
||
|
||
String? nestedSignKey;
|
||
String? nestedVersion;
|
||
final rawEventSignature = decoded['eventSignature'];
|
||
if (rawEventSignature is Map) {
|
||
final eventSignature = Map<String, dynamic>.from(rawEventSignature);
|
||
nestedSignKey = _firstConfigString(eventSignature, const [
|
||
'signKey',
|
||
'key',
|
||
'hmacKey',
|
||
]);
|
||
nestedVersion = _firstConfigString(eventSignature, const [
|
||
'version',
|
||
'signVersion',
|
||
'eventSigVersion',
|
||
'event_sig_version',
|
||
]);
|
||
}
|
||
|
||
final signKey = directSignKey ?? nestedSignKey;
|
||
if (signKey == null) return;
|
||
|
||
SdkContext.configureEventSignature(
|
||
signKey: signKey,
|
||
version: directVersion ?? nestedVersion,
|
||
);
|
||
} catch (e) {
|
||
Logger.analyticsSdk('签名配置解析失败,事件签名将保持当前状态: $e', level: LogLevel.warn);
|
||
}
|
||
}
|
||
|
||
String? _firstConfigString(
|
||
Map<String, dynamic> decoded,
|
||
List<String> keys,
|
||
) {
|
||
for (final key in keys) {
|
||
final value = decoded[key];
|
||
if (value is String && value.trim().isNotEmpty) {
|
||
return value.trim();
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
Future<void> _applyDomainCache(String appId) async {
|
||
try {
|
||
final cachedDomains = await DomainManager.instance.loadCachedDomains();
|
||
if (cachedDomains.isNotEmpty) {
|
||
Logger.analyticsSdk('使用缓存域名降级: $cachedDomains');
|
||
_startSpeedTest(cachedDomains, appId);
|
||
} else {
|
||
Logger.analyticsSdk(
|
||
'无可用域名缓存,上报 URL 保持未配置,事件将在 refreshDomainConfig() 后上报',
|
||
level: LogLevel.warn);
|
||
}
|
||
} catch (e) {
|
||
Logger.analyticsSdk('加载域名缓存失败: $e', level: LogLevel.warn);
|
||
}
|
||
}
|
||
|
||
/// 设置域名测速回调并启动测速(fire-and-forget)
|
||
void _startSpeedTest(List<String> domains, String appId) {
|
||
DomainManager.instance.onFastestDomainChanged = (fastest) {
|
||
try {
|
||
if (fastest != null && fastest.isNotEmpty) {
|
||
final url = ApiConfig.getReportUrl(fastest, appId: appId);
|
||
_eventProcessor.updateReportUrl(url);
|
||
Logger.analyticsSdk('域名测速完成,已更新上报URL: $url');
|
||
}
|
||
} catch (e) {
|
||
Logger.analyticsSdk('_startSpeedTest 回调异常,已安全处理: $e',
|
||
level: LogLevel.warn);
|
||
}
|
||
};
|
||
DomainManager.instance.initWithDomains(domains);
|
||
}
|
||
}
|
||
|
||
/// 广告展示去重结果(仅在 [AnalyticsSdk] 内部使用)
|
||
class _AdDedupeResult {
|
||
/// 经过去重处理后的事件(可能是过滤了部分 adId 的 [AdImpressionEvent],或原始事件)
|
||
final dynamic processedEvent;
|
||
|
||
/// 成功入队后需要标记为已上报的 adId 字符串;非广告展示事件时为 null
|
||
final String? adsToMark;
|
||
|
||
/// 广告所在页面的 pageKey,用于页面级去重;非广告事件时为空字符串
|
||
final String pageKey;
|
||
|
||
/// 成功入队后需要标记为已上报的广告 SDK trace_id。
|
||
final String? sdkTraceToMark;
|
||
|
||
const _AdDedupeResult(
|
||
this.processedEvent,
|
||
this.adsToMark, [
|
||
this.pageKey = '',
|
||
this.sdkTraceToMark,
|
||
]);
|
||
}
|
||
|
||
/// track() 管道辅助方法(纯静态,不依赖 AnalyticsSdk 实例)
|
||
class _TrackPipeline {
|
||
/// 广告展示事件去重处理。返回 null 表示全部 adId 均已上报,应跳过整条事件。
|
||
static _AdDedupeResult? deduplicateAdImpression(dynamic event) {
|
||
if (event is AdImpressionSdkEvent) {
|
||
return _deduplicateAdSdkImpression(event);
|
||
}
|
||
if (event is! AdImpressionEvent) {
|
||
return _AdDedupeResult(event, null);
|
||
}
|
||
final AdImpressionEvent adEvent = event;
|
||
try {
|
||
final unreportedAdIds = _getUnreportedAdIds(adEvent);
|
||
if (unreportedAdIds != null && unreportedAdIds.isEmpty) {
|
||
Logger.analyticsSdk('广告展示事件已上报,跳过: ${adEvent.adId}');
|
||
return null;
|
||
}
|
||
if (unreportedAdIds != null) {
|
||
final originalAdIdsCount = adEvent.adId
|
||
.split(',')
|
||
.map((id) => id.trim())
|
||
.where((id) => id.isNotEmpty)
|
||
.length;
|
||
if (unreportedAdIds.length < originalAdIdsCount) {
|
||
final filtered = _createFilteredAdEvent(adEvent, unreportedAdIds);
|
||
Logger.analyticsSdk(
|
||
'广告展示事件已过滤: 原始$originalAdIdsCount个ID,只上报${unreportedAdIds.length}个未上报的ID: ${filtered.adId}');
|
||
return _AdDedupeResult(filtered, filtered.adId, filtered.pageKey);
|
||
}
|
||
}
|
||
return _AdDedupeResult(adEvent, adEvent.adId, adEvent.pageKey);
|
||
} catch (e) {
|
||
Logger.analyticsSdk('广告去重检查异常,跳过去重: $e');
|
||
return _AdDedupeResult(adEvent, adEvent.adId, adEvent.pageKey);
|
||
}
|
||
}
|
||
|
||
/// 广告 SDK 曝光以服务端下发的 trace_id 为单次投放标识,在同一 session 内去重。
|
||
static _AdDedupeResult? _deduplicateAdSdkImpression(
|
||
AdImpressionSdkEvent event,
|
||
) {
|
||
final traceId = event.traceId.trim();
|
||
if (traceId.isEmpty) return _AdDedupeResult(event, null);
|
||
if (AdImpressionManager.instance.isSdkTraceReported(traceId)) {
|
||
Logger.analyticsSdk('广告 SDK 曝光已上报,跳过: $traceId');
|
||
return null;
|
||
}
|
||
return _AdDedupeResult(event, null, '', traceId);
|
||
}
|
||
|
||
/// 将事件对象序列化为 JSON Map。返回 null 表示序列化失败,应丢弃事件。
|
||
static Map<String, dynamic>? serializeEvent(dynamic event) {
|
||
try {
|
||
final json = event is Map<String, dynamic> ? event : event.toJson();
|
||
if (json == null) {
|
||
Logger.analyticsSdk('事件 toJson() 返回 null,跳过');
|
||
return null;
|
||
}
|
||
return SdkContext.attachDeviceEnvIfNeeded(json);
|
||
} catch (e) {
|
||
Logger.analyticsSdk('事件序列化失败,跳过: $e');
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// 校验事件字段。返回 null 表示校验不通过,应丢弃事件。
|
||
static Map<String, dynamic>? validateEvent(Map<String, dynamic> json) {
|
||
final validated = EventValidator.validate(json);
|
||
if (validated == null) {
|
||
Logger.analyticsSdk('事件校验不通过,已丢弃: ${json['event']}',
|
||
level: LogLevel.warn);
|
||
}
|
||
return validated;
|
||
}
|
||
|
||
/// 检查单条事件大小。返回 false 表示超限应丢弃。
|
||
static bool checkEventSize(Map<String, dynamic> json) {
|
||
try {
|
||
final encoded = jsonEncode(json);
|
||
final sizeBytes = utf8.encode(encoded).length;
|
||
if (sizeBytes > SdkConfig.maxSingleEventSize) {
|
||
final sizeKB = (sizeBytes / 1024).toStringAsFixed(1);
|
||
final limitKB =
|
||
(SdkConfig.maxSingleEventSize / 1024).toStringAsFixed(0);
|
||
Logger.analyticsSdk(
|
||
'事件过大 (${sizeKB}KB > ${limitKB}KB 限制),丢弃。event=${json['event']}',
|
||
level: LogLevel.warn);
|
||
return false;
|
||
}
|
||
return true;
|
||
} catch (e) {
|
||
Logger.analyticsSdk('事件大小检查失败,继续上报: $e');
|
||
return true;
|
||
}
|
||
}
|
||
|
||
/// 检查事件类型是否在配置中启用。返回 false 表示未启用应跳过。
|
||
static bool isEventTypeEnabled(Map<String, dynamic> json) {
|
||
try {
|
||
final eventType = json['event'] as String?;
|
||
if (eventType != null &&
|
||
!EventTypeConfigManager.instance.isEventTypeEnabled(eventType)) {
|
||
Logger.analyticsSdk('事件类型 $eventType 未在配置中启用,跳过上报');
|
||
return false;
|
||
}
|
||
return true;
|
||
} catch (e) {
|
||
Logger.analyticsSdk('事件类型检查失败,继续上报: $e');
|
||
return true;
|
||
}
|
||
}
|
||
|
||
static List<String>? _getUnreportedAdIds(AdImpressionEvent event) {
|
||
try {
|
||
if (event.adId.isEmpty) return null;
|
||
final trimmedAdId = event.adId.trim();
|
||
if (trimmedAdId.isEmpty) return null;
|
||
return AdImpressionManager.instance
|
||
.getUnreportedAdIds(trimmedAdId, pageKey: event.pageKey);
|
||
} catch (e) {
|
||
Logger.analyticsSdk('广告去重检查异常,跳过: $e');
|
||
return null;
|
||
}
|
||
}
|
||
|
||
static AdImpressionEvent _createFilteredAdEvent(
|
||
AdImpressionEvent originalEvent, List<String> unreportedIds) {
|
||
try {
|
||
final originalAdIds = originalEvent.adId
|
||
.split(',')
|
||
.map((id) => id.trim())
|
||
.where((id) => id.isNotEmpty)
|
||
.toList();
|
||
|
||
List<String>? originalCreativeIds;
|
||
if (originalEvent.creativeId.isNotEmpty) {
|
||
originalCreativeIds =
|
||
originalEvent.creativeId.split(',').map((id) => id.trim()).toList();
|
||
}
|
||
|
||
final filteredAdIds = <String>[];
|
||
final filteredCreativeIds = <String>[];
|
||
|
||
for (int i = 0; i < originalAdIds.length; i++) {
|
||
if (unreportedIds.contains(originalAdIds[i])) {
|
||
filteredAdIds.add(originalAdIds[i]);
|
||
if (originalCreativeIds != null && i < originalCreativeIds.length) {
|
||
filteredCreativeIds.add(originalCreativeIds[i]);
|
||
} else {
|
||
filteredCreativeIds.add('');
|
||
}
|
||
}
|
||
}
|
||
|
||
return AdImpressionEvent(
|
||
pageKey: originalEvent.pageKey,
|
||
pageName: originalEvent.pageName,
|
||
adSlotKey: originalEvent.adSlotKey,
|
||
requestId: originalEvent.requestId,
|
||
adId: filteredAdIds.join(','),
|
||
materialSize: originalEvent.materialSize,
|
||
creativeId: originalEvent.creativeId.isNotEmpty
|
||
? filteredCreativeIds.join(',')
|
||
: "",
|
||
adType: originalEvent.adType,
|
||
);
|
||
} catch (e) {
|
||
Logger.analyticsSdk('创建过滤后的广告事件失败,使用原始事件: $e');
|
||
return originalEvent;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Debug 调试辅助方法(纯静态,不依赖 AnalyticsSdk 实例)
|
||
class _DebugInspector {
|
||
static String formatTime(DateTime? dt) {
|
||
if (dt == null) return '-';
|
||
return '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}';
|
||
}
|
||
|
||
/// 验证加密配置字符串格式(仅 debug 包可用)
|
||
static Map<String, dynamic> validateEncryptedConfig(String ciphertext) {
|
||
if (kReleaseMode) {
|
||
return {'success': false, 'preview': null, 'error': 'release 包不可用'};
|
||
}
|
||
try {
|
||
final plaintext = AesGcmUtil.decryptResponseAuto(ciphertext);
|
||
final raw = jsonDecode(plaintext);
|
||
|
||
if (raw is List) {
|
||
if (raw.isNotEmpty) {
|
||
return {
|
||
'success': true,
|
||
'preview': raw[0].toString(),
|
||
'count': raw.length,
|
||
'error': null,
|
||
};
|
||
}
|
||
final preview =
|
||
plaintext.length > 60 ? plaintext.substring(0, 60) : plaintext;
|
||
return {
|
||
'success': false,
|
||
'preview': preview,
|
||
'error': '解密成功但列表为空(0 个元素),请检查配置',
|
||
};
|
||
}
|
||
|
||
if (raw is! Map<String, dynamic>) {
|
||
return {
|
||
'success': false,
|
||
'preview':
|
||
plaintext.length > 60 ? plaintext.substring(0, 60) : plaintext,
|
||
'error': '解密成功但格式错误:应为 JSON 数组或对象,实际为 ${raw.runtimeType}',
|
||
};
|
||
}
|
||
|
||
final decoded = raw;
|
||
List? items;
|
||
if (decoded['domainList'] is List) {
|
||
items = decoded['domainList'] as List;
|
||
} else if (decoded['eventList'] is List) {
|
||
items = decoded['eventList'] as List;
|
||
} else if (decoded['enabled_event_types'] is List) {
|
||
items = decoded['enabled_event_types'] as List;
|
||
}
|
||
|
||
if (items != null && items.isNotEmpty) {
|
||
return {
|
||
'success': true,
|
||
'preview': items[0].toString(),
|
||
'count': items.length,
|
||
'error': null,
|
||
};
|
||
}
|
||
|
||
final preview =
|
||
plaintext.length > 60 ? plaintext.substring(0, 60) : plaintext;
|
||
return {
|
||
'success': false,
|
||
'preview': preview,
|
||
'error': '解密成功但格式无法识别(0 个元素),请检查 JSON 格式',
|
||
};
|
||
} catch (e) {
|
||
return {'success': false, 'preview': null, 'error': '解密失败: $e'};
|
||
}
|
||
}
|
||
}
|