From 33b993ca0c755a0d9a6f2dbfa0c5cdc216543e91 Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Mon, 7 Sep 2026 17:04:16 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9A=E5=9F=8B=E7=82=B9?= =?UTF-8?q?=E5=88=86=E6=AE=B5=E6=8C=81=E4=B9=85=E5=8C=96=E9=98=9F=E5=88=97?= =?UTF-8?q?=EF=BC=8813=20=E5=8F=B7=E8=A7=84=E8=8C=83=20=C2=A73.3=EF=BC=8CM?= =?UTF-8?q?2=20=E7=AC=AC=E4=BA=8C=E6=B3=A2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- lib/analytics/analytics_event_store.dart | 229 ++++++++++++++++++ lib/analytics/analytics_service.dart | 74 +++--- lib/app/app.dart | 2 + .../analytics/analytics_event_store_test.dart | 159 ++++++++++++ .../analytics_persistent_queue_test.dart | 141 +++++++++++ 5 files changed, 577 insertions(+), 28 deletions(-) create mode 100644 lib/analytics/analytics_event_store.dart create mode 100644 test/analytics/analytics_event_store_test.dart create mode 100644 test/analytics/analytics_persistent_queue_test.dart diff --git a/lib/analytics/analytics_event_store.dart b/lib/analytics/analytics_event_store.dart new file mode 100644 index 0000000..2caa12d --- /dev/null +++ b/lib/analytics/analytics_event_store.dart @@ -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> get events => + List.unmodifiable([for (final seg in _segments) ...seg.events]); + + /// 冷启动恢复:读回未上传段。损坏段(JSON 解析失败)删 key 丢弃、 + /// 计入丢弃数,不崩溃;索引本身损坏则按 key 前缀清扫孤儿段后重建。 + /// 恢复的事件排在本实例已入队事件之前(更旧优先上传/淘汰)。 + Future 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 []; + 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.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 add(Map 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.from), + ], + ); + } + + /// 上传终态后删段:202 受理或 4xx 永久拒绝(后者 [countAsDropped] + /// 计入丢弃诊断数)。段可能已被溢出淘汰,缺失即忽略。 + Future removeSegments( + List 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 _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 _persistIndex() => _guard( + () => _prefs?.setString( + segIndexKey, + jsonEncode([for (final seg in _segments) seg.id]), + ), + ); + + Future _persistSegment(_Segment seg) => _guard( + () => _prefs?.setString('$segKeyPrefix${seg.id}', jsonEncode(seg.events)), + ); + + Future _removeSegmentKey(String id) => + _guard(() => _prefs?.remove('$segKeyPrefix$id')); + + Future _persistDroppedCount() => + _guard(() => _prefs?.setInt(droppedCountKey, _droppedCount)); + + /// 持久化是尽力而为的镜像,失败只打日志,绝不影响内存队列。 + Future _guard(Future? 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 segmentIds; + final List> events; + + bool get isEmpty => events.isEmpty; +} + +class _Segment { + _Segment(this.id, this.events, {this.sealed = false}); + + final String id; + final List> events; + + /// 封段后不再接受追加(满 20 条或已被取入上传批次)。 + bool sealed; +} diff --git a/lib/analytics/analytics_service.dart b/lib/analytics/analytics_service.dart index b0e5954..a8c8d80 100644 --- a/lib/analytics/analytics_service.dart +++ b/lib/analytics/analytics_service.dart @@ -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(); /// 异步设置 appVersion(app.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> _pendingEvents = []; + final AnalyticsEventStore _store; /// 待上报事件(测试断言用,生产代码不得直接操作)。 @visibleForTesting - List> get pendingEvents => - List.unmodifiable(_pendingEvents); + List> get pendingEvents => _store.events; /// 粗粒度 osVersion(13 号规范 §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 restore() async { + try { + await _store.restore(); + if (_store.length > 0) { + await _flush(); + } + } catch (error) { + debugPrint('Analytics restore failed: $error'); + } + } + /// 立即冲刷队列(退后台/会话切换时调用,避免低活跃用户凑不满 /// [_flushThreshold] 条导致事件永不上传)。 Future flushNow() => _flush(); Future _flush() async { - if (_flushing || _pendingEvents.isEmpty) return; + if (_flushing) return; _flushing = true; - final batch = List>.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 _upload(List> events) async { + /// 上传一批事件。返回 true 表示 4xx 永久拒绝(调用方删段并计丢弃); + /// 网络错误 / 5xx 抛异常(调用方保留段)。 + Future _upload(List> 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 props) { diff --git a/lib/app/app.dart b/lib/app/app.dart index 247fcef..0274338 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -54,6 +54,8 @@ class _AppState extends State { getSessionId: () => _sessionTracker.sessionId, ); _initAppVersion(); + // 冷启动恢复持久化埋点队列并冲刷离线积压(后台任务,不阻塞渲染)。 + _analytics.restore(); _pageViewTracker = PageViewTracker(_analytics.trackEvent); _routeObserver = AnalyticsRouteObserver( diff --git a/test/analytics/analytics_event_store_test.dart b/test/analytics/analytics_event_store_test.dart new file mode 100644 index 0000000..69a0ba0 --- /dev/null +++ b/test/analytics/analytics_event_store_test.dart @@ -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 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); + }); + }); +} diff --git a/test/analytics/analytics_persistent_queue_test.dart b/test/analytics/analytics_persistent_queue_test.dart new file mode 100644 index 0000000..f0d5905 --- /dev/null +++ b/test/analytics/analytics_persistent_queue_test.dart @@ -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)> startServer(int statusCode) async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final batchSizes = []; + server.listen((request) async { + final body = + jsonDecode(await utf8.decoder.bind(request).join()) + as Map; + 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); + }); + }); +}