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'}'; } /// 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': Platform.isAndroid ? 'android' : 'ios', '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'); } } 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 != 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)); } }