Files
patbond-flutter/lib/analytics/analytics_service.dart
T
lixi f501a959f3
CI / flutter-gates (push) Successful in 1m9s
修复:埋点接线与三处偏差(sessionId/eventId/设备信息)
- 生产接线修复:app.dart 组装时传 analytics 实例给 ApiAuthRepository(_buildRepository),修复 M1 遗留的「生产环境 _analytics 恒为 null、挂接点空转」问题
- sessionId 生命周期:新建 SessionTracker (WidgetsBindingObserver),冷启动生成 UUIDv7、后台超 30 分钟换新、未超阈值沿用原值,不再每事件随机生成
- eventId 改 UUIDv7:对齐 13 号规范(uuid 包已在依赖,直接用 v7()),保留插入时间局部性
- appVersion 动态注入:package_info_plus(新增依赖)异步读取后 setAppVersion,不再硬编码 '1.0.0+1'
- osVersion 动态读取:Platform.operatingSystemVersion 正则提取主版本(如 android-14),不再硬编码
- 队列顺手加固:上传失败批次重回队首而非整批丢弃(一行级缓解,分段持久化属 M2 第二波)

测试新增 9 例:session_tracker_test(5 例:冷启动/短后台/长后台/级联状态/连续幂等),analytics_service_test 补强 4 例(UUIDv7/sessionId 不再逐事件生成/appVersion 可注入/失败重回队列)。

验收对照(06 号报告 §5.1 六条):1✓ SessionTracker 新建、2✓ 三条语义、3✓ 同会话 sessionId 一致、4✓ sessionId 为 UUID 不持久化、5✓ 单测 3 例(实际 5 例)、6✓ 真机脚本(开发者手测)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-07 14:45:41 +08:00

150 lines
5.0 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
/// 异步设置 appVersionapp.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<Map<String, dynamic>> _pendingEvents = [];
/// 待上报事件(测试断言用,生产代码不得直接操作)。
@visibleForTesting
List<Map<String, dynamic>> get pendingEvents =>
List.unmodifiable(_pendingEvents);
/// 粗粒度 osVersion13 号规范 §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;
}
/// 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<void> trackEvent(
String eventName, [
Map<String, dynamic>? 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<void> _flush() async {
if (_flushing || _pendingEvents.isEmpty) return;
_flushing = true;
final batch = List<Map<String, dynamic>>.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<void> _upload(List<Map<String, dynamic>> 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<String, dynamic> 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));
}
}