From f708bce585f18a1778e2e40e72a752e39dd7de77 Mon Sep 17 00:00:00 2001 From: qzl Date: Thu, 23 Apr 2026 15:55:35 +0800 Subject: [PATCH] refactor: remove analytics module, update tool postprocessor tests --- .gitignore | 3 + .../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 | 2 - .../auth/presentation/bloc/auth_bloc.dart | 21 - .../presentation/screens/login_screen.dart | 3 - .../chat/presentation/bloc/chat_bloc.dart | 8 - .../presentation/screens/home_screen.dart | 37 +- .../screens/home_screen_interactions.dart | 1 - apps/lib/main.dart | 6 - backend/src/app.py | 10 - .../src/core/agentscope/tools/cli/adapter.py | 10 +- .../agentscope/tools/tool_postprocessor.py | 47 ++ backend/src/core/config/settings.py | 6 - backend/src/v1/analytics/__init__.py | 0 backend/src/v1/analytics/router.py | 174 ------- backend/src/v1/analytics/schemas.py | 56 --- backend/src/v1/analytics/service.py | 60 --- backend/src/v1/analytics/tasks.py | 43 -- backend/src/v1/analytics/web/index.html | 466 ------------------ backend/src/v1/auth/dev_phone_session.py | 105 +++- backend/src/v1/auth/gateway.py | 13 +- backend/src/v1/router.py | 2 - .../tools/test_tool_postprocessor.py | 22 + .../tests/unit/v1/auth/test_auth_gateway.py | 35 ++ .../unit/v1/auth/test_dev_phone_session.py | 57 +++ 34 files changed, 294 insertions(+), 1490 deletions(-) delete mode 100644 apps/lib/core/analytics/events/base_event.dart delete mode 100644 apps/lib/core/analytics/events/click_event.dart delete mode 100644 apps/lib/core/analytics/events/conversation_event.dart delete mode 100644 apps/lib/core/analytics/events/events.dart delete mode 100644 apps/lib/core/analytics/events/login_event.dart delete mode 100644 apps/lib/core/analytics/events/logout_event.dart delete mode 100644 apps/lib/core/analytics/events/page_view_event.dart delete mode 100644 apps/lib/core/analytics/queue/event_queue.dart delete mode 100644 apps/lib/core/analytics/sender.dart delete mode 100644 apps/lib/core/analytics/tracker.dart delete mode 100644 backend/src/v1/analytics/__init__.py delete mode 100644 backend/src/v1/analytics/router.py delete mode 100644 backend/src/v1/analytics/schemas.py delete mode 100644 backend/src/v1/analytics/service.py delete mode 100644 backend/src/v1/analytics/tasks.py delete mode 100644 backend/src/v1/analytics/web/index.html create mode 100644 backend/tests/unit/v1/auth/test_dev_phone_session.py diff --git a/.gitignore b/.gitignore index 026ae76..d90cf97 100644 --- a/.gitignore +++ b/.gitignore @@ -331,3 +331,6 @@ deploy/static/releases/*.ipa # Local agents and skills .agents/ + +# Midscene automation reports and screenshots +midscene_run/ diff --git a/apps/lib/core/analytics/events/base_event.dart b/apps/lib/core/analytics/events/base_event.dart deleted file mode 100644 index 462f9a1..0000000 --- a/apps/lib/core/analytics/events/base_event.dart +++ /dev/null @@ -1,111 +0,0 @@ -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 deleted file mode 100644 index 47811c6..0000000 --- a/apps/lib/core/analytics/events/click_event.dart +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index e6253f3..0000000 --- a/apps/lib/core/analytics/events/conversation_event.dart +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index 2e9bead..0000000 --- a/apps/lib/core/analytics/events/events.dart +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 9da77aa..0000000 --- a/apps/lib/core/analytics/events/login_event.dart +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index eb26b37..0000000 --- a/apps/lib/core/analytics/events/logout_event.dart +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index d2dffff..0000000 --- a/apps/lib/core/analytics/events/page_view_event.dart +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index 5618042..0000000 --- a/apps/lib/core/analytics/queue/event_queue.dart +++ /dev/null @@ -1,46 +0,0 @@ -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 deleted file mode 100644 index 6a2e89e..0000000 --- a/apps/lib/core/analytics/sender.dart +++ /dev/null @@ -1,45 +0,0 @@ -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 deleted file mode 100644 index e736999..0000000 --- a/apps/lib/core/analytics/tracker.dart +++ /dev/null @@ -1,247 +0,0 @@ -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 90e5c8a..c0393c6 100644 --- a/apps/lib/core/config/env.dart +++ b/apps/lib/core/config/env.dart @@ -16,8 +16,6 @@ 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 = ''; diff --git a/apps/lib/features/auth/presentation/bloc/auth_bloc.dart b/apps/lib/features/auth/presentation/bloc/auth_bloc.dart index ee9de69..1421686 100644 --- a/apps/lib/features/auth/presentation/bloc/auth_bloc.dart +++ b/apps/lib/features/auth/presentation/bloc/auth_bloc.dart @@ -1,5 +1,4 @@ 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'; @@ -8,7 +7,6 @@ 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); @@ -23,8 +21,6 @@ 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), @@ -60,7 +56,6 @@ 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)); } @@ -69,9 +64,6 @@ 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'); @@ -82,11 +74,6 @@ class AuthBloc extends Bloc { stackTrace: stackTrace, ); } finally { - AnalyticsTracker.instance.trackLogout( - reason: 'manual', - sessionDurationS: sessionDuration, - ); - _loginTime = null; emit( const AuthUnauthenticated(reason: AuthUnauthenticatedReason.signedOut), ); @@ -97,9 +84,6 @@ 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(); @@ -110,11 +94,6 @@ 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 0092e14..90a7c70 100644 --- a/apps/lib/features/auth/presentation/screens/login_screen.dart +++ b/apps/lib/features/auth/presentation/screens/login_screen.dart @@ -6,7 +6,6 @@ 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'; @@ -70,8 +69,6 @@ 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/features/chat/presentation/bloc/chat_bloc.dart b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart index 2e70696..6460a77 100644 --- a/apps/lib/features/chat/presentation/bloc/chat_bloc.dart +++ b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart @@ -12,7 +12,6 @@ import 'package:social_app/core/chat/chat_list_item.dart'; import 'package:social_app/core/chat/chat_orchestrator.dart'; import 'package:social_app/core/chat/chat_history_repository.dart'; import 'package:social_app/core/chat/chat_timeline_reconciler.dart'; -import 'package:social_app/core/analytics/tracker.dart'; import 'package:social_app/core/l10n/l10n.dart'; import 'chat_bloc_recovery_utils.dart'; @@ -318,14 +317,7 @@ class ChatBloc extends Cubit implements ChatOrchestrator { messageCount: 1, responseTimeMs: responseTimeMs, ); - return; } - AnalyticsTracker.instance.trackAgentChatCompleted( - conversationId: conversationId, - scenario: 'assistant', - messageCount: 1, - responseTimeMs: responseTimeMs, - ); } void _clearRunMetrics() { diff --git a/apps/lib/features/home/presentation/screens/home_screen.dart b/apps/lib/features/home/presentation/screens/home_screen.dart index b6b8f39..088cd9c 100644 --- a/apps/lib/features/home/presentation/screens/home_screen.dart +++ b/apps/lib/features/home/presentation/screens/home_screen.dart @@ -12,7 +12,6 @@ import '../../../../app/di/injection.dart'; import '../../../../app/router/app_route_observer.dart'; import '../../../../app/router/app_routes.dart'; import '../../../../core/l10n/l10n.dart'; -import '../../../../core/analytics/tracker.dart'; import '../../../../core/theme/design_tokens.dart'; import '../../../../core/inbox/inbox_sync_store.dart'; import '../../../chat/presentation/bloc/chat_bloc.dart'; @@ -99,8 +98,6 @@ class _HomeScreenState extends State int _previousItemCount = 0; bool _previousIsLoadingHistory = false; bool _routeAwareSubscribed = false; - late final DateTime _pageEnteredAt; - int _pageClickCount = 0; double? _historyViewportPixels; double? _historyViewportMaxExtent; final GlobalKey _inputHostKey = @@ -124,7 +121,6 @@ class _HomeScreenState extends State duration: const Duration(milliseconds: _rippleDurationMs), ); _selectedImages.addAll(widget.initialSelectedImages); - _pageEnteredAt = DateTime.now(); final initialUserId = widget.initialUserId?.trim(); if (initialUserId != null && initialUserId.isNotEmpty) { unawaited(_chatBloc.switchUser(initialUserId)); @@ -152,14 +148,6 @@ class _HomeScreenState extends State @override void dispose() { - final stayDurationMs = DateTime.now() - .difference(_pageEnteredAt) - .inMilliseconds; - AnalyticsTracker.instance.trackPageView( - pageName: 'home', - stayDurationMs: stayDurationMs, - clickCount: _pageClickCount, - ); _messageController.dispose(); _scrollController.removeListener(_handleScrollChanged); _scrollController.dispose(); @@ -294,15 +282,15 @@ class _HomeScreenState extends State return HomeFloatingHeader( unreadCount: _unreadCount, onTapSettings: () { - _trackClick('header_settings'); + context.push(AppRoutes.settingsMain); }, onTapCalendar: () { - _trackClick('header_calendar'); + context.push('${AppRoutes.calendarDayWeek}?from=home'); }, onTapMessages: () { - _trackClick('header_messages'); + context.push(AppRoutes.messageInviteList); }, ); @@ -414,7 +402,7 @@ class _HomeScreenState extends State child: HomeUnreadBadge( count: _chatUnreadBadgeCount, onTap: () { - _trackClick('unread_badge'); + _scheduleAutoScroll(animated: true); if (mounted) { setState(() => _chatUnreadBadgeCount = 0); @@ -467,7 +455,7 @@ class _HomeScreenState extends State } Future _onLoadMore(BuildContext context) async { - _trackClick('history_load_more'); + final chatBloc = context.read(); await _loadMoreHistoryPreservingViewport(chatBloc); } @@ -681,15 +669,15 @@ class _HomeScreenState extends State messageController: _messageController, onTapPlus: _isRecording ? () { - _trackClick('record_stop'); + _stopRecording(autoSendAfterTranscribe: false); } : () { - _trackClick('input_plus'); + _showBottomSheet(context); }, onStopGenerating: () { - _trackClick('stop_generating'); + _onStopGenerating(); }, onHoldToSpeakStart: _onHoldToSpeakStart, @@ -701,15 +689,6 @@ class _HomeScreenState extends State ); } - void _trackClick(String elementId) { - _pageClickCount += 1; - AnalyticsTracker.instance.trackClick( - pageName: 'home', - elementId: elementId, - elementType: 'button', - ); - } - void _removeImage(int index) { setState(() { _selectedImages.removeAt(index); diff --git a/apps/lib/features/home/presentation/screens/home_screen_interactions.dart b/apps/lib/features/home/presentation/screens/home_screen_interactions.dart index 8f0601c..9d82ae1 100644 --- a/apps/lib/features/home/presentation/screens/home_screen_interactions.dart +++ b/apps/lib/features/home/presentation/screens/home_screen_interactions.dart @@ -53,7 +53,6 @@ extension _HomeScreenInteractions on _HomeScreenState { }); try { - _trackClick('send_message'); await _chatBloc.sendMessage(content, images: images); } finally { if (mounted) { diff --git a/apps/lib/main.dart b/apps/lib/main.dart index e00b31d..f3f19a6 100644 --- a/apps/lib/main.dart +++ b/apps/lib/main.dart @@ -3,7 +3,6 @@ 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'; @@ -18,11 +17,6 @@ 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/backend/src/app.py b/backend/src/app.py index 8295a04..9342295 100644 --- a/backend/src/app.py +++ b/backend/src/app.py @@ -1,7 +1,6 @@ from __future__ import annotations from contextlib import asynccontextmanager -from pathlib import Path from typing import Any, AsyncGenerator from fastapi import FastAPI, HTTPException, Request @@ -9,7 +8,6 @@ from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from pydantic import BaseModel -from starlette.staticfiles import StaticFiles from starlette.exceptions import HTTPException as StarletteHTTPException from core.config.settings import config @@ -61,14 +59,6 @@ app.add_middleware( ) app.include_router(mobile_router) -_analytics_web_dir = Path(__file__).resolve().parent / "v1" / "analytics" / "web" - -app.mount( - "/analytics", - StaticFiles(directory=_analytics_web_dir, html=True), - name="analytics-web", -) - logger.info( "Web application initialized", environment=config.runtime.environment, diff --git a/backend/src/core/agentscope/tools/cli/adapter.py b/backend/src/core/agentscope/tools/cli/adapter.py index d19a48a..4679e4d 100644 --- a/backend/src/core/agentscope/tools/cli/adapter.py +++ b/backend/src/core/agentscope/tools/cli/adapter.py @@ -180,13 +180,17 @@ def _build_error( result={"status": "failure", "code": code, "message": message}, error=ErrorInfo(code=code, message=message, retryable=False), ) - payload = output.model_dump(mode="json", exclude_none=True) - store_tool_agent_output(tool_call_id=tool_call_id, payload=payload) + + from core.agentscope.tools.tool_postprocessor import postprocess_tool_output + processed = postprocess_tool_output(output) + + payload = processed.model_dump(mode="json", exclude_none=True) + store_tool_agent_output(tool_call_id=processed.tool_call_id, payload=payload) return ToolResponse( content=[ TextBlock( type="text", - text=project_tool_result_text(output.result), + text=project_tool_result_text(processed.result), ) ] ) diff --git a/backend/src/core/agentscope/tools/tool_postprocessor.py b/backend/src/core/agentscope/tools/tool_postprocessor.py index 2fb8840..6867b0e 100644 --- a/backend/src/core/agentscope/tools/tool_postprocessor.py +++ b/backend/src/core/agentscope/tools/tool_postprocessor.py @@ -186,6 +186,52 @@ def _calendar_delete_ui_hints(tool_output: ToolAgentOutput) -> dict[str, Any] | return _calendar_mutation_ui_hints(tool_output=tool_output, action_label="删除") +def _calendar_share_ui_hints(tool_output: ToolAgentOutput) -> dict[str, Any] | None: + data = _result_data(tool_output) + if data is None: + return None + + success_count = int(data.get("success") or 0) + failed_count = int(data.get("failed") or 0) + list_items: list[dict[str, Any]] = [] + + for item in _results_list(data): + phone = str(item.get("phone") or "").strip() + status = _status_from_result_item(item.get("status")).value + code = str(item.get("code") or "").strip() + subtitle_parts: list[str] = [] + if phone: + subtitle_parts.append(f"phone={phone}") + if code: + subtitle_parts.append(f"code={code}") + list_items.append( + { + "id": phone or None, + "title": "邀请分享", + "subtitle": " / ".join(subtitle_parts) if subtitle_parts else None, + "status": status, + } + ) + + return _build_status_ui_hints( + tool_output=tool_output, + intent=UiHintIntent.STATUS, + title="日程分享结果", + description="仅展示本次日程分享调用结果。", + items=[ + {"key": "success", "label": "成功", "value": success_count}, + {"key": "failed", "label": "失败", "value": failed_count}, + { + "key": "status", + "label": "总体状态", + "value": str(data.get("status") or tool_output.status.value), + }, + ], + list_title="分享明细", + list_items=list_items, + ) + + def _memory_update_ui_hints(tool_output: ToolAgentOutput) -> dict[str, Any] | None: data = _result_data(tool_output) if data is None: @@ -284,6 +330,7 @@ _UI_HINTS_BUILDERS: dict[tuple[str, str], Callable[[ToolAgentOutput], dict[str, ("calendar", "read"): _calendar_read_ui_hints, ("calendar", "update"): _calendar_update_ui_hints, ("calendar", "delete"): _calendar_delete_ui_hints, + ("calendar", "share"): _calendar_share_ui_hints, ("contacts", "read"): _contacts_read_ui_hints, ("memory", "update"): _memory_update_ui_hints, } diff --git a/backend/src/core/config/settings.py b/backend/src/core/config/settings.py index ac2d368..a6fae53 100644 --- a/backend/src/core/config/settings.py +++ b/backend/src/core/config/settings.py @@ -89,11 +89,6 @@ 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 @@ -280,7 +275,6 @@ 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/__init__.py b/backend/src/v1/analytics/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/src/v1/analytics/router.py b/backend/src/v1/analytics/router.py deleted file mode 100644 index 69367e5..0000000 --- a/backend/src/v1/analytics/router.py +++ /dev/null @@ -1,174 +0,0 @@ -import base64 -import hashlib -import hmac -import json -import re -import time -from pathlib import Path - -from fastapi import APIRouter, Header, status -from fastapi.responses import PlainTextResponse - -from core.config.settings import config -from core.http.errors import ApiProblemError -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"]) -_TOKEN_TTL_SECONDS = 300 -_DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$") - - -def _get_signing_secret() -> bytes: - return config.analytics.password.encode("utf-8") - - -def _issue_access_token() -> str: - expires_at = int(time.time()) + _TOKEN_TTL_SECONDS - payload = {"exp": expires_at} - payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") - signature = hmac.new(_get_signing_secret(), payload_bytes, hashlib.sha256).digest() - return ( - base64.urlsafe_b64encode(payload_bytes).decode("utf-8") - + "." - + base64.urlsafe_b64encode(signature).decode("utf-8") - ) - - -def _parse_bearer_token(authorization: str | None) -> str: - if authorization is None: - raise ApiProblemError( - status_code=status.HTTP_401_UNAUTHORIZED, - code="ANALYTICS_AUTH_HEADER_MISSING", - detail="Missing authorization header", - ) - if not authorization.startswith("Bearer "): - raise ApiProblemError( - status_code=status.HTTP_401_UNAUTHORIZED, - code="ANALYTICS_AUTH_SCHEME_INVALID", - detail="Invalid authorization scheme", - ) - token = authorization.removeprefix("Bearer ").strip() - if not token: - raise ApiProblemError( - status_code=status.HTTP_401_UNAUTHORIZED, - code="ANALYTICS_AUTH_TOKEN_MISSING", - detail="Missing token", - ) - return token - - -def _verify_access_token(token: str) -> None: - parts = token.split(".") - if len(parts) != 2: - raise ApiProblemError( - status_code=status.HTTP_401_UNAUTHORIZED, - code="ANALYTICS_TOKEN_MALFORMED", - detail="Malformed token", - ) - payload_b64, signature_b64 = parts - try: - payload_bytes = base64.urlsafe_b64decode(payload_b64.encode("utf-8")) - provided_signature = base64.urlsafe_b64decode(signature_b64.encode("utf-8")) - except Exception as exc: - raise ApiProblemError( - status_code=status.HTTP_401_UNAUTHORIZED, - code="ANALYTICS_TOKEN_MALFORMED", - detail="Malformed token", - ) from exc - - expected_signature = hmac.new( - _get_signing_secret(), payload_bytes, hashlib.sha256 - ).digest() - if not hmac.compare_digest(provided_signature, expected_signature): - raise ApiProblemError( - status_code=status.HTTP_401_UNAUTHORIZED, - code="ANALYTICS_TOKEN_SIGNATURE_INVALID", - detail="Invalid token signature", - ) - - try: - payload = json.loads(payload_bytes) - except json.JSONDecodeError as exc: - raise ApiProblemError( - status_code=status.HTTP_401_UNAUTHORIZED, - code="ANALYTICS_TOKEN_PAYLOAD_INVALID", - detail="Malformed token payload", - ) from exc - - expires_at = payload.get("exp") - if not isinstance(expires_at, int) or int(time.time()) > expires_at: - raise ApiProblemError( - status_code=status.HTTP_401_UNAUTHORIZED, - code="ANALYTICS_TOKEN_EXPIRED", - detail="Token expired", - ) - - -@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 ApiProblemError( - status_code=status.HTTP_401_UNAUTHORIZED, - code="ANALYTICS_LOGIN_PASSWORD_INVALID", - detail="Invalid password", - ) - - logger.info("Analytics login success") - return AnalyticsLoginResponse( - success=True, - data_base_url="/api/v1/analytics/data", - token=_issue_access_token(), - ) - - -@router.get("/data/{date}", response_class=PlainTextResponse) -async def read_day_events( - date: str, - authorization: str | None = Header(default=None), -) -> PlainTextResponse: - token = _parse_bearer_token(authorization) - _verify_access_token(token) - - if not _DATE_PATTERN.match(date): - raise ApiProblemError( - status_code=status.HTTP_400_BAD_REQUEST, - code="ANALYTICS_DATE_FORMAT_INVALID", - detail="Invalid date format", - ) - - file_path = Path(config.analytics.data_path) / f"{date}.jsonl" - if not file_path.exists() or not file_path.is_file(): - raise ApiProblemError( - status_code=status.HTTP_404_NOT_FOUND, - code="ANALYTICS_FILE_NOT_FOUND", - detail="Analytics file not found", - ) - - content = file_path.read_text(encoding="utf-8") - return PlainTextResponse(content=content, media_type="application/x-ndjson") diff --git a/backend/src/v1/analytics/schemas.py b/backend/src/v1/analytics/schemas.py deleted file mode 100644 index dafa9df..0000000 --- a/backend/src/v1/analytics/schemas.py +++ /dev/null @@ -1,56 +0,0 @@ -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 - data_base_url: str - token: str diff --git a/backend/src/v1/analytics/service.py b/backend/src/v1/analytics/service.py deleted file mode 100644 index ed30be2..0000000 --- a/backend/src/v1/analytics/service.py +++ /dev/null @@ -1,60 +0,0 @@ -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 deleted file mode 100644 index c6d1fa8..0000000 --- a/backend/src/v1/analytics/tasks.py +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index 03f0fdd..0000000 --- a/backend/src/v1/analytics/web/index.html +++ /dev/null @@ -1,466 +0,0 @@ - - - - - - Analytics Dashboard - - - -
-
-

Analytics 登录

-

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

-
-
- - -
-
- -
-

-
-
- - -
- - - - diff --git a/backend/src/v1/auth/dev_phone_session.py b/backend/src/v1/auth/dev_phone_session.py index b1365a5..5210f04 100644 --- a/backend/src/v1/auth/dev_phone_session.py +++ b/backend/src/v1/auth/dev_phone_session.py @@ -11,10 +11,17 @@ from core.config.settings import config from core.http.errors import ApiProblemError from core.logging import get_logger from services.base.supabase import supabase_service -from v1.auth.schemas import AuthUser, PhoneSessionCreateRequest, SessionResponse +from v1.auth.schemas import ( + AuthUser, + PhoneSessionCreateRequest, + SessionRefreshRequest, + SessionResponse, +) logger = get_logger("v1.auth.dev_phone_session") +_DEV_REFRESH_TTL_SECONDS = 30 * 24 * 60 * 60 + def _auth_error(*, status_code: int, code: str, detail: str) -> ApiProblemError: return ApiProblemError(status_code=status_code, code=code, detail=detail) @@ -27,15 +34,49 @@ async def create_dev_phone_session( user_id = await _find_or_create_user_by_phone(request.phone) token = _sign_access_token(sub=str(user_id)) + refresh_token = _sign_refresh_token(sub=str(user_id), phone=request.phone) return SessionResponse( access_token=token, - refresh_token="dev-refresh-token", + refresh_token=refresh_token, expires_in=3600, token_type="bearer", user=AuthUser(id=str(user_id), phone=request.phone), ) +async def refresh_dev_phone_session( + *, + request: SessionRefreshRequest, +) -> SessionResponse: + claims = _decode_dev_refresh_token(request.refresh_token) + user_id = str(claims.get("sub", "")).strip() + phone = str(claims.get("phone", "")).strip() + if not user_id or not phone: + raise _auth_error( + status_code=401, + code="AUTH_REFRESH_TOKEN_INVALID", + detail="Invalid refresh token", + ) + + return SessionResponse( + access_token=_sign_access_token(sub=user_id), + refresh_token=_sign_refresh_token(sub=user_id, phone=phone), + expires_in=3600, + token_type="bearer", + user=AuthUser(id=user_id, phone=phone), + ) + + +async def delete_dev_session(*, refresh_token: str | None) -> None: + if not refresh_token: + raise _auth_error( + status_code=401, + code="AUTH_REFRESH_TOKEN_MISSING", + detail="Missing refresh token", + ) + _decode_dev_refresh_token(refresh_token) + + async def _find_or_create_user_by_phone(phone: str) -> UUID: admin_client = supabase_service.get_admin_client() users = await asyncio.to_thread( @@ -142,3 +183,63 @@ def _sign_access_token(*, sub: str) -> str: str, jwt.encode(payload, secret.get_secret_value(), algorithm=config.supabase.jwt_algorithm), ) + + +def _sign_refresh_token(*, sub: str, phone: str) -> str: + secret = config.supabase.jwt_secret + if secret is None: + raise _auth_error( + status_code=500, + code="AUTH_CONFIG_ERROR", + detail="JWT secret not configured", + ) + now = int(time.time()) + payload = { + "sub": sub, + "phone": phone, + "aud": "authenticated", + "iss": config.supabase.jwt_issuer, + "exp": now + _DEV_REFRESH_TTL_SECONDS, + "iat": now, + "typ": "refresh", + "env": "dev", + } + return cast( + str, + jwt.encode(payload, secret.get_secret_value(), algorithm=config.supabase.jwt_algorithm), + ) + + +def _decode_dev_refresh_token(token: str) -> dict[str, Any]: + secret = config.supabase.jwt_secret + if secret is None: + raise _auth_error( + status_code=500, + code="AUTH_CONFIG_ERROR", + detail="JWT secret not configured", + ) + try: + payload = cast( + dict[str, Any], + jwt.decode( + token, + secret.get_secret_value(), + algorithms=[config.supabase.jwt_algorithm], + audience="authenticated", + issuer=config.supabase.jwt_issuer, + ), + ) + except jwt.InvalidTokenError as exc: + raise _auth_error( + status_code=401, + code="AUTH_REFRESH_TOKEN_INVALID", + detail="Invalid refresh token", + ) from exc + + if payload.get("typ") != "refresh" or payload.get("env") != "dev": + raise _auth_error( + status_code=401, + code="AUTH_REFRESH_TOKEN_INVALID", + detail="Invalid refresh token", + ) + return payload diff --git a/backend/src/v1/auth/gateway.py b/backend/src/v1/auth/gateway.py index 2f2553c..f0bae9e 100644 --- a/backend/src/v1/auth/gateway.py +++ b/backend/src/v1/auth/gateway.py @@ -12,7 +12,11 @@ from core.config.settings import config from core.http.errors import ApiProblemError from core.logging import get_logger from services.base.supabase import supabase_service -from v1.auth.dev_phone_session import create_dev_phone_session +from v1.auth.dev_phone_session import ( + create_dev_phone_session, + delete_dev_session, + refresh_dev_phone_session, +) from v1.auth.schemas import ( AuthUser, OtpSendRequest, @@ -113,6 +117,9 @@ class SupabaseAuthGateway(AuthServiceGateway): ) from exc async def refresh_session(self, request: SessionRefreshRequest) -> SessionResponse: + if config.runtime.environment == "dev": + return await refresh_dev_phone_session(request=request) + client = self._get_client() try: response = await asyncio.to_thread( @@ -139,6 +146,10 @@ class SupabaseAuthGateway(AuthServiceGateway): ) from exc async def delete_session(self, refresh_token: str | None) -> None: + if config.runtime.environment == "dev": + await delete_dev_session(refresh_token=refresh_token) + return + if not refresh_token: raise _auth_error( status_code=401, diff --git a/backend/src/v1/router.py b/backend/src/v1/router.py index d5d363b..12b6497 100644 --- a/backend/src/v1/router.py +++ b/backend/src/v1/router.py @@ -3,7 +3,6 @@ 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 @@ -19,7 +18,6 @@ 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/backend/tests/unit/core/agentscope/tools/test_tool_postprocessor.py b/backend/tests/unit/core/agentscope/tools/test_tool_postprocessor.py index f0befe2..2356359 100644 --- a/backend/tests/unit/core/agentscope/tools/test_tool_postprocessor.py +++ b/backend/tests/unit/core/agentscope/tools/test_tool_postprocessor.py @@ -37,6 +37,28 @@ def test_postprocess_calendar_create_partial() -> None: assert processed.ui_hints["status"] == "warning" +def test_postprocess_calendar_share_has_ui_hints() -> None: + output = _make_tool_output( + command="calendar", + subcommand="share", + status=ToolStatus.SUCCESS, + data={ + "status": "success", + "success": 2, + "failed": 0, + "results": [ + {"phone": "+8613800138001", "status": "success"}, + {"phone": "+8613800138002", "status": "success"}, + ], + }, + ) + processed = postprocess_tool_output(output) + assert processed.ui_hints is not None + assert processed.ui_hints["intent"] == "status" + assert processed.ui_hints["status"] == "success" + assert processed.ui_hints["title"] == "日程分享结果" + + def test_postprocess_contacts_read_has_ui_hints() -> None: output = _make_tool_output(command="contacts", subcommand="read", status=ToolStatus.SUCCESS, data={"friends_count": 3, "friends": []}) processed = postprocess_tool_output(output) diff --git a/backend/tests/unit/v1/auth/test_auth_gateway.py b/backend/tests/unit/v1/auth/test_auth_gateway.py index 25fa347..f38099d 100644 --- a/backend/tests/unit/v1/auth/test_auth_gateway.py +++ b/backend/tests/unit/v1/auth/test_auth_gateway.py @@ -9,8 +9,10 @@ from core.config.settings import config from v1.auth.gateway import SupabaseAuthGateway from v1.auth.schemas import ( + AuthUser, OtpSendRequest, PhoneSessionCreateRequest, + SessionResponse, SessionRefreshRequest, ) @@ -108,6 +110,39 @@ class TestSupabaseAuthGateway: assert exc_info.value.status_code == 401 + @pytest.mark.asyncio + async def test_refresh_session_uses_dev_flow_in_dev_environment( + self, + gateway: tuple[SupabaseAuthGateway, MagicMock, MagicMock], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + sut, mock_client, _ = gateway + monkeypatch.setattr(config.runtime, "environment", "dev") + + expected = SessionResponse( + access_token="dev-access", + refresh_token="dev-refresh", + expires_in=3600, + token_type="bearer", + user=AuthUser(id="user-dev", phone="+8613812345678"), + ) + + async def _fake_refresh_dev_phone_session(*, request: SessionRefreshRequest) -> SessionResponse: + assert request.refresh_token == "dev-refresh" + return expected + + monkeypatch.setattr( + "v1.auth.gateway.refresh_dev_phone_session", + _fake_refresh_dev_phone_session, + ) + + response = await sut.refresh_session( + SessionRefreshRequest(refresh_token="dev-refresh") + ) + + assert response == expected + assert mock_client.auth.refresh_session.call_count == 0 + @pytest.mark.asyncio async def test_get_user_by_phone_uses_in_memory_cache( self, diff --git a/backend/tests/unit/v1/auth/test_dev_phone_session.py b/backend/tests/unit/v1/auth/test_dev_phone_session.py new file mode 100644 index 0000000..db063a6 --- /dev/null +++ b/backend/tests/unit/v1/auth/test_dev_phone_session.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from uuid import UUID + +import pytest +from pydantic import SecretStr + +from core.config.settings import config +from core.http.errors import ApiProblemError +from v1.auth.dev_phone_session import create_dev_phone_session, refresh_dev_phone_session +from v1.auth.schemas import PhoneSessionCreateRequest, SessionRefreshRequest + + +_TEST_JWT_SECRET = "test-secret-key-with-32-bytes-minimum!!" + + +@pytest.mark.asyncio +async def test_dev_session_refresh_round_trip(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(config.runtime, "environment", "dev") + monkeypatch.setattr(config.supabase, "jwt_secret", SecretStr(_TEST_JWT_SECRET)) + monkeypatch.setattr(config.supabase, "jwt_issuer", "http://localhost:8001/auth/v1") + async def _fake_find_or_create_user_by_phone(_phone: str) -> UUID: + return UUID("00000000-0000-0000-0000-000000000123") + + monkeypatch.setattr( + "v1.auth.dev_phone_session._find_or_create_user_by_phone", + _fake_find_or_create_user_by_phone, + ) + + created = await create_dev_phone_session( + request=PhoneSessionCreateRequest(phone="+8613812345678", token="123456") + ) + refreshed = await refresh_dev_phone_session( + request=SessionRefreshRequest(refresh_token=created.refresh_token) + ) + + assert refreshed.user.id == "00000000-0000-0000-0000-000000000123" + assert refreshed.user.phone == "+8613812345678" + assert refreshed.access_token + assert refreshed.refresh_token + + +@pytest.mark.asyncio +async def test_dev_session_refresh_rejects_invalid_token( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(config.runtime, "environment", "dev") + monkeypatch.setattr(config.supabase, "jwt_secret", SecretStr(_TEST_JWT_SECRET)) + monkeypatch.setattr(config.supabase, "jwt_issuer", "http://localhost:8001/auth/v1") + + with pytest.raises(ApiProblemError) as exc_info: + await refresh_dev_phone_session( + request=SessionRefreshRequest(refresh_token="invalid-token") + ) + + assert exc_info.value.status_code == 401 + assert exc_info.value.code == "AUTH_REFRESH_TOKEN_INVALID"