From 2cdf075e92dd289186b380771cea4a7452ff574d Mon Sep 17 00:00:00 2001 From: qzl Date: Wed, 1 Apr 2026 18:35:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E8=A1=8C=E4=B8=BA=E5=88=86=E6=9E=90=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 6 + .../lib/core/analytics/events/base_event.dart | 111 +++++ .../core/analytics/events/click_event.dart | 27 ++ .../analytics/events/conversation_event.dart | 34 ++ apps/lib/core/analytics/events/events.dart | 6 + .../core/analytics/events/login_event.dart | 24 + .../core/analytics/events/logout_event.dart | 28 ++ .../analytics/events/page_view_event.dart | 29 ++ .../lib/core/analytics/queue/event_queue.dart | 46 ++ apps/lib/core/analytics/sender.dart | 45 ++ apps/lib/core/analytics/tracker.dart | 247 ++++++++++ apps/lib/core/config/env.dart | 22 + .../auth/presentation/bloc/auth_bloc.dart | 21 + .../presentation/screens/login_screen.dart | 3 + apps/lib/main.dart | 6 + apps/pubspec.yaml | 1 + backend/src/core/config/settings.py | 6 + backend/src/v1/analytics/router.py | 44 ++ backend/src/v1/analytics/schemas.py | 54 +++ backend/src/v1/analytics/service.py | 60 +++ backend/src/v1/analytics/tasks.py | 43 ++ backend/src/v1/analytics/web/index.html | 445 ++++++++++++++++++ backend/src/v1/router.py | 2 + deploy/.env.prod.example | 6 + docs/plans/2026-04-01-analytics-design.md | 10 +- 25 files changed, 1321 insertions(+), 5 deletions(-) create mode 100644 apps/lib/core/analytics/events/base_event.dart create mode 100644 apps/lib/core/analytics/events/click_event.dart create mode 100644 apps/lib/core/analytics/events/conversation_event.dart create mode 100644 apps/lib/core/analytics/events/events.dart create mode 100644 apps/lib/core/analytics/events/login_event.dart create mode 100644 apps/lib/core/analytics/events/logout_event.dart create mode 100644 apps/lib/core/analytics/events/page_view_event.dart create mode 100644 apps/lib/core/analytics/queue/event_queue.dart create mode 100644 apps/lib/core/analytics/sender.dart create mode 100644 apps/lib/core/analytics/tracker.dart create mode 100644 backend/src/v1/analytics/router.py create mode 100644 backend/src/v1/analytics/schemas.py create mode 100644 backend/src/v1/analytics/service.py create mode 100644 backend/src/v1/analytics/tasks.py create mode 100644 backend/src/v1/analytics/web/index.html diff --git a/.env.example b/.env.example index 2897a8e..570a099 100644 --- a/.env.example +++ b/.env.example @@ -91,6 +91,12 @@ SOCIAL_APP_VERSION__MANIFEST_PATH=deploy/static/releases/manifest.json SOCIAL_APP_VERSION__RELEASE_PATH_PREFIX=releases SOCIAL_APP_VERSION__DOWNLOAD_BASE_URL= +############ +# Analytics 配置 +############ +SOCIAL_ANALYTICS__DATA_PATH=backend/data/analytics +SOCIAL_ANALYTICS__PASSWORD=analytics-secret-change-me + ############ # Test相关 ############ diff --git a/apps/lib/core/analytics/events/base_event.dart b/apps/lib/core/analytics/events/base_event.dart new file mode 100644 index 0000000..462f9a1 --- /dev/null +++ b/apps/lib/core/analytics/events/base_event.dart @@ -0,0 +1,111 @@ +import 'package:equatable/equatable.dart'; + +class AnalyticsContext extends Equatable { + final String? networkType; + final String? osVersion; + final String? deviceModel; + final String? locale; + final String? timezone; + + const AnalyticsContext({ + this.networkType, + this.osVersion, + this.deviceModel, + this.locale, + this.timezone, + }); + + Map toJson() => { + 'network_type': networkType, + 'os_version': osVersion, + 'device_model': deviceModel, + 'locale': locale, + 'timezone': timezone, + }; + + @override + List get props => [ + networkType, + osVersion, + deviceModel, + locale, + timezone, + ]; +} + +class BaseAnalyticsEvent extends Equatable { + final String eventId; + final String eventType; + final DateTime timestamp; + final String userId; + final String deviceId; + final String sessionId; + final String platform; + final String appVersion; + final String? appBuild; + final String env; + final String? pageName; + final String? traceId; + final String? requestId; + final Map attributes; + final Map metrics; + final AnalyticsContext? context; + + const BaseAnalyticsEvent({ + required this.eventId, + required this.eventType, + required this.timestamp, + required this.userId, + required this.deviceId, + required this.sessionId, + required this.platform, + required this.appVersion, + this.appBuild, + required this.env, + this.pageName, + this.traceId, + this.requestId, + this.attributes = const {}, + this.metrics = const {}, + this.context, + }); + + Map toJson() => { + 'event_id': eventId, + 'event_type': eventType, + 'timestamp': timestamp.toUtc().toIso8601String(), + 'user_id': userId, + 'device_id': deviceId, + 'session_id': sessionId, + 'platform': platform, + 'app_version': appVersion, + 'app_build': appBuild, + 'env': env, + 'page_name': pageName, + 'trace_id': traceId, + 'request_id': requestId, + 'attributes': attributes, + 'metrics': metrics, + 'context': context?.toJson(), + }; + + @override + List get props => [ + eventId, + eventType, + timestamp, + userId, + deviceId, + sessionId, + platform, + appVersion, + appBuild, + env, + pageName, + traceId, + requestId, + attributes, + metrics, + context, + ]; +} diff --git a/apps/lib/core/analytics/events/click_event.dart b/apps/lib/core/analytics/events/click_event.dart new file mode 100644 index 0000000..47811c6 --- /dev/null +++ b/apps/lib/core/analytics/events/click_event.dart @@ -0,0 +1,27 @@ +import 'base_event.dart'; + +class UiClickEvent extends BaseAnalyticsEvent { + UiClickEvent({ + required super.eventId, + required super.timestamp, + required super.userId, + required super.deviceId, + required super.sessionId, + required super.platform, + required super.appVersion, + super.appBuild, + required super.env, + required super.pageName, + super.traceId, + super.requestId, + required String elementId, + String? elementType, + super.context, + }) : super( + eventType: 'ui.click', + attributes: { + 'element_id': elementId, + if (elementType != null) 'element_type': elementType, + }, + ); +} diff --git a/apps/lib/core/analytics/events/conversation_event.dart b/apps/lib/core/analytics/events/conversation_event.dart new file mode 100644 index 0000000..e6253f3 --- /dev/null +++ b/apps/lib/core/analytics/events/conversation_event.dart @@ -0,0 +1,34 @@ +import 'base_event.dart'; + +class AgentChatCompletedEvent extends BaseAnalyticsEvent { + AgentChatCompletedEvent({ + required super.eventId, + required super.timestamp, + required super.userId, + required super.deviceId, + required super.sessionId, + required super.platform, + required super.appVersion, + super.appBuild, + required super.env, + super.pageName, + super.traceId, + super.requestId, + required String conversationId, + String? scenario, + int? messageCount, + int? responseTimeMs, + AnalyticsContext? context, + }) : super( + eventType: 'agent.chat_completed', + attributes: { + 'conversation_id': conversationId, + if (scenario != null) 'scenario': scenario, + }, + metrics: { + if (messageCount != null) 'message_count': messageCount, + if (responseTimeMs != null) 'response_time_ms': responseTimeMs, + }, + context: context, + ); +} diff --git a/apps/lib/core/analytics/events/events.dart b/apps/lib/core/analytics/events/events.dart new file mode 100644 index 0000000..2e9bead --- /dev/null +++ b/apps/lib/core/analytics/events/events.dart @@ -0,0 +1,6 @@ +export 'base_event.dart'; +export 'login_event.dart'; +export 'logout_event.dart'; +export 'conversation_event.dart'; +export 'page_view_event.dart'; +export 'click_event.dart'; diff --git a/apps/lib/core/analytics/events/login_event.dart b/apps/lib/core/analytics/events/login_event.dart new file mode 100644 index 0000000..9da77aa --- /dev/null +++ b/apps/lib/core/analytics/events/login_event.dart @@ -0,0 +1,24 @@ +import 'base_event.dart'; + +class SessionLoginEvent extends BaseAnalyticsEvent { + SessionLoginEvent({ + required super.eventId, + required super.timestamp, + required super.userId, + required super.deviceId, + required super.sessionId, + required super.platform, + required super.appVersion, + super.appBuild, + required super.env, + super.pageName, + super.traceId, + super.requestId, + required String method, + AnalyticsContext? context, + }) : super( + eventType: 'session.login', + attributes: {'method': method}, + context: context, + ); +} diff --git a/apps/lib/core/analytics/events/logout_event.dart b/apps/lib/core/analytics/events/logout_event.dart new file mode 100644 index 0000000..eb26b37 --- /dev/null +++ b/apps/lib/core/analytics/events/logout_event.dart @@ -0,0 +1,28 @@ +import 'base_event.dart'; + +class SessionLogoutEvent extends BaseAnalyticsEvent { + SessionLogoutEvent({ + required super.eventId, + required super.timestamp, + required super.userId, + required super.deviceId, + required super.sessionId, + required super.platform, + required super.appVersion, + super.appBuild, + required super.env, + super.pageName, + super.traceId, + super.requestId, + String? reason, + int? sessionDurationS, + AnalyticsContext? context, + }) : super( + eventType: 'session.logout', + attributes: reason != null ? {'reason': reason} : const {}, + metrics: sessionDurationS != null + ? {'session_duration_s': sessionDurationS} + : const {}, + context: context, + ); +} diff --git a/apps/lib/core/analytics/events/page_view_event.dart b/apps/lib/core/analytics/events/page_view_event.dart new file mode 100644 index 0000000..d2dffff --- /dev/null +++ b/apps/lib/core/analytics/events/page_view_event.dart @@ -0,0 +1,29 @@ +import 'base_event.dart'; + +class PageViewEvent extends BaseAnalyticsEvent { + PageViewEvent({ + required super.eventId, + required super.timestamp, + required super.userId, + required super.deviceId, + required super.sessionId, + required super.platform, + required super.appVersion, + super.appBuild, + required super.env, + required super.pageName, + super.traceId, + super.requestId, + String? pageFrom, + int? stayDurationMs, + int? clickCount, + super.context, + }) : super( + eventType: 'page.view', + attributes: pageFrom != null ? {'page_from': pageFrom} : const {}, + metrics: { + if (stayDurationMs != null) 'stay_duration_ms': stayDurationMs, + if (clickCount != null) 'click_count': clickCount, + }, + ); +} diff --git a/apps/lib/core/analytics/queue/event_queue.dart b/apps/lib/core/analytics/queue/event_queue.dart new file mode 100644 index 0000000..5618042 --- /dev/null +++ b/apps/lib/core/analytics/queue/event_queue.dart @@ -0,0 +1,46 @@ +import 'dart:async'; + +import '../events/events.dart'; + +class EventQueue { + final List _queue = []; + final int maxSize; + final Duration flushInterval; + final void Function(List) onFlush; + + Timer? _timer; + + EventQueue({ + this.maxSize = 50, + this.flushInterval = const Duration(seconds: 30), + required this.onFlush, + }); + + void start() { + _timer?.cancel(); + _timer = Timer.periodic(flushInterval, (_) => _tryFlush()); + } + + void stop() { + _timer?.cancel(); + _timer = null; + } + + void add(BaseAnalyticsEvent event) { + _queue.add(event); + if (_queue.length >= maxSize) { + _tryFlush(); + } + } + + void _tryFlush() { + if (_queue.isEmpty) return; + final events = List.from(_queue); + _queue.clear(); + onFlush(events); + } + + List get pendingEvents => List.unmodifiable(_queue); + + int get pendingCount => _queue.length; +} diff --git a/apps/lib/core/analytics/sender.dart b/apps/lib/core/analytics/sender.dart new file mode 100644 index 0000000..6a2e89e --- /dev/null +++ b/apps/lib/core/analytics/sender.dart @@ -0,0 +1,45 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; + +import 'events/events.dart'; + +class AnalyticsSender { + final Dio _dio; + final String endpoint; + + AnalyticsSender({required this.endpoint, Dio? dio}) : _dio = dio ?? Dio(); + + Future send(List events) async { + if (events.isEmpty) return; + + final body = {'events': events.map((e) => e.toJson()).toList()}; + + try { + await _dio.post( + endpoint, + data: jsonEncode(body), + options: Options( + headers: {'Content-Type': 'application/json'}, + sendTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 10), + ), + ); + } on DioException catch (e) { + throw AnalyticsSendException( + 'Failed to send analytics events: ${e.message}', + events: events, + ); + } + } +} + +class AnalyticsSendException implements Exception { + final String message; + final List events; + + AnalyticsSendException(this.message, {required this.events}); + + @override + String toString() => 'AnalyticsSendException: $message'; +} diff --git a/apps/lib/core/analytics/tracker.dart b/apps/lib/core/analytics/tracker.dart new file mode 100644 index 0000000..e736999 --- /dev/null +++ b/apps/lib/core/analytics/tracker.dart @@ -0,0 +1,247 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:uuid/uuid.dart'; + +import 'events/events.dart'; +import 'queue/event_queue.dart'; +import 'sender.dart'; + +class AnalyticsTracker { + static AnalyticsTracker? _instance; + + late final AnalyticsSender _sender; + late final EventQueue _queue; + late final String _deviceId; + late final String _sessionId; + late final String _platform; + late final String _appVersion; + late final String? _appBuild; + late final String _env; + + String? _userId; + + AnalyticsTracker._(); + + static AnalyticsTracker get instance { + if (_instance == null) { + throw StateError('AnalyticsTracker not initialized. Call init() first.'); + } + return _instance!; + } + + static Future init({ + required String endpoint, + required String deviceId, + }) async { + if (_instance != null) return; + + final packageInfo = await PackageInfo.fromPlatform(); + + final sessionId = await _getOrCreateSessionId(); + final platform = Platform.isAndroid ? 'android' : 'ios'; + final env = kDebugMode ? 'dev' : 'prod'; + + final tracker = AnalyticsTracker._(); + tracker._sender = AnalyticsSender(endpoint: endpoint); + tracker._queue = EventQueue( + maxSize: 50, + flushInterval: const Duration(seconds: 30), + onFlush: tracker._handleFlush, + ); + tracker._deviceId = deviceId; + tracker._sessionId = sessionId; + tracker._platform = platform; + tracker._appVersion = packageInfo.version; + tracker._appBuild = packageInfo.buildNumber.isNotEmpty + ? packageInfo.buildNumber + : null; + tracker._env = env; + + tracker._queue.start(); + _instance = tracker; + } + + static Future _getOrCreateSessionId() async { + const uuid = Uuid(); + final prefs = await SharedPreferences.getInstance(); + var sessionId = prefs.getString('_analytics_session_id'); + if (sessionId == null) { + sessionId = 'sess_${uuid.v4()}'; + await prefs.setString('_analytics_session_id', sessionId); + } + return sessionId; + } + + void setUserId(String? userId) { + _userId = userId; + } + + String get userId => _userId ?? 'anonymous'; + + String get sessionId => _sessionId; + + void track(BaseAnalyticsEvent event) { + _queue.add(event); + } + + void trackLogin({ + required String method, + String? traceId, + String? requestId, + AnalyticsContext? context, + }) { + track( + SessionLoginEvent( + eventId: _generateEventId(), + timestamp: DateTime.now(), + userId: userId, + deviceId: _deviceId, + sessionId: _sessionId, + platform: _platform, + appVersion: _appVersion, + appBuild: _appBuild, + env: _env, + pageName: 'login', + traceId: traceId, + requestId: requestId, + method: method, + context: context, + ), + ); + } + + void trackLogout({ + String? reason, + int? sessionDurationS, + String? pageName, + String? traceId, + AnalyticsContext? context, + }) { + track( + SessionLogoutEvent( + eventId: _generateEventId(), + timestamp: DateTime.now(), + userId: userId, + deviceId: _deviceId, + sessionId: _sessionId, + platform: _platform, + appVersion: _appVersion, + appBuild: _appBuild, + env: _env, + pageName: pageName, + traceId: traceId, + reason: reason, + sessionDurationS: sessionDurationS, + context: context, + ), + ); + } + + void trackAgentChatCompleted({ + required String conversationId, + String? scenario, + int? messageCount, + int? responseTimeMs, + String? traceId, + String? requestId, + AnalyticsContext? context, + }) { + track( + AgentChatCompletedEvent( + eventId: _generateEventId(), + timestamp: DateTime.now(), + userId: userId, + deviceId: _deviceId, + sessionId: _sessionId, + platform: _platform, + appVersion: _appVersion, + appBuild: _appBuild, + env: _env, + pageName: 'chat', + traceId: traceId, + requestId: requestId, + conversationId: conversationId, + scenario: scenario, + messageCount: messageCount, + responseTimeMs: responseTimeMs, + context: context, + ), + ); + } + + void trackPageView({ + required String pageName, + String? pageFrom, + int? stayDurationMs, + int? clickCount, + String? traceId, + AnalyticsContext? context, + }) { + track( + PageViewEvent( + eventId: _generateEventId(), + timestamp: DateTime.now(), + userId: userId, + deviceId: _deviceId, + sessionId: _sessionId, + platform: _platform, + appVersion: _appVersion, + appBuild: _appBuild, + env: _env, + pageName: pageName, + pageFrom: pageFrom, + stayDurationMs: stayDurationMs, + clickCount: clickCount, + traceId: traceId, + context: context, + ), + ); + } + + void trackClick({ + required String pageName, + required String elementId, + String? elementType, + String? traceId, + AnalyticsContext? context, + }) { + track( + UiClickEvent( + eventId: _generateEventId(), + timestamp: DateTime.now(), + userId: userId, + deviceId: _deviceId, + sessionId: _sessionId, + platform: _platform, + appVersion: _appVersion, + appBuild: _appBuild, + env: _env, + pageName: pageName, + elementId: elementId, + elementType: elementType, + traceId: traceId, + context: context, + ), + ); + } + + String _generateEventId() { + return const Uuid().v4(); + } + + Future _handleFlush(List events) async { + try { + await _sender.send(events); + } catch (e) { + // TODO: 失败时落盘本地,下次启动重试 + debugPrint('Analytics send failed: $e'); + } + } + + void dispose() { + _queue.stop(); + } +} diff --git a/apps/lib/core/config/env.dart b/apps/lib/core/config/env.dart index c88424a..90e5c8a 100644 --- a/apps/lib/core/config/env.dart +++ b/apps/lib/core/config/env.dart @@ -1,6 +1,8 @@ import 'dart:io'; import 'package:package_info_plus/package_info_plus.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; class Env { static String get apiUrl { @@ -14,13 +16,33 @@ class Env { return 'http://localhost:5775'; } + static String get analyticsEndpoint => '$apiUrl/api/v1/analytics/events'; + static String version = '0.1.0'; static int build = 1; + static String deviceId = ''; static Future init() async { final info = await PackageInfo.fromPlatform(); version = info.version; final buildStr = info.buildNumber.isEmpty ? '1' : info.buildNumber; build = int.tryParse(buildStr) ?? 1; + + deviceId = await _getOrCreateDeviceId(); + } + + static Future _getOrCreateDeviceId() async { + const storage = FlutterSecureStorage(); + var deviceId = await storage.read(key: 'device_id'); + if (deviceId == null || deviceId.isEmpty) { + final prefs = await SharedPreferences.getInstance(); + deviceId = prefs.getString('device_id') ?? ''; + if (deviceId.isEmpty) { + deviceId = 'install_${DateTime.now().millisecondsSinceEpoch}'; + await prefs.setString('device_id', deviceId); + } + await storage.write(key: 'device_id', value: deviceId); + } + return deviceId; } } diff --git a/apps/lib/features/auth/presentation/bloc/auth_bloc.dart b/apps/lib/features/auth/presentation/bloc/auth_bloc.dart index 1421686..ee9de69 100644 --- a/apps/lib/features/auth/presentation/bloc/auth_bloc.dart +++ b/apps/lib/features/auth/presentation/bloc/auth_bloc.dart @@ -1,4 +1,5 @@ import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../../core/analytics/tracker.dart'; import '../../../../core/logging/logger.dart'; import '../../data/repositories/auth_repository.dart'; import 'auth_event.dart'; @@ -7,6 +8,7 @@ import 'auth_state.dart'; class AuthBloc extends Bloc { final AuthRepository _repository; final Logger _logger = getLogger('features.auth.bloc'); + DateTime? _loginTime; AuthBloc(this._repository) : super(AuthInitial()) { on(_onStarted); @@ -21,6 +23,8 @@ class AuthBloc extends Bloc { final refreshToken = await _repository.getRefreshToken(); if (refreshToken != null) { final response = await _repository.refreshSession(refreshToken); + _loginTime = DateTime.now(); + AnalyticsTracker.instance.setUserId(response.user.id); emit( AuthAuthenticated( user: AuthUser(id: response.user.id, phone: response.user.phone), @@ -56,6 +60,7 @@ class AuthBloc extends Bloc { } void _onLoggedIn(AuthLoggedIn event, Emitter emit) { + _loginTime = DateTime.now(); _logger.info(message: 'User logged in', extra: {'user_id': event.user.id}); emit(AuthAuthenticated(user: event.user)); } @@ -64,6 +69,9 @@ class AuthBloc extends Bloc { AuthLoggedOut event, Emitter emit, ) async { + final sessionDuration = _loginTime != null + ? DateTime.now().difference(_loginTime!).inSeconds + : null; try { await _repository.deleteSession(); _logger.info(message: 'User logged out'); @@ -74,6 +82,11 @@ class AuthBloc extends Bloc { stackTrace: stackTrace, ); } finally { + AnalyticsTracker.instance.trackLogout( + reason: 'manual', + sessionDurationS: sessionDuration, + ); + _loginTime = null; emit( const AuthUnauthenticated(reason: AuthUnauthenticatedReason.signedOut), ); @@ -84,6 +97,9 @@ class AuthBloc extends Bloc { AuthSessionInvalidated event, Emitter emit, ) async { + final sessionDuration = _loginTime != null + ? DateTime.now().difference(_loginTime!).inSeconds + : null; _logger.warning(message: 'Session invalidated by server'); try { await _repository.clearSessionLocalOnly(); @@ -94,6 +110,11 @@ class AuthBloc extends Bloc { stackTrace: stackTrace, ); } finally { + AnalyticsTracker.instance.trackLogout( + reason: 'expired', + sessionDurationS: sessionDuration, + ); + _loginTime = null; emit( const AuthUnauthenticated(reason: AuthUnauthenticatedReason.expired), ); diff --git a/apps/lib/features/auth/presentation/screens/login_screen.dart b/apps/lib/features/auth/presentation/screens/login_screen.dart index 90a7c70..0092e14 100644 --- a/apps/lib/features/auth/presentation/screens/login_screen.dart +++ b/apps/lib/features/auth/presentation/screens/login_screen.dart @@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart'; import '../../../../app/di/injection.dart'; import '../../../../app/router/app_routes.dart'; +import '../../../../core/analytics/tracker.dart'; import '../../../../core/l10n/l10n.dart'; import '../../../../core/theme/design_tokens.dart'; import '../../../../shared/widgets/app_button.dart'; @@ -69,6 +70,8 @@ class _LoginViewState extends State { final response = await cubit.submit(); if (response != null && mounted) { + AnalyticsTracker.instance.trackLogin(method: 'phone_code'); + AnalyticsTracker.instance.setUserId(response.user.id); context.read().add(AuthLoggedIn(user: response.user)); context.go(AppRoutes.homeMain); } diff --git a/apps/lib/main.dart b/apps/lib/main.dart index f3f19a6..e00b31d 100644 --- a/apps/lib/main.dart +++ b/apps/lib/main.dart @@ -3,6 +3,7 @@ import 'core/config/env.dart'; import 'core/logging/logger.dart'; import 'core/logging/log_service.dart'; import 'core/logging/error_handler.dart'; +import 'core/analytics/tracker.dart'; import 'app/di/injection.dart'; import 'app/app.dart'; @@ -17,6 +18,11 @@ void main() async { await configureDependencies(); await Env.init(); + await AnalyticsTracker.init( + endpoint: Env.analyticsEndpoint, + deviceId: Env.deviceId, + ); + getLogger( 'app', ).info(message: 'App starting...', extra: {'version': Env.version}); diff --git a/apps/pubspec.yaml b/apps/pubspec.yaml index 40c9f84..2e109b5 100644 --- a/apps/pubspec.yaml +++ b/apps/pubspec.yaml @@ -29,6 +29,7 @@ dependencies: package_info_plus: ^8.0.3 url_launcher: ^6.3.1 path_provider: ^2.1.2 + uuid: ^4.5.1 dev_dependencies: flutter_test: diff --git a/backend/src/core/config/settings.py b/backend/src/core/config/settings.py index cdab1df..6e37dfd 100644 --- a/backend/src/core/config/settings.py +++ b/backend/src/core/config/settings.py @@ -89,6 +89,11 @@ class RuntimeSettings(BaseModel): return self +class AnalyticsSettings(BaseModel): + data_path: str = "backend/data/analytics" + password: str = "analytics-secret" + + class TaskiqSettings(BaseModel): broker_url: str | None = None result_backend_url: str | None = None @@ -274,6 +279,7 @@ class Settings(BaseSettings): taskiq: TaskiqSettings = TaskiqSettings() database: DatabaseSettings = DatabaseSettings() app_version: AppVersionSettings = AppVersionSettings() + analytics: AnalyticsSettings = AnalyticsSettings() test: TestSettings = Field(default_factory=TestSettings) @computed_field diff --git a/backend/src/v1/analytics/router.py b/backend/src/v1/analytics/router.py new file mode 100644 index 0000000..29020d5 --- /dev/null +++ b/backend/src/v1/analytics/router.py @@ -0,0 +1,44 @@ +from fastapi import APIRouter, HTTPException, status + +from core.config.settings import config +from core.logging import get_logger +from v1.analytics.schemas import ( + AnalyticsBatchRequest, + AnalyticsBatchResponse, + AnalyticsLoginRequest, + AnalyticsLoginResponse, +) +from v1.analytics.service import get_analytics_service +from v1.analytics.tasks import write_analytics_events + + +logger = get_logger("v1.analytics.router") + +router = APIRouter(prefix="/analytics", tags=["analytics"]) + + +@router.post("/events", response_model=AnalyticsBatchResponse) +async def receive_events(request: AnalyticsBatchRequest) -> AnalyticsBatchResponse: + """接收埋点事件批次""" + service = get_analytics_service() + received = await service.enqueue_events(request) + + events, date = service.get_and_clear_buffer() + if events: + await write_analytics_events(batch=events, date=date) + + return AnalyticsBatchResponse(received=received, queued=True) + + +@router.post("/login", response_model=AnalyticsLoginResponse) +async def login(request: AnalyticsLoginRequest) -> AnalyticsLoginResponse: + """Analytics Dashboard 登录""" + if request.password != config.analytics.password: + logger.warning("Analytics login failed: invalid password") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid password", + ) + + logger.info("Analytics login success") + return AnalyticsLoginResponse(success=True) diff --git a/backend/src/v1/analytics/schemas.py b/backend/src/v1/analytics/schemas.py new file mode 100644 index 0000000..29a2dac --- /dev/null +++ b/backend/src/v1/analytics/schemas.py @@ -0,0 +1,54 @@ +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, Field + + +class AnalyticsContext(BaseModel): + network_type: str | None = None + os_version: str | None = None + device_model: str | None = None + locale: str | None = None + timezone: str | None = None + + +class AnalyticsEvent(BaseModel): + event_id: str + event_type: str + timestamp: datetime + + user_id: str + device_id: str + session_id: str + + platform: Literal["android", "ios", "web"] + app_version: str + app_build: str | None = None + env: Literal["dev", "staging", "prod"] + + page_name: str | None = None + trace_id: str | None = None + request_id: str | None = None + + attributes: dict[str, Any] = Field(default_factory=dict) + metrics: dict[str, int | float] = Field(default_factory=dict) + context: AnalyticsContext | None = None + + +class AnalyticsBatchRequest(BaseModel): + client_time: datetime | None = None + sdk_version: str | None = None + events: list[AnalyticsEvent] + + +class AnalyticsBatchResponse(BaseModel): + received: int + queued: bool = True + + +class AnalyticsLoginRequest(BaseModel): + password: str + + +class AnalyticsLoginResponse(BaseModel): + success: bool diff --git a/backend/src/v1/analytics/service.py b/backend/src/v1/analytics/service.py new file mode 100644 index 0000000..ed30be2 --- /dev/null +++ b/backend/src/v1/analytics/service.py @@ -0,0 +1,60 @@ +from datetime import datetime, timezone +from typing import Any + +from core.logging import get_logger +from v1.analytics.schemas import AnalyticsBatchRequest + + +logger = get_logger("v1.analytics.service") + + +class AnalyticsService: + def __init__(self) -> None: + self._buffer: list[dict[str, Any]] = [] + self._buffer_date: str | None = None + + async def enqueue_events(self, request: AnalyticsBatchRequest) -> int: + """接收事件并放入内存缓冲,返回接收数量""" + now = datetime.now(timezone.utc) + received_count = 0 + + for event in request.events: + event_dict = event.model_dump(mode="json") + self._buffer.append(event_dict) + received_count += 1 + + if self._buffer_date is None: + self._buffer_date = now.strftime("%Y-%m-%d") + + logger.info( + "Analytics events received", + count=received_count, + buffer_size=len(self._buffer), + ) + + return received_count + + def get_and_clear_buffer(self) -> tuple[list[dict[str, Any]], str]: + """获取当前缓冲并清空,返回 (events, date)""" + if not self._buffer: + return [], self._buffer_date or datetime.now(timezone.utc).strftime( + "%Y-%m-%d" + ) + + events = self._buffer.copy() + date = self._buffer_date or datetime.now(timezone.utc).strftime("%Y-%m-%d") + + self._buffer.clear() + self._buffer_date = None + + return events, date + + +_analytics_service: AnalyticsService | None = None + + +def get_analytics_service() -> AnalyticsService: + global _analytics_service + if _analytics_service is None: + _analytics_service = AnalyticsService() + return _analytics_service diff --git a/backend/src/v1/analytics/tasks.py b/backend/src/v1/analytics/tasks.py new file mode 100644 index 0000000..c6d1fa8 --- /dev/null +++ b/backend/src/v1/analytics/tasks.py @@ -0,0 +1,43 @@ +import json +from pathlib import Path + +from core.config.settings import config +from core.logging import get_logger +from core.taskiq.app import worker_general_broker + + +logger = get_logger("v1.analytics.tasks") + + +def _get_analytics_data_path() -> Path: + return Path(config.analytics.data_path) + + +@worker_general_broker.task(task_name="v1.analytics.write_events") +async def write_analytics_events(batch: list[dict], date: str) -> dict: + """批量写入事件到 JSONL 文件""" + data_path = _get_analytics_data_path() + data_path.mkdir(parents=True, exist_ok=True) + + events_by_type: dict[str, list[str]] = {} + + for event_dict in batch: + event_type = event_dict.get("event_type", "unknown") + if event_type not in events_by_type: + events_by_type[event_type] = [] + events_by_type[event_type].append(json.dumps(event_dict, ensure_ascii=False)) + + for event_type, lines in events_by_type.items(): + file_path = data_path / f"{date}.jsonl" + with open(file_path, "a", encoding="utf-8") as f: + for line in lines: + f.write(line + "\n") + + logger.info( + "Analytics events written", + date=date, + total_count=len(batch), + types=list(events_by_type.keys()), + ) + + return {"written": len(batch), "date": date} diff --git a/backend/src/v1/analytics/web/index.html b/backend/src/v1/analytics/web/index.html new file mode 100644 index 0000000..2d7d401 --- /dev/null +++ b/backend/src/v1/analytics/web/index.html @@ -0,0 +1,445 @@ + + + + + + Analytics Dashboard + + + +
+
+

