feat: 添加 Analytics 分析功能(行为追踪、错误码、协议更新)
This commit is contained in:
@@ -295,6 +295,7 @@ deploy/.env.prod
|
||||
.history
|
||||
/logs/
|
||||
backend/logs/
|
||||
backend/data/analytics/
|
||||
*.tar.gz
|
||||
*.tar
|
||||
# Docker volumes (local data)
|
||||
@@ -307,6 +308,9 @@ infra/docker/supabase/volumes/storage/
|
||||
# .opencode/ is now tracked - see .opencode/.gitignore for exclusions
|
||||
.opencode/opencode.json.old
|
||||
|
||||
# Agents and skills
|
||||
.agents/
|
||||
|
||||
# Local git worktrees
|
||||
.worktrees/
|
||||
worktrees/
|
||||
|
||||
@@ -79,6 +79,26 @@ String? mapErrorCodeToL10nKey(
|
||||
return 'errorNotFound';
|
||||
case 'AUTH_UNAUTHORIZED':
|
||||
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':
|
||||
return 'errorServer';
|
||||
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_history_repository.dart';
|
||||
import 'package:social_app/core/chat/chat_timeline_reconciler.dart';
|
||||
import 'package:social_app/core/analytics/tracker.dart';
|
||||
import 'package:social_app/core/l10n/l10n.dart';
|
||||
import 'chat_bloc_recovery_utils.dart';
|
||||
|
||||
@@ -20,6 +21,13 @@ part 'chat_bloc_send.dart';
|
||||
part 'chat_bloc_history.dart';
|
||||
part 'chat_bloc_attachments.dart';
|
||||
|
||||
typedef ChatCompletedCallback =
|
||||
void Function({
|
||||
required String conversationId,
|
||||
required int messageCount,
|
||||
required int responseTimeMs,
|
||||
});
|
||||
|
||||
class ChatState implements ChatOrchestratorState {
|
||||
@override
|
||||
final List<ChatListItem> items;
|
||||
@@ -115,12 +123,14 @@ class ChatBloc extends Cubit<ChatState> implements ChatOrchestrator {
|
||||
required ChatApi chatApi,
|
||||
ChatHistoryRepository? historyRepository,
|
||||
Future<void> Function()? onCalendarMutated,
|
||||
ChatCompletedCallback? onChatCompleted,
|
||||
Duration recoveryPollInterval = const Duration(milliseconds: 700),
|
||||
Duration recoveryTimeout = const Duration(seconds: 20),
|
||||
}) : _service =
|
||||
service ??
|
||||
AgUiService(chatApi: chatApi, historyRepository: historyRepository),
|
||||
_onCalendarMutated = onCalendarMutated,
|
||||
_onChatCompleted = onChatCompleted,
|
||||
_recoveryPollInterval = recoveryPollInterval,
|
||||
_recoveryTimeout = recoveryTimeout,
|
||||
super(const ChatState()) {
|
||||
@@ -129,9 +139,14 @@ class ChatBloc extends Cubit<ChatState> implements ChatOrchestrator {
|
||||
|
||||
final AgUiService _service;
|
||||
final Future<void> Function()? _onCalendarMutated;
|
||||
final ChatCompletedCallback? _onChatCompleted;
|
||||
final Duration _recoveryPollInterval;
|
||||
final Duration _recoveryTimeout;
|
||||
String? _activeUserId;
|
||||
DateTime? _activeRunStartedAt;
|
||||
DateTime? _activeRunFirstResponseAt;
|
||||
String? _activeRunId;
|
||||
String? _activeThreadId;
|
||||
int _sessionEpoch = 0;
|
||||
final Map<String, Uint8List> _attachmentPreviewCache = <String, Uint8List>{};
|
||||
final Map<String, Future<Uint8List?>> _attachmentPreviewInflight =
|
||||
@@ -259,4 +274,54 @@ class ChatBloc extends Cubit<ChatState> implements ChatOrchestrator {
|
||||
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) {
|
||||
switch (event.type) {
|
||||
case AgUiEventType.runStarted:
|
||||
final runStartedEvent = event as RunStartedEvent;
|
||||
_recordRunStarted(
|
||||
runId: runStartedEvent.runId,
|
||||
threadId: runStartedEvent.threadId,
|
||||
);
|
||||
emit(
|
||||
state.copyWith(
|
||||
isSending: false,
|
||||
@@ -17,11 +22,14 @@ extension _ChatBlocEvents on ChatBloc {
|
||||
),
|
||||
);
|
||||
case AgUiEventType.runFinished:
|
||||
_trackChatCompleted();
|
||||
_clearRunMetrics();
|
||||
emit(
|
||||
_resetRunState().copyWith(items: _removeToolCallItems(state.items)),
|
||||
);
|
||||
case AgUiEventType.runError:
|
||||
final errorEvent = event as RunErrorEvent;
|
||||
_clearRunMetrics();
|
||||
final isCanceledByUser = errorEvent.code == 'RUN_CANCELED';
|
||||
emit(
|
||||
_resetRunState(
|
||||
@@ -72,6 +80,7 @@ extension _ChatBlocEvents on ChatBloc {
|
||||
}
|
||||
|
||||
void _handleTextMessageEnd(TextMessageEndEvent event) {
|
||||
_recordRunFirstResponse();
|
||||
final timestamp = DateTime.now();
|
||||
final items = _updateOrAddMessage(
|
||||
state.items,
|
||||
|
||||
@@ -12,6 +12,7 @@ import '../../../../app/di/injection.dart';
|
||||
import '../../../../app/router/app_route_observer.dart';
|
||||
import '../../../../app/router/app_routes.dart';
|
||||
import '../../../../core/l10n/l10n.dart';
|
||||
import '../../../../core/analytics/tracker.dart';
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
import '../../../../core/inbox/inbox_sync_store.dart';
|
||||
import '../../../chat/presentation/bloc/chat_bloc.dart';
|
||||
@@ -98,6 +99,8 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
int _previousItemCount = 0;
|
||||
bool _previousIsLoadingHistory = false;
|
||||
bool _routeAwareSubscribed = false;
|
||||
late final DateTime _pageEnteredAt;
|
||||
int _pageClickCount = 0;
|
||||
double? _historyViewportPixels;
|
||||
double? _historyViewportMaxExtent;
|
||||
final GlobalKey<HomeInputHostState> _inputHostKey =
|
||||
@@ -121,6 +124,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
duration: const Duration(milliseconds: _rippleDurationMs),
|
||||
);
|
||||
_selectedImages.addAll(widget.initialSelectedImages);
|
||||
_pageEnteredAt = DateTime.now();
|
||||
final initialUserId = widget.initialUserId?.trim();
|
||||
if (initialUserId != null && initialUserId.isNotEmpty) {
|
||||
unawaited(_chatBloc.switchUser(initialUserId));
|
||||
@@ -148,6 +152,14 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
final stayDurationMs = DateTime.now()
|
||||
.difference(_pageEnteredAt)
|
||||
.inMilliseconds;
|
||||
AnalyticsTracker.instance.trackPageView(
|
||||
pageName: 'home',
|
||||
stayDurationMs: stayDurationMs,
|
||||
clickCount: _pageClickCount,
|
||||
);
|
||||
_messageController.dispose();
|
||||
_scrollController.removeListener(_handleScrollChanged);
|
||||
_scrollController.dispose();
|
||||
@@ -281,10 +293,18 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
Widget _buildHeader(BuildContext context) {
|
||||
return HomeFloatingHeader(
|
||||
unreadCount: _unreadCount,
|
||||
onTapSettings: () => context.push(AppRoutes.settingsMain),
|
||||
onTapCalendar: () =>
|
||||
context.push('${AppRoutes.calendarDayWeek}?from=home'),
|
||||
onTapMessages: () => context.push(AppRoutes.messageInviteList),
|
||||
onTapSettings: () {
|
||||
_trackClick('header_settings');
|
||||
context.push(AppRoutes.settingsMain);
|
||||
},
|
||||
onTapCalendar: () {
|
||||
_trackClick('header_calendar');
|
||||
context.push('${AppRoutes.calendarDayWeek}?from=home');
|
||||
},
|
||||
onTapMessages: () {
|
||||
_trackClick('header_messages');
|
||||
context.push(AppRoutes.messageInviteList);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -386,6 +406,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
child: HomeUnreadBadge(
|
||||
count: _chatUnreadBadgeCount,
|
||||
onTap: () {
|
||||
_trackClick('unread_badge');
|
||||
_scheduleAutoScroll(animated: true);
|
||||
if (mounted) {
|
||||
setState(() => _chatUnreadBadgeCount = 0);
|
||||
@@ -438,6 +459,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
}
|
||||
|
||||
Future<void> _onLoadMore(BuildContext context) async {
|
||||
_trackClick('history_load_more');
|
||||
final chatBloc = context.read<ChatBloc>();
|
||||
await _loadMoreHistoryPreservingViewport(chatBloc);
|
||||
}
|
||||
@@ -650,9 +672,18 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
isWaitingAgent: isWaitingAgent,
|
||||
messageController: _messageController,
|
||||
onTapPlus: _isRecording
|
||||
? () => _stopRecording(autoSendAfterTranscribe: false)
|
||||
: () => _showBottomSheet(context),
|
||||
onStopGenerating: _onStopGenerating,
|
||||
? () {
|
||||
_trackClick('record_stop');
|
||||
_stopRecording(autoSendAfterTranscribe: false);
|
||||
}
|
||||
: () {
|
||||
_trackClick('input_plus');
|
||||
_showBottomSheet(context);
|
||||
},
|
||||
onStopGenerating: () {
|
||||
_trackClick('stop_generating');
|
||||
_onStopGenerating();
|
||||
},
|
||||
onHoldToSpeakStart: _onHoldToSpeakStart,
|
||||
onHoldToSpeakEnd: _onHoldToSpeakEnd,
|
||||
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) {
|
||||
setState(() {
|
||||
_selectedImages.removeAt(index);
|
||||
|
||||
@@ -53,6 +53,7 @@ extension _HomeScreenInteractions on _HomeScreenState {
|
||||
});
|
||||
|
||||
try {
|
||||
_trackClick('send_message');
|
||||
await _chatBloc.sendMessage(content, images: images);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: social_app
|
||||
description: "Social App - A Flutter mobile application"
|
||||
publish_to: 'none'
|
||||
version: 0.1.2+5
|
||||
version: 0.1.2+7
|
||||
environment:
|
||||
sdk: ^3.10.7
|
||||
|
||||
|
||||
@@ -385,4 +385,86 @@ void main() {
|
||||
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 contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any, AsyncGenerator
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
@@ -8,6 +9,7 @@ from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
from starlette.staticfiles import StaticFiles
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from core.config.settings import config
|
||||
@@ -59,6 +61,14 @@ app.add_middleware(
|
||||
)
|
||||
app.include_router(mobile_router)
|
||||
|
||||
_analytics_web_dir = Path(__file__).resolve().parent / "v1" / "analytics" / "web"
|
||||
|
||||
app.mount(
|
||||
"/analytics",
|
||||
StaticFiles(directory=_analytics_web_dir, html=True),
|
||||
name="analytics-web",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Web application initialized",
|
||||
environment=config.runtime.environment,
|
||||
|
||||
@@ -48,12 +48,12 @@ class TaskiqQueueClient:
|
||||
def _select_queue_task(command: dict[str, object]) -> Any:
|
||||
from core.agentscope.runtime.tasks import (
|
||||
run_command_task_agent,
|
||||
run_command_task_automation,
|
||||
run_command_task_general,
|
||||
)
|
||||
|
||||
queue = str(command.get("queue", "agent")).strip().lower()
|
||||
if queue == "automation":
|
||||
return run_command_task_automation
|
||||
if queue == "general":
|
||||
return run_command_task_general
|
||||
return run_command_task_agent
|
||||
|
||||
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.http.errors import ApiProblemError
|
||||
from core.logging import get_logger
|
||||
from v1.analytics.schemas import (
|
||||
AnalyticsBatchRequest,
|
||||
@@ -15,6 +25,94 @@ 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)
|
||||
@@ -35,10 +133,42 @@ async def login(request: AnalyticsLoginRequest) -> AnalyticsLoginResponse:
|
||||
"""Analytics Dashboard 登录"""
|
||||
if request.password != config.analytics.password:
|
||||
logger.warning("Analytics login failed: invalid password")
|
||||
raise HTTPException(
|
||||
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)
|
||||
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):
|
||||
success: bool
|
||||
data_base_url: str
|
||||
token: str
|
||||
|
||||
@@ -204,6 +204,8 @@
|
||||
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();
|
||||
@@ -240,8 +242,11 @@
|
||||
}
|
||||
|
||||
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",
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
if (res.status === 404) {
|
||||
return [];
|
||||
@@ -397,7 +402,17 @@
|
||||
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() {
|
||||
@@ -407,6 +422,8 @@
|
||||
|
||||
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");
|
||||
}
|
||||
@@ -436,6 +453,10 @@
|
||||
startDateInput.value = formatDate(start);
|
||||
endDateInput.value = formatDate(today);
|
||||
if (sessionStorage.getItem(AUTH_KEY) === "1") {
|
||||
if (!sessionStorage.getItem(AUTH_TOKEN_KEY)) {
|
||||
exitDashboard();
|
||||
return;
|
||||
}
|
||||
enterDashboard();
|
||||
loadData();
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@ deploy/
|
||||
├── build-prod-image.sh
|
||||
├── docker-compose.prod.yml
|
||||
├── .env.prod.example
|
||||
├── data/
|
||||
│ └── analytics/
|
||||
├── static/
|
||||
│ └── releases/
|
||||
└── README.md
|
||||
```
|
||||
|
||||
@@ -80,6 +84,18 @@ cp deploy/.env.prod.example deploy/.env.prod
|
||||
|
||||
### 2) 启动常驻服务
|
||||
|
||||
确保 analytics 数据目录已存在(用于持久化 `SOCIAL_ANALYTICS__DATA_PATH`):
|
||||
|
||||
```bash
|
||||
mkdir -p deploy/data/analytics
|
||||
```
|
||||
|
||||
如果服务器启用了更严格权限策略(含 rootless Docker 或自定义容器运行用户),请确保该目录对容器运行用户可写:
|
||||
|
||||
```bash
|
||||
chmod 775 deploy/data/analytics
|
||||
```
|
||||
|
||||
```bash
|
||||
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` 指向该路径。
|
||||
|
||||
## 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 时需指定后端地址:
|
||||
|
||||
@@ -46,6 +46,7 @@ services:
|
||||
volumes:
|
||||
- ../logs:/app/logs
|
||||
- ./static/releases:/app/deploy/static/releases:ro
|
||||
- ./data/analytics:/app/backend/data/analytics
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
@@ -101,6 +102,7 @@ services:
|
||||
volumes:
|
||||
- ../logs:/app/logs
|
||||
- ./static/releases:/app/deploy/static/releases:ro
|
||||
- ./data/analytics:/app/backend/data/analytics
|
||||
|
||||
scheduler:
|
||||
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",
|
||||
"file_size": 61813288,
|
||||
"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. 前端登录页输入密码
|
||||
2. 调用 `POST /api/v1/analytics/login` 验证
|
||||
3. 后端读取 `.env` 中 `ANALYTICS_PASSWORD` 验证
|
||||
4. 验证成功返回 HMAC Token(5分钟有效),前端存 sessionStorage
|
||||
5. 后续请求带 Token,后端验证
|
||||
4. 验证成功返回 HMAC Token(5分钟有效)和数据读取基地址,前端存 sessionStorage
|
||||
5. 后续请求带 Bearer Token,后端验证后返回对应日期 JSONL 内容
|
||||
|
||||
### 6.5 页面设计
|
||||
|
||||
@@ -569,9 +569,9 @@ app.mount("/analytics", StaticFiles(directory="web/dist", html=True), name="anal
|
||||
|
||||
### 6.6 数据读取
|
||||
|
||||
- 前端通过 `GET /api/v1/analytics/summary` 获取聚合数据
|
||||
- 后端解析 `backend/data/analytics/*.jsonl` 文件并聚合
|
||||
- 提供 `GET /api/v1/analytics/daily` 等查询接口
|
||||
- 前端登录成功后获取 `data_base_url`(当前为 `/api/v1/analytics/data`)
|
||||
- 前端按日期请求 `GET /api/v1/analytics/data/{YYYY-MM-DD}` 获取 JSONL 文本并在页面聚合
|
||||
- 后端读取 `backend/data/analytics/*.jsonl` 原始数据返回
|
||||
|
||||
---
|
||||
|
||||
@@ -601,7 +601,9 @@ SOCIAL_ANALYTICS__PASSWORD=your-secure-password
|
||||
**响应(成功):**
|
||||
```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",
|
||||
"threadId": "...",
|
||||
"runId": "...",
|
||||
"stepName": "router" | "worker" | "memory"
|
||||
"stepName": "router" | "worker"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -121,7 +121,7 @@ data: <json>
|
||||
"type": "STEP_FINISHED",
|
||||
"threadId": "...",
|
||||
"runId": "...",
|
||||
"stepName": "router" | "worker" | "memory"
|
||||
"stepName": "router" | "worker"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -137,7 +137,7 @@ data: <json>
|
||||
"messageId": "...",
|
||||
"toolCallId": "...",
|
||||
"toolCallName": "...",
|
||||
"stage": "worker" | "memory"
|
||||
"stage": "worker"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -152,7 +152,7 @@ data: <json>
|
||||
"toolCallId": "...",
|
||||
"toolCallName": "...",
|
||||
"args": {},
|
||||
"stage": "worker" | "memory"
|
||||
"stage": "worker"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -166,7 +166,7 @@ data: <json>
|
||||
"messageId": "...",
|
||||
"toolCallId": "...",
|
||||
"toolCallName": "...",
|
||||
"stage": "worker" | "memory"
|
||||
"stage": "worker"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -179,7 +179,7 @@ data: <json>
|
||||
"runId": "...",
|
||||
"messageId": "...",
|
||||
"role": "tool",
|
||||
"stage": "worker" | "memory",
|
||||
"stage": "worker",
|
||||
"tool_name": "...",
|
||||
"tool_call_id": "...",
|
||||
"tool_call_args": {},
|
||||
@@ -239,7 +239,7 @@ SSE 协议中的工具名字段保持后端原样,不做服务端翻译:
|
||||
"runId": "...",
|
||||
"messageId": "...",
|
||||
"role": "assistant",
|
||||
"stage": "worker" | "memory",
|
||||
"stage": "worker",
|
||||
"status": "success" | "partial_success" | "failed",
|
||||
"answer": "...",
|
||||
"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_USER_NOT_FOUND` | auth | 404 | User lookup by phone returns no match |
|
||||
| `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 |
|
||||
| `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 |
|
||||
@@ -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_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
|
||||
python3 scripts/check_error_code_registry.py
|
||||
```
|
||||
|
||||
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
|
||||
- 更新本文件错误码时,同步检查前端映射文件:`apps/lib/data/network/error_code_mapper.dart`
|
||||
- 任何新增/变更/废弃错误码必须在同一 PR 中完成「协议文档 + 前端映射 + 后端返回码」三方对齐
|
||||
- 若后续补充自动校验脚本,需在本节追加命令与输出约定
|
||||
|
||||
## Agent Error Code Set
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ scheduler computation, and Flutter settings pages.
|
||||
- `owner_id`: UUID
|
||||
- `title`: string
|
||||
- `bootstrap_key`: string | null (引导配置键,用于标识预设任务模板)
|
||||
- `is_system`: boolean (`bootstrap_key != null` 时为 `true`,只读派生字段)
|
||||
- `config`: object
|
||||
- `input_template`: string
|
||||
- `enabled_tools`: string[]
|
||||
|
||||
@@ -60,7 +60,7 @@ Base URL: `/api/v1/inbox/messages`
|
||||
"phone": "string | null"
|
||||
},
|
||||
"summary": "string",
|
||||
"permission": "int (1=view, 4=edit, 8=invite)",
|
||||
"permission": "int (1=view, 2=invite, 4=edit, 8=delete, 15=owner)",
|
||||
"action": "pending"
|
||||
}
|
||||
```
|
||||
@@ -141,7 +141,6 @@ Base URL: `/api/v1/inbox/messages`
|
||||
"message_type": "InboxMessageType",
|
||||
"schedule_item_id": "uuid | null",
|
||||
"friendship_id": "uuid | null",
|
||||
"group_id": "uuid | null",
|
||||
"content": "CalendarInviteContent | CalendarUpdateContent | CalendarDeleteContent | FriendshipContent | null",
|
||||
"is_read": "boolean",
|
||||
"status": "InboxMessageStatus",
|
||||
|
||||
@@ -4,6 +4,18 @@
|
||||
|
||||
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
|
||||
|
||||
- `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`
|
||||
- `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
|
||||
|
||||
- Todo list API returns items sorted by `priority ASC`, then `order ASC`.
|
||||
|
||||
@@ -12,6 +12,7 @@ Base URL: `/api/v1/users`
|
||||
|---|---|---|
|
||||
| GET | `/me` | 获取当前用户信息 |
|
||||
| PATCH | `/me` | 更新当前用户信息 |
|
||||
| POST | `/me/avatar` | 上传头像图片并更新头像地址 |
|
||||
| POST | `/search` | 搜索用户 |
|
||||
| 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