diff --git a/lib/analytics/analytics_service.dart b/lib/analytics/analytics_service.dart index 64f7cdb..b0e5954 100644 --- a/lib/analytics/analytics_service.dart +++ b/lib/analytics/analytics_service.dart @@ -58,6 +58,21 @@ class AnalyticsService { 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; @@ -89,7 +104,7 @@ class AnalyticsService { 'sessionId': getSessionId(), 'clientTs': DateTime.now().toUtc().toIso8601String(), 'appVersion': _appVersion, - 'platform': Platform.isAndroid ? 'android' : 'ios', + 'platform': _platformName(), 'osVersion': _osVersion, if (props != null && props.isNotEmpty) 'props': props, }; @@ -103,6 +118,10 @@ class AnalyticsService { } } + /// 立即冲刷队列(退后台/会话切换时调用,避免低活跃用户凑不满 + /// [_flushThreshold] 条导致事件永不上传)。 + Future flushNow() => _flush(); + Future _flush() async { if (_flushing || _pendingEvents.isEmpty) return; _flushing = true; @@ -137,6 +156,15 @@ class AnalyticsService { 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}'); } diff --git a/lib/analytics/session_tracker.dart b/lib/analytics/session_tracker.dart index c4668bc..ea9d4d6 100644 --- a/lib/analytics/session_tracker.dart +++ b/lib/analytics/session_tracker.dart @@ -14,6 +14,7 @@ import 'package:uuid/uuid.dart'; class SessionTracker with WidgetsBindingObserver { SessionTracker({ this.timeout = const Duration(minutes: 30), + this.onLeaveForeground, DateTime Function()? now, }) : _now = now ?? DateTime.now, _sessionId = const Uuid().v7(); @@ -21,6 +22,9 @@ class SessionTracker with WidgetsBindingObserver { /// 后台超时阈值;构造参数化便于测试注入。 final Duration timeout; + /// 首次离开前台时回调(app 装配层用于触发埋点队列冲刷)。 + final VoidCallback? onLeaveForeground; + /// 时钟注入口,测试免真实等待。 final DateTime Function() _now; @@ -42,6 +46,7 @@ class SessionTracker with WidgetsBindingObserver { } else if (_lastState == AppLifecycleState.resumed) { // 首次离开前台才记时;inactive/hidden/paused 级联不覆盖。 _leftForegroundAt = _now(); + onLeaveForeground?.call(); } _lastState = state; } diff --git a/lib/app/app.dart b/lib/app/app.dart index daf89e3..247fcef 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -43,11 +43,13 @@ class _AppState extends State { widget.sessionManager ?? SessionManager(store: const SecureTokenStore()); - _sessionTracker = SessionTracker(); + _sessionTracker = SessionTracker( + onLeaveForeground: () => _analytics.flushNow(), + ); WidgetsBinding.instance.addObserver(_sessionTracker); _analytics = AnalyticsService( - apiBaseUrl: patbondApiBaseUrl, + apiBaseUrl: patbondUserApiBaseUrl, getAccessToken: () => sessionManager.accessToken, getSessionId: () => _sessionTracker.sessionId, ); diff --git a/lib/core/network/api_client.dart b/lib/core/network/api_client.dart index 9a7644d..545afdb 100644 --- a/lib/core/network/api_client.dart +++ b/lib/core/network/api_client.dart @@ -10,6 +10,13 @@ const String patbondApiBaseUrl = String.fromEnvironment( 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 实例。 /// /// `validateStatus` 放行所有状态码:错误信封由 [ApiClient] 统一解析成 diff --git a/lib/core/widgets/app_text_field.dart b/lib/core/widgets/app_text_field.dart index 50a46f2..966ad93 100644 --- a/lib/core/widgets/app_text_field.dart +++ b/lib/core/widgets/app_text_field.dart @@ -11,6 +11,7 @@ class AppTextField extends StatefulWidget { super.key, this.controller, this.prefixIcon, + this.prefixText, this.errorText, this.helperText, this.enabled = true, @@ -25,6 +26,10 @@ class AppTextField extends StatefulWidget { final String label; final TextEditingController? controller; final IconData? prefixIcon; + + /// 固定前缀文本(如手机号的 '+86 '),仅展示与拼接语义,不进输入值。 + final String? prefixText; + final String? errorText; final String? helperText; final bool enabled; @@ -62,6 +67,7 @@ class _AppTextFieldState extends State { labelText: widget.label, errorText: widget.errorText, helperText: widget.helperText, + prefixText: widget.prefixText, prefixIcon: widget.prefixIcon == null ? null : Icon(widget.prefixIcon, color: AppColors.muted), diff --git a/lib/features/auth/register_page.dart b/lib/features/auth/register_page.dart index 1175e92..51311cf 100644 --- a/lib/features/auth/register_page.dart +++ b/lib/features/auth/register_page.dart @@ -147,7 +147,7 @@ class _RegisterPageState extends State { try { await widget.authRepository.register( username: _usernameCtrl.text.trim(), - phone: _phoneCtrl.text.trim(), + phone: '+86${_phoneCtrl.text.trim()}', password: _passwordCtrl.text, ); TextInput.finishAutofillContext(); @@ -232,6 +232,7 @@ class _RegisterPageState extends State { label: '手机号', controller: _phoneCtrl, prefixIcon: Icons.phone_iphone_rounded, + prefixText: '+86 ', errorText: _phoneError, enabled: !_submitting, keyboardType: TextInputType.phone,