fix: 埋点上传链路三处修复 + 注册手机号 +86 前缀
CI / flutter-gates (push) Successful in 1m3s

- 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>
This commit is contained in:
2026-09-07 16:48:36 +08:00
parent 8ea6265046
commit 1afec6aad1
6 changed files with 53 additions and 4 deletions
+29 -1
View File
@@ -58,6 +58,21 @@ class AnalyticsService {
return '${Platform.operatingSystem}-${major ?? 'unknown'}'; 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. /// Sets userId after login/restore.
void identify(String userId) { void identify(String userId) {
_userId = userId; _userId = userId;
@@ -89,7 +104,7 @@ class AnalyticsService {
'sessionId': getSessionId(), 'sessionId': getSessionId(),
'clientTs': DateTime.now().toUtc().toIso8601String(), 'clientTs': DateTime.now().toUtc().toIso8601String(),
'appVersion': _appVersion, 'appVersion': _appVersion,
'platform': Platform.isAndroid ? 'android' : 'ios', 'platform': _platformName(),
'osVersion': _osVersion, 'osVersion': _osVersion,
if (props != null && props.isNotEmpty) 'props': props, if (props != null && props.isNotEmpty) 'props': props,
}; };
@@ -103,6 +118,10 @@ class AnalyticsService {
} }
} }
/// 立即冲刷队列(退后台/会话切换时调用,避免低活跃用户凑不满
/// [_flushThreshold] 条导致事件永不上传)。
Future<void> flushNow() => _flush();
Future<void> _flush() async { Future<void> _flush() async {
if (_flushing || _pendingEvents.isEmpty) return; if (_flushing || _pendingEvents.isEmpty) return;
_flushing = true; _flushing = true;
@@ -137,6 +156,15 @@ class AnalyticsService {
request.add(utf8.encode(jsonEncode({'events': events}))); request.add(utf8.encode(jsonEncode({'events': events})));
final response = await request.close(); 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) { if (response.statusCode != 202) {
throw Exception('Upload failed with ${response.statusCode}'); throw Exception('Upload failed with ${response.statusCode}');
} }
+5
View File
@@ -14,6 +14,7 @@ import 'package:uuid/uuid.dart';
class SessionTracker with WidgetsBindingObserver { class SessionTracker with WidgetsBindingObserver {
SessionTracker({ SessionTracker({
this.timeout = const Duration(minutes: 30), this.timeout = const Duration(minutes: 30),
this.onLeaveForeground,
DateTime Function()? now, DateTime Function()? now,
}) : _now = now ?? DateTime.now, }) : _now = now ?? DateTime.now,
_sessionId = const Uuid().v7(); _sessionId = const Uuid().v7();
@@ -21,6 +22,9 @@ class SessionTracker with WidgetsBindingObserver {
/// 后台超时阈值;构造参数化便于测试注入。 /// 后台超时阈值;构造参数化便于测试注入。
final Duration timeout; final Duration timeout;
/// 首次离开前台时回调(app 装配层用于触发埋点队列冲刷)。
final VoidCallback? onLeaveForeground;
/// 时钟注入口,测试免真实等待。 /// 时钟注入口,测试免真实等待。
final DateTime Function() _now; final DateTime Function() _now;
@@ -42,6 +46,7 @@ class SessionTracker with WidgetsBindingObserver {
} else if (_lastState == AppLifecycleState.resumed) { } else if (_lastState == AppLifecycleState.resumed) {
// 首次离开前台才记时;inactive/hidden/paused 级联不覆盖。 // 首次离开前台才记时;inactive/hidden/paused 级联不覆盖。
_leftForegroundAt = _now(); _leftForegroundAt = _now();
onLeaveForeground?.call();
} }
_lastState = state; _lastState = state;
} }
+4 -2
View File
@@ -43,11 +43,13 @@ class _AppState extends State<App> {
widget.sessionManager ?? widget.sessionManager ??
SessionManager(store: const SecureTokenStore()); SessionManager(store: const SecureTokenStore());
_sessionTracker = SessionTracker(); _sessionTracker = SessionTracker(
onLeaveForeground: () => _analytics.flushNow(),
);
WidgetsBinding.instance.addObserver(_sessionTracker); WidgetsBinding.instance.addObserver(_sessionTracker);
_analytics = AnalyticsService( _analytics = AnalyticsService(
apiBaseUrl: patbondApiBaseUrl, apiBaseUrl: patbondUserApiBaseUrl,
getAccessToken: () => sessionManager.accessToken, getAccessToken: () => sessionManager.accessToken,
getSessionId: () => _sessionTracker.sessionId, getSessionId: () => _sessionTracker.sessionId,
); );
+7
View File
@@ -10,6 +10,13 @@ const String patbondApiBaseUrl = String.fromEnvironment(
defaultValue: 'http://127.0.0.1:8081', defaultValue: 'http://127.0.0.1:8081',
); );
/// user 服务基地址(/api/v1/me、/api/v1/events):MVP 阶段 auth 与 user
/// 分端口直连(ADR-002 无网关),`--dart-define=PATBOND_USER_API_BASE_URL=...` 注入。
const String patbondUserApiBaseUrl = String.fromEnvironment(
'PATBOND_USER_API_BASE_URL',
defaultValue: 'http://127.0.0.1:8082',
);
/// 构建全局共用的 Dio 实例。 /// 构建全局共用的 Dio 实例。
/// ///
/// `validateStatus` 放行所有状态码:错误信封由 [ApiClient] 统一解析成 /// `validateStatus` 放行所有状态码:错误信封由 [ApiClient] 统一解析成
+6
View File
@@ -11,6 +11,7 @@ class AppTextField extends StatefulWidget {
super.key, super.key,
this.controller, this.controller,
this.prefixIcon, this.prefixIcon,
this.prefixText,
this.errorText, this.errorText,
this.helperText, this.helperText,
this.enabled = true, this.enabled = true,
@@ -25,6 +26,10 @@ class AppTextField extends StatefulWidget {
final String label; final String label;
final TextEditingController? controller; final TextEditingController? controller;
final IconData? prefixIcon; final IconData? prefixIcon;
/// 固定前缀文本(如手机号的 '+86 '),仅展示与拼接语义,不进输入值。
final String? prefixText;
final String? errorText; final String? errorText;
final String? helperText; final String? helperText;
final bool enabled; final bool enabled;
@@ -62,6 +67,7 @@ class _AppTextFieldState extends State<AppTextField> {
labelText: widget.label, labelText: widget.label,
errorText: widget.errorText, errorText: widget.errorText,
helperText: widget.helperText, helperText: widget.helperText,
prefixText: widget.prefixText,
prefixIcon: widget.prefixIcon == null prefixIcon: widget.prefixIcon == null
? null ? null
: Icon(widget.prefixIcon, color: AppColors.muted), : Icon(widget.prefixIcon, color: AppColors.muted),
+2 -1
View File
@@ -147,7 +147,7 @@ class _RegisterPageState extends State<RegisterPage> {
try { try {
await widget.authRepository.register( await widget.authRepository.register(
username: _usernameCtrl.text.trim(), username: _usernameCtrl.text.trim(),
phone: _phoneCtrl.text.trim(), phone: '+86${_phoneCtrl.text.trim()}',
password: _passwordCtrl.text, password: _passwordCtrl.text,
); );
TextInput.finishAutofillContext(); TextInput.finishAutofillContext();
@@ -232,6 +232,7 @@ class _RegisterPageState extends State<RegisterPage> {
label: '手机号', label: '手机号',
controller: _phoneCtrl, controller: _phoneCtrl,
prefixIcon: Icons.phone_iphone_rounded, prefixIcon: Icons.phone_iphone_rounded,
prefixText: '+86 ',
errorText: _phoneError, errorText: _phoneError,
enabled: !_submitting, enabled: !_submitting,
keyboardType: TextInputType.phone, keyboardType: TextInputType.phone,