feat: 添加 Analytics 分析功能(行为追踪、错误码、协议更新)
This commit is contained in:
@@ -295,6 +295,7 @@ deploy/.env.prod
|
|||||||
.history
|
.history
|
||||||
/logs/
|
/logs/
|
||||||
backend/logs/
|
backend/logs/
|
||||||
|
backend/data/analytics/
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
*.tar
|
*.tar
|
||||||
# Docker volumes (local data)
|
# Docker volumes (local data)
|
||||||
@@ -307,6 +308,9 @@ infra/docker/supabase/volumes/storage/
|
|||||||
# .opencode/ is now tracked - see .opencode/.gitignore for exclusions
|
# .opencode/ is now tracked - see .opencode/.gitignore for exclusions
|
||||||
.opencode/opencode.json.old
|
.opencode/opencode.json.old
|
||||||
|
|
||||||
|
# Agents and skills
|
||||||
|
.agents/
|
||||||
|
|
||||||
# Local git worktrees
|
# Local git worktrees
|
||||||
.worktrees/
|
.worktrees/
|
||||||
worktrees/
|
worktrees/
|
||||||
|
|||||||
@@ -79,6 +79,26 @@ String? mapErrorCodeToL10nKey(
|
|||||||
return 'errorNotFound';
|
return 'errorNotFound';
|
||||||
case 'AUTH_UNAUTHORIZED':
|
case 'AUTH_UNAUTHORIZED':
|
||||||
return 'errorReLogin';
|
return 'errorReLogin';
|
||||||
|
case 'ANALYTICS_LOGIN_PASSWORD_INVALID':
|
||||||
|
return 'errorGenericSafe';
|
||||||
|
case 'ANALYTICS_AUTH_HEADER_MISSING':
|
||||||
|
return 'errorReLogin';
|
||||||
|
case 'ANALYTICS_AUTH_SCHEME_INVALID':
|
||||||
|
return 'errorReLogin';
|
||||||
|
case 'ANALYTICS_AUTH_TOKEN_MISSING':
|
||||||
|
return 'errorReLogin';
|
||||||
|
case 'ANALYTICS_TOKEN_MALFORMED':
|
||||||
|
return 'errorReLogin';
|
||||||
|
case 'ANALYTICS_TOKEN_SIGNATURE_INVALID':
|
||||||
|
return 'errorReLogin';
|
||||||
|
case 'ANALYTICS_TOKEN_PAYLOAD_INVALID':
|
||||||
|
return 'errorReLogin';
|
||||||
|
case 'ANALYTICS_TOKEN_EXPIRED':
|
||||||
|
return 'errorReLogin';
|
||||||
|
case 'ANALYTICS_DATE_FORMAT_INVALID':
|
||||||
|
return 'errorGenericSafe';
|
||||||
|
case 'ANALYTICS_FILE_NOT_FOUND':
|
||||||
|
return 'errorNotFound';
|
||||||
case 'JWT_VERIFIER_NOT_CONFIGURED':
|
case 'JWT_VERIFIER_NOT_CONFIGURED':
|
||||||
return 'errorServer';
|
return 'errorServer';
|
||||||
case 'AUTOMATION_JOB_LIMIT_EXCEEDED':
|
case 'AUTOMATION_JOB_LIMIT_EXCEEDED':
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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';
|
||||||
|
|
||||||
@@ -20,6 +21,13 @@ part 'chat_bloc_send.dart';
|
|||||||
part 'chat_bloc_history.dart';
|
part 'chat_bloc_history.dart';
|
||||||
part 'chat_bloc_attachments.dart';
|
part 'chat_bloc_attachments.dart';
|
||||||
|
|
||||||
|
typedef ChatCompletedCallback =
|
||||||
|
void Function({
|
||||||
|
required String conversationId,
|
||||||
|
required int messageCount,
|
||||||
|
required int responseTimeMs,
|
||||||
|
});
|
||||||
|
|
||||||
class ChatState implements ChatOrchestratorState {
|
class ChatState implements ChatOrchestratorState {
|
||||||
@override
|
@override
|
||||||
final List<ChatListItem> items;
|
final List<ChatListItem> items;
|
||||||
@@ -115,12 +123,14 @@ class ChatBloc extends Cubit<ChatState> implements ChatOrchestrator {
|
|||||||
required ChatApi chatApi,
|
required ChatApi chatApi,
|
||||||
ChatHistoryRepository? historyRepository,
|
ChatHistoryRepository? historyRepository,
|
||||||
Future<void> Function()? onCalendarMutated,
|
Future<void> Function()? onCalendarMutated,
|
||||||
|
ChatCompletedCallback? onChatCompleted,
|
||||||
Duration recoveryPollInterval = const Duration(milliseconds: 700),
|
Duration recoveryPollInterval = const Duration(milliseconds: 700),
|
||||||
Duration recoveryTimeout = const Duration(seconds: 20),
|
Duration recoveryTimeout = const Duration(seconds: 20),
|
||||||
}) : _service =
|
}) : _service =
|
||||||
service ??
|
service ??
|
||||||
AgUiService(chatApi: chatApi, historyRepository: historyRepository),
|
AgUiService(chatApi: chatApi, historyRepository: historyRepository),
|
||||||
_onCalendarMutated = onCalendarMutated,
|
_onCalendarMutated = onCalendarMutated,
|
||||||
|
_onChatCompleted = onChatCompleted,
|
||||||
_recoveryPollInterval = recoveryPollInterval,
|
_recoveryPollInterval = recoveryPollInterval,
|
||||||
_recoveryTimeout = recoveryTimeout,
|
_recoveryTimeout = recoveryTimeout,
|
||||||
super(const ChatState()) {
|
super(const ChatState()) {
|
||||||
@@ -129,9 +139,14 @@ class ChatBloc extends Cubit<ChatState> implements ChatOrchestrator {
|
|||||||
|
|
||||||
final AgUiService _service;
|
final AgUiService _service;
|
||||||
final Future<void> Function()? _onCalendarMutated;
|
final Future<void> Function()? _onCalendarMutated;
|
||||||
|
final ChatCompletedCallback? _onChatCompleted;
|
||||||
final Duration _recoveryPollInterval;
|
final Duration _recoveryPollInterval;
|
||||||
final Duration _recoveryTimeout;
|
final Duration _recoveryTimeout;
|
||||||
String? _activeUserId;
|
String? _activeUserId;
|
||||||
|
DateTime? _activeRunStartedAt;
|
||||||
|
DateTime? _activeRunFirstResponseAt;
|
||||||
|
String? _activeRunId;
|
||||||
|
String? _activeThreadId;
|
||||||
int _sessionEpoch = 0;
|
int _sessionEpoch = 0;
|
||||||
final Map<String, Uint8List> _attachmentPreviewCache = <String, Uint8List>{};
|
final Map<String, Uint8List> _attachmentPreviewCache = <String, Uint8List>{};
|
||||||
final Map<String, Future<Uint8List?>> _attachmentPreviewInflight =
|
final Map<String, Future<Uint8List?>> _attachmentPreviewInflight =
|
||||||
@@ -259,4 +274,54 @@ class ChatBloc extends Cubit<ChatState> implements ChatOrchestrator {
|
|||||||
emit(state.copyWith(error: error.toString()));
|
emit(state.copyWith(error: error.toString()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _recordRunStarted({required String runId, required String threadId}) {
|
||||||
|
_activeRunStartedAt = DateTime.now();
|
||||||
|
_activeRunFirstResponseAt = null;
|
||||||
|
_activeRunId = runId;
|
||||||
|
_activeThreadId = threadId;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _recordRunFirstResponse() {
|
||||||
|
_activeRunFirstResponseAt ??= DateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _trackChatCompleted() {
|
||||||
|
final startedAt = _activeRunStartedAt;
|
||||||
|
if (startedAt == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final firstResponseAt = _activeRunFirstResponseAt ?? DateTime.now();
|
||||||
|
final responseTimeMs = firstResponseAt.difference(startedAt).inMilliseconds;
|
||||||
|
final threadId = _activeThreadId?.trim();
|
||||||
|
final runId = _activeRunId?.trim();
|
||||||
|
final conversationId = (threadId != null && threadId.isNotEmpty)
|
||||||
|
? threadId
|
||||||
|
: runId;
|
||||||
|
if (conversationId == null || conversationId.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final onChatCompleted = _onChatCompleted;
|
||||||
|
if (onChatCompleted != null) {
|
||||||
|
onChatCompleted(
|
||||||
|
conversationId: conversationId,
|
||||||
|
messageCount: 1,
|
||||||
|
responseTimeMs: responseTimeMs,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
AnalyticsTracker.instance.trackAgentChatCompleted(
|
||||||
|
conversationId: conversationId,
|
||||||
|
scenario: 'assistant',
|
||||||
|
messageCount: 1,
|
||||||
|
responseTimeMs: responseTimeMs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearRunMetrics() {
|
||||||
|
_activeRunStartedAt = null;
|
||||||
|
_activeRunFirstResponseAt = null;
|
||||||
|
_activeRunId = null;
|
||||||
|
_activeThreadId = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ extension _ChatBlocEvents on ChatBloc {
|
|||||||
void _handleEvent(AgUiEvent event) {
|
void _handleEvent(AgUiEvent event) {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case AgUiEventType.runStarted:
|
case AgUiEventType.runStarted:
|
||||||
|
final runStartedEvent = event as RunStartedEvent;
|
||||||
|
_recordRunStarted(
|
||||||
|
runId: runStartedEvent.runId,
|
||||||
|
threadId: runStartedEvent.threadId,
|
||||||
|
);
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
isSending: false,
|
isSending: false,
|
||||||
@@ -17,11 +22,14 @@ extension _ChatBlocEvents on ChatBloc {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
case AgUiEventType.runFinished:
|
case AgUiEventType.runFinished:
|
||||||
|
_trackChatCompleted();
|
||||||
|
_clearRunMetrics();
|
||||||
emit(
|
emit(
|
||||||
_resetRunState().copyWith(items: _removeToolCallItems(state.items)),
|
_resetRunState().copyWith(items: _removeToolCallItems(state.items)),
|
||||||
);
|
);
|
||||||
case AgUiEventType.runError:
|
case AgUiEventType.runError:
|
||||||
final errorEvent = event as RunErrorEvent;
|
final errorEvent = event as RunErrorEvent;
|
||||||
|
_clearRunMetrics();
|
||||||
final isCanceledByUser = errorEvent.code == 'RUN_CANCELED';
|
final isCanceledByUser = errorEvent.code == 'RUN_CANCELED';
|
||||||
emit(
|
emit(
|
||||||
_resetRunState(
|
_resetRunState(
|
||||||
@@ -72,6 +80,7 @@ extension _ChatBlocEvents on ChatBloc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleTextMessageEnd(TextMessageEndEvent event) {
|
void _handleTextMessageEnd(TextMessageEndEvent event) {
|
||||||
|
_recordRunFirstResponse();
|
||||||
final timestamp = DateTime.now();
|
final timestamp = DateTime.now();
|
||||||
final items = _updateOrAddMessage(
|
final items = _updateOrAddMessage(
|
||||||
state.items,
|
state.items,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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';
|
||||||
@@ -98,6 +99,8 @@ 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 =
|
||||||
@@ -121,6 +124,7 @@ 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));
|
||||||
@@ -148,6 +152,14 @@ 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();
|
||||||
@@ -281,10 +293,18 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
Widget _buildHeader(BuildContext context) {
|
Widget _buildHeader(BuildContext context) {
|
||||||
return HomeFloatingHeader(
|
return HomeFloatingHeader(
|
||||||
unreadCount: _unreadCount,
|
unreadCount: _unreadCount,
|
||||||
onTapSettings: () => context.push(AppRoutes.settingsMain),
|
onTapSettings: () {
|
||||||
onTapCalendar: () =>
|
_trackClick('header_settings');
|
||||||
context.push('${AppRoutes.calendarDayWeek}?from=home'),
|
context.push(AppRoutes.settingsMain);
|
||||||
onTapMessages: () => context.push(AppRoutes.messageInviteList),
|
},
|
||||||
|
onTapCalendar: () {
|
||||||
|
_trackClick('header_calendar');
|
||||||
|
context.push('${AppRoutes.calendarDayWeek}?from=home');
|
||||||
|
},
|
||||||
|
onTapMessages: () {
|
||||||
|
_trackClick('header_messages');
|
||||||
|
context.push(AppRoutes.messageInviteList);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,6 +406,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);
|
||||||
@@ -438,6 +459,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);
|
||||||
}
|
}
|
||||||
@@ -650,9 +672,18 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
isWaitingAgent: isWaitingAgent,
|
isWaitingAgent: isWaitingAgent,
|
||||||
messageController: _messageController,
|
messageController: _messageController,
|
||||||
onTapPlus: _isRecording
|
onTapPlus: _isRecording
|
||||||
? () => _stopRecording(autoSendAfterTranscribe: false)
|
? () {
|
||||||
: () => _showBottomSheet(context),
|
_trackClick('record_stop');
|
||||||
onStopGenerating: _onStopGenerating,
|
_stopRecording(autoSendAfterTranscribe: false);
|
||||||
|
}
|
||||||
|
: () {
|
||||||
|
_trackClick('input_plus');
|
||||||
|
_showBottomSheet(context);
|
||||||
|
},
|
||||||
|
onStopGenerating: () {
|
||||||
|
_trackClick('stop_generating');
|
||||||
|
_onStopGenerating();
|
||||||
|
},
|
||||||
onHoldToSpeakStart: _onHoldToSpeakStart,
|
onHoldToSpeakStart: _onHoldToSpeakStart,
|
||||||
onHoldToSpeakEnd: _onHoldToSpeakEnd,
|
onHoldToSpeakEnd: _onHoldToSpeakEnd,
|
||||||
onHoldToSpeakMoveUpdate: _onHoldToSpeakMoveUpdate,
|
onHoldToSpeakMoveUpdate: _onHoldToSpeakMoveUpdate,
|
||||||
@@ -662,6 +693,15 @@ 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,6 +53,7 @@ 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) {
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: social_app
|
name: social_app
|
||||||
description: "Social App - A Flutter mobile application"
|
description: "Social App - A Flutter mobile application"
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 0.1.2+5
|
version: 0.1.2+7
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.7
|
sdk: ^3.10.7
|
||||||
|
|
||||||
|
|||||||
@@ -385,4 +385,86 @@ void main() {
|
|||||||
expect(bloc.state.currentStage, isNull);
|
expect(bloc.state.currentStage, isNull);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test('chat completed analytics triggers once only on RUN_FINISHED', () async {
|
||||||
|
final service = _FakeAgUiService();
|
||||||
|
var completedCalls = 0;
|
||||||
|
String? lastConversationId;
|
||||||
|
int? lastMessageCount;
|
||||||
|
int? lastResponseTimeMs;
|
||||||
|
|
||||||
|
final bloc = ChatBloc(
|
||||||
|
service: service,
|
||||||
|
chatApi: _NoopChatApi(),
|
||||||
|
onChatCompleted:
|
||||||
|
({
|
||||||
|
required String conversationId,
|
||||||
|
required int messageCount,
|
||||||
|
required int responseTimeMs,
|
||||||
|
}) {
|
||||||
|
completedCalls += 1;
|
||||||
|
lastConversationId = conversationId;
|
||||||
|
lastMessageCount = messageCount;
|
||||||
|
lastResponseTimeMs = responseTimeMs;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
service.emitEventForTest(
|
||||||
|
RunStartedEvent(threadId: 'thread-1', runId: 'run-1'),
|
||||||
|
);
|
||||||
|
service.emitEventForTest(
|
||||||
|
TextMessageEndEvent(
|
||||||
|
messageId: 'msg-1',
|
||||||
|
answer: 'hello',
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'success',
|
||||||
|
uiSchema: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(completedCalls, 0);
|
||||||
|
|
||||||
|
service.emitEventForTest(
|
||||||
|
RunFinishedEvent(threadId: 'thread-1', runId: 'run-1'),
|
||||||
|
);
|
||||||
|
service.emitEventForTest(
|
||||||
|
RunFinishedEvent(threadId: 'thread-1', runId: 'run-1'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(completedCalls, 1);
|
||||||
|
expect(lastConversationId, 'thread-1');
|
||||||
|
expect(lastMessageCount, 1);
|
||||||
|
expect(lastResponseTimeMs, isNotNull);
|
||||||
|
expect(lastResponseTimeMs, greaterThanOrEqualTo(0));
|
||||||
|
|
||||||
|
bloc.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chat completed analytics does not trigger on RUN_ERROR', () async {
|
||||||
|
final service = _FakeAgUiService();
|
||||||
|
var completedCalls = 0;
|
||||||
|
|
||||||
|
final bloc = ChatBloc(
|
||||||
|
service: service,
|
||||||
|
chatApi: _NoopChatApi(),
|
||||||
|
onChatCompleted:
|
||||||
|
({
|
||||||
|
required String conversationId,
|
||||||
|
required int messageCount,
|
||||||
|
required int responseTimeMs,
|
||||||
|
}) {
|
||||||
|
completedCalls += 1;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
service.emitEventForTest(
|
||||||
|
RunStartedEvent(threadId: 'thread-1', runId: 'run-1'),
|
||||||
|
);
|
||||||
|
service.emitEventForTest(
|
||||||
|
RunErrorEvent(message: 'run failed', code: 'RUN_FAILED'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(completedCalls, 0);
|
||||||
|
bloc.close();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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
|
||||||
@@ -8,6 +9,7 @@ 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
|
||||||
@@ -59,6 +61,14 @@ 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,
|
||||||
|
|||||||
@@ -48,12 +48,12 @@ class TaskiqQueueClient:
|
|||||||
def _select_queue_task(command: dict[str, object]) -> Any:
|
def _select_queue_task(command: dict[str, object]) -> Any:
|
||||||
from core.agentscope.runtime.tasks import (
|
from core.agentscope.runtime.tasks import (
|
||||||
run_command_task_agent,
|
run_command_task_agent,
|
||||||
run_command_task_automation,
|
run_command_task_general,
|
||||||
)
|
)
|
||||||
|
|
||||||
queue = str(command.get("queue", "agent")).strip().lower()
|
queue = str(command.get("queue", "agent")).strip().lower()
|
||||||
if queue == "automation":
|
if queue == "general":
|
||||||
return run_command_task_automation
|
return run_command_task_general
|
||||||
return run_command_task_agent
|
return run_command_task_agent
|
||||||
|
|
||||||
async def enqueue(
|
async def enqueue(
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
from fastapi import APIRouter, HTTPException, status
|
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.config.settings import config
|
||||||
|
from core.http.errors import ApiProblemError
|
||||||
from core.logging import get_logger
|
from core.logging import get_logger
|
||||||
from v1.analytics.schemas import (
|
from v1.analytics.schemas import (
|
||||||
AnalyticsBatchRequest,
|
AnalyticsBatchRequest,
|
||||||
@@ -15,6 +25,94 @@ from v1.analytics.tasks import write_analytics_events
|
|||||||
logger = get_logger("v1.analytics.router")
|
logger = get_logger("v1.analytics.router")
|
||||||
|
|
||||||
router = APIRouter(prefix="/analytics", tags=["analytics"])
|
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)
|
@router.post("/events", response_model=AnalyticsBatchResponse)
|
||||||
@@ -35,10 +133,42 @@ async def login(request: AnalyticsLoginRequest) -> AnalyticsLoginResponse:
|
|||||||
"""Analytics Dashboard 登录"""
|
"""Analytics Dashboard 登录"""
|
||||||
if request.password != config.analytics.password:
|
if request.password != config.analytics.password:
|
||||||
logger.warning("Analytics login failed: invalid password")
|
logger.warning("Analytics login failed: invalid password")
|
||||||
raise HTTPException(
|
raise ApiProblemError(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
code="ANALYTICS_LOGIN_PASSWORD_INVALID",
|
||||||
detail="Invalid password",
|
detail="Invalid password",
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("Analytics login success")
|
logger.info("Analytics login success")
|
||||||
return AnalyticsLoginResponse(success=True)
|
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")
|
||||||
|
|||||||
@@ -52,3 +52,5 @@ class AnalyticsLoginRequest(BaseModel):
|
|||||||
|
|
||||||
class AnalyticsLoginResponse(BaseModel):
|
class AnalyticsLoginResponse(BaseModel):
|
||||||
success: bool
|
success: bool
|
||||||
|
data_base_url: str
|
||||||
|
token: str
|
||||||
|
|||||||
@@ -204,6 +204,8 @@
|
|||||||
const dailyTable = document.getElementById("dailyTable");
|
const dailyTable = document.getElementById("dailyTable");
|
||||||
|
|
||||||
const AUTH_KEY = "analytics_logged_in";
|
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) {
|
function formatDate(date) {
|
||||||
const y = date.getUTCFullYear();
|
const y = date.getUTCFullYear();
|
||||||
@@ -240,8 +242,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchDayEvents(date) {
|
async function fetchDayEvents(date) {
|
||||||
const res = await fetch(`/analytics-data/${date}.jsonl`, {
|
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",
|
method: "GET",
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
});
|
});
|
||||||
if (res.status === 404) {
|
if (res.status === 404) {
|
||||||
return [];
|
return [];
|
||||||
@@ -397,7 +402,17 @@
|
|||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error("密码错误");
|
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(AUTH_KEY, "1");
|
||||||
|
sessionStorage.setItem(DATA_BASE_URL_KEY, dataBaseUrl);
|
||||||
|
sessionStorage.setItem(AUTH_TOKEN_KEY, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
function enterDashboard() {
|
function enterDashboard() {
|
||||||
@@ -407,6 +422,8 @@
|
|||||||
|
|
||||||
function exitDashboard() {
|
function exitDashboard() {
|
||||||
sessionStorage.removeItem(AUTH_KEY);
|
sessionStorage.removeItem(AUTH_KEY);
|
||||||
|
sessionStorage.removeItem(DATA_BASE_URL_KEY);
|
||||||
|
sessionStorage.removeItem(AUTH_TOKEN_KEY);
|
||||||
dashboard.classList.add("hidden");
|
dashboard.classList.add("hidden");
|
||||||
loginCard.classList.remove("hidden");
|
loginCard.classList.remove("hidden");
|
||||||
}
|
}
|
||||||
@@ -436,6 +453,10 @@
|
|||||||
startDateInput.value = formatDate(start);
|
startDateInput.value = formatDate(start);
|
||||||
endDateInput.value = formatDate(today);
|
endDateInput.value = formatDate(today);
|
||||||
if (sessionStorage.getItem(AUTH_KEY) === "1") {
|
if (sessionStorage.getItem(AUTH_KEY) === "1") {
|
||||||
|
if (!sessionStorage.getItem(AUTH_TOKEN_KEY)) {
|
||||||
|
exitDashboard();
|
||||||
|
return;
|
||||||
|
}
|
||||||
enterDashboard();
|
enterDashboard();
|
||||||
loadData();
|
loadData();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ deploy/
|
|||||||
├── build-prod-image.sh
|
├── build-prod-image.sh
|
||||||
├── docker-compose.prod.yml
|
├── docker-compose.prod.yml
|
||||||
├── .env.prod.example
|
├── .env.prod.example
|
||||||
|
├── data/
|
||||||
|
│ └── analytics/
|
||||||
|
├── static/
|
||||||
|
│ └── releases/
|
||||||
└── README.md
|
└── README.md
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -80,6 +84,18 @@ cp deploy/.env.prod.example deploy/.env.prod
|
|||||||
|
|
||||||
### 2) 启动常驻服务
|
### 2) 启动常驻服务
|
||||||
|
|
||||||
|
确保 analytics 数据目录已存在(用于持久化 `SOCIAL_ANALYTICS__DATA_PATH`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p deploy/data/analytics
|
||||||
|
```
|
||||||
|
|
||||||
|
如果服务器启用了更严格权限策略(含 rootless Docker 或自定义容器运行用户),请确保该目录对容器运行用户可写:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod 775 deploy/data/analytics
|
||||||
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --env-file deploy/.env.prod -f deploy/docker-compose.prod.yml up -d redis web worker-agent worker-general scheduler
|
docker compose --env-file deploy/.env.prod -f deploy/docker-compose.prod.yml up -d redis web worker-agent worker-general scheduler
|
||||||
```
|
```
|
||||||
@@ -122,6 +138,15 @@ docker compose --env-file deploy/.env.prod -f deploy/docker-compose.prod.yml up
|
|||||||
|
|
||||||
在 nginx 增加静态目录映射:`location /releases/ { alias /你的项目绝对路径/deploy/static/releases/; }`,这样 `https://你的域名/releases/xxx.apk` 可直接下载安装包。并在 `deploy/.env.prod` 设置 `SOCIAL_APP_VERSION__DOWNLOAD_BASE_URL=https://你的域名` 与 `SOCIAL_APP_VERSION__RELEASE_PATH_PREFIX=releases`,确保 `check-updates` 返回的 `download_url` 指向该路径。
|
在 nginx 增加静态目录映射:`location /releases/ { alias /你的项目绝对路径/deploy/static/releases/; }`,这样 `https://你的域名/releases/xxx.apk` 可直接下载安装包。并在 `deploy/.env.prod` 设置 `SOCIAL_APP_VERSION__DOWNLOAD_BASE_URL=https://你的域名` 与 `SOCIAL_APP_VERSION__RELEASE_PATH_PREFIX=releases`,确保 `check-updates` 返回的 `download_url` 指向该路径。
|
||||||
|
|
||||||
|
## Analytics 数据持久化
|
||||||
|
|
||||||
|
- `SOCIAL_ANALYTICS__DATA_PATH` 默认值是 `backend/data/analytics`。
|
||||||
|
- 生产编排已挂载:`deploy/data/analytics -> /app/backend/data/analytics`。
|
||||||
|
- `web` 和 `worker-general` 共用同一挂载目录,避免写入与读取不一致。
|
||||||
|
- 若你在 `.env.prod` 覆盖了 `SOCIAL_ANALYTICS__DATA_PATH`,请同步调整 `docker-compose.prod.yml` 的挂载目标路径。
|
||||||
|
- analytics 挂载为独立目录,不会影响 `deploy/static/releases -> /app/deploy/static/releases` 的 APK 发布链路。
|
||||||
|
- 建议将 `deploy/data/analytics` 纳入日常备份策略,避免宿主机磁盘故障导致数据丢失。
|
||||||
|
|
||||||
## Android APK 打包
|
## Android APK 打包
|
||||||
|
|
||||||
打包 Android APK 时需指定后端地址:
|
打包 Android APK 时需指定后端地址:
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ../logs:/app/logs
|
- ../logs:/app/logs
|
||||||
- ./static/releases:/app/deploy/static/releases:ro
|
- ./static/releases:/app/deploy/static/releases:ro
|
||||||
|
- ./data/analytics:/app/backend/data/analytics
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
[
|
[
|
||||||
@@ -101,6 +102,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ../logs:/app/logs
|
- ../logs:/app/logs
|
||||||
- ./static/releases:/app/deploy/static/releases:ro
|
- ./static/releases:/app/deploy/static/releases:ro
|
||||||
|
- ./data/analytics:/app/backend/data/analytics
|
||||||
|
|
||||||
scheduler:
|
scheduler:
|
||||||
image: ${SOCIAL_BACKEND_IMAGE:-social-app-backend:prod}
|
image: ${SOCIAL_BACKEND_IMAGE:-social-app-backend:prod}
|
||||||
|
|||||||
@@ -54,6 +54,28 @@
|
|||||||
"release_notes": "\u91cd\u6784 Reminder Notification \u7cfb\u7edf\u5e76\u66f4\u65b0\u5e94\u7528\u5305\u540d",
|
"release_notes": "\u91cd\u6784 Reminder Notification \u7cfb\u7edf\u5e76\u66f4\u65b0\u5e94\u7528\u5305\u540d",
|
||||||
"file_size": 61813288,
|
"file_size": 61813288,
|
||||||
"sha256": "899a3ae89f9931d9ef1bf5354eeae75d4b5a81ecce83f05a2820c95ff6771e55"
|
"sha256": "899a3ae89f9931d9ef1bf5354eeae75d4b5a81ecce83f05a2820c95ff6771e55"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"platform": "android",
|
||||||
|
"channel": "release",
|
||||||
|
"version_name": "0.1.2",
|
||||||
|
"version_code": 6,
|
||||||
|
"min_supported_version_code": 6,
|
||||||
|
"file_name": "social-app-android-v0.1.2+6-release.apk",
|
||||||
|
"release_notes": "\u90e8\u7f72\u914d\u7f6e\u4e0e\u7a33\u5b9a\u6027\u4f18\u5316",
|
||||||
|
"file_size": 62506070,
|
||||||
|
"sha256": "96f2b4f003540d83b70cd33c51465b4b71615d6fc35b735c01e409e9e1b1660d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"platform": "android",
|
||||||
|
"channel": "release",
|
||||||
|
"version_name": "0.1.2",
|
||||||
|
"version_code": 7,
|
||||||
|
"min_supported_version_code": 7,
|
||||||
|
"file_name": "social-app-android-v0.1.2+7-release.apk",
|
||||||
|
"release_notes": "\u5207\u6362\u4e0b\u8f7d\u57df\u540d\u4e3a 115.190.63.157",
|
||||||
|
"file_size": 62506070,
|
||||||
|
"sha256": "261352e4731121659152d0060b4524053bc90412d440a49348d2215ec098d5d6"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -545,8 +545,8 @@ app.mount("/analytics", StaticFiles(directory="web/dist", html=True), name="anal
|
|||||||
1. 前端登录页输入密码
|
1. 前端登录页输入密码
|
||||||
2. 调用 `POST /api/v1/analytics/login` 验证
|
2. 调用 `POST /api/v1/analytics/login` 验证
|
||||||
3. 后端读取 `.env` 中 `ANALYTICS_PASSWORD` 验证
|
3. 后端读取 `.env` 中 `ANALYTICS_PASSWORD` 验证
|
||||||
4. 验证成功返回 HMAC Token(5分钟有效),前端存 sessionStorage
|
4. 验证成功返回 HMAC Token(5分钟有效)和数据读取基地址,前端存 sessionStorage
|
||||||
5. 后续请求带 Token,后端验证
|
5. 后续请求带 Bearer Token,后端验证后返回对应日期 JSONL 内容
|
||||||
|
|
||||||
### 6.5 页面设计
|
### 6.5 页面设计
|
||||||
|
|
||||||
@@ -569,9 +569,9 @@ app.mount("/analytics", StaticFiles(directory="web/dist", html=True), name="anal
|
|||||||
|
|
||||||
### 6.6 数据读取
|
### 6.6 数据读取
|
||||||
|
|
||||||
- 前端通过 `GET /api/v1/analytics/summary` 获取聚合数据
|
- 前端登录成功后获取 `data_base_url`(当前为 `/api/v1/analytics/data`)
|
||||||
- 后端解析 `backend/data/analytics/*.jsonl` 文件并聚合
|
- 前端按日期请求 `GET /api/v1/analytics/data/{YYYY-MM-DD}` 获取 JSONL 文本并在页面聚合
|
||||||
- 提供 `GET /api/v1/analytics/daily` 等查询接口
|
- 后端读取 `backend/data/analytics/*.jsonl` 原始数据返回
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -601,7 +601,9 @@ SOCIAL_ANALYTICS__PASSWORD=your-secure-password
|
|||||||
**响应(成功):**
|
**响应(成功):**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"token": "jwt-token-here"
|
"success": true,
|
||||||
|
"token": "signed-token",
|
||||||
|
"data_base_url": "/api/v1/analytics/data"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ data: <json>
|
|||||||
"type": "STEP_STARTED",
|
"type": "STEP_STARTED",
|
||||||
"threadId": "...",
|
"threadId": "...",
|
||||||
"runId": "...",
|
"runId": "...",
|
||||||
"stepName": "router" | "worker" | "memory"
|
"stepName": "router" | "worker"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -121,7 +121,7 @@ data: <json>
|
|||||||
"type": "STEP_FINISHED",
|
"type": "STEP_FINISHED",
|
||||||
"threadId": "...",
|
"threadId": "...",
|
||||||
"runId": "...",
|
"runId": "...",
|
||||||
"stepName": "router" | "worker" | "memory"
|
"stepName": "router" | "worker"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ data: <json>
|
|||||||
"messageId": "...",
|
"messageId": "...",
|
||||||
"toolCallId": "...",
|
"toolCallId": "...",
|
||||||
"toolCallName": "...",
|
"toolCallName": "...",
|
||||||
"stage": "worker" | "memory"
|
"stage": "worker"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -152,7 +152,7 @@ data: <json>
|
|||||||
"toolCallId": "...",
|
"toolCallId": "...",
|
||||||
"toolCallName": "...",
|
"toolCallName": "...",
|
||||||
"args": {},
|
"args": {},
|
||||||
"stage": "worker" | "memory"
|
"stage": "worker"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -166,7 +166,7 @@ data: <json>
|
|||||||
"messageId": "...",
|
"messageId": "...",
|
||||||
"toolCallId": "...",
|
"toolCallId": "...",
|
||||||
"toolCallName": "...",
|
"toolCallName": "...",
|
||||||
"stage": "worker" | "memory"
|
"stage": "worker"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -179,7 +179,7 @@ data: <json>
|
|||||||
"runId": "...",
|
"runId": "...",
|
||||||
"messageId": "...",
|
"messageId": "...",
|
||||||
"role": "tool",
|
"role": "tool",
|
||||||
"stage": "worker" | "memory",
|
"stage": "worker",
|
||||||
"tool_name": "...",
|
"tool_name": "...",
|
||||||
"tool_call_id": "...",
|
"tool_call_id": "...",
|
||||||
"tool_call_args": {},
|
"tool_call_args": {},
|
||||||
@@ -239,7 +239,7 @@ SSE 协议中的工具名字段保持后端原样,不做服务端翻译:
|
|||||||
"runId": "...",
|
"runId": "...",
|
||||||
"messageId": "...",
|
"messageId": "...",
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"stage": "worker" | "memory",
|
"stage": "worker",
|
||||||
"status": "success" | "partial_success" | "failed",
|
"status": "success" | "partial_success" | "failed",
|
||||||
"answer": "...",
|
"answer": "...",
|
||||||
"key_points": [],
|
"key_points": [],
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# Analytics Events And Dashboard Protocol
|
||||||
|
|
||||||
|
本文档定义 analytics 采集与查询协议,覆盖移动端/前端事件上报、Dashboard 登录与按日数据读取。
|
||||||
|
|
||||||
|
## 1. Scope
|
||||||
|
|
||||||
|
- 事件写入接口:`POST /api/v1/analytics/events`
|
||||||
|
- Dashboard 登录接口:`POST /api/v1/analytics/login`
|
||||||
|
- 按日数据读取接口:`GET /api/v1/analytics/data/{YYYY-MM-DD}`
|
||||||
|
- 存储介质:服务端 JSONL 文件(由 `SOCIAL_ANALYTICS__DATA_PATH` 指定)
|
||||||
|
|
||||||
|
## 2. Event Ingestion
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
- Method: `POST`
|
||||||
|
- Path: `/api/v1/analytics/events`
|
||||||
|
|
||||||
|
### Request Body
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"client_time": "2026-04-02T10:00:00Z",
|
||||||
|
"sdk_version": "1.0.0",
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"event_id": "evt-1",
|
||||||
|
"event_type": "page_view",
|
||||||
|
"timestamp": "2026-04-02T10:00:00Z",
|
||||||
|
"user_id": "user-1",
|
||||||
|
"device_id": "device-1",
|
||||||
|
"session_id": "session-1",
|
||||||
|
"platform": "android",
|
||||||
|
"app_version": "0.1.2",
|
||||||
|
"app_build": "5",
|
||||||
|
"env": "prod",
|
||||||
|
"page_name": "home",
|
||||||
|
"trace_id": "trace-1",
|
||||||
|
"request_id": "request-1",
|
||||||
|
"attributes": {
|
||||||
|
"from": "banner"
|
||||||
|
},
|
||||||
|
"metrics": {
|
||||||
|
"latency_ms": 123
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"network_type": "wifi",
|
||||||
|
"os_version": "Android 14",
|
||||||
|
"device_model": "Pixel",
|
||||||
|
"locale": "zh-CN",
|
||||||
|
"timezone": "Asia/Shanghai"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response Body
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"received": 1,
|
||||||
|
"queued": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
语义说明:
|
||||||
|
|
||||||
|
- `received` 表示本次接收的事件条数。
|
||||||
|
- `queued=true` 表示事件已被服务端接收并触发写入流程;该字段不承诺具体调度方式(请求内写入或队列写入)。
|
||||||
|
|
||||||
|
## 3. Dashboard Login
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
- Method: `POST`
|
||||||
|
- Path: `/api/v1/analytics/login`
|
||||||
|
|
||||||
|
### Request Body
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"password": "<analytics-password>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response Body
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data_base_url": "/api/v1/analytics/data",
|
||||||
|
"token": "<bearer-token>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
语义说明:
|
||||||
|
|
||||||
|
- `token` 为短时效 Bearer Token。
|
||||||
|
- `data_base_url` 为前端读取按日数据的基路径。
|
||||||
|
|
||||||
|
## 4. Read Daily Data
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
- Method: `GET`
|
||||||
|
- Path: `/api/v1/analytics/data/{date}`
|
||||||
|
- Header: `Authorization: Bearer <token>`
|
||||||
|
- `date` 格式要求:`YYYY-MM-DD`
|
||||||
|
|
||||||
|
### Success Response
|
||||||
|
|
||||||
|
- Status: `200`
|
||||||
|
- Content-Type: `application/x-ndjson`
|
||||||
|
- Body: 当日 JSONL 文本(每行一个事件 JSON)
|
||||||
|
|
||||||
|
## 5. Error Contract
|
||||||
|
|
||||||
|
错误响应必须遵循 RFC7807 + 稳定错误码规范,错误码注册表见:
|
||||||
|
|
||||||
|
- `docs/protocols/common/http-error-codes.md`
|
||||||
|
|
||||||
|
analytics 相关错误码包括:
|
||||||
|
|
||||||
|
- `ANALYTICS_LOGIN_PASSWORD_INVALID`
|
||||||
|
- `ANALYTICS_AUTH_HEADER_MISSING`
|
||||||
|
- `ANALYTICS_AUTH_SCHEME_INVALID`
|
||||||
|
- `ANALYTICS_AUTH_TOKEN_MISSING`
|
||||||
|
- `ANALYTICS_TOKEN_MALFORMED`
|
||||||
|
- `ANALYTICS_TOKEN_SIGNATURE_INVALID`
|
||||||
|
- `ANALYTICS_TOKEN_PAYLOAD_INVALID`
|
||||||
|
- `ANALYTICS_TOKEN_EXPIRED`
|
||||||
|
- `ANALYTICS_DATE_FORMAT_INVALID`
|
||||||
|
- `ANALYTICS_FILE_NOT_FOUND`
|
||||||
|
|
||||||
|
## 6. Storage Contract
|
||||||
|
|
||||||
|
- 后端写入路径由 `SOCIAL_ANALYTICS__DATA_PATH` 控制,默认:`backend/data/analytics`。
|
||||||
|
- 按日存储文件名:`{YYYY-MM-DD}.jsonl`。
|
||||||
|
- 生产部署推荐将宿主机目录挂载到容器内该路径,避免容器重建导致数据丢失。
|
||||||
|
|
||||||
|
## 7. Compatibility Strategy
|
||||||
|
|
||||||
|
- 策略:**backward-compatible additive change**。
|
||||||
|
- 允许在事件对象中新增可选字段(`attributes` / `metrics` / `context` 内部字段)。
|
||||||
|
- 不允许移除现有必填字段或修改既有字段语义;若必须变更,需新增版本化协议文档并提供迁移说明。
|
||||||
@@ -84,6 +84,16 @@ When creating/modifying/deprecating any code, this table must be updated in the
|
|||||||
| `AUTH_REFRESH_TOKEN_MISSING` | auth | 401 | Refresh token is missing for logout/refresh |
|
| `AUTH_REFRESH_TOKEN_MISSING` | auth | 401 | Refresh token is missing for logout/refresh |
|
||||||
| `AUTH_USER_NOT_FOUND` | auth | 404 | User lookup by phone returns no match |
|
| `AUTH_USER_NOT_FOUND` | auth | 404 | User lookup by phone returns no match |
|
||||||
| `AUTH_UNAUTHORIZED` | auth | 401 | Authorization header or token is invalid |
|
| `AUTH_UNAUTHORIZED` | auth | 401 | Authorization header or token is invalid |
|
||||||
|
| `ANALYTICS_LOGIN_PASSWORD_INVALID` | analytics | 401 | Analytics dashboard password is invalid |
|
||||||
|
| `ANALYTICS_AUTH_HEADER_MISSING` | analytics | 401 | Authorization header is missing when reading analytics data |
|
||||||
|
| `ANALYTICS_AUTH_SCHEME_INVALID` | analytics | 401 | Authorization scheme is invalid; Bearer token required |
|
||||||
|
| `ANALYTICS_AUTH_TOKEN_MISSING` | analytics | 401 | Bearer token is missing |
|
||||||
|
| `ANALYTICS_TOKEN_MALFORMED` | analytics | 401 | Analytics token format is malformed |
|
||||||
|
| `ANALYTICS_TOKEN_SIGNATURE_INVALID` | analytics | 401 | Analytics token signature verification failed |
|
||||||
|
| `ANALYTICS_TOKEN_PAYLOAD_INVALID` | analytics | 401 | Analytics token payload cannot be parsed |
|
||||||
|
| `ANALYTICS_TOKEN_EXPIRED` | analytics | 401 | Analytics token is expired |
|
||||||
|
| `ANALYTICS_DATE_FORMAT_INVALID` | analytics | 400 | Analytics date must use YYYY-MM-DD format |
|
||||||
|
| `ANALYTICS_FILE_NOT_FOUND` | analytics | 404 | Analytics day file does not exist |
|
||||||
| `JWT_VERIFIER_NOT_CONFIGURED` | auth | 503 | JWT verifier configuration is missing |
|
| `JWT_VERIFIER_NOT_CONFIGURED` | auth | 503 | JWT verifier configuration is missing |
|
||||||
| `AUTOMATION_JOB_LIMIT_EXCEEDED` | automation_jobs | 400 | User-created automation jobs exceed allowed limit |
|
| `AUTOMATION_JOB_LIMIT_EXCEEDED` | automation_jobs | 400 | User-created automation jobs exceed allowed limit |
|
||||||
| `AUTOMATION_SYSTEM_JOB_MODIFICATION_FORBIDDEN` | automation_jobs | 403 | System bootstrap job cannot be modified |
|
| `AUTOMATION_SYSTEM_JOB_MODIFICATION_FORBIDDEN` | automation_jobs | 403 | System bootstrap job cannot be modified |
|
||||||
@@ -150,29 +160,13 @@ When creating/modifying/deprecating any code, this table must be updated in the
|
|||||||
| `FRIENDSHIP_NOT_FOUND` | friendships | 404 | Friendship record not found |
|
| `FRIENDSHIP_NOT_FOUND` | friendships | 404 | Friendship record not found |
|
||||||
| `FRIENDSHIP_REMOVE_REQUIRES_ACCEPTED` | friendships | 400 | Only accepted friendships can be removed |
|
| `FRIENDSHIP_REMOVE_REQUIRES_ACCEPTED` | friendships | 400 | Only accepted friendships can be removed |
|
||||||
|
|
||||||
## Registry Coverage Check Script
|
## Registry Coverage Check
|
||||||
|
|
||||||
Use the checker script to ensure this registry and frontend code mapping stay aligned:
|
当前仓库未内置自动校验脚本,维护流程按以下约束执行:
|
||||||
|
|
||||||
```bash
|
- 更新本文件错误码时,同步检查前端映射文件:`apps/lib/data/network/error_code_mapper.dart`
|
||||||
python3 scripts/check_error_code_registry.py
|
- 任何新增/变更/废弃错误码必须在同一 PR 中完成「协议文档 + 前端映射 + 后端返回码」三方对齐
|
||||||
```
|
- 若后续补充自动校验脚本,需在本节追加命令与输出约定
|
||||||
|
|
||||||
Optional arguments:
|
|
||||||
|
|
||||||
- `--doc`: custom registry markdown path
|
|
||||||
- `--mapper`: custom frontend mapper path (default: `apps/lib/core/network/error_code_mapper.dart`)
|
|
||||||
|
|
||||||
Output always includes three result groups:
|
|
||||||
|
|
||||||
- doc has code but frontend has no mapping
|
|
||||||
- frontend maps code but doc has no such code
|
|
||||||
- duplicate codes
|
|
||||||
|
|
||||||
Exit code policy:
|
|
||||||
|
|
||||||
- `0`: no inconsistency found
|
|
||||||
- non-`0`: at least one inconsistency found or input path invalid
|
|
||||||
|
|
||||||
## Agent Error Code Set
|
## Agent Error Code Set
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ scheduler computation, and Flutter settings pages.
|
|||||||
- `owner_id`: UUID
|
- `owner_id`: UUID
|
||||||
- `title`: string
|
- `title`: string
|
||||||
- `bootstrap_key`: string | null (引导配置键,用于标识预设任务模板)
|
- `bootstrap_key`: string | null (引导配置键,用于标识预设任务模板)
|
||||||
|
- `is_system`: boolean (`bootstrap_key != null` 时为 `true`,只读派生字段)
|
||||||
- `config`: object
|
- `config`: object
|
||||||
- `input_template`: string
|
- `input_template`: string
|
||||||
- `enabled_tools`: string[]
|
- `enabled_tools`: string[]
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ Base URL: `/api/v1/inbox/messages`
|
|||||||
"phone": "string | null"
|
"phone": "string | null"
|
||||||
},
|
},
|
||||||
"summary": "string",
|
"summary": "string",
|
||||||
"permission": "int (1=view, 4=edit, 8=invite)",
|
"permission": "int (1=view, 2=invite, 4=edit, 8=delete, 15=owner)",
|
||||||
"action": "pending"
|
"action": "pending"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -141,7 +141,6 @@ Base URL: `/api/v1/inbox/messages`
|
|||||||
"message_type": "InboxMessageType",
|
"message_type": "InboxMessageType",
|
||||||
"schedule_item_id": "uuid | null",
|
"schedule_item_id": "uuid | null",
|
||||||
"friendship_id": "uuid | null",
|
"friendship_id": "uuid | null",
|
||||||
"group_id": "uuid | null",
|
|
||||||
"content": "CalendarInviteContent | CalendarUpdateContent | CalendarDeleteContent | FriendshipContent | null",
|
"content": "CalendarInviteContent | CalendarUpdateContent | CalendarDeleteContent | FriendshipContent | null",
|
||||||
"is_read": "boolean",
|
"is_read": "boolean",
|
||||||
"status": "InboxMessageStatus",
|
"status": "InboxMessageStatus",
|
||||||
|
|||||||
@@ -4,6 +4,18 @@
|
|||||||
|
|
||||||
Defines the backend/frontend data contract for `/api/v1/todos`.
|
Defines the backend/frontend data contract for `/api/v1/todos`.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| POST | `/api/v1/todos` | Create todo |
|
||||||
|
| GET | `/api/v1/todos` | List todos (supports status/priority filter) |
|
||||||
|
| GET | `/api/v1/todos/{todo_id}` | Get todo detail |
|
||||||
|
| PATCH | `/api/v1/todos/reorder` | Batch reorder todos |
|
||||||
|
| PATCH | `/api/v1/todos/{todo_id}` | Update todo |
|
||||||
|
| POST | `/api/v1/todos/{todo_id}/complete` | Mark todo completed |
|
||||||
|
| DELETE | `/api/v1/todos/{todo_id}` | Delete todo |
|
||||||
|
|
||||||
## Field Definitions
|
## Field Definitions
|
||||||
|
|
||||||
- `id`: string (UUID)
|
- `id`: string (UUID)
|
||||||
@@ -31,6 +43,24 @@ Defines the backend/frontend data contract for `/api/v1/todos`.
|
|||||||
- optional: `title`, `description`, `priority`, `order`, `status`, `schedule_item_ids`
|
- optional: `title`, `description`, `priority`, `order`, `status`, `schedule_item_ids`
|
||||||
- `order` is interpreted inside the todo's final `priority` quadrant.
|
- `order` is interpreted inside the todo's final `priority` quadrant.
|
||||||
|
|
||||||
|
### List Todos (`GET /api/v1/todos`)
|
||||||
|
|
||||||
|
- query `status`: `pending | done | canceled` (optional)
|
||||||
|
- query `priority`: integer in `[1, 4]` (optional)
|
||||||
|
|
||||||
|
### Reorder Todos (`PATCH /api/v1/todos/reorder`)
|
||||||
|
|
||||||
|
- body: `{ items: Array<{ id, priority, order }> }`
|
||||||
|
- each item requires:
|
||||||
|
- `id`: UUID
|
||||||
|
- `priority`: integer in `[1, 4]`
|
||||||
|
- `order`: integer `>= 0`
|
||||||
|
|
||||||
|
### Complete Todo (`POST /api/v1/todos/{todo_id}/complete`)
|
||||||
|
|
||||||
|
- body: `{}`
|
||||||
|
- effect: sets todo status to `done` and updates `completed_at`
|
||||||
|
|
||||||
## Ordering Rules
|
## Ordering Rules
|
||||||
|
|
||||||
- Todo list API returns items sorted by `priority ASC`, then `order ASC`.
|
- Todo list API returns items sorted by `priority ASC`, then `order ASC`.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ Base URL: `/api/v1/users`
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GET | `/me` | 获取当前用户信息 |
|
| GET | `/me` | 获取当前用户信息 |
|
||||||
| PATCH | `/me` | 更新当前用户信息 |
|
| PATCH | `/me` | 更新当前用户信息 |
|
||||||
|
| POST | `/me/avatar` | 上传头像图片并更新头像地址 |
|
||||||
| POST | `/search` | 搜索用户 |
|
| POST | `/search` | 搜索用户 |
|
||||||
| GET | `/{user_id}` | 获取指定用户信息 |
|
| GET | `/{user_id}` | 获取指定用户信息 |
|
||||||
|
|
||||||
@@ -106,7 +107,31 @@ Base URL: `/api/v1/users`
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4) GET `/{user_id}`
|
## 4) POST `/me/avatar`
|
||||||
|
|
||||||
|
上传头像(`multipart/form-data`)。
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `file` | file | 是 | 图片文件(`image/jpeg` / `image/png` / `image/webp`) |
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"url": "https://..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
- 上传成功后后端会同步更新当前用户 `avatar_url`。
|
||||||
|
- 文件大小上限由后端配置 `storage.avatar.max_size_mb` 控制。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) GET `/{user_id}`
|
||||||
|
|
||||||
获取指定用户信息。
|
获取指定用户信息。
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user