Files
patbond-flutter/lib/analytics/analytics_service.dart
T
lixi 33b993ca0c
CI / flutter-gates (push) Successful in 1m6s
新增:埋点分段持久化队列(13 号规范 §3.3,M2 第二波)
- 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>
2026-09-07 17:04:16 +08:00

200 lines
6.8 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
/// 异步设置 appVersionapp.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;
/// 粗粒度 osVersion13 号规范 §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));
}
}