import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:patbond_flutter/analytics/analytics_event_store.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 and on leaving foreground; cold start [restore] /// re-uploads offline backlog. Privacy red-line enforced locally. class AnalyticsService { AnalyticsService({ required this.apiBaseUrl, required this.getAccessToken, required this.getSessionId, String? anonymousId, AnalyticsEventStore? store, }) : _anonymousId = anonymousId ?? const Uuid().v4(), _appVersion = 'unknown', _osVersion = _defaultOsVersion(), _store = store ?? AnalyticsEventStore(); /// 异步设置 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; final String apiBaseUrl; final String? Function()? getAccessToken; /// 会话标识来源(SessionTracker 注入),冷启动/长后台换新由其管理。 final String Function() getSessionId; final String _anonymousId; String _appVersion; final String _osVersion; String? _userId; bool _flushing = false; final AnalyticsEventStore _store; /// 待上报事件(测试断言用,生产代码不得直接操作)。 @visibleForTesting List> get pendingEvents => _store.events; /// 粗粒度 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'); } } /// 冷启动恢复持久化队列(离线积压约两周容量),有积压即冲刷一次 /// (13 号规范 §3.4 冷启动触发)。app 启动时调用,不阻塞渲染。 Future restore() async { try { await _store.restore(); if (_store.length > 0) { await _flush(); } } catch (error) { debugPrint('Analytics restore failed: $error'); } } /// 立即冲刷队列(退后台/会话切换时调用,避免低活跃用户凑不满 /// [_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 _upload(batch.events); // at-least-once:拿到终态(202 受理 / 4xx 永久拒绝)才删段; // 4xx 批次计入本地丢弃诊断数。 await _store.removeSegments(batch.segmentIds, countAsDropped: rejected); } } catch (error) { // 网络错误 / 5xx:段保留在持久化队列,等下次触发或冷启动重传。 debugPrint('Analytics upload failed, events kept queued: $error'); } finally { _flushing = false; } } /// 上传一批事件。返回 true 表示 4xx 永久拒绝(调用方删段并计丢弃); /// 网络错误 / 5xx 抛异常(调用方保留段)。 Future _upload(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 >= 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)); } }