refactor: remove analytics module, update tool postprocessor tests
This commit is contained in:
@@ -331,3 +331,6 @@ deploy/static/releases/*.ipa
|
|||||||
|
|
||||||
# Local agents and skills
|
# Local agents and skills
|
||||||
.agents/
|
.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;
|
|
||||||
}
|
|
||||||
@@ -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';
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,8 +16,6 @@ class Env {
|
|||||||
return 'http://localhost:5775';
|
return 'http://localhost:5775';
|
||||||
}
|
}
|
||||||
|
|
||||||
static String get analyticsEndpoint => '$apiUrl/api/v1/analytics/events';
|
|
||||||
|
|
||||||
static String version = '0.1.0';
|
static String version = '0.1.0';
|
||||||
static int build = 1;
|
static int build = 1;
|
||||||
static String deviceId = '';
|
static String deviceId = '';
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import '../../../../core/analytics/tracker.dart';
|
|
||||||
import '../../../../core/logging/logger.dart';
|
import '../../../../core/logging/logger.dart';
|
||||||
import '../../data/repositories/auth_repository.dart';
|
import '../../data/repositories/auth_repository.dart';
|
||||||
import 'auth_event.dart';
|
import 'auth_event.dart';
|
||||||
@@ -8,7 +7,6 @@ import 'auth_state.dart';
|
|||||||
class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||||
final AuthRepository _repository;
|
final AuthRepository _repository;
|
||||||
final Logger _logger = getLogger('features.auth.bloc');
|
final Logger _logger = getLogger('features.auth.bloc');
|
||||||
DateTime? _loginTime;
|
|
||||||
|
|
||||||
AuthBloc(this._repository) : super(AuthInitial()) {
|
AuthBloc(this._repository) : super(AuthInitial()) {
|
||||||
on<AuthStarted>(_onStarted);
|
on<AuthStarted>(_onStarted);
|
||||||
@@ -23,8 +21,6 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
|||||||
final refreshToken = await _repository.getRefreshToken();
|
final refreshToken = await _repository.getRefreshToken();
|
||||||
if (refreshToken != null) {
|
if (refreshToken != null) {
|
||||||
final response = await _repository.refreshSession(refreshToken);
|
final response = await _repository.refreshSession(refreshToken);
|
||||||
_loginTime = DateTime.now();
|
|
||||||
AnalyticsTracker.instance.setUserId(response.user.id);
|
|
||||||
emit(
|
emit(
|
||||||
AuthAuthenticated(
|
AuthAuthenticated(
|
||||||
user: AuthUser(id: response.user.id, phone: response.user.phone),
|
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) {
|
void _onLoggedIn(AuthLoggedIn event, Emitter<AuthState> emit) {
|
||||||
_loginTime = DateTime.now();
|
|
||||||
_logger.info(message: 'User logged in', extra: {'user_id': event.user.id});
|
_logger.info(message: 'User logged in', extra: {'user_id': event.user.id});
|
||||||
emit(AuthAuthenticated(user: event.user));
|
emit(AuthAuthenticated(user: event.user));
|
||||||
}
|
}
|
||||||
@@ -69,9 +64,6 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
|||||||
AuthLoggedOut event,
|
AuthLoggedOut event,
|
||||||
Emitter<AuthState> emit,
|
Emitter<AuthState> emit,
|
||||||
) async {
|
) async {
|
||||||
final sessionDuration = _loginTime != null
|
|
||||||
? DateTime.now().difference(_loginTime!).inSeconds
|
|
||||||
: null;
|
|
||||||
try {
|
try {
|
||||||
await _repository.deleteSession();
|
await _repository.deleteSession();
|
||||||
_logger.info(message: 'User logged out');
|
_logger.info(message: 'User logged out');
|
||||||
@@ -82,11 +74,6 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
|||||||
stackTrace: stackTrace,
|
stackTrace: stackTrace,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
AnalyticsTracker.instance.trackLogout(
|
|
||||||
reason: 'manual',
|
|
||||||
sessionDurationS: sessionDuration,
|
|
||||||
);
|
|
||||||
_loginTime = null;
|
|
||||||
emit(
|
emit(
|
||||||
const AuthUnauthenticated(reason: AuthUnauthenticatedReason.signedOut),
|
const AuthUnauthenticated(reason: AuthUnauthenticatedReason.signedOut),
|
||||||
);
|
);
|
||||||
@@ -97,9 +84,6 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
|||||||
AuthSessionInvalidated event,
|
AuthSessionInvalidated event,
|
||||||
Emitter<AuthState> emit,
|
Emitter<AuthState> emit,
|
||||||
) async {
|
) async {
|
||||||
final sessionDuration = _loginTime != null
|
|
||||||
? DateTime.now().difference(_loginTime!).inSeconds
|
|
||||||
: null;
|
|
||||||
_logger.warning(message: 'Session invalidated by server');
|
_logger.warning(message: 'Session invalidated by server');
|
||||||
try {
|
try {
|
||||||
await _repository.clearSessionLocalOnly();
|
await _repository.clearSessionLocalOnly();
|
||||||
@@ -110,11 +94,6 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
|||||||
stackTrace: stackTrace,
|
stackTrace: stackTrace,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
AnalyticsTracker.instance.trackLogout(
|
|
||||||
reason: 'expired',
|
|
||||||
sessionDurationS: sessionDuration,
|
|
||||||
);
|
|
||||||
_loginTime = null;
|
|
||||||
emit(
|
emit(
|
||||||
const AuthUnauthenticated(reason: AuthUnauthenticatedReason.expired),
|
const AuthUnauthenticated(reason: AuthUnauthenticatedReason.expired),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import 'package:go_router/go_router.dart';
|
|||||||
|
|
||||||
import '../../../../app/di/injection.dart';
|
import '../../../../app/di/injection.dart';
|
||||||
import '../../../../app/router/app_routes.dart';
|
import '../../../../app/router/app_routes.dart';
|
||||||
import '../../../../core/analytics/tracker.dart';
|
|
||||||
import '../../../../core/l10n/l10n.dart';
|
import '../../../../core/l10n/l10n.dart';
|
||||||
import '../../../../core/theme/design_tokens.dart';
|
import '../../../../core/theme/design_tokens.dart';
|
||||||
import '../../../../shared/widgets/app_button.dart';
|
import '../../../../shared/widgets/app_button.dart';
|
||||||
@@ -70,8 +69,6 @@ class _LoginViewState extends State<LoginView> {
|
|||||||
|
|
||||||
final response = await cubit.submit();
|
final response = await cubit.submit();
|
||||||
if (response != null && mounted) {
|
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.read<AuthBloc>().add(AuthLoggedIn(user: response.user));
|
||||||
context.go(AppRoutes.homeMain);
|
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_orchestrator.dart';
|
||||||
import 'package:social_app/core/chat/chat_history_repository.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/chat/chat_timeline_reconciler.dart';
|
||||||
import 'package:social_app/core/analytics/tracker.dart';
|
|
||||||
import 'package:social_app/core/l10n/l10n.dart';
|
import 'package:social_app/core/l10n/l10n.dart';
|
||||||
import 'chat_bloc_recovery_utils.dart';
|
import 'chat_bloc_recovery_utils.dart';
|
||||||
|
|
||||||
@@ -318,14 +317,7 @@ class ChatBloc extends Cubit<ChatState> implements ChatOrchestrator {
|
|||||||
messageCount: 1,
|
messageCount: 1,
|
||||||
responseTimeMs: responseTimeMs,
|
responseTimeMs: responseTimeMs,
|
||||||
);
|
);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
AnalyticsTracker.instance.trackAgentChatCompleted(
|
|
||||||
conversationId: conversationId,
|
|
||||||
scenario: 'assistant',
|
|
||||||
messageCount: 1,
|
|
||||||
responseTimeMs: responseTimeMs,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _clearRunMetrics() {
|
void _clearRunMetrics() {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import '../../../../app/di/injection.dart';
|
|||||||
import '../../../../app/router/app_route_observer.dart';
|
import '../../../../app/router/app_route_observer.dart';
|
||||||
import '../../../../app/router/app_routes.dart';
|
import '../../../../app/router/app_routes.dart';
|
||||||
import '../../../../core/l10n/l10n.dart';
|
import '../../../../core/l10n/l10n.dart';
|
||||||
import '../../../../core/analytics/tracker.dart';
|
|
||||||
import '../../../../core/theme/design_tokens.dart';
|
import '../../../../core/theme/design_tokens.dart';
|
||||||
import '../../../../core/inbox/inbox_sync_store.dart';
|
import '../../../../core/inbox/inbox_sync_store.dart';
|
||||||
import '../../../chat/presentation/bloc/chat_bloc.dart';
|
import '../../../chat/presentation/bloc/chat_bloc.dart';
|
||||||
@@ -99,8 +98,6 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
int _previousItemCount = 0;
|
int _previousItemCount = 0;
|
||||||
bool _previousIsLoadingHistory = false;
|
bool _previousIsLoadingHistory = false;
|
||||||
bool _routeAwareSubscribed = false;
|
bool _routeAwareSubscribed = false;
|
||||||
late final DateTime _pageEnteredAt;
|
|
||||||
int _pageClickCount = 0;
|
|
||||||
double? _historyViewportPixels;
|
double? _historyViewportPixels;
|
||||||
double? _historyViewportMaxExtent;
|
double? _historyViewportMaxExtent;
|
||||||
final GlobalKey<HomeInputHostState> _inputHostKey =
|
final GlobalKey<HomeInputHostState> _inputHostKey =
|
||||||
@@ -124,7 +121,6 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
duration: const Duration(milliseconds: _rippleDurationMs),
|
duration: const Duration(milliseconds: _rippleDurationMs),
|
||||||
);
|
);
|
||||||
_selectedImages.addAll(widget.initialSelectedImages);
|
_selectedImages.addAll(widget.initialSelectedImages);
|
||||||
_pageEnteredAt = DateTime.now();
|
|
||||||
final initialUserId = widget.initialUserId?.trim();
|
final initialUserId = widget.initialUserId?.trim();
|
||||||
if (initialUserId != null && initialUserId.isNotEmpty) {
|
if (initialUserId != null && initialUserId.isNotEmpty) {
|
||||||
unawaited(_chatBloc.switchUser(initialUserId));
|
unawaited(_chatBloc.switchUser(initialUserId));
|
||||||
@@ -152,14 +148,6 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
final stayDurationMs = DateTime.now()
|
|
||||||
.difference(_pageEnteredAt)
|
|
||||||
.inMilliseconds;
|
|
||||||
AnalyticsTracker.instance.trackPageView(
|
|
||||||
pageName: 'home',
|
|
||||||
stayDurationMs: stayDurationMs,
|
|
||||||
clickCount: _pageClickCount,
|
|
||||||
);
|
|
||||||
_messageController.dispose();
|
_messageController.dispose();
|
||||||
_scrollController.removeListener(_handleScrollChanged);
|
_scrollController.removeListener(_handleScrollChanged);
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
@@ -294,15 +282,15 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
return HomeFloatingHeader(
|
return HomeFloatingHeader(
|
||||||
unreadCount: _unreadCount,
|
unreadCount: _unreadCount,
|
||||||
onTapSettings: () {
|
onTapSettings: () {
|
||||||
_trackClick('header_settings');
|
|
||||||
context.push(AppRoutes.settingsMain);
|
context.push(AppRoutes.settingsMain);
|
||||||
},
|
},
|
||||||
onTapCalendar: () {
|
onTapCalendar: () {
|
||||||
_trackClick('header_calendar');
|
|
||||||
context.push('${AppRoutes.calendarDayWeek}?from=home');
|
context.push('${AppRoutes.calendarDayWeek}?from=home');
|
||||||
},
|
},
|
||||||
onTapMessages: () {
|
onTapMessages: () {
|
||||||
_trackClick('header_messages');
|
|
||||||
context.push(AppRoutes.messageInviteList);
|
context.push(AppRoutes.messageInviteList);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -414,7 +402,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
child: HomeUnreadBadge(
|
child: HomeUnreadBadge(
|
||||||
count: _chatUnreadBadgeCount,
|
count: _chatUnreadBadgeCount,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
_trackClick('unread_badge');
|
|
||||||
_scheduleAutoScroll(animated: true);
|
_scheduleAutoScroll(animated: true);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _chatUnreadBadgeCount = 0);
|
setState(() => _chatUnreadBadgeCount = 0);
|
||||||
@@ -467,7 +455,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onLoadMore(BuildContext context) async {
|
Future<void> _onLoadMore(BuildContext context) async {
|
||||||
_trackClick('history_load_more');
|
|
||||||
final chatBloc = context.read<ChatBloc>();
|
final chatBloc = context.read<ChatBloc>();
|
||||||
await _loadMoreHistoryPreservingViewport(chatBloc);
|
await _loadMoreHistoryPreservingViewport(chatBloc);
|
||||||
}
|
}
|
||||||
@@ -681,15 +669,15 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
messageController: _messageController,
|
messageController: _messageController,
|
||||||
onTapPlus: _isRecording
|
onTapPlus: _isRecording
|
||||||
? () {
|
? () {
|
||||||
_trackClick('record_stop');
|
|
||||||
_stopRecording(autoSendAfterTranscribe: false);
|
_stopRecording(autoSendAfterTranscribe: false);
|
||||||
}
|
}
|
||||||
: () {
|
: () {
|
||||||
_trackClick('input_plus');
|
|
||||||
_showBottomSheet(context);
|
_showBottomSheet(context);
|
||||||
},
|
},
|
||||||
onStopGenerating: () {
|
onStopGenerating: () {
|
||||||
_trackClick('stop_generating');
|
|
||||||
_onStopGenerating();
|
_onStopGenerating();
|
||||||
},
|
},
|
||||||
onHoldToSpeakStart: _onHoldToSpeakStart,
|
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) {
|
void _removeImage(int index) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedImages.removeAt(index);
|
_selectedImages.removeAt(index);
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ extension _HomeScreenInteractions on _HomeScreenState {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_trackClick('send_message');
|
|
||||||
await _chatBloc.sendMessage(content, images: images);
|
await _chatBloc.sendMessage(content, images: images);
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import 'core/config/env.dart';
|
|||||||
import 'core/logging/logger.dart';
|
import 'core/logging/logger.dart';
|
||||||
import 'core/logging/log_service.dart';
|
import 'core/logging/log_service.dart';
|
||||||
import 'core/logging/error_handler.dart';
|
import 'core/logging/error_handler.dart';
|
||||||
import 'core/analytics/tracker.dart';
|
|
||||||
import 'app/di/injection.dart';
|
import 'app/di/injection.dart';
|
||||||
import 'app/app.dart';
|
import 'app/app.dart';
|
||||||
|
|
||||||
@@ -18,11 +17,6 @@ void main() async {
|
|||||||
await configureDependencies();
|
await configureDependencies();
|
||||||
await Env.init();
|
await Env.init();
|
||||||
|
|
||||||
await AnalyticsTracker.init(
|
|
||||||
endpoint: Env.analyticsEndpoint,
|
|
||||||
deviceId: Env.deviceId,
|
|
||||||
);
|
|
||||||
|
|
||||||
getLogger(
|
getLogger(
|
||||||
'app',
|
'app',
|
||||||
).info(message: 'App starting...', extra: {'version': Env.version});
|
).info(message: 'App starting...', extra: {'version': Env.version});
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, AsyncGenerator
|
from typing import Any, AsyncGenerator
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Request
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
@@ -9,7 +8,6 @@ from fastapi.exceptions import RequestValidationError
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from starlette.staticfiles import StaticFiles
|
|
||||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||||
|
|
||||||
from core.config.settings import config
|
from core.config.settings import config
|
||||||
@@ -61,14 +59,6 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
app.include_router(mobile_router)
|
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(
|
logger.info(
|
||||||
"Web application initialized",
|
"Web application initialized",
|
||||||
environment=config.runtime.environment,
|
environment=config.runtime.environment,
|
||||||
|
|||||||
@@ -180,13 +180,17 @@ def _build_error(
|
|||||||
result={"status": "failure", "code": code, "message": message},
|
result={"status": "failure", "code": code, "message": message},
|
||||||
error=ErrorInfo(code=code, message=message, retryable=False),
|
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(
|
return ToolResponse(
|
||||||
content=[
|
content=[
|
||||||
TextBlock(
|
TextBlock(
|
||||||
type="text",
|
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="删除")
|
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:
|
def _memory_update_ui_hints(tool_output: ToolAgentOutput) -> dict[str, Any] | None:
|
||||||
data = _result_data(tool_output)
|
data = _result_data(tool_output)
|
||||||
if data is None:
|
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", "read"): _calendar_read_ui_hints,
|
||||||
("calendar", "update"): _calendar_update_ui_hints,
|
("calendar", "update"): _calendar_update_ui_hints,
|
||||||
("calendar", "delete"): _calendar_delete_ui_hints,
|
("calendar", "delete"): _calendar_delete_ui_hints,
|
||||||
|
("calendar", "share"): _calendar_share_ui_hints,
|
||||||
("contacts", "read"): _contacts_read_ui_hints,
|
("contacts", "read"): _contacts_read_ui_hints,
|
||||||
("memory", "update"): _memory_update_ui_hints,
|
("memory", "update"): _memory_update_ui_hints,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,11 +89,6 @@ class RuntimeSettings(BaseModel):
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
class AnalyticsSettings(BaseModel):
|
|
||||||
data_path: str = "backend/data/analytics"
|
|
||||||
password: str = "analytics-secret"
|
|
||||||
|
|
||||||
|
|
||||||
class TaskiqSettings(BaseModel):
|
class TaskiqSettings(BaseModel):
|
||||||
broker_url: str | None = None
|
broker_url: str | None = None
|
||||||
result_backend_url: str | None = None
|
result_backend_url: str | None = None
|
||||||
@@ -280,7 +275,6 @@ class Settings(BaseSettings):
|
|||||||
taskiq: TaskiqSettings = TaskiqSettings()
|
taskiq: TaskiqSettings = TaskiqSettings()
|
||||||
database: DatabaseSettings = DatabaseSettings()
|
database: DatabaseSettings = DatabaseSettings()
|
||||||
app_version: AppVersionSettings = AppVersionSettings()
|
app_version: AppVersionSettings = AppVersionSettings()
|
||||||
analytics: AnalyticsSettings = AnalyticsSettings()
|
|
||||||
test: TestSettings = Field(default_factory=TestSettings)
|
test: TestSettings = Field(default_factory=TestSettings)
|
||||||
|
|
||||||
@computed_field
|
@computed_field
|
||||||
|
|||||||
@@ -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")
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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}
|
|
||||||
@@ -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>
|
|
||||||
@@ -11,10 +11,17 @@ from core.config.settings import config
|
|||||||
from core.http.errors import ApiProblemError
|
from core.http.errors import ApiProblemError
|
||||||
from core.logging import get_logger
|
from core.logging import get_logger
|
||||||
from services.base.supabase import supabase_service
|
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")
|
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:
|
def _auth_error(*, status_code: int, code: str, detail: str) -> ApiProblemError:
|
||||||
return ApiProblemError(status_code=status_code, code=code, detail=detail)
|
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)
|
user_id = await _find_or_create_user_by_phone(request.phone)
|
||||||
|
|
||||||
token = _sign_access_token(sub=str(user_id))
|
token = _sign_access_token(sub=str(user_id))
|
||||||
|
refresh_token = _sign_refresh_token(sub=str(user_id), phone=request.phone)
|
||||||
return SessionResponse(
|
return SessionResponse(
|
||||||
access_token=token,
|
access_token=token,
|
||||||
refresh_token="dev-refresh-token",
|
refresh_token=refresh_token,
|
||||||
expires_in=3600,
|
expires_in=3600,
|
||||||
token_type="bearer",
|
token_type="bearer",
|
||||||
user=AuthUser(id=str(user_id), phone=request.phone),
|
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:
|
async def _find_or_create_user_by_phone(phone: str) -> UUID:
|
||||||
admin_client = supabase_service.get_admin_client()
|
admin_client = supabase_service.get_admin_client()
|
||||||
users = await asyncio.to_thread(
|
users = await asyncio.to_thread(
|
||||||
@@ -142,3 +183,63 @@ def _sign_access_token(*, sub: str) -> str:
|
|||||||
str,
|
str,
|
||||||
jwt.encode(payload, secret.get_secret_value(), algorithm=config.supabase.jwt_algorithm),
|
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,7 +12,11 @@ from core.config.settings import config
|
|||||||
from core.http.errors import ApiProblemError
|
from core.http.errors import ApiProblemError
|
||||||
from core.logging import get_logger
|
from core.logging import get_logger
|
||||||
from services.base.supabase import supabase_service
|
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 (
|
from v1.auth.schemas import (
|
||||||
AuthUser,
|
AuthUser,
|
||||||
OtpSendRequest,
|
OtpSendRequest,
|
||||||
@@ -113,6 +117,9 @@ class SupabaseAuthGateway(AuthServiceGateway):
|
|||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
async def refresh_session(self, request: SessionRefreshRequest) -> SessionResponse:
|
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()
|
client = self._get_client()
|
||||||
try:
|
try:
|
||||||
response = await asyncio.to_thread(
|
response = await asyncio.to_thread(
|
||||||
@@ -139,6 +146,10 @@ class SupabaseAuthGateway(AuthServiceGateway):
|
|||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
async def delete_session(self, refresh_token: str | None) -> None:
|
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:
|
if not refresh_token:
|
||||||
raise _auth_error(
|
raise _auth_error(
|
||||||
status_code=401,
|
status_code=401,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ from __future__ import annotations
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from v1.agent.router import router as agent_router
|
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.app.router import router as app_router
|
||||||
from v1.automation_jobs.router import router as automation_jobs_router
|
from v1.automation_jobs.router import router as automation_jobs_router
|
||||||
from v1.auth.router import router as auth_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(app_router)
|
||||||
router.include_router(auth_router)
|
router.include_router(auth_router)
|
||||||
router.include_router(agent_router)
|
router.include_router(agent_router)
|
||||||
router.include_router(analytics_router)
|
|
||||||
router.include_router(automation_jobs_router)
|
router.include_router(automation_jobs_router)
|
||||||
router.include_router(friendships_router)
|
router.include_router(friendships_router)
|
||||||
router.include_router(memories_router)
|
router.include_router(memories_router)
|
||||||
|
|||||||
@@ -37,6 +37,28 @@ def test_postprocess_calendar_create_partial() -> None:
|
|||||||
assert processed.ui_hints["status"] == "warning"
|
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:
|
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": []})
|
output = _make_tool_output(command="contacts", subcommand="read", status=ToolStatus.SUCCESS, data={"friends_count": 3, "friends": []})
|
||||||
processed = postprocess_tool_output(output)
|
processed = postprocess_tool_output(output)
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ from core.config.settings import config
|
|||||||
|
|
||||||
from v1.auth.gateway import SupabaseAuthGateway
|
from v1.auth.gateway import SupabaseAuthGateway
|
||||||
from v1.auth.schemas import (
|
from v1.auth.schemas import (
|
||||||
|
AuthUser,
|
||||||
OtpSendRequest,
|
OtpSendRequest,
|
||||||
PhoneSessionCreateRequest,
|
PhoneSessionCreateRequest,
|
||||||
|
SessionResponse,
|
||||||
SessionRefreshRequest,
|
SessionRefreshRequest,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -108,6 +110,39 @@ class TestSupabaseAuthGateway:
|
|||||||
|
|
||||||
assert exc_info.value.status_code == 401
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_get_user_by_phone_uses_in_memory_cache(
|
async def test_get_user_by_phone_uses_in_memory_cache(
|
||||||
self,
|
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"
|
||||||
Reference in New Issue
Block a user