import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:patbond_flutter/analytics/analytics_event_store.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:uuid/uuid.dart'; /// Analytics client for report 13: track events to backend POST /// /api/v1/events. Events land in a segmented persistent queue /// ([AnalyticsEventStore], shared_preferences, cap 500 oldest-dropped), /// flushed every 20 events, every [flushInterval] while foregrounded /// ([startPeriodicFlush]) and on leaving foreground; cold start [restore] /// re-uploads offline backlog. Upload failures back off exponentially /// (periodic flush only; explicit triggers unaffected). Privacy red-line /// enforced locally. class AnalyticsService { AnalyticsService({ required this.apiBaseUrl, required this.getAccessToken, required this.getSessionId, String? anonymousId, AnalyticsEventStore? store, this.flushInterval = const Duration(seconds: 30), DateTime Function()? now, }) : _anonymousId = anonymousId ?? const Uuid().v4(), _anonymousIdInjected = anonymousId != null, _appVersion = 'unknown', _osVersion = _defaultOsVersion(), _store = store ?? AnalyticsEventStore(), _now = now ?? DateTime.now; /// 异步设置 appVersion(app.dart 启动时从 package_info_plus 读取后注入)。 void setAppVersion(String version) { _appVersion = version; } // 满 _flushThreshold 条触发一次冲刷(13 号规范 §3.4)。 static const _flushThreshold = 20; // 契约单批上限(13 号规范 §1.1:单批 1–50 条),冲刷时按段拼批循环上传。 static const _maxBatchEvents = 50; // 失败退避:首次 30 秒,×2 递增封顶 5 分钟(13 号 §3.4 / iteration-3 // 06 号 §2.3);只挡定时冲刷,显式触发(flushNow / 满 20 / 冷启动)不受限。 static const _backoffInitial = Duration(seconds: 30); static const _backoffCap = Duration(minutes: 5); /// anonymousId 持久化 key(13 号规范 §3.3):首次生成后跨冷启动稳定, /// 登录前事件才能跨启动归并(A/B 前置 #4 硬依赖)。 static const anonymousIdKey = 'pb.analytics.anonymousId'; final String apiBaseUrl; final String? Function()? getAccessToken; /// 会话标识来源(SessionTracker 注入),冷启动/长后台换新由其管理。 final String Function() getSessionId; /// 前台定时冲刷周期(13 号 §3.4 第 4 触发点),构造参数化便于测试注入。 final Duration flushInterval; String _anonymousId; /// 构造显式注入 anonymousId 的测试通道不参与持久化采用/落盘。 final bool _anonymousIdInjected; String _appVersion; final String _osVersion; String? _userId; bool _flushing = false; final AnalyticsEventStore _store; /// 时钟注入口(照 SessionTracker 先例),退避判定测试免真实等待。 final DateTime Function() _now; Timer? _flushTimer; Duration? _backoffDelay; DateTime? _retryNotBefore; /// 待上报事件(测试断言用,生产代码不得直接操作)。 @visibleForTesting List> get pendingEvents => _store.events; /// 当前匿名标识(测试断言用)。 @visibleForTesting String get anonymousId => _anonymousId; /// 粗粒度 osVersion(13 号规范 §4.0:主版本级,如 android-14)。 /// Web 平台不支持 Platform.operatingSystemVersion,降级为 'web-unknown'。 static String _defaultOsVersion() { if (kIsWeb) { return 'web-unknown'; } final major = RegExp( r'\d+', ).firstMatch(Platform.operatingSystemVersion)?.group(0); return '${Platform.operatingSystem}-${major ?? 'unknown'}'; } /// 平台标识。契约枚举为 android/ios;Web/桌面为开发调试形态, /// 上报值不在枚举内会被服务端逐条 rejected(不影响客户端),属预期。 static String _platformName() { if (kIsWeb) { return 'web'; } if (Platform.isAndroid) { return 'android'; } if (Platform.isIOS) { return 'ios'; } return Platform.operatingSystem; } /// Sets userId after login/restore. void identify(String userId) { _userId = userId; } /// Clears userId on logout (anonymousId remains). void reset() { _userId = null; } /// Tracks event (never throws, never awaits network). Props are validated /// for privacy red-line patterns locally before queuing. Future trackEvent( String eventName, [ Map? props, ]) async { try { if (props != null && _containsForbiddenField(props)) { debugPrint('Analytics: event $eventName rejected (forbidden field)'); return; } final event = { 'eventId': const Uuid().v7(), 'eventName': eventName, 'eventVersion': 1, 'anonymousId': _anonymousId, if (_userId != null) 'userId': _userId, 'sessionId': getSessionId(), 'clientTs': DateTime.now().toUtc().toIso8601String(), 'appVersion': _appVersion, 'platform': _platformName(), 'osVersion': _osVersion, if (props != null && props.isNotEmpty) 'props': props, }; await _store.add(event); if (_store.length >= _flushThreshold) { await _flush(); } } catch (error) { debugPrint('Analytics track failed: $error'); } } /// 冷启动恢复:先采用持久化 anonymousId,再恢复持久化队列(离线积压 /// 约两周容量),有积压即冲刷一次(13 号规范 §3.4 冷启动触发)。 /// app 启动时调用,不阻塞渲染。 Future restore() async { try { await _restoreAnonymousId(); await _store.restore(); if (_store.length > 0) { await _flush(); } } catch (error) { debugPrint('Analytics restore failed: $error'); } } /// 采用/落盘持久化 anonymousId:已有存储值则采用(跨启动稳定), /// 无则把本次生成的落盘。持久化不可用时降级为进程内临时 id, /// 绝不抛出(埋点旁路原则)。 Future _restoreAnonymousId() async { if (_anonymousIdInjected) return; try { final prefs = await SharedPreferences.getInstance(); final stored = prefs.getString(anonymousIdKey); if (stored != null && stored.isNotEmpty) { _anonymousId = stored; } else { await prefs.setString(anonymousIdKey, _anonymousId); } } catch (error) { debugPrint('Analytics: anonymousId falls back to ephemeral: $error'); } } /// 启动前台定时冲刷(13 号 §3.4 第 4 触发点):长前台会话(刷 Feed /// 半小时不切页)不再积压不上传。app 启动与回前台时调用,幂等。 void startPeriodicFlush() { _flushTimer ??= Timer.periodic(flushInterval, (_) => _onFlushTimerTick()); } /// 停止定时冲刷(退后台与 App dispose 时调用),退避状态保留。 void stopPeriodicFlush() { _flushTimer?.cancel(); _flushTimer = null; } void _onFlushTimerTick() { // 退避窗口内跳过定时冲刷;显式触发(flushNow / 满 20 / 冷启动)不受限。 final notBefore = _retryNotBefore; if (notBefore != null && _now().isBefore(notBefore)) return; _flush(); } /// 立即冲刷队列(退后台/会话切换时调用,避免低活跃用户凑不满 /// [_flushThreshold] 条导致事件永不上传)。 Future flushNow() => _flush(); Future _flush() async { if (_flushing) return; _flushing = true; try { while (true) { // 取段拼批(入选段即封段,冲刷中的新事件写入新开放段不会丢)。 final batch = _store.takeBatch(_maxBatchEvents); if (batch.isEmpty) break; final rejected = await uploadBatch(batch.events); // 拿到服务端应答即连通性恢复,重置退避。 _resetBackoff(); // at-least-once:拿到终态(202 受理 / 4xx 永久拒绝)才删段; // 4xx 批次计入本地丢弃诊断数。 await _store.removeSegments(batch.segmentIds, countAsDropped: rejected); } } catch (error) { // 网络错误 / 5xx:段保留在持久化队列,指数退避后由定时冲刷重试, // 或等显式触发/冷启动重传。 _scheduleBackoff(); debugPrint('Analytics upload failed, events kept queued: $error'); } finally { _flushing = false; } } void _scheduleBackoff() { final next = _backoffDelay == null ? _backoffInitial : _backoffDelay! * 2; _backoffDelay = next > _backoffCap ? _backoffCap : next; _retryNotBefore = _now().add(_backoffDelay!); } void _resetBackoff() { _backoffDelay = null; _retryNotBefore = null; } /// 上传一批事件。返回 true 表示 4xx 永久拒绝(调用方删段并计丢弃); /// 网络错误 / 5xx / 429 抛异常(调用方保留段并退避)。 /// protected:测试子类以假上传替换,免起真实 HttpServer。 @protected @visibleForTesting Future uploadBatch(List> events) async { final token = getAccessToken?.call(); final request = await HttpClient().postUrl(Uri.parse('$apiBaseUrl/api/v1/events')) ..headers.contentType = ContentType.json; if (token != null) { request.headers.add('Authorization', 'Bearer $token'); } request.add(utf8.encode(jsonEncode({'events': events}))); final response = await request.close(); if (response.statusCode == 429) { // 429 按网络错误同路径处理(保段 + 指数退避):后端限流尚未实现 // (iteration-2/09 出入项),Retry-After 分支待其落地后一并做。 throw Exception('Upload rate limited (429), events kept queued'); } if (response.statusCode >= 400 && response.statusCode < 500) { // 4xx 为永久性拒绝(校验失败/批量超限等),重试不可能成功; // 丢弃并打日志,避免毒丸批次无限重回队列阻塞后续事件。 debugPrint( 'Analytics batch permanently rejected ' '(${response.statusCode}), dropping ${events.length} events', ); return true; } if (response.statusCode != 202) { throw Exception('Upload failed with ${response.statusCode}'); } return false; } bool _containsForbiddenField(Map props) { // Dart RegExp 不支持 (?i) 内联标志(原写法构造即抛异常,事件被静默丢弃)。 final pattern = RegExp( r'password|token|secret|phone|mobile|email|credential|idfa|gaid', caseSensitive: false, ); return props.keys.any((key) => pattern.hasMatch(key)); } }