Analytics 登录

+

输入密码进入聚合分析页面

+
+
+ + +
+
+ +
+

+
+
+ + +
+ + + + diff --git a/backend/src/v1/router.py b/backend/src/v1/router.py index 12b6497..d5d363b 100644 --- a/backend/src/v1/router.py +++ b/backend/src/v1/router.py @@ -3,6 +3,7 @@ from __future__ import annotations from fastapi import APIRouter from v1.agent.router import router as agent_router +from v1.analytics.router import router as analytics_router from v1.app.router import router as app_router from v1.automation_jobs.router import router as automation_jobs_router from v1.auth.router import router as auth_router @@ -18,6 +19,7 @@ router = APIRouter(prefix="/api/v1") router.include_router(app_router) router.include_router(auth_router) router.include_router(agent_router) +router.include_router(analytics_router) router.include_router(automation_jobs_router) router.include_router(friendships_router) router.include_router(memories_router) diff --git a/deploy/.env.prod.example b/deploy/.env.prod.example index 4be1b08..86002ce 100644 --- a/deploy/.env.prod.example +++ b/deploy/.env.prod.example @@ -89,3 +89,9 @@ SOCIAL_LLM__PROVIDER_KEYS__ZAI= SOCIAL_APP_VERSION__MANIFEST_PATH=deploy/static/releases/manifest.json SOCIAL_APP_VERSION__RELEASE_PATH_PREFIX=releases SOCIAL_APP_VERSION__DOWNLOAD_BASE_URL= + +############ +# Analytics 配置 +############ +SOCIAL_ANALYTICS__DATA_PATH=backend/data/analytics +SOCIAL_ANALYTICS__PASSWORD=analytics-secret-change-me diff --git a/docs/plans/2026-04-01-analytics-design.md b/docs/plans/2026-04-01-analytics-design.md index 195394d..390b33e 100644 --- a/docs/plans/2026-04-01-analytics-design.md +++ b/docs/plans/2026-04-01-analytics-design.md @@ -540,12 +540,12 @@ app.mount("/analytics", StaticFiles(directory="web/dist", html=True), name="anal ### 6.4 认证方式 -**推荐:后端验证密码,返回 JWT Token** +**后端验证密码,返回简单 Token(HMAC)** 1. 前端登录页输入密码 2. 调用 `POST /api/v1/analytics/login` 验证 3. 后端读取 `.env` 中 `ANALYTICS_PASSWORD` 验证 -4. 验证成功返回 JWT Token,前端存 sessionStorage +4. 验证成功返回 HMAC Token(5分钟有效),前端存 sessionStorage 5. 后续请求带 Token,后端验证 ### 6.5 页面设计 @@ -581,10 +581,10 @@ app.mount("/analytics", StaticFiles(directory="web/dist", html=True), name="anal ```env # Analytics 数据存储路径 -ANALYTICS_DATA_PATH=backend/data/analytics +SOCIAL_ANALYTICS__DATA_PATH=backend/data/analytics -# 可视化网站密码(用于 /api/v1/analytics/login 验证) -ANALYTICS_PASSWORD=your-secure-password +# 可视化网站密码 +SOCIAL_ANALYTICS__PASSWORD=your-secure-password ``` ### 7.2 后端登录验证 API