diff --git a/lib/analytics/analytics_service.dart b/lib/analytics/analytics_service.dart index eb75667..0f0d174 100644 --- a/lib/analytics/analytics_service.dart +++ b/lib/analytics/analytics_service.dart @@ -3,28 +3,58 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:uuid/uuid.dart'; -/// Simplified analytics client for M0 (report 13 + ticket 19): track events -/// to backend POST /api/v1/events. Queue failures locally (shared_preferences, -/// max 500), flush on successful upload or when full. Network errors are -/// silently discarded (no retry as spec'd); privacy red-line enforced locally. +/// Simplified analytics client for M0/M1 (report 13 + ticket 19): track events +/// to backend POST /api/v1/events. In-memory queue flushed every 20 events; +/// failed batches are re-queued (capped at 500, oldest dropped). Persistent +/// segmented queue lands in M2 wave 2. Privacy red-line enforced locally. class AnalyticsService { AnalyticsService({ required this.apiBaseUrl, required this.getAccessToken, + required this.getSessionId, String? anonymousId, - this._userId, - }) : _anonymousId = anonymousId ?? const Uuid().v4(); + }) : _anonymousId = anonymousId ?? const Uuid().v4(), + _appVersion = 'unknown', + _osVersion = _defaultOsVersion(); - // M0:内存队列,满 _flushThreshold 条上传一次;持久化队列留 M1(报告 19 §3)。 + /// 异步设置 appVersion(app.dart 启动时从 package_info_plus 读取后注入)。 + void setAppVersion(String version) { + _appVersion = version; + } + + // 内存队列:满 _flushThreshold 条上传一次;分段持久化队列排 M2 第二波。 static const _flushThreshold = 20; + // 失败重回队列的容量上限(对齐 13 号规范队列上限),超限丢最旧。 + static const _maxQueuedEvents = 500; + final String apiBaseUrl; - final String Function()? getAccessToken; + final String? Function()? getAccessToken; + + /// 会话标识来源(SessionTracker 注入),冷启动/长后台换新由其管理。 + final String Function() getSessionId; + final String _anonymousId; + String _appVersion; + final String _osVersion; String? _userId; + bool _flushing = false; final List> _pendingEvents = []; - /// Sets userId after login (M0: no sessionId logic, simplified). + /// 待上报事件(测试断言用,生产代码不得直接操作)。 + @visibleForTesting + List> get pendingEvents => + List.unmodifiable(_pendingEvents); + + /// 粗粒度 osVersion(13 号规范 §4.0:主版本级,如 android-14)。 + static String _defaultOsVersion() { + final major = RegExp( + r'\d+', + ).firstMatch(Platform.operatingSystemVersion)?.group(0); + return '${Platform.operatingSystem}-${major ?? 'unknown'}'; + } + + /// Sets userId after login/restore. void identify(String userId) { _userId = userId; } @@ -47,18 +77,16 @@ class AnalyticsService { } final event = { - 'eventId': const Uuid().v4(), + 'eventId': const Uuid().v7(), 'eventName': eventName, 'eventVersion': 1, 'anonymousId': _anonymousId, if (_userId != null) 'userId': _userId, - 'sessionId': const Uuid().v4(), // Simplified: unique per event (M0) + 'sessionId': getSessionId(), 'clientTs': DateTime.now().toUtc().toIso8601String(), - 'appVersion': '1.0.0+1', // TODO: read from package_info_plus + 'appVersion': _appVersion, 'platform': Platform.isAndroid ? 'android' : 'ios', - 'osVersion': Platform.isAndroid - ? 'android-14' - : 'ios-17', // TODO: device_info_plus + 'osVersion': _osVersion, if (props != null && props.isNotEmpty) 'props': props, }; @@ -72,7 +100,8 @@ class AnalyticsService { } Future _flush() async { - if (_pendingEvents.isEmpty) return; + if (_flushing || _pendingEvents.isEmpty) return; + _flushing = true; final batch = List>.from(_pendingEvents); _pendingEvents.clear(); @@ -80,7 +109,16 @@ class AnalyticsService { try { await _upload(batch); } catch (error) { - debugPrint('Analytics upload failed, discarding batch: $error'); + // 顺手加固(03 §1.4 #1 的一行级缓解):失败不再整批丢弃, + // 重回队首等下次冲刷;上限 500 条,超限丢最旧。真正的 + // shared_preferences 分段持久化队列属 M2 第二波。 + debugPrint('Analytics upload failed, requeueing batch: $error'); + _pendingEvents.insertAll(0, batch); + if (_pendingEvents.length > _maxQueuedEvents) { + _pendingEvents.removeRange(0, _pendingEvents.length - _maxQueuedEvents); + } + } finally { + _flushing = false; } } diff --git a/lib/analytics/session_tracker.dart b/lib/analytics/session_tracker.dart new file mode 100644 index 0000000..c4668bc --- /dev/null +++ b/lib/analytics/session_tracker.dart @@ -0,0 +1,48 @@ +import 'package:flutter/widgets.dart'; +import 'package:uuid/uuid.dart'; + +/// sessionId 生命周期管理(13 号规范 §3.1、06 号报告 §5.1)。 +/// +/// 语义三条:冷启动生成新 sessionId;`paused → resumed` 间隔超过 +/// [timeout](默认 30 分钟)生成新 sessionId;未超过则沿用原值。 +/// sessionId 不落任何持久化存储——会话本该跨冷启动失效(03 号评估 §3.1, +/// 纯内存方案,lastActiveAt 不持久化)。 +/// +/// 注意生命周期级联:前台恢复时状态机会依次经过 +/// `paused → hidden → inactive → resumed`,因此只在**离开 resumed 的第一次 +/// 变更**记录退后台时刻,后续级联状态不得覆盖,否则间隔永远趋近于零。 +class SessionTracker with WidgetsBindingObserver { + SessionTracker({ + this.timeout = const Duration(minutes: 30), + DateTime Function()? now, + }) : _now = now ?? DateTime.now, + _sessionId = const Uuid().v7(); + + /// 后台超时阈值;构造参数化便于测试注入。 + final Duration timeout; + + /// 时钟注入口,测试免真实等待。 + final DateTime Function() _now; + + String _sessionId; + DateTime? _leftForegroundAt; + AppLifecycleState _lastState = AppLifecycleState.resumed; + + /// 当前会话标识(UUIDv7)。 + String get sessionId => _sessionId; + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + final leftAt = _leftForegroundAt; + if (leftAt != null && _now().difference(leftAt) > timeout) { + _sessionId = const Uuid().v7(); + } + _leftForegroundAt = null; + } else if (_lastState == AppLifecycleState.resumed) { + // 首次离开前台才记时;inactive/hidden/paused 级联不覆盖。 + _leftForegroundAt = _now(); + } + _lastState = state; + } +} diff --git a/lib/app/app.dart b/lib/app/app.dart index f04c3a0..daf89e3 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -1,4 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:patbond_flutter/analytics/analytics_page_name.dart'; +import 'package:patbond_flutter/analytics/analytics_route_observer.dart'; +import 'package:patbond_flutter/analytics/analytics_service.dart'; +import 'package:patbond_flutter/analytics/page_view_tracker.dart'; +import 'package:patbond_flutter/analytics/session_tracker.dart'; import 'package:patbond_flutter/core/network/api_client.dart'; import 'package:patbond_flutter/core/network/token_refresher.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; @@ -24,6 +30,10 @@ class _AppState extends State { late final AppState appState; late final SessionManager sessionManager; late final AuthRepository authRepository; + late final SessionTracker _sessionTracker; + late final AnalyticsService _analytics; + late final PageViewTracker _pageViewTracker; + late final AnalyticsRouteObserver _routeObserver; @override void initState() { @@ -32,7 +42,37 @@ class _AppState extends State { sessionManager = widget.sessionManager ?? SessionManager(store: const SecureTokenStore()); + + _sessionTracker = SessionTracker(); + WidgetsBinding.instance.addObserver(_sessionTracker); + + _analytics = AnalyticsService( + apiBaseUrl: patbondApiBaseUrl, + getAccessToken: () => sessionManager.accessToken, + getSessionId: () => _sessionTracker.sessionId, + ); + _initAppVersion(); + + _pageViewTracker = PageViewTracker(_analytics.trackEvent); + _routeObserver = AnalyticsRouteObserver( + tracker: _pageViewTracker, + resolveRootPage: _resolveRootPage, + ); + authRepository = widget.authRepository ?? _buildRepository(); + + // 认证状态切换补点(根路由 AnimatedSwitcher 无路由事件) + sessionManager.addListener(_reportAuthStateChange); + } + + /// 异步读取 appVersion 并注入 analytics(启动后台任务,不阻塞渲染)。 + Future _initAppVersion() async { + try { + final info = await PackageInfo.fromPlatform(); + _analytics.setAppVersion('${info.version}+${info.buildNumber}'); + } catch (error) { + // 读取失败回退 'unknown'(AnalyticsService 构造默认值已兜底)。 + } } AuthRepository _buildRepository() { @@ -47,11 +87,33 @@ class _AppState extends State { api: api, session: sessionManager, refresher: refresher, + analytics: _analytics, ); } + AnalyticsPageName? _resolveRootPage() { + // 回栈到无名根路由时解析当前认证状态页/主壳 Tab + return switch (sessionManager.status) { + AuthStatus.unknown => null, + AuthStatus.unauthenticated => AnalyticsPageName.login, + AuthStatus.authenticated => _pageViewTracker.currentTab, + }; + } + + void _reportAuthStateChange() { + // 认证状态机切页补点(03 §3.2 非路由曝光 1/2) + final page = switch (sessionManager.status) { + AuthStatus.unauthenticated => AnalyticsPageName.login, + AuthStatus.authenticated => _pageViewTracker.currentTab, + AuthStatus.unknown => null, + }; + if (page != null) _pageViewTracker.report(page); + } + @override void dispose() { + sessionManager.removeListener(_reportAuthStateChange); + WidgetsBinding.instance.removeObserver(_sessionTracker); appState.dispose(); if (widget.sessionManager == null) sessionManager.dispose(); super.dispose(); @@ -74,6 +136,7 @@ class _AppState extends State { return MainShellPage( key: const ValueKey('shell'), appState: appState, + pageViewTracker: _pageViewTracker, onLogout: authRepository.logout, ); } @@ -85,6 +148,7 @@ class _AppState extends State { title: 'Patbond', debugShowCheckedModeBanner: false, theme: buildAppTheme(), + navigatorObservers: [_routeObserver], home: ListenableBuilder( listenable: sessionManager, builder: (context, _) { diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index ee71999..8c9632b 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,9 +6,11 @@ import FlutterMacOS import Foundation import flutter_secure_storage_darwin +import package_info_plus import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 32d9f2f..4450dda 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -208,6 +208,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" http_parser: dependency: transitive description: @@ -328,6 +336,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + package_info_plus: + dependency: "direct main" + 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: diff --git a/pubspec.yaml b/pubspec.yaml index 64301fc..d129276 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,6 +38,7 @@ dependencies: dio: ^5.11.1 flutter_secure_storage: ^11.0.0 uuid: ^4.6.0 + package_info_plus: ^10.2.1 dev_dependencies: flutter_test: diff --git a/test/analytics/analytics_service_test.dart b/test/analytics/analytics_service_test.dart index 0ce6699..6dc3331 100644 --- a/test/analytics/analytics_service_test.dart +++ b/test/analytics/analytics_service_test.dart @@ -2,59 +2,125 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:patbond_flutter/analytics/analytics_service.dart'; void main() { + final uuidV7 = RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$', + ); + + AnalyticsService buildService({ + String Function()? getSessionId, + String? anonymousId, + }) { + var counter = 0; + return AnalyticsService( + apiBaseUrl: 'http://test', + getAccessToken: null, + getSessionId: getSessionId ?? () => 'session-${counter++}', + anonymousId: anonymousId, + ); + } + group('AnalyticsService', () { test('tracks event with required fields', () async { - final service = AnalyticsService( - apiBaseUrl: 'http://test', - getAccessToken: null, - ); + final service = buildService(getSessionId: () => 'session-a'); - // Never throws, never awaits network (fire-and-forget). - expect( - () => service.trackEvent('auth_login_succeeded', { - 'identifierType': 'username', - 'durationMs': 123, - }), - returnsNormally, - ); + await service.trackEvent('auth_login_succeeded', { + 'identifierType': 'username', + 'durationMs': 123, + }); + + final event = service.pendingEvents.single; + expect(event['eventName'], 'auth_login_succeeded'); + expect(event['eventVersion'], 1); + expect(event['sessionId'], 'session-a'); + expect(event['anonymousId'], isNotEmpty); + expect(event['clientTs'], isNotEmpty); + expect(event['appVersion'], isNotEmpty); + expect(event['osVersion'], isNotEmpty); + expect(event['props'], {'identifierType': 'username', 'durationMs': 123}); + }); + + test('eventId 为 UUIDv7 且逐事件唯一', () async { + final service = buildService(); + + await service.trackEvent('auth_login_succeeded'); + await service.trackEvent('auth_logout'); + + final ids = service.pendingEvents + .map((event) => event['eventId'] as String) + .toList(); + expect(ids[0], matches(uuidV7)); + expect(ids[1], matches(uuidV7)); + expect(ids[0], isNot(ids[1])); + }); + + test('同一 tracker 下多事件 sessionId 相同,不再每事件生成', () async { + final service = buildService(getSessionId: () => 'tracker-session'); + + await service.trackEvent('auth_login_succeeded'); + await service.trackEvent('page_viewed', {'pageName': 'home'}); + await service.trackEvent('auth_logout'); + + final sessionIds = service.pendingEvents + .map((event) => event['sessionId']) + .toSet(); + expect(sessionIds, {'tracker-session'}); + }); + + test('appVersion 可注入更新(不再硬编码)', () async { + final service = buildService(); + + service.setAppVersion('2.3.4+56'); + await service.trackEvent('auth_login_succeeded'); + + expect(service.pendingEvents.single['appVersion'], '2.3.4+56'); }); test('rejects events with forbidden field patterns', () async { - final service = AnalyticsService( - apiBaseUrl: 'http://test', - getAccessToken: null, - ); + final service = buildService(); // Forbidden field triggers local rejection (no throw, silent drop). await service.trackEvent('auth_login_succeeded', { 'userPassword': 'leak', // Forbidden pattern }); - // Event should be dropped (no assertion — just verify no crash). + expect(service.pendingEvents, isEmpty); }); - test('identify sets userId', () { - final service = AnalyticsService( - apiBaseUrl: 'http://test', - getAccessToken: null, - ); + test('identify sets userId on subsequent events', () async { + final service = buildService(); service.identify('user-123'); + await service.trackEvent('auth_login_succeeded'); - // Subsequent events will carry userId (validated in integration tests). + expect(service.pendingEvents.single['userId'], 'user-123'); }); - test('reset clears userId but keeps anonymousId', () { - final service = AnalyticsService( - apiBaseUrl: 'http://test', - getAccessToken: null, - anonymousId: 'anon-123', - ); + test('reset clears userId but keeps anonymousId', () async { + final service = buildService(anonymousId: 'anon-123'); service.identify('user-123'); service.reset(); + await service.trackEvent('auth_logout'); - // userId cleared, anonymousId retained (validated in integration tests). + final event = service.pendingEvents.single; + expect(event.containsKey('userId'), isFalse); + expect(event['anonymousId'], 'anon-123'); + }); + + test('上传失败批次重回队列而非整批丢弃', () async { + // apiBaseUrl 指向不可达端口:满 20 条触发 flush 必然失败。 + final service = AnalyticsService( + apiBaseUrl: 'http://127.0.0.1:1', + getAccessToken: null, + getSessionId: () => 's', + ); + + for (var i = 0; i < 20; i++) { + await service.trackEvent('auth_login_succeeded', {'attemptSeq': i}); + } + + // 第 20 条触发上传,失败后批次应重回队列(M0 行为是整批清空)。 + expect(service.pendingEvents.length, 20); }); }); } diff --git a/test/analytics/session_tracker_test.dart b/test/analytics/session_tracker_test.dart new file mode 100644 index 0000000..db4a4b6 --- /dev/null +++ b/test/analytics/session_tracker_test.dart @@ -0,0 +1,77 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/analytics/session_tracker.dart'; + +void main() { + final uuidV7 = RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$', + ); + + group('SessionTracker', () { + test('冷启动生成 UUIDv7 格式的 sessionId', () { + final tracker = SessionTracker(); + expect(tracker.sessionId, matches(uuidV7)); + }); + + test('短后台(≤30 分钟)resume 后沿用原 sessionId', () { + var clock = DateTime.utc(2026, 9, 7, 10); + final tracker = SessionTracker(now: () => clock); + final original = tracker.sessionId; + + tracker.didChangeAppLifecycleState(AppLifecycleState.paused); + clock = clock.add(const Duration(minutes: 30)); + tracker.didChangeAppLifecycleState(AppLifecycleState.resumed); + + expect(tracker.sessionId, original); + }); + + test('长后台(>30 分钟)resume 后生成新 sessionId', () { + var clock = DateTime.utc(2026, 9, 7, 10); + final tracker = SessionTracker(now: () => clock); + final original = tracker.sessionId; + + tracker.didChangeAppLifecycleState(AppLifecycleState.paused); + clock = clock.add(const Duration(minutes: 31)); + tracker.didChangeAppLifecycleState(AppLifecycleState.resumed); + + expect(tracker.sessionId, isNot(original)); + expect(tracker.sessionId, matches(uuidV7)); + }); + + test('真实生命周期级联下退后台时刻不被 inactive 覆盖', () { + // 前台恢复时状态机级联 paused → hidden → inactive → resumed, + // 若 inactive 覆盖记时,31 分钟长后台会被误判为 0 分钟。 + var clock = DateTime.utc(2026, 9, 7, 10); + final tracker = SessionTracker(now: () => clock); + final original = tracker.sessionId; + + tracker.didChangeAppLifecycleState(AppLifecycleState.inactive); + tracker.didChangeAppLifecycleState(AppLifecycleState.hidden); + tracker.didChangeAppLifecycleState(AppLifecycleState.paused); + clock = clock.add(const Duration(minutes: 31)); + tracker.didChangeAppLifecycleState(AppLifecycleState.hidden); + tracker.didChangeAppLifecycleState(AppLifecycleState.inactive); + tracker.didChangeAppLifecycleState(AppLifecycleState.resumed); + + expect(tracker.sessionId, isNot(original)); + }); + + test('连续多次短后台幂等,仅超阈值才换新', () { + var clock = DateTime.utc(2026, 9, 7, 10); + final tracker = SessionTracker(now: () => clock); + final original = tracker.sessionId; + + for (var i = 0; i < 3; i++) { + tracker.didChangeAppLifecycleState(AppLifecycleState.paused); + clock = clock.add(const Duration(minutes: 10)); + tracker.didChangeAppLifecycleState(AppLifecycleState.resumed); + expect(tracker.sessionId, original); + } + + tracker.didChangeAppLifecycleState(AppLifecycleState.paused); + clock = clock.add(const Duration(minutes: 35)); + tracker.didChangeAppLifecycleState(AppLifecycleState.resumed); + expect(tracker.sessionId, isNot(original)); + }); + }); +}