refactor: remove analytics module, update tool postprocessor tests

This commit is contained in:
qzl
2026-04-23 15:55:35 +08:00
parent 1052e19134
commit f708bce585
34 changed files with 294 additions and 1490 deletions
+3
View File
@@ -331,3 +331,6 @@ deploy/static/releases/*.ipa
# Local agents and skills
.agents/
# Midscene automation reports and screenshots
midscene_run/
@@ -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<String, dynamic> toJson() => {
'network_type': networkType,
'os_version': osVersion,
'device_model': deviceModel,
'locale': locale,
'timezone': timezone,
};
@override
List<Object?> 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<String, dynamic> attributes;
final Map<String, num> 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<String, dynamic> 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<Object?> get props => [
eventId,
eventType,
timestamp,
userId,
deviceId,
sessionId,
platform,
appVersion,
appBuild,
env,
pageName,
traceId,
requestId,
attributes,
metrics,
context,
];
}
@@ -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,
},
);
}
@@ -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,
);
}
@@ -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';
@@ -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,
);
}
@@ -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,
);
}
@@ -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,
},
);
}
@@ -1,46 +0,0 @@
import 'dart:async';
import '../events/events.dart';
class EventQueue {
final List<BaseAnalyticsEvent> _queue = [];
final int maxSize;
final Duration flushInterval;
final void Function(List<BaseAnalyticsEvent>) 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<BaseAnalyticsEvent>.from(_queue);
_queue.clear();
onFlush(events);
}
List<BaseAnalyticsEvent> get pendingEvents => List.unmodifiable(_queue);
int get pendingCount => _queue.length;
}
-45
View File
@@ -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<void> send(List<BaseAnalyticsEvent> 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<BaseAnalyticsEvent> events;
AnalyticsSendException(this.message, {required this.events});
@override
String toString() => 'AnalyticsSendException: $message';
}
-247
View File
@@ -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<void> 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<String> _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<void> _handleFlush(List<BaseAnalyticsEvent> events) async {
try {
await _sender.send(events);
} catch (e) {
// TODO: 失败时落盘本地,下次启动重试
debugPrint('Analytics send failed: $e');
}
}
void dispose() {
_queue.stop();
}
}
-2
View File
@@ -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 = '';
@@ -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<AuthEvent, AuthState> {
final AuthRepository _repository;
final Logger _logger = getLogger('features.auth.bloc');
DateTime? _loginTime;
AuthBloc(this._repository) : super(AuthInitial()) {
on<AuthStarted>(_onStarted);
@@ -23,8 +21,6 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
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<AuthEvent, AuthState> {
}
void _onLoggedIn(AuthLoggedIn event, Emitter<AuthState> 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<AuthEvent, AuthState> {
AuthLoggedOut event,
Emitter<AuthState> 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<AuthEvent, AuthState> {
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<AuthEvent, AuthState> {
AuthSessionInvalidated event,
Emitter<AuthState> 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<AuthEvent, AuthState> {
stackTrace: stackTrace,
);
} finally {
AnalyticsTracker.instance.trackLogout(
reason: 'expired',
sessionDurationS: sessionDuration,
);
_loginTime = null;
emit(
const AuthUnauthenticated(reason: AuthUnauthenticatedReason.expired),
);
@@ -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<LoginView> {
final response = await cubit.submit();
if (response != null && mounted) {
AnalyticsTracker.instance.trackLogin(method: 'phone_code');
AnalyticsTracker.instance.setUserId(response.user.id);
context.read<AuthBloc>().add(AuthLoggedIn(user: response.user));
context.go(AppRoutes.homeMain);
}
@@ -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<ChatState> implements ChatOrchestrator {
messageCount: 1,
responseTimeMs: responseTimeMs,
);
return;
}
AnalyticsTracker.instance.trackAgentChatCompleted(
conversationId: conversationId,
scenario: 'assistant',
messageCount: 1,
responseTimeMs: responseTimeMs,
);
}
void _clearRunMetrics() {
@@ -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<HomeScreen>
int _previousItemCount = 0;
bool _previousIsLoadingHistory = false;
bool _routeAwareSubscribed = false;
late final DateTime _pageEnteredAt;
int _pageClickCount = 0;
double? _historyViewportPixels;
double? _historyViewportMaxExtent;
final GlobalKey<HomeInputHostState> _inputHostKey =
@@ -124,7 +121,6 @@ class _HomeScreenState extends State<HomeScreen>
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<HomeScreen>
@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<HomeScreen>
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<HomeScreen>
child: HomeUnreadBadge(
count: _chatUnreadBadgeCount,
onTap: () {
_trackClick('unread_badge');
_scheduleAutoScroll(animated: true);
if (mounted) {
setState(() => _chatUnreadBadgeCount = 0);
@@ -467,7 +455,7 @@ class _HomeScreenState extends State<HomeScreen>
}
Future<void> _onLoadMore(BuildContext context) async {
_trackClick('history_load_more');
final chatBloc = context.read<ChatBloc>();
await _loadMoreHistoryPreservingViewport(chatBloc);
}
@@ -681,15 +669,15 @@ class _HomeScreenState extends State<HomeScreen>
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<HomeScreen>
);
}
void _trackClick(String elementId) {
_pageClickCount += 1;
AnalyticsTracker.instance.trackClick(
pageName: 'home',
elementId: elementId,
elementType: 'button',
);
}
void _removeImage(int index) {
setState(() {
_selectedImages.removeAt(index);
@@ -53,7 +53,6 @@ extension _HomeScreenInteractions on _HomeScreenState {
});
try {
_trackClick('send_message');
await _chatBloc.sendMessage(content, images: images);
} finally {
if (mounted) {
-6
View File
@@ -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});
-10
View File
@@ -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,
@@ -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),
)
]
)
@@ -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,
}
-6
View File
@@ -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
-174
View File
@@ -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")
-56
View File
@@ -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
-60
View File
@@ -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
-43
View File
@@ -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}
-466
View File
@@ -1,466 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Analytics Dashboard</title>
<style>
:root {
--bg: #f3f4f6;
--card: #ffffff;
--text: #111827;
--muted: #6b7280;
--line: #e5e7eb;
--primary: #0f766e;
--danger: #b91c1c;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
color: var(--text);
background: linear-gradient(180deg, #e6fffb 0%, var(--bg) 240px);
min-height: 100vh;
}
.container {
width: min(1100px, 92vw);
margin: 24px auto 40px;
}
.card {
background: var(--card);
border: 1px solid var(--line);
border-radius: 12px;
padding: 16px;
box-shadow: 0 6px 24px rgba(15, 118, 110, 0.08);
}
.grid {
display: grid;
gap: 12px;
}
.stats {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: end;
margin-bottom: 12px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 170px;
}
input {
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 8px 10px;
font-size: 14px;
}
button {
border: 0;
border-radius: 8px;
padding: 9px 14px;
font-size: 14px;
cursor: pointer;
color: #fff;
background: var(--primary);
}
button[disabled] { opacity: 0.6; cursor: not-allowed; }
.btn-ghost {
background: #374151;
}
.value {
margin-top: 8px;
font-size: 26px;
font-weight: 700;
}
.muted { color: var(--muted); }
.danger { color: var(--danger); }
table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
font-size: 14px;
}
th, td {
border-bottom: 1px solid var(--line);
padding: 8px;
text-align: left;
white-space: nowrap;
}
.chart-row {
display: flex;
align-items: center;
gap: 10px;
margin: 8px 0;
}
.bar {
height: 10px;
background: linear-gradient(90deg, #14b8a6, #0f766e);
border-radius: 999px;
min-width: 2px;
}
.hidden { display: none; }
</style>
</head>
<body>
<div class="container">
<div id="loginCard" class="card" style="max-width: 420px; margin: 100px auto;">
<h2 style="margin: 0 0 12px;">Analytics 登录</h2>
<p class="muted" style="margin-top: 0;">输入密码进入聚合分析页面</p>
<form id="loginForm">
<div class="field">
<label for="password">密码</label>
<input id="password" type="password" required />
</div>
<div style="margin-top: 12px; display: flex; gap: 10px;">
<button id="loginBtn" type="submit">登录</button>
</div>
<p id="loginError" class="danger" style="margin-bottom: 0;"></p>
</form>
</div>
<div id="dashboard" class="hidden">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<h1 style="margin: 0;">Analytics 聚合看板</h1>
<button id="logoutBtn" class="btn-ghost">退出</button>
</div>
<div class="card" style="margin-bottom: 12px;">
<div class="toolbar">
<div class="field">
<label for="startDate">开始日期</label>
<input id="startDate" type="date" />
</div>
<div class="field">
<label for="endDate">结束日期</label>
<input id="endDate" type="date" />
</div>
<button id="loadBtn" type="button">加载数据</button>
</div>
<div id="status" class="muted"></div>
</div>
<div class="grid stats" id="summaryCards"></div>
<div class="card" style="margin-top: 12px;">
<h3 style="margin-top: 0;">按天趋势</h3>
<div id="dailyBars"></div>
</div>
<div class="card" style="margin-top: 12px; overflow-x: auto;">
<h3 style="margin-top: 0;">按天明细</h3>
<table>
<thead>
<tr>
<th>日期</th>
<th>DAU</th>
<th>登录数</th>
<th>对话完成数</th>
<th>平均停留(ms)</th>
</tr>
</thead>
<tbody id="dailyTable"></tbody>
</table>
</div>
</div>
</div>
<script>
const loginCard = document.getElementById("loginCard");
const dashboard = document.getElementById("dashboard");
const loginForm = document.getElementById("loginForm");
const loginBtn = document.getElementById("loginBtn");
const loginError = document.getElementById("loginError");
const logoutBtn = document.getElementById("logoutBtn");
const loadBtn = document.getElementById("loadBtn");
const startDateInput = document.getElementById("startDate");
const endDateInput = document.getElementById("endDate");
const statusEl = document.getElementById("status");
const summaryCards = document.getElementById("summaryCards");
const dailyBars = document.getElementById("dailyBars");
const dailyTable = document.getElementById("dailyTable");
const AUTH_KEY = "analytics_logged_in";
const DATA_BASE_URL_KEY = "analytics_data_base_url";
const AUTH_TOKEN_KEY = "analytics_auth_token";
function formatDate(date) {
const y = date.getUTCFullYear();
const m = String(date.getUTCMonth() + 1).padStart(2, "0");
const d = String(date.getUTCDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
function dateRange(startDate, endDate) {
const list = [];
const cursor = new Date(`${startDate}T00:00:00Z`);
const end = new Date(`${endDate}T00:00:00Z`);
while (cursor <= end) {
list.push(formatDate(cursor));
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
return list;
}
function parseJsonl(text) {
if (!text.trim()) return [];
return text
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line);
} catch {
return null;
}
})
.filter(Boolean);
}
async function fetchDayEvents(date) {
const dataBaseUrl = sessionStorage.getItem(DATA_BASE_URL_KEY) || "/api/v1/analytics/data";
const token = sessionStorage.getItem(AUTH_TOKEN_KEY);
const res = await fetch(`${dataBaseUrl}/${date}`, {
method: "GET",
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (res.status === 404) {
return [];
}
if (!res.ok) {
throw new Error(`读取 ${date} 失败: ${res.status}`);
}
return parseJsonl(await res.text());
}
function aggregateDay(events) {
const users = new Set();
let loginCount = 0;
let chatCount = 0;
let staySum = 0;
let stayCnt = 0;
for (const event of events) {
if (event.user_id) users.add(event.user_id);
if (event.event_type === "session.login") loginCount += 1;
if (event.event_type === "agent.chat_completed") chatCount += 1;
if (event.event_type === "page.view") {
const stay = event.metrics && event.metrics.stay_duration_ms;
if (typeof stay === "number") {
staySum += stay;
stayCnt += 1;
}
}
}
return {
dau: users.size,
loginCount,
chatCount,
avgStay: stayCnt ? staySum / stayCnt : 0,
};
}
function renderSummary(rows) {
const allUsers = new Set();
let totalLogins = 0;
let totalChats = 0;
let staySum = 0;
let stayCnt = 0;
rows.forEach((row) => {
row.users.forEach((u) => allUsers.add(u));
totalLogins += row.loginCount;
totalChats += row.chatCount;
staySum += row.staySum;
stayCnt += row.stayCnt;
});
const cards = [
{ label: "DAU(区间去重)", value: allUsers.size },
{ label: "总登录次数", value: totalLogins },
{ label: "总对话完成数", value: totalChats },
{ label: "平均停留(ms)", value: Math.round(stayCnt ? staySum / stayCnt : 0) },
];
summaryCards.innerHTML = cards
.map((card) => `<div class="card"><div class="muted">${card.label}</div><div class="value">${card.value}</div></div>`)
.join("");
}
function renderDaily(rows) {
const maxLogin = Math.max(1, ...rows.map((r) => r.loginCount));
dailyBars.innerHTML = rows
.map((r) => {
const width = Math.max(2, Math.round((r.loginCount / maxLogin) * 100));
return `<div class="chart-row"><div style="width:92px">${r.date}</div><div class="bar" style="width:${width}%"></div><div class="muted">登录 ${r.loginCount}</div></div>`;
})
.join("");
dailyTable.innerHTML = rows
.map(
(r) => `<tr>
<td>${r.date}</td>
<td>${r.dau}</td>
<td>${r.loginCount}</td>
<td>${r.chatCount}</td>
<td>${Math.round(r.avgStay)}</td>
</tr>`,
)
.join("");
}
async function loadData() {
const startDate = startDateInput.value;
const endDate = endDateInput.value;
if (!startDate || !endDate || startDate > endDate) {
statusEl.textContent = "请选择有效日期区间";
return;
}
loadBtn.disabled = true;
statusEl.textContent = "正在读取并聚合数据...";
try {
const dates = dateRange(startDate, endDate);
const rows = [];
for (const date of dates) {
const events = await fetchDayEvents(date);
const users = new Set();
let loginCount = 0;
let chatCount = 0;
let staySum = 0;
let stayCnt = 0;
for (const event of events) {
if (event.user_id) users.add(event.user_id);
if (event.event_type === "session.login") loginCount += 1;
if (event.event_type === "agent.chat_completed") chatCount += 1;
if (event.event_type === "page.view") {
const stay = event.metrics && event.metrics.stay_duration_ms;
if (typeof stay === "number") {
staySum += stay;
stayCnt += 1;
}
}
}
rows.push({
date,
users,
dau: users.size,
loginCount,
chatCount,
staySum,
stayCnt,
avgStay: stayCnt ? staySum / stayCnt : 0,
});
}
renderSummary(rows);
renderDaily(rows);
statusEl.textContent = `加载完成,共 ${rows.length}`;
} catch (err) {
statusEl.textContent = err.message || "加载失败";
} finally {
loadBtn.disabled = false;
}
}
async function login(password) {
const res = await fetch("/api/v1/analytics/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
if (!res.ok) {
throw new Error("密码错误");
}
const payload = await res.json();
const dataBaseUrl = typeof payload.data_base_url === "string" && payload.data_base_url
? payload.data_base_url
: "/api/v1/analytics/data";
const token = typeof payload.token === "string" ? payload.token : "";
if (!token) {
throw new Error("登录响应缺少 token");
}
sessionStorage.setItem(AUTH_KEY, "1");
sessionStorage.setItem(DATA_BASE_URL_KEY, dataBaseUrl);
sessionStorage.setItem(AUTH_TOKEN_KEY, token);
}
function enterDashboard() {
loginCard.classList.add("hidden");
dashboard.classList.remove("hidden");
}
function exitDashboard() {
sessionStorage.removeItem(AUTH_KEY);
sessionStorage.removeItem(DATA_BASE_URL_KEY);
sessionStorage.removeItem(AUTH_TOKEN_KEY);
dashboard.classList.add("hidden");
loginCard.classList.remove("hidden");
}
loginForm.addEventListener("submit", async (event) => {
event.preventDefault();
loginError.textContent = "";
loginBtn.disabled = true;
try {
const password = document.getElementById("password").value;
await login(password);
enterDashboard();
await loadData();
} catch (err) {
loginError.textContent = err.message || "登录失败";
} finally {
loginBtn.disabled = false;
}
});
loadBtn.addEventListener("click", loadData);
logoutBtn.addEventListener("click", exitDashboard);
(function init() {
const today = new Date();
const start = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);
startDateInput.value = formatDate(start);
endDateInput.value = formatDate(today);
if (sessionStorage.getItem(AUTH_KEY) === "1") {
if (!sessionStorage.getItem(AUTH_TOKEN_KEY)) {
exitDashboard();
return;
}
enterDashboard();
loadData();
}
})();
</script>
</body>
</html>
+103 -2
View File
@@ -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
+12 -1
View File
@@ -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,
-2
View File
@@ -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)
@@ -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)
@@ -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,
@@ -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"