修复:埋点接线与三处偏差(sessionId/eventId/设备信息)
CI / flutter-gates (push) Successful in 1m9s

- 生产接线修复: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:
2026-09-07 14:45:41 +08:00
parent 3f8388e5d4
commit f501a959f3
15 changed files with 767 additions and 50 deletions
+48
View File
@@ -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;
}
}