新增:埋点分段持久化队列(13 号规范 §3.3,M2 第二波)
CI / flutter-gates (push) Successful in 1m6s

- AnalyticsEventStore:shared_preferences 分段存储(每段 ≤20 条、
  总上限 500 超限丢最旧整段)、冷启动恢复、损坏段/损坏索引容错、
  droppedCount 丢弃诊断计数
- AnalyticsService 接入持久化队列:上传拿到终态(202/4xx)才删段
  实现 at-least-once;冲刷按段拼批 ≤50 条循环上传(契约单批上限);
  取批即封段,冲刷在途新事件写入新开放段不丢
- app.dart 冷启动 restore() 恢复离线积压并冲刷(13 号 §3.4 触发点)
- 保留第一波语义:flushNow()、4xx 毒丸丢弃、满 20 条冲刷触发
- 新增 13 个单测(恢复/上限淘汰/损坏容错/202 清段/flushNow 协同/
  分批上传),全套 64 测试全绿

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-07 17:04:16 +08:00
parent 1afec6aad1
commit 33b993ca0c
5 changed files with 577 additions and 28 deletions
+46 -28
View File
@@ -1,32 +1,36 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:patbond_flutter/analytics/analytics_event_store.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.
/// Analytics client for report 13: track events to backend POST
/// /api/v1/events. Events land in a segmented persistent queue
/// ([AnalyticsEventStore], shared_preferences, cap 500 oldest-dropped),
/// flushed every 20 events and on leaving foreground; cold start [restore]
/// re-uploads offline backlog. Privacy red-line enforced locally.
class AnalyticsService {
AnalyticsService({
required this.apiBaseUrl,
required this.getAccessToken,
required this.getSessionId,
String? anonymousId,
AnalyticsEventStore? store,
}) : _anonymousId = anonymousId ?? const Uuid().v4(),
_appVersion = 'unknown',
_osVersion = _defaultOsVersion();
_osVersion = _defaultOsVersion(),
_store = store ?? AnalyticsEventStore();
/// 异步设置 appVersionapp.dart 启动时从 package_info_plus 读取后注入)。
void setAppVersion(String version) {
_appVersion = version;
}
// 内存队列:满 _flushThreshold 条上传一次;分段持久化队列排 M2 第二波
// 满 _flushThreshold 条触发一次冲刷(13 号规范 §3.4
static const _flushThreshold = 20;
// 失败重回队列的容量上限(对齐 13 号规范队列上限),超限丢最旧
static const _maxQueuedEvents = 500;
// 契约单批上限(13 号规范 §1.1:单批 1–50 条),冲刷时按段拼批循环上传
static const _maxBatchEvents = 50;
final String apiBaseUrl;
final String? Function()? getAccessToken;
@@ -39,12 +43,11 @@ class AnalyticsService {
final String _osVersion;
String? _userId;
bool _flushing = false;
final List<Map<String, dynamic>> _pendingEvents = [];
final AnalyticsEventStore _store;
/// 待上报事件(测试断言用,生产代码不得直接操作)。
@visibleForTesting
List<Map<String, dynamic>> get pendingEvents =>
List.unmodifiable(_pendingEvents);
List<Map<String, dynamic>> get pendingEvents => _store.events;
/// 粗粒度 osVersion13 号规范 §4.0:主版本级,如 android-14)。
/// Web 平台不支持 Platform.operatingSystemVersion,降级为 'web-unknown'。
@@ -109,8 +112,8 @@ class AnalyticsService {
if (props != null && props.isNotEmpty) 'props': props,
};
_pendingEvents.add(event);
if (_pendingEvents.length >= _flushThreshold) {
await _store.add(event);
if (_store.length >= _flushThreshold) {
await _flush();
}
} catch (error) {
@@ -118,34 +121,48 @@ class AnalyticsService {
}
}
/// 冷启动恢复持久化队列(离线积压约两周容量),有积压即冲刷一次
/// (13 号规范 §3.4 冷启动触发)。app 启动时调用,不阻塞渲染。
Future<void> restore() async {
try {
await _store.restore();
if (_store.length > 0) {
await _flush();
}
} catch (error) {
debugPrint('Analytics restore failed: $error');
}
}
/// 立即冲刷队列(退后台/会话切换时调用,避免低活跃用户凑不满
/// [_flushThreshold] 条导致事件永不上传)。
Future<void> flushNow() => _flush();
Future<void> _flush() async {
if (_flushing || _pendingEvents.isEmpty) return;
if (_flushing) 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);
while (true) {
// 取段拼批(入选段即封段,冲刷中的新事件写入新开放段不会丢)。
final batch = _store.takeBatch(_maxBatchEvents);
if (batch.isEmpty) break;
final rejected = await _upload(batch.events);
// at-least-once:拿到终态(202 受理 / 4xx 永久拒绝)才删段;
// 4xx 批次计入本地丢弃诊断数。
await _store.removeSegments(batch.segmentIds, countAsDropped: rejected);
}
} catch (error) {
// 网络错误 / 5xx:段保留在持久化队列,等下次触发或冷启动重传。
debugPrint('Analytics upload failed, events kept queued: $error');
} finally {
_flushing = false;
}
}
Future<void> _upload(List<Map<String, dynamic>> events) async {
/// 上传一批事件。返回 true 表示 4xx 永久拒绝(调用方删段并计丢弃);
/// 网络错误 / 5xx 抛异常(调用方保留段)。
Future<bool> _upload(List<Map<String, dynamic>> events) async {
final token = getAccessToken?.call();
final request =
await HttpClient().postUrl(Uri.parse('$apiBaseUrl/api/v1/events'))
@@ -163,11 +180,12 @@ class AnalyticsService {
'Analytics batch permanently rejected '
'(${response.statusCode}), dropping ${events.length} events',
);
return;
return true;
}
if (response.statusCode != 202) {
throw Exception('Upload failed with ${response.statusCode}');
}
return false;
}
bool _containsForbiddenField(Map<String, dynamic> props) {