import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:uuid/uuid.dart'; /// 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, }) : _anonymousId = anonymousId ?? const Uuid().v4(), _appVersion = 'unknown', _osVersion = _defaultOsVersion(); /// 异步设置 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; /// 会话标识来源(SessionTracker 注入),冷启动/长后台换新由其管理。 final String Function() getSessionId; final String _anonymousId; String _appVersion; final String _osVersion; String? _userId; bool _flushing = false; final List> _pendingEvents = []; /// 待上报事件(测试断言用,生产代码不得直接操作)。 @visibleForTesting List> get pendingEvents => List.unmodifiable(_pendingEvents); /// 粗粒度 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, }; _pendingEvents.add(event); if (_pendingEvents.length >= _flushThreshold) { await _flush(); } } catch (error) { debugPrint('Analytics track failed: $error'); } } /// 立即冲刷队列(退后台/会话切换时调用,避免低活跃用户凑不满 /// [_flushThreshold] 条导致事件永不上传)。 Future flushNow() => _flush(); Future _flush() async { if (_flushing || _pendingEvents.isEmpty) return; _flushing = true; final batch = List>.from(_pendingEvents); _pendingEvents.clear(); try { await _upload(batch); } catch (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; } } 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; } if (response.statusCode != 202) { throw Exception('Upload failed with ${response.statusCode}'); } } 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)); } }