Files
patbond-flutter/lib/analytics/analytics_service.dart
T
lixi 1afec6aad1
CI / flutter-gates (push) Successful in 1m3s
fix: 埋点上传链路三处修复 + 注册手机号 +86 前缀
- events 上传地址接错:AnalyticsService 误用 auth(8081),实际端点在
  user 服务(8082),新增 patbondUserApiBaseUrl 并接线
- 冲刷时机:新增 flushNow(),SessionTracker 首次离开前台触发,
  修复低活跃用户凑不满 20 条事件永不上传
- 毒丸批次:4xx 永久性拒绝不再重回队列无限重试,丢弃并打日志
- Web/桌面 Platform API 降级(kIsWeb + _platformName)
- 注册手机号 UI 固定 +86 前缀、提交拼 E.164;AppTextField 支持 prefixText

51 测试全绿、analyze 0 问题;Linux 桌面实测注册成功,
curl 实测后端 /api/v1/events 校验行为符合契约。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-07 16:48:36 +08:00

182 lines
6.1 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: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.
class AnalyticsService {
AnalyticsService({
required this.apiBaseUrl,
required this.getAccessToken,
required this.getSessionId,
String? anonymousId,
}) : _anonymousId = anonymousId ?? const Uuid().v4(),
_appVersion = 'unknown',
_osVersion = _defaultOsVersion();
/// 异步设置 appVersionapp.dart 启动时从 package_info_plus 读取后注入)。
void setAppVersion(String version) {
_appVersion = version;
}
// 内存队列:满 _flushThreshold 条上传一次;分段持久化队列排 M2 第二波。
static const _flushThreshold = 20;
// 失败重回队列的容量上限(对齐 13 号规范队列上限),超限丢最旧。
static const _maxQueuedEvents = 500;
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 List<Map<String, dynamic>> _pendingEvents = [];
/// 待上报事件(测试断言用,生产代码不得直接操作)。
@visibleForTesting
List<Map<String, dynamic>> get pendingEvents =>
List.unmodifiable(_pendingEvents);
/// 粗粒度 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,
};
_pendingEvents.add(event);
if (_pendingEvents.length >= _flushThreshold) {
await _flush();
}
} catch (error) {
debugPrint('Analytics track failed: $error');
}
}
/// 立即冲刷队列(退后台/会话切换时调用,避免低活跃用户凑不满
/// [_flushThreshold] 条导致事件永不上传)。
Future<void> flushNow() => _flush();
Future<void> _flush() async {
if (_flushing || _pendingEvents.isEmpty) 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);
}
} finally {
_flushing = false;
}
}
Future<void> _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;
}
if (response.statusCode != 202) {
throw Exception('Upload failed with ${response.statusCode}');
}
}
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));
}
}