修复:埋点接线与三处偏差(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>
This commit is contained in:
@@ -3,28 +3,58 @@ import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// Simplified analytics client for M0 (report 13 + ticket 19): track events
|
||||
/// to backend POST /api/v1/events. Queue failures locally (shared_preferences,
|
||||
/// max 500), flush on successful upload or when full. Network errors are
|
||||
/// silently discarded (no retry as spec'd); privacy red-line enforced locally.
|
||||
/// 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,
|
||||
this._userId,
|
||||
}) : _anonymousId = anonymousId ?? const Uuid().v4();
|
||||
}) : _anonymousId = anonymousId ?? const Uuid().v4(),
|
||||
_appVersion = 'unknown',
|
||||
_osVersion = _defaultOsVersion();
|
||||
|
||||
// M0:内存队列,满 _flushThreshold 条上传一次;持久化队列留 M1(报告 19 §3)。
|
||||
/// 异步设置 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;
|
||||
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 = [];
|
||||
|
||||
/// Sets userId after login (M0: no sessionId logic, simplified).
|
||||
/// 待上报事件(测试断言用,生产代码不得直接操作)。
|
||||
@visibleForTesting
|
||||
List<Map<String, dynamic>> get pendingEvents =>
|
||||
List.unmodifiable(_pendingEvents);
|
||||
|
||||
/// 粗粒度 osVersion(13 号规范 §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;
|
||||
}
|
||||
@@ -47,18 +77,16 @@ class AnalyticsService {
|
||||
}
|
||||
|
||||
final event = {
|
||||
'eventId': const Uuid().v4(),
|
||||
'eventId': const Uuid().v7(),
|
||||
'eventName': eventName,
|
||||
'eventVersion': 1,
|
||||
'anonymousId': _anonymousId,
|
||||
if (_userId != null) 'userId': _userId,
|
||||
'sessionId': const Uuid().v4(), // Simplified: unique per event (M0)
|
||||
'sessionId': getSessionId(),
|
||||
'clientTs': DateTime.now().toUtc().toIso8601String(),
|
||||
'appVersion': '1.0.0+1', // TODO: read from package_info_plus
|
||||
'appVersion': _appVersion,
|
||||
'platform': Platform.isAndroid ? 'android' : 'ios',
|
||||
'osVersion': Platform.isAndroid
|
||||
? 'android-14'
|
||||
: 'ios-17', // TODO: device_info_plus
|
||||
'osVersion': _osVersion,
|
||||
if (props != null && props.isNotEmpty) 'props': props,
|
||||
};
|
||||
|
||||
@@ -72,7 +100,8 @@ class AnalyticsService {
|
||||
}
|
||||
|
||||
Future<void> _flush() async {
|
||||
if (_pendingEvents.isEmpty) return;
|
||||
if (_flushing || _pendingEvents.isEmpty) return;
|
||||
_flushing = true;
|
||||
|
||||
final batch = List<Map<String, dynamic>>.from(_pendingEvents);
|
||||
_pendingEvents.clear();
|
||||
@@ -80,7 +109,16 @@ class AnalyticsService {
|
||||
try {
|
||||
await _upload(batch);
|
||||
} catch (error) {
|
||||
debugPrint('Analytics upload failed, discarding batch: $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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// sessionId 生命周期管理(13 号规范 §3.1、06 号报告 §5.1)。
|
||||
///
|
||||
/// 语义三条:冷启动生成新 sessionId;`paused → resumed` 间隔超过
|
||||
/// [timeout](默认 30 分钟)生成新 sessionId;未超过则沿用原值。
|
||||
/// sessionId 不落任何持久化存储——会话本该跨冷启动失效(03 号评估 §3.1,
|
||||
/// 纯内存方案,lastActiveAt 不持久化)。
|
||||
///
|
||||
/// 注意生命周期级联:前台恢复时状态机会依次经过
|
||||
/// `paused → hidden → inactive → resumed`,因此只在**离开 resumed 的第一次
|
||||
/// 变更**记录退后台时刻,后续级联状态不得覆盖,否则间隔永远趋近于零。
|
||||
class SessionTracker with WidgetsBindingObserver {
|
||||
SessionTracker({
|
||||
this.timeout = const Duration(minutes: 30),
|
||||
DateTime Function()? now,
|
||||
}) : _now = now ?? DateTime.now,
|
||||
_sessionId = const Uuid().v7();
|
||||
|
||||
/// 后台超时阈值;构造参数化便于测试注入。
|
||||
final Duration timeout;
|
||||
|
||||
/// 时钟注入口,测试免真实等待。
|
||||
final DateTime Function() _now;
|
||||
|
||||
String _sessionId;
|
||||
DateTime? _leftForegroundAt;
|
||||
AppLifecycleState _lastState = AppLifecycleState.resumed;
|
||||
|
||||
/// 当前会话标识(UUIDv7)。
|
||||
String get sessionId => _sessionId;
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
final leftAt = _leftForegroundAt;
|
||||
if (leftAt != null && _now().difference(leftAt) > timeout) {
|
||||
_sessionId = const Uuid().v7();
|
||||
}
|
||||
_leftForegroundAt = null;
|
||||
} else if (_lastState == AppLifecycleState.resumed) {
|
||||
// 首次离开前台才记时;inactive/hidden/paused 级联不覆盖。
|
||||
_leftForegroundAt = _now();
|
||||
}
|
||||
_lastState = state;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user