diff --git a/lib/analytics/analytics_page_name.dart b/lib/analytics/analytics_page_name.dart new file mode 100644 index 0000000..8be1e15 --- /dev/null +++ b/lib/analytics/analytics_page_name.dart @@ -0,0 +1,36 @@ +/// page_viewed 的 pageName 编译期枚举(06 号报告 §5.2 字典 v2 + 03 号评估 +/// §3.2 Tab 映射)。禁止自由路由字符串;带参数路由必须归一化,任何 +/// UUID/ID 不得出现在 pageName 或 referrer 中(隐私红线第 5 条)。 +/// +/// 尚不存在的 M2 页面(petList/petDetail/petForm/recordForm/recordDetail) +/// 先留枚举定义、不接线,待健康档案页面族落地时启用。 +enum AnalyticsPageName { + // —— 字典 v2 初始集合(06 §5.2 验收 2)—— + login('login'), + register('register'), + home('home'), + profile('profile'), + petList('pet_list'), + petDetail('pet_detail'), + petForm('pet_form'), + recordForm('record_form'), + recordDetail('record_detail'), + // —— 客户端现存页面/Tab 补充(03 §3.2 Tab 映射,需数据侧同步进字典)—— + create('create'), + petArchive('pet_archive'), + services('services'), + postDetail('post_detail'); + + const AnalyticsPageName(this.pageName); + + /// 上报值,snake_case,与 RouteSettings.name 一致。 + final String pageName; + + static final Map _byName = { + for (final page in values) page.pageName: page, + }; + + /// 路由名 → 枚举;不在枚举内返回 null(调用方不上报,06 §5.2 验收 4)。 + static AnalyticsPageName? fromRouteName(String? name) => + name == null ? null : _byName[name]; +} diff --git a/lib/analytics/analytics_route_observer.dart b/lib/analytics/analytics_route_observer.dart new file mode 100644 index 0000000..6c62c16 --- /dev/null +++ b/lib/analytics/analytics_route_observer.dart @@ -0,0 +1,49 @@ +import 'package:flutter/widgets.dart'; +import 'package:patbond_flutter/analytics/analytics_page_name.dart'; +import 'package:patbond_flutter/analytics/page_view_tracker.dart'; + +/// 集中式 page_viewed 路由埋点(03 号评估 §3.2:NavigatorObserver 派生, +/// 页面零侵入;06 号报告 §5.2 验收 1/4)。 +/// +/// 只统计 [PageRoute](bottom sheet / dialog 是 PopupRoute,不计曝光); +/// 路由名不在 [AnalyticsPageName] 枚举内不上报(后端会整条 rejected, +/// 不白白消耗队列)。didPop 补报「回退后重新曝光的前一页」;弹回到 +/// 无名根路由(认证状态机的 AnimatedSwitcher 宿主)时经 [resolveRootPage] +/// 解析当前根部页面。 +class AnalyticsRouteObserver extends NavigatorObserver { + AnalyticsRouteObserver({required this.tracker, this.resolveRootPage}); + + final PageViewTracker tracker; + + /// 根路由(无 RouteSettings.name)当前展示页的解析器,由 app.dart + /// 按认证状态与主壳当前 Tab 提供;返回 null 则不补报。 + final AnalyticsPageName? Function()? resolveRootPage; + + void _reportRoute(Route? route) { + if (route is! PageRoute) return; + final page = AnalyticsPageName.fromRouteName(route.settings.name); + if (page != null) tracker.report(page); + } + + @override + void didPush(Route route, Route? previousRoute) { + _reportRoute(route); + } + + @override + void didReplace({Route? newRoute, Route? oldRoute}) { + _reportRoute(newRoute); + } + + @override + void didPop(Route route, Route? previousRoute) { + if (previousRoute is! PageRoute) return; + final page = AnalyticsPageName.fromRouteName(previousRoute.settings.name); + if (page != null) { + tracker.report(page); + } else if (previousRoute.isFirst) { + final rootPage = resolveRootPage?.call(); + if (rootPage != null) tracker.report(rootPage); + } + } +} 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/page_view_tracker.dart b/lib/analytics/page_view_tracker.dart new file mode 100644 index 0000000..ccb3c07 --- /dev/null +++ b/lib/analytics/page_view_tracker.dart @@ -0,0 +1,38 @@ +import 'package:patbond_flutter/analytics/analytics_page_name.dart'; + +/// trackEvent 形状的回调(生产传 `AnalyticsService.trackEvent`,测试传录制桩)。 +typedef TrackEventFn = + Future Function(String eventName, [Map? props]); + +/// page_viewed 上报的单一出口:维护 referrer 链与去重。 +/// +/// 路由事件(AnalyticsRouteObserver)、主壳 Tab 切换、认证状态机切页 +/// 三类曝光都经此上报,referrer(前一页 pageName)才能跨机制连贯; +/// 栈底/冷启动首页 referrer 为 null(06 §5.2 验收 3)。 +class PageViewTracker { + PageViewTracker(this._track); + + final TrackEventFn _track; + + String? _lastPageName; + AnalyticsPageName? _currentTab; + + /// 主壳当前 Tab(供回栈补报解析根路由曝光页)。 + AnalyticsPageName? get currentTab => _currentTab; + + /// 上报一次页面曝光;与上一条相同的页面去重(Tab 重复点选等)。 + void report(AnalyticsPageName page) { + if (page.pageName == _lastPageName) return; + final referrer = _lastPageName; + _lastPageName = page.pageName; + final props = {'pageName': page.pageName}; + if (referrer != null) props['referrer'] = referrer; + _track('page_viewed', props); + } + + /// 主壳 Tab 曝光:记录当前 Tab 后上报(IndexedStack 无路由事件,手动补点)。 + void reportTab(AnalyticsPageName page) { + _currentTab = page; + report(page); + } +} 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/lib/core/navigation/fade_route.dart b/lib/core/navigation/fade_route.dart index 4dcb87f..a84ea58 100644 --- a/lib/core/navigation/fade_route.dart +++ b/lib/core/navigation/fade_route.dart @@ -1,8 +1,12 @@ import 'package:flutter/material.dart'; /// 统一页面切换:300ms 淡入淡出(12 号组装稿 §7 流程约束)。 -Route fadePageRoute(Widget page) { +/// +/// [settings] 从 M2 起必传路由名(page_viewed 取数来源,03 号评估 §2.1), +/// 名字用 `AnalyticsPageName` 枚举的 pageName 值。 +Route fadePageRoute(Widget page, {RouteSettings? settings}) { return PageRouteBuilder( + settings: settings, transitionDuration: const Duration(milliseconds: 300), reverseTransitionDuration: const Duration(milliseconds: 300), pageBuilder: (context, animation, secondaryAnimation) => page, diff --git a/lib/features/auth/login_page.dart b/lib/features/auth/login_page.dart index cb1e623..41c0823 100644 --- a/lib/features/auth/login_page.dart +++ b/lib/features/auth/login_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/semantics.dart'; import 'package:flutter/services.dart'; +import 'package:patbond_flutter/analytics/analytics_page_name.dart'; import 'package:patbond_flutter/core/navigation/fade_route.dart'; import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; @@ -130,7 +131,10 @@ class _LoginPageState extends State { void _goRegister() { Navigator.of(context).push( - fadePageRoute(RegisterPage(authRepository: widget.authRepository)), + fadePageRoute( + RegisterPage(authRepository: widget.authRepository), + settings: RouteSettings(name: AnalyticsPageName.register.pageName), + ), ); } diff --git a/lib/features/main/main_shell_page.dart b/lib/features/main/main_shell_page.dart index 310b9b4..ae0380a 100644 --- a/lib/features/main/main_shell_page.dart +++ b/lib/features/main/main_shell_page.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:patbond_flutter/analytics/analytics_page_name.dart'; +import 'package:patbond_flutter/analytics/page_view_tracker.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; import 'package:patbond_flutter/features/create/create_page.dart'; import 'package:patbond_flutter/features/home/home_page.dart'; @@ -11,10 +13,18 @@ import 'package:patbond_flutter/state/app_state.dart'; import 'package:patbond_flutter/widgets/common.dart'; class MainShellPage extends StatefulWidget { - const MainShellPage({required this.appState, super.key, this.onLogout}); + const MainShellPage({ + required this.appState, + super.key, + this.pageViewTracker, + this.onLogout, + }); final AppState appState; + /// Tab 曝光补点(IndexedStack 切换不产生路由事件,03 号评估 §3.2)。 + final PageViewTracker? pageViewTracker; + /// 退出登录:调 logout 接口并清会话,认证状态机自动回登录页。 final Future Function()? onLogout; @@ -28,8 +38,25 @@ class _MainShellPageState extends State { static const titles = ['首页', '创作中心', '健康档案', '本地服务', '我的资料']; + /// Tab 索引 → pageName 枚举(03 号评估 §3.2 映射表)。 + static const _tabPages = [ + AnalyticsPageName.home, + AnalyticsPageName.create, + AnalyticsPageName.petArchive, + AnalyticsPageName.services, + AnalyticsPageName.profile, + ]; + + @override + void initState() { + super.initState(); + // 初始 Tab 曝光补一次(03 号评估 §3.2 手动补点 1)。 + widget.pageViewTracker?.reportTab(_tabPages[currentIndex]); + } + void selectTab(int index) { setState(() => currentIndex = index); + widget.pageViewTracker?.reportTab(_tabPages[index]); } void openServices(bool personal) { @@ -37,11 +64,13 @@ class _MainShellPageState extends State { showPersonalServices = personal; currentIndex = 3; }); + widget.pageViewTracker?.reportTab(_tabPages[3]); } void openPost(PostModel post) { Navigator.of(context).push( MaterialPageRoute( + settings: RouteSettings(name: AnalyticsPageName.postDetail.pageName), builder: (context) => PostDetailPage(appState: widget.appState, postId: post.id), ), 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_route_observer_test.dart b/test/analytics/analytics_route_observer_test.dart new file mode 100644 index 0000000..fa9a3a9 --- /dev/null +++ b/test/analytics/analytics_route_observer_test.dart @@ -0,0 +1,237 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/analytics/analytics_page_name.dart'; +import 'package:patbond_flutter/analytics/analytics_route_observer.dart'; +import 'package:patbond_flutter/analytics/page_view_tracker.dart'; + +void main() { + group('AnalyticsRouteObserver', () { + testWidgets('push 路由报 page_viewed', (tester) async { + final events = >[]; + final tracker = PageViewTracker((name, [props]) async { + events.add({'event': name, ...?props}); + }); + final observer = AnalyticsRouteObserver(tracker: tracker); + + await tester.pumpWidget( + MaterialApp( + navigatorObservers: [observer], + home: Builder( + builder: (context) => ElevatedButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + settings: RouteSettings( + name: AnalyticsPageName.login.pageName, + ), + builder: (_) => const Placeholder(), + ), + ), + child: const Text('Go'), + ), + ), + ), + ); + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + expect(events, hasLength(1)); + expect(events.first['event'], 'page_viewed'); + expect(events.first['pageName'], 'login'); + }); + + testWidgets('push 两页 referrer 链正确,pop 返回补报前一页', (tester) async { + final events = >[]; + final tracker = PageViewTracker((name, [props]) async { + events.add({'event': name, ...?props}); + }); + final observer = AnalyticsRouteObserver(tracker: tracker); + + await tester.pumpWidget( + MaterialApp( + navigatorObservers: [observer], + home: Builder( + builder: (context) => ElevatedButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + settings: RouteSettings( + name: AnalyticsPageName.login.pageName, + ), + builder: (loginContext) => ElevatedButton( + onPressed: () => Navigator.of(loginContext).push( + MaterialPageRoute( + settings: RouteSettings( + name: AnalyticsPageName.register.pageName, + ), + builder: (registerContext) => ElevatedButton( + onPressed: () => Navigator.of(registerContext).pop(), + child: const Text('Back'), + ), + ), + ), + child: const Text('Next'), + ), + ), + ), + child: const Text('Go'), + ), + ), + ), + ); + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Next')); + await tester.pumpAndSettle(); + + // push 两页:两条事件,referrer 链正确(06 §5.2 验收 5)。 + expect(events.map((e) => e['pageName']), ['login', 'register']); + expect(events[0].containsKey('referrer'), isFalse); + expect(events[1]['referrer'], 'login'); + + // pop 返回:didPop 补报重新曝光的前一页,referrer 为弹出页。 + await tester.tap(find.text('Back')); + await tester.pumpAndSettle(); + expect(events, hasLength(3)); + expect(events[2]['pageName'], 'login'); + expect(events[2]['referrer'], 'register'); + }); + + testWidgets('pop 回无名根路由经 resolveRootPage 补报', (tester) async { + final events = >[]; + final tracker = PageViewTracker((name, [props]) async { + events.add({'event': name, ...?props}); + }); + final observer = AnalyticsRouteObserver( + tracker: tracker, + resolveRootPage: () => AnalyticsPageName.home, + ); + + await tester.pumpWidget( + MaterialApp( + navigatorObservers: [observer], + home: Builder( + builder: (context) => ElevatedButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + settings: RouteSettings( + name: AnalyticsPageName.profile.pageName, + ), + builder: (innerContext) => ElevatedButton( + onPressed: () => Navigator.of(innerContext).pop(), + child: const Text('Back'), + ), + ), + ), + child: const Text('Go'), + ), + ), + ), + ); + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Back')); + await tester.pumpAndSettle(); + + expect(events.map((e) => e['pageName']), ['profile', 'home']); + expect(events[1]['referrer'], 'profile'); + }); + + testWidgets('未在枚举的路由名不上报', (tester) async { + final events = >[]; + final tracker = PageViewTracker((name, [props]) async { + events.add({'event': name, ...?props}); + }); + final observer = AnalyticsRouteObserver(tracker: tracker); + + await tester.pumpWidget( + MaterialApp( + navigatorObservers: [observer], + home: Builder( + builder: (context) => ElevatedButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + settings: const RouteSettings(name: '/debug/unknown'), + builder: (_) => const Placeholder(), + ), + ), + child: const Text('Go'), + ), + ), + ), + ); + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + expect(events, isEmpty); + }); + + testWidgets('dialog 不上报(PopupRoute 不是 PageRoute)', (tester) async { + final events = >[]; + final tracker = PageViewTracker((name, [props]) async { + events.add({'event': name, ...?props}); + }); + final observer = AnalyticsRouteObserver(tracker: tracker); + + await tester.pumpWidget( + MaterialApp( + navigatorObservers: [observer], + home: Builder( + builder: (context) => ElevatedButton( + onPressed: () => showDialog( + context: context, + builder: (_) => const AlertDialog(title: Text('Dialog')), + ), + child: const Text('Show'), + ), + ), + ), + ); + await tester.tap(find.text('Show')); + await tester.pumpAndSettle(); + + expect(events, isEmpty); + }); + }); + + group('PageViewTracker', () { + test('连续相同页面去重(Tab 重复点选)', () { + final events = >[]; + final tracker = PageViewTracker((name, [props]) async { + events.add({'event': name, ...?props}); + }); + + tracker.report(AnalyticsPageName.home); + tracker.report(AnalyticsPageName.home); + tracker.report(AnalyticsPageName.profile); + tracker.report(AnalyticsPageName.home); + + expect(events, hasLength(3)); + expect(events.map((e) => e['pageName']), ['home', 'profile', 'home']); + }); + + test('referrer 链跨机制连贯(首页无 referrer)', () { + final events = >[]; + final tracker = PageViewTracker((name, [props]) async { + events.add({'event': name, ...?props}); + }); + + tracker.report(AnalyticsPageName.home); + tracker.report(AnalyticsPageName.login); + tracker.report(AnalyticsPageName.profile); + + expect(events[0].containsKey('referrer'), isFalse); + expect(events[1]['referrer'], 'home'); + expect(events[2]['referrer'], 'login'); + }); + + test('reportTab 记录当前 Tab', () { + final events = >[]; + final tracker = PageViewTracker((name, [props]) async { + events.add({'event': name, ...?props}); + }); + + tracker.reportTab(AnalyticsPageName.petArchive); + expect(tracker.currentTab, AnalyticsPageName.petArchive); + expect(events.single['pageName'], 'pet_archive'); + }); + }); +} 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)); + }); + }); +}