33b993ca0c
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>
200 lines
6.8 KiB
Dart
200 lines
6.8 KiB
Dart
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';
|
||
|
||
/// 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(),
|
||
_store = store ?? AnalyticsEventStore();
|
||
|
||
/// 异步设置 appVersion(app.dart 启动时从 package_info_plus 读取后注入)。
|
||
void setAppVersion(String version) {
|
||
_appVersion = version;
|
||
}
|
||
|
||
// 满 _flushThreshold 条触发一次冲刷(13 号规范 §3.4)。
|
||
static const _flushThreshold = 20;
|
||
|
||
// 契约单批上限(13 号规范 §1.1:单批 1–50 条),冲刷时按段拼批循环上传。
|
||
static const _maxBatchEvents = 50;
|
||
|
||
final String apiBaseUrl;
|
||
final String? Function()? getAccessToken;
|
||
|
||
/// 会话标识来源(SessionTracker 注入),冷启动/长后台换新由其管理。
|
||
final String Function() getSessionId;
|
||
|
||
final String _anonymousId;
|
||
String _appVersion;
|
||
final String _osVersion;
|
||
String? _userId;
|
||
bool _flushing = false;
|
||
final AnalyticsEventStore _store;
|
||
|
||
/// 待上报事件(测试断言用,生产代码不得直接操作)。
|
||
@visibleForTesting
|
||
List<Map<String, dynamic>> get pendingEvents => _store.events;
|
||
|
||
/// 粗粒度 osVersion(13 号规范 §4.0:主版本级,如 android-14)。
|
||
/// Web 平台不支持 Platform.operatingSystemVersion,降级为 'web-unknown'。
|
||
static String _defaultOsVersion() {
|
||
if (kIsWeb) {
|
||
return 'web-unknown';
|
||
}
|
||
final major = RegExp(
|
||
r'\d+',
|
||
).firstMatch(Platform.operatingSystemVersion)?.group(0);
|
||
return '${Platform.operatingSystem}-${major ?? 'unknown'}';
|
||
}
|
||
|
||
/// 平台标识。契约枚举为 android/ios;Web/桌面为开发调试形态,
|
||
/// 上报值不在枚举内会被服务端逐条 rejected(不影响客户端),属预期。
|
||
static String _platformName() {
|
||
if (kIsWeb) {
|
||
return 'web';
|
||
}
|
||
if (Platform.isAndroid) {
|
||
return 'android';
|
||
}
|
||
if (Platform.isIOS) {
|
||
return 'ios';
|
||
}
|
||
return Platform.operatingSystem;
|
||
}
|
||
|
||
/// Sets userId after login/restore.
|
||
void identify(String userId) {
|
||
_userId = userId;
|
||
}
|
||
|
||
/// Clears userId on logout (anonymousId remains).
|
||
void reset() {
|
||
_userId = null;
|
||
}
|
||
|
||
/// Tracks event (never throws, never awaits network). Props are validated
|
||
/// for privacy red-line patterns locally before queuing.
|
||
Future<void> trackEvent(
|
||
String eventName, [
|
||
Map<String, dynamic>? props,
|
||
]) async {
|
||
try {
|
||
if (props != null && _containsForbiddenField(props)) {
|
||
debugPrint('Analytics: event $eventName rejected (forbidden field)');
|
||
return;
|
||
}
|
||
|
||
final event = {
|
||
'eventId': const Uuid().v7(),
|
||
'eventName': eventName,
|
||
'eventVersion': 1,
|
||
'anonymousId': _anonymousId,
|
||
if (_userId != null) 'userId': _userId,
|
||
'sessionId': getSessionId(),
|
||
'clientTs': DateTime.now().toUtc().toIso8601String(),
|
||
'appVersion': _appVersion,
|
||
'platform': _platformName(),
|
||
'osVersion': _osVersion,
|
||
if (props != null && props.isNotEmpty) 'props': props,
|
||
};
|
||
|
||
await _store.add(event);
|
||
if (_store.length >= _flushThreshold) {
|
||
await _flush();
|
||
}
|
||
} catch (error) {
|
||
debugPrint('Analytics track failed: $error');
|
||
}
|
||
}
|
||
|
||
/// 冷启动恢复持久化队列(离线积压约两周容量),有积压即冲刷一次
|
||
/// (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) return;
|
||
_flushing = true;
|
||
|
||
try {
|
||
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;
|
||
}
|
||
}
|
||
|
||
/// 上传一批事件。返回 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'))
|
||
..headers.contentType = ContentType.json;
|
||
if (token != null) {
|
||
request.headers.add('Authorization', 'Bearer $token');
|
||
}
|
||
request.add(utf8.encode(jsonEncode({'events': events})));
|
||
|
||
final response = await request.close();
|
||
if (response.statusCode >= 400 && response.statusCode < 500) {
|
||
// 4xx 为永久性拒绝(校验失败/批量超限等),重试不可能成功;
|
||
// 丢弃并打日志,避免毒丸批次无限重回队列阻塞后续事件。
|
||
debugPrint(
|
||
'Analytics batch permanently rejected '
|
||
'(${response.statusCode}), dropping ${events.length} events',
|
||
);
|
||
return true;
|
||
}
|
||
if (response.statusCode != 202) {
|
||
throw Exception('Upload failed with ${response.statusCode}');
|
||
}
|
||
return false;
|
||
}
|
||
|
||
bool _containsForbiddenField(Map<String, dynamic> props) {
|
||
// Dart RegExp 不支持 (?i) 内联标志(原写法构造即抛异常,事件被静默丢弃)。
|
||
final pattern = RegExp(
|
||
r'password|token|secret|phone|mobile|email|credential|idfa|gaid',
|
||
caseSensitive: false,
|
||
);
|
||
return props.keys.any((key) => pattern.hasMatch(key));
|
||
}
|
||
}
|