新增:埋点分段持久化队列(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
+229
View File
@@ -0,0 +1,229 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// 13 号规范 §3.3 分段持久化事件队列(shared_preferences 版)。
///
/// 事件按段(每段最多 [segmentCapacity] 条)存储,避免单 key 整队列
/// 重写的 O(n) 放大;总量超过 [maxEvents] 时丢最旧的整段。内存中的
/// 段列表是唯一事实来源,shared_preferences 是尽力而为的镜像——
/// 持久化不可用(如未初始化插件的测试环境)时降级为纯内存队列,
/// 任何存取失败都不向调用方抛出。
///
/// at-least-once 语义:段只在上传拿到终态(202 受理 / 4xx 永久拒绝)
/// 后由调用方删除;应用在响应前被杀,事件仍在本地,冷启动 [restore]
/// 恢复后重发,服务端靠 eventId 幂等去重。
class AnalyticsEventStore {
AnalyticsEventStore({this.maxEvents = 500, this.segmentCapacity = 20});
/// 段 ID 有序列表(旧 → 新),JSON 数组。
static const segIndexKey = 'pb.analytics.segIndex';
/// 单段序列化事件 JSON 数组,key 为前缀 + 段 ID。
static const segKeyPrefix = 'pb.analytics.seg.';
/// 本地累计丢弃计数(溢出淘汰 + 4xx 丢批 + 损坏段),诊断用。
static const droppedCountKey = 'pb.analytics.droppedCount';
final int maxEvents;
final int segmentCapacity;
SharedPreferences? _prefs;
final List<_Segment> _segments = [];
int _seq = 0;
int _droppedCount = 0;
int get length => _segments.fold(0, (sum, seg) => sum + seg.events.length);
int get droppedCount => _droppedCount;
/// 全部待上传事件快照(旧 → 新)。
List<Map<String, dynamic>> get events =>
List.unmodifiable([for (final seg in _segments) ...seg.events]);
/// 冷启动恢复:读回未上传段。损坏段(JSON 解析失败)删 key 丢弃、
/// 计入丢弃数,不崩溃;索引本身损坏则按 key 前缀清扫孤儿段后重建。
/// 恢复的事件排在本实例已入队事件之前(更旧优先上传/淘汰)。
Future<void> restore() async {
try {
_prefs = await SharedPreferences.getInstance();
} catch (error) {
debugPrint('Analytics store: prefs unavailable, memory-only: $error');
return;
}
final prefs = _prefs!;
_droppedCount = prefs.getInt(droppedCountKey) ?? 0;
final restored = <_Segment>[];
final rawIndex = prefs.getString(segIndexKey);
if (rawIndex != null) {
var ids = const <String>[];
try {
ids = [for (final id in jsonDecode(rawIndex) as List) id.toString()];
} catch (error) {
debugPrint('Analytics store: corrupted index, sweeping: $error');
final orphanKeys = prefs
.getKeys()
.where((key) => key.startsWith(segKeyPrefix))
.toList();
for (final key in orphanKeys) {
await _guard(() => prefs.remove(key));
}
_droppedCount += 1;
}
for (final id in ids) {
final raw = prefs.getString('$segKeyPrefix$id');
if (raw == null) continue;
try {
final events = [
for (final event in jsonDecode(raw) as List)
Map<String, dynamic>.from(event as Map),
];
if (events.isNotEmpty) {
restored.add(_Segment(id, events, sealed: true));
}
} catch (error) {
debugPrint('Analytics store: dropping corrupted seg $id: $error');
await _guard(() => prefs.remove('$segKeyPrefix$id'));
_droppedCount += 1;
}
}
}
// restore 前入队的事件此前无处可写,恢复后连同清理过的索引一并落盘。
final preExisting = List<_Segment>.of(_segments);
_segments
..clear()
..addAll(restored)
..addAll(preExisting);
await _evictOverflow();
for (final seg in preExisting.where(_segments.contains)) {
await _persistSegment(seg);
}
await _persistIndex();
await _persistDroppedCount();
}
/// 追加事件到当前开放段并持久化该段(重写 ≤ [segmentCapacity] 条);
/// 段满即封段,下次写入开新段;超总量上限丢最旧整段。
Future<void> add(Map<String, dynamic> event) async {
var open = _segments.isEmpty ? null : _segments.last;
if (open == null || open.sealed || open.events.length >= segmentCapacity) {
open = _Segment(_newSegId(), []);
_segments.add(open);
await _persistIndex();
}
open.events.add(event);
if (open.events.length >= segmentCapacity) open.sealed = true;
await _persistSegment(open);
await _evictOverflow();
}
/// 从最旧段起取整段组一批(≤ [maxBatchEvents] 条)。入选段即封段,
/// 上传在途期间新事件只会写入新的开放段,批内容与对应段不再变化,
/// 上传成功后可安全整段删除(冲刷中新事件不丢)。
UploadBatch takeBatch(int maxBatchEvents) {
final selected = <_Segment>[];
var count = 0;
for (final seg in _segments) {
if (count + seg.events.length > maxBatchEvents) break;
selected.add(seg);
count += seg.events.length;
}
for (final seg in selected) {
seg.sealed = true;
}
return UploadBatch(
segmentIds: [for (final seg in selected) seg.id],
events: [
for (final seg in selected)
...seg.events.map(Map<String, dynamic>.from),
],
);
}
/// 上传终态后删段:202 受理或 4xx 永久拒绝(后者 [countAsDropped]
/// 计入丢弃诊断数)。段可能已被溢出淘汰,缺失即忽略。
Future<void> removeSegments(
List<String> ids, {
bool countAsDropped = false,
}) async {
final idSet = ids.toSet();
var removed = 0;
_segments.removeWhere((seg) {
if (!idSet.contains(seg.id)) return false;
removed += seg.events.length;
return true;
});
if (countAsDropped) _droppedCount += removed;
for (final id in ids) {
await _removeSegmentKey(id);
}
await _persistIndex();
if (countAsDropped) await _persistDroppedCount();
}
Future<void> _evictOverflow() async {
var indexDirty = false;
while (length > maxEvents && _segments.length > 1) {
final victim = _segments.removeAt(0);
_droppedCount += victim.events.length;
await _removeSegmentKey(victim.id);
indexDirty = true;
}
if (indexDirty) {
await _persistIndex();
await _persistDroppedCount();
}
}
Future<void> _persistIndex() => _guard(
() => _prefs?.setString(
segIndexKey,
jsonEncode([for (final seg in _segments) seg.id]),
),
);
Future<void> _persistSegment(_Segment seg) => _guard(
() => _prefs?.setString('$segKeyPrefix${seg.id}', jsonEncode(seg.events)),
);
Future<void> _removeSegmentKey(String id) =>
_guard(() => _prefs?.remove('$segKeyPrefix$id'));
Future<void> _persistDroppedCount() =>
_guard(() => _prefs?.setInt(droppedCountKey, _droppedCount));
/// 持久化是尽力而为的镜像,失败只打日志,绝不影响内存队列。
Future<void> _guard(Future<void>? Function() write) async {
try {
await write();
} catch (error) {
debugPrint('Analytics store: persistence write failed: $error');
}
}
String _newSegId() =>
'${DateTime.now().microsecondsSinceEpoch.toRadixString(36)}-${_seq++}';
}
/// 一次上传的段快照:事件按旧 → 新排列,段 ID 用于成功后删段。
class UploadBatch {
const UploadBatch({required this.segmentIds, required this.events});
final List<String> segmentIds;
final List<Map<String, dynamic>> events;
bool get isEmpty => events.isEmpty;
}
class _Segment {
_Segment(this.id, this.events, {this.sealed = false});
final String id;
final List<Map<String, dynamic>> events;
/// 封段后不再接受追加(满 20 条或已被取入上传批次)。
bool sealed;
}
+46 -28
View File
@@ -1,32 +1,36 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:patbond_flutter/analytics/analytics_event_store.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
/// Simplified analytics client for M0/M1 (report 13 + ticket 19): track events /// Analytics client for report 13: track events to backend POST
/// to backend POST /api/v1/events. In-memory queue flushed every 20 events; /// /api/v1/events. Events land in a segmented persistent queue
/// failed batches are re-queued (capped at 500, oldest dropped). Persistent /// ([AnalyticsEventStore], shared_preferences, cap 500 oldest-dropped),
/// segmented queue lands in M2 wave 2. Privacy red-line enforced locally. /// flushed every 20 events and on leaving foreground; cold start [restore]
/// re-uploads offline backlog. Privacy red-line enforced locally.
class AnalyticsService { class AnalyticsService {
AnalyticsService({ AnalyticsService({
required this.apiBaseUrl, required this.apiBaseUrl,
required this.getAccessToken, required this.getAccessToken,
required this.getSessionId, required this.getSessionId,
String? anonymousId, String? anonymousId,
AnalyticsEventStore? store,
}) : _anonymousId = anonymousId ?? const Uuid().v4(), }) : _anonymousId = anonymousId ?? const Uuid().v4(),
_appVersion = 'unknown', _appVersion = 'unknown',
_osVersion = _defaultOsVersion(); _osVersion = _defaultOsVersion(),
_store = store ?? AnalyticsEventStore();
/// 异步设置 appVersionapp.dart 启动时从 package_info_plus 读取后注入)。 /// 异步设置 appVersionapp.dart 启动时从 package_info_plus 读取后注入)。
void setAppVersion(String version) { void setAppVersion(String version) {
_appVersion = version; _appVersion = version;
} }
// 内存队列:满 _flushThreshold 条上传一次;分段持久化队列排 M2 第二波 // 满 _flushThreshold 条触发一次冲刷(13 号规范 §3.4
static const _flushThreshold = 20; static const _flushThreshold = 20;
// 失败重回队列的容量上限(对齐 13 号规范队列上限),超限丢最旧 // 契约单批上限(13 号规范 §1.1:单批 1–50 条),冲刷时按段拼批循环上传
static const _maxQueuedEvents = 500; static const _maxBatchEvents = 50;
final String apiBaseUrl; final String apiBaseUrl;
final String? Function()? getAccessToken; final String? Function()? getAccessToken;
@@ -39,12 +43,11 @@ class AnalyticsService {
final String _osVersion; final String _osVersion;
String? _userId; String? _userId;
bool _flushing = false; bool _flushing = false;
final List<Map<String, dynamic>> _pendingEvents = []; final AnalyticsEventStore _store;
/// 待上报事件(测试断言用,生产代码不得直接操作)。 /// 待上报事件(测试断言用,生产代码不得直接操作)。
@visibleForTesting @visibleForTesting
List<Map<String, dynamic>> get pendingEvents => List<Map<String, dynamic>> get pendingEvents => _store.events;
List.unmodifiable(_pendingEvents);
/// 粗粒度 osVersion13 号规范 §4.0:主版本级,如 android-14)。 /// 粗粒度 osVersion13 号规范 §4.0:主版本级,如 android-14)。
/// Web 平台不支持 Platform.operatingSystemVersion,降级为 'web-unknown'。 /// Web 平台不支持 Platform.operatingSystemVersion,降级为 'web-unknown'。
@@ -109,8 +112,8 @@ class AnalyticsService {
if (props != null && props.isNotEmpty) 'props': props, if (props != null && props.isNotEmpty) 'props': props,
}; };
_pendingEvents.add(event); await _store.add(event);
if (_pendingEvents.length >= _flushThreshold) { if (_store.length >= _flushThreshold) {
await _flush(); await _flush();
} }
} catch (error) { } 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] 条导致事件永不上传)。 /// [_flushThreshold] 条导致事件永不上传)。
Future<void> flushNow() => _flush(); Future<void> flushNow() => _flush();
Future<void> _flush() async { Future<void> _flush() async {
if (_flushing || _pendingEvents.isEmpty) return; if (_flushing) return;
_flushing = true; _flushing = true;
final batch = List<Map<String, dynamic>>.from(_pendingEvents);
_pendingEvents.clear();
try { try {
await _upload(batch); while (true) {
} catch (error) { // 取段拼批(入选段即封段,冲刷中的新事件写入新开放段不会丢)。
// 顺手加固(03 §1.4 #1 的一行级缓解):失败不再整批丢弃, final batch = _store.takeBatch(_maxBatchEvents);
// 重回队首等下次冲刷;上限 500 条,超限丢最旧。真正的 if (batch.isEmpty) break;
// shared_preferences 分段持久化队列属 M2 第二波。 final rejected = await _upload(batch.events);
debugPrint('Analytics upload failed, requeueing batch: $error'); // at-least-once:拿到终态(202 受理 / 4xx 永久拒绝)才删段;
_pendingEvents.insertAll(0, batch); // 4xx 批次计入本地丢弃诊断数。
if (_pendingEvents.length > _maxQueuedEvents) { await _store.removeSegments(batch.segmentIds, countAsDropped: rejected);
_pendingEvents.removeRange(0, _pendingEvents.length - _maxQueuedEvents);
} }
} catch (error) {
// 网络错误 / 5xx:段保留在持久化队列,等下次触发或冷启动重传。
debugPrint('Analytics upload failed, events kept queued: $error');
} finally { } finally {
_flushing = false; _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 token = getAccessToken?.call();
final request = final request =
await HttpClient().postUrl(Uri.parse('$apiBaseUrl/api/v1/events')) await HttpClient().postUrl(Uri.parse('$apiBaseUrl/api/v1/events'))
@@ -163,11 +180,12 @@ class AnalyticsService {
'Analytics batch permanently rejected ' 'Analytics batch permanently rejected '
'(${response.statusCode}), dropping ${events.length} events', '(${response.statusCode}), dropping ${events.length} events',
); );
return; return true;
} }
if (response.statusCode != 202) { if (response.statusCode != 202) {
throw Exception('Upload failed with ${response.statusCode}'); throw Exception('Upload failed with ${response.statusCode}');
} }
return false;
} }
bool _containsForbiddenField(Map<String, dynamic> props) { bool _containsForbiddenField(Map<String, dynamic> props) {
+2
View File
@@ -54,6 +54,8 @@ class _AppState extends State<App> {
getSessionId: () => _sessionTracker.sessionId, getSessionId: () => _sessionTracker.sessionId,
); );
_initAppVersion(); _initAppVersion();
// 冷启动恢复持久化埋点队列并冲刷离线积压(后台任务,不阻塞渲染)。
_analytics.restore();
_pageViewTracker = PageViewTracker(_analytics.trackEvent); _pageViewTracker = PageViewTracker(_analytics.trackEvent);
_routeObserver = AnalyticsRouteObserver( _routeObserver = AnalyticsRouteObserver(
@@ -0,0 +1,159 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/analytics/analytics_event_store.dart';
import 'package:shared_preferences/shared_preferences.dart';
Map<String, dynamic> event(int seq) => {
'eventId': 'id-$seq',
'eventName': 'auth_login_succeeded',
'seq': seq,
};
void main() {
group('AnalyticsEventStore', () {
test('事件持久化后新实例冷启动可恢复(顺序保持旧 → 新)', () async {
SharedPreferences.setMockInitialValues({});
final store = AnalyticsEventStore();
await store.restore();
for (var i = 0; i < 45; i++) {
await store.add(event(i));
}
final reborn = AnalyticsEventStore();
await reborn.restore();
expect(reborn.length, 45);
expect(reborn.events.first['seq'], 0);
expect(reborn.events.last['seq'], 44);
// 45 条按容量 20 分段应为 3 段
final prefs = await SharedPreferences.getInstance();
final index =
jsonDecode(prefs.getString(AnalyticsEventStore.segIndexKey)!) as List;
expect(index.length, 3);
});
test('超过 500 条总上限时丢最旧整段', () async {
SharedPreferences.setMockInitialValues({});
final store = AnalyticsEventStore();
await store.restore();
for (var i = 0; i < 520; i++) {
await store.add(event(i));
}
// 第 501 条触发淘汰最旧一段(20 条),此后回落到上限内
expect(store.length, 500);
expect(store.events.first['seq'], 20);
expect(store.events.last['seq'], 519);
expect(store.droppedCount, 20);
});
test('损坏段丢弃不崩溃,其余段正常恢复且索引被清理', () async {
SharedPreferences.setMockInitialValues({
AnalyticsEventStore.segIndexKey: jsonEncode(['bad', 'good']),
'${AnalyticsEventStore.segKeyPrefix}bad': '{not-valid-json[[',
'${AnalyticsEventStore.segKeyPrefix}good': jsonEncode([event(7)]),
});
final store = AnalyticsEventStore();
await store.restore();
expect(store.length, 1);
expect(store.events.single['seq'], 7);
expect(store.droppedCount, 1);
final prefs = await SharedPreferences.getInstance();
expect(prefs.getString('${AnalyticsEventStore.segKeyPrefix}bad'), isNull);
expect(jsonDecode(prefs.getString(AnalyticsEventStore.segIndexKey)!), [
'good',
]);
});
test('段索引本身损坏时清扫孤儿段、从空队列重建', () async {
SharedPreferences.setMockInitialValues({
AnalyticsEventStore.segIndexKey: 'garbage-not-json',
'${AnalyticsEventStore.segKeyPrefix}orphan': jsonEncode([event(1)]),
});
final store = AnalyticsEventStore();
await store.restore();
expect(store.length, 0);
final prefs = await SharedPreferences.getInstance();
expect(
prefs.getString('${AnalyticsEventStore.segKeyPrefix}orphan'),
isNull,
);
// 可继续正常写入
await store.add(event(2));
expect(store.length, 1);
});
test('takeBatch 封段:在途批次不受新事件影响,删段后新事件保留', () async {
SharedPreferences.setMockInitialValues({});
final store = AnalyticsEventStore();
await store.restore();
for (var i = 0; i < 5; i++) {
await store.add(event(i));
}
final batch = store.takeBatch(50);
// 模拟上传在途期间进入的新事件
await store.add(event(99));
expect(batch.events.length, 5);
await store.removeSegments(batch.segmentIds);
expect(store.length, 1);
expect(store.events.single['seq'], 99);
});
test('takeBatch 按段拼批不超过单批上限', () async {
SharedPreferences.setMockInitialValues({});
final store = AnalyticsEventStore();
await store.restore();
for (var i = 0; i < 60; i++) {
await store.add(event(i));
}
// 段容量 20、单批上限 50 → 每批最多 2 整段(40 条)
final first = store.takeBatch(50);
expect(first.events.length, 40);
await store.removeSegments(first.segmentIds);
final second = store.takeBatch(50);
expect(second.events.length, 20);
});
test('上传成功删段后 shared_preferences 无残留', () async {
SharedPreferences.setMockInitialValues({});
final store = AnalyticsEventStore();
await store.restore();
for (var i = 0; i < 20; i++) {
await store.add(event(i));
}
final batch = store.takeBatch(50);
await store.removeSegments(batch.segmentIds);
expect(store.length, 0);
final prefs = await SharedPreferences.getInstance();
expect(
prefs.getKeys().where(
(key) => key.startsWith(AnalyticsEventStore.segKeyPrefix),
),
isEmpty,
);
expect(
jsonDecode(prefs.getString(AnalyticsEventStore.segIndexKey)!),
isEmpty,
);
});
test('未调用 restore(持久化不可用)时降级为纯内存队列', () async {
final store = AnalyticsEventStore();
await store.add(event(1));
expect(store.length, 1);
final batch = store.takeBatch(50);
await store.removeSegments(batch.segmentIds);
expect(store.length, 0);
});
});
}
@@ -0,0 +1,141 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/analytics/analytics_event_store.dart';
import 'package:patbond_flutter/analytics/analytics_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// 服务级持久化队列联动测试(13 号规范 §3.3/§3.4):
/// 本地 HttpServer 模拟 /api/v1/events 的 202 / 400 响应。
void main() {
Future<(HttpServer, List<int>)> startServer(int statusCode) async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final batchSizes = <int>[];
server.listen((request) async {
final body =
jsonDecode(await utf8.decoder.bind(request).join())
as Map<String, dynamic>;
batchSizes.add((body['events'] as List).length);
request.response.statusCode = statusCode;
await request.response.close();
});
return (server, batchSizes);
}
AnalyticsService buildService(String apiBaseUrl, AnalyticsEventStore store) {
return AnalyticsService(
apiBaseUrl: apiBaseUrl,
getAccessToken: null,
getSessionId: () => 'session-x',
store: store,
);
}
group('AnalyticsService 持久化队列', () {
test('满 20 条冲刷、202 受理后清空持久化段', () async {
SharedPreferences.setMockInitialValues({});
final (server, batchSizes) = await startServer(202);
addTearDown(() => server.close(force: true));
final store = AnalyticsEventStore();
await store.restore();
final service = buildService('http://127.0.0.1:${server.port}', store);
for (var i = 0; i < 20; i++) {
await service.trackEvent('auth_login_succeeded', {'attemptSeq': i});
}
expect(batchSizes, [20]);
expect(service.pendingEvents, isEmpty);
final prefs = await SharedPreferences.getInstance();
expect(
prefs.getKeys().where(
(key) => key.startsWith(AnalyticsEventStore.segKeyPrefix),
),
isEmpty,
);
});
test('flushNow 冲刷不满 20 条的持久化队列', () async {
SharedPreferences.setMockInitialValues({});
final (server, batchSizes) = await startServer(202);
addTearDown(() => server.close(force: true));
final store = AnalyticsEventStore();
await store.restore();
final service = buildService('http://127.0.0.1:${server.port}', store);
await service.trackEvent('auth_login_succeeded');
await service.trackEvent('auth_logout');
await service.flushNow();
expect(batchSizes, [2]);
expect(service.pendingEvents, isEmpty);
expect(store.length, 0);
});
test('上传失败事件持久化,冷启动恢复后自动重传', () async {
SharedPreferences.setMockInitialValues({});
// 第一个进程:端口不可达,20 条触发冲刷失败,事件留在持久化队列
final storeA = AnalyticsEventStore();
await storeA.restore();
final serviceA = buildService('http://127.0.0.1:1', storeA);
for (var i = 0; i < 20; i++) {
await serviceA.trackEvent('auth_login_succeeded', {'attemptSeq': i});
}
expect(serviceA.pendingEvents.length, 20);
// 模拟冷启动:新实例 restore 恢复并冲刷到可用服务端
final (server, batchSizes) = await startServer(202);
addTearDown(() => server.close(force: true));
final storeB = AnalyticsEventStore();
final serviceB = buildService('http://127.0.0.1:${server.port}', storeB);
await serviceB.restore();
expect(batchSizes, [20]);
expect(serviceB.pendingEvents, isEmpty);
});
test('4xx 永久拒绝直接删段丢弃(毒丸防护),计入丢弃诊断数', () async {
SharedPreferences.setMockInitialValues({});
final (server, batchSizes) = await startServer(400);
addTearDown(() => server.close(force: true));
final store = AnalyticsEventStore();
await store.restore();
final service = buildService('http://127.0.0.1:${server.port}', store);
for (var i = 0; i < 20; i++) {
await service.trackEvent('auth_login_succeeded', {'attemptSeq': i});
}
expect(batchSizes, [20]);
expect(service.pendingEvents, isEmpty);
expect(store.droppedCount, 20);
final prefs = await SharedPreferences.getInstance();
expect(
prefs.getKeys().where(
(key) => key.startsWith(AnalyticsEventStore.segKeyPrefix),
),
isEmpty,
);
});
test('积压超过单批上限时按 50 条分批循环上传', () async {
SharedPreferences.setMockInitialValues({});
final (server, batchSizes) = await startServer(202);
addTearDown(() => server.close(force: true));
final store = AnalyticsEventStore();
await store.restore();
// 直接预置 60 条积压(绕过 trackEvent 的满 20 冲刷触发)
for (var i = 0; i < 60; i++) {
await store.add({'eventId': 'id-$i', 'eventName': 'page_viewed'});
}
final service = buildService('http://127.0.0.1:${server.port}', store);
await service.flushNow();
// 段容量 20、单批上限 50 → 40 + 20 两批
expect(batchSizes, [40, 20]);
expect(service.pendingEvents, isEmpty);
});
});
}