diff --git a/.gitignore b/.gitignore index d5bbdc8..026ae76 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/apps/lib/data/network/error_code_mapper.dart b/apps/lib/data/network/error_code_mapper.dart index 2910fe2..89cc37c 100644 --- a/apps/lib/data/network/error_code_mapper.dart +++ b/apps/lib/data/network/error_code_mapper.dart @@ -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': diff --git a/apps/lib/features/chat/presentation/bloc/chat_bloc.dart b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart index 59564aa..ff81cf3 100644 --- a/apps/lib/features/chat/presentation/bloc/chat_bloc.dart +++ b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart @@ -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 items; @@ -115,12 +123,14 @@ class ChatBloc extends Cubit implements ChatOrchestrator { required ChatApi chatApi, ChatHistoryRepository? historyRepository, Future 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 implements ChatOrchestrator { final AgUiService _service; final Future 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 _attachmentPreviewCache = {}; final Map> _attachmentPreviewInflight = @@ -259,4 +274,54 @@ class ChatBloc extends Cubit 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; + } } diff --git a/apps/lib/features/chat/presentation/bloc/chat_bloc_events.dart b/apps/lib/features/chat/presentation/bloc/chat_bloc_events.dart index a5ec915..028650a 100644 --- a/apps/lib/features/chat/presentation/bloc/chat_bloc_events.dart +++ b/apps/lib/features/chat/presentation/bloc/chat_bloc_events.dart @@ -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, diff --git a/apps/lib/features/home/presentation/screens/home_screen.dart b/apps/lib/features/home/presentation/screens/home_screen.dart index d3fa1f0..67a21a0 100644 --- a/apps/lib/features/home/presentation/screens/home_screen.dart +++ b/apps/lib/features/home/presentation/screens/home_screen.dart @@ -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 int _previousItemCount = 0; bool _previousIsLoadingHistory = false; bool _routeAwareSubscribed = false; + late final DateTime _pageEnteredAt; + int _pageClickCount = 0; double? _historyViewportPixels; double? _historyViewportMaxExtent; final GlobalKey _inputHostKey = @@ -121,6 +124,7 @@ class _HomeScreenState extends State duration: const Duration(milliseconds: _rippleDurationMs), ); _selectedImages.addAll(widget.initialSelectedImages); + _pageEnteredAt = DateTime.now(); final initialUserId = widget.initialUserId?.trim(); if (initialUserId != null && initialUserId.isNotEmpty) { unawaited(_chatBloc.switchUser(initialUserId)); @@ -148,6 +152,14 @@ class _HomeScreenState extends State @override void dispose() { + final stayDurationMs = DateTime.now() + .difference(_pageEnteredAt) + .inMilliseconds; + AnalyticsTracker.instance.trackPageView( + pageName: 'home', + stayDurationMs: stayDurationMs, + clickCount: _pageClickCount, + ); _messageController.dispose(); _scrollController.removeListener(_handleScrollChanged); _scrollController.dispose(); @@ -281,10 +293,18 @@ class _HomeScreenState extends State 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 child: HomeUnreadBadge( count: _chatUnreadBadgeCount, onTap: () { + _trackClick('unread_badge'); _scheduleAutoScroll(animated: true); if (mounted) { setState(() => _chatUnreadBadgeCount = 0); @@ -438,6 +459,7 @@ class _HomeScreenState extends State } Future _onLoadMore(BuildContext context) async { + _trackClick('history_load_more'); final chatBloc = context.read(); await _loadMoreHistoryPreservingViewport(chatBloc); } @@ -650,9 +672,18 @@ class _HomeScreenState extends State 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 ); } + void _trackClick(String elementId) { + _pageClickCount += 1; + AnalyticsTracker.instance.trackClick( + pageName: 'home', + elementId: elementId, + elementType: 'button', + ); + } + void _removeImage(int index) { setState(() { _selectedImages.removeAt(index); diff --git a/apps/lib/features/home/presentation/screens/home_screen_interactions.dart b/apps/lib/features/home/presentation/screens/home_screen_interactions.dart index 9d82ae1..8f0601c 100644 --- a/apps/lib/features/home/presentation/screens/home_screen_interactions.dart +++ b/apps/lib/features/home/presentation/screens/home_screen_interactions.dart @@ -53,6 +53,7 @@ extension _HomeScreenInteractions on _HomeScreenState { }); try { + _trackClick('send_message'); await _chatBloc.sendMessage(content, images: images); } finally { if (mounted) { diff --git a/apps/pubspec.yaml b/apps/pubspec.yaml index 2e109b5..14caae2 100644 --- a/apps/pubspec.yaml +++ b/apps/pubspec.yaml @@ -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 diff --git a/apps/test/features/chat/presentation/bloc/chat_bloc_test.dart b/apps/test/features/chat/presentation/bloc/chat_bloc_test.dart index 6111590..d378cf3 100644 --- a/apps/test/features/chat/presentation/bloc/chat_bloc_test.dart +++ b/apps/test/features/chat/presentation/bloc/chat_bloc_test.dart @@ -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(); + }); } diff --git a/backend/src/app.py b/backend/src/app.py index 9342295..8295a04 100644 --- a/backend/src/app.py +++ b/backend/src/app.py @@ -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, diff --git a/backend/src/v1/agent/dependencies.py b/backend/src/v1/agent/dependencies.py index f6c40d9..37672a7 100644 --- a/backend/src/v1/agent/dependencies.py +++ b/backend/src/v1/agent/dependencies.py @@ -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( diff --git a/backend/src/v1/analytics/router.py b/backend/src/v1/analytics/router.py index 29020d5..69367e5 100644 --- a/backend/src/v1/analytics/router.py +++ b/backend/src/v1/analytics/router.py @@ -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") diff --git a/backend/src/v1/analytics/schemas.py b/backend/src/v1/analytics/schemas.py index 29a2dac..dafa9df 100644 --- a/backend/src/v1/analytics/schemas.py +++ b/backend/src/v1/analytics/schemas.py @@ -52,3 +52,5 @@ class AnalyticsLoginRequest(BaseModel): class AnalyticsLoginResponse(BaseModel): success: bool + data_base_url: str + token: str diff --git a/backend/src/v1/analytics/web/index.html b/backend/src/v1/analytics/web/index.html index 2d7d401..03f0fdd 100644 --- a/backend/src/v1/analytics/web/index.html +++ b/backend/src/v1/analytics/web/index.html @@ -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(); } diff --git a/deploy/README.md b/deploy/README.md index a390ceb..895b4ca 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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 时需指定后端地址: diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index 94e0c07..7cb7daa 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -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} diff --git a/deploy/static/releases/manifest.json b/deploy/static/releases/manifest.json index bef1130..24f769b 100644 --- a/deploy/static/releases/manifest.json +++ b/deploy/static/releases/manifest.json @@ -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" } ] } diff --git a/docs/plans/2026-04-01-analytics-design.md b/docs/plans/2026-04-01-analytics-design.md index 390b33e..1e2fe11 100644 --- a/docs/plans/2026-04-01-analytics-design.md +++ b/docs/plans/2026-04-01-analytics-design.md @@ -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" } ``` diff --git a/docs/protocols/agent/sse-events.md b/docs/protocols/agent/sse-events.md index 943d349..3666305 100644 --- a/docs/protocols/agent/sse-events.md +++ b/docs/protocols/agent/sse-events.md @@ -110,7 +110,7 @@ data: "type": "STEP_STARTED", "threadId": "...", "runId": "...", - "stepName": "router" | "worker" | "memory" + "stepName": "router" | "worker" } ``` @@ -121,7 +121,7 @@ data: "type": "STEP_FINISHED", "threadId": "...", "runId": "...", - "stepName": "router" | "worker" | "memory" + "stepName": "router" | "worker" } ``` @@ -137,7 +137,7 @@ data: "messageId": "...", "toolCallId": "...", "toolCallName": "...", - "stage": "worker" | "memory" + "stage": "worker" } ``` @@ -152,7 +152,7 @@ data: "toolCallId": "...", "toolCallName": "...", "args": {}, - "stage": "worker" | "memory" + "stage": "worker" } ``` @@ -166,7 +166,7 @@ data: "messageId": "...", "toolCallId": "...", "toolCallName": "...", - "stage": "worker" | "memory" + "stage": "worker" } ``` @@ -179,7 +179,7 @@ data: "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": [], diff --git a/docs/protocols/app/analytics-events.md b/docs/protocols/app/analytics-events.md new file mode 100644 index 0000000..a257810 --- /dev/null +++ b/docs/protocols/app/analytics-events.md @@ -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": "" +} +``` + +### Response Body + +```json +{ + "success": true, + "data_base_url": "/api/v1/analytics/data", + "token": "" +} +``` + +语义说明: + +- `token` 为短时效 Bearer Token。 +- `data_base_url` 为前端读取按日数据的基路径。 + +## 4. Read Daily Data + +### Endpoint + +- Method: `GET` +- Path: `/api/v1/analytics/data/{date}` +- Header: `Authorization: Bearer ` +- `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` 内部字段)。 +- 不允许移除现有必填字段或修改既有字段语义;若必须变更,需新增版本化协议文档并提供迁移说明。 diff --git a/docs/protocols/common/http-error-codes.md b/docs/protocols/common/http-error-codes.md index af8d2e6..c4b9d59 100644 --- a/docs/protocols/common/http-error-codes.md +++ b/docs/protocols/common/http-error-codes.md @@ -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 diff --git a/docs/protocols/models/automation-jobs.md b/docs/protocols/models/automation-jobs.md index 1a76325..0fccbe4 100644 --- a/docs/protocols/models/automation-jobs.md +++ b/docs/protocols/models/automation-jobs.md @@ -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[] diff --git a/docs/protocols/models/inbox-messages.md b/docs/protocols/models/inbox-messages.md index 1a31e51..77bb752 100644 --- a/docs/protocols/models/inbox-messages.md +++ b/docs/protocols/models/inbox-messages.md @@ -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", diff --git a/docs/protocols/models/todo.md b/docs/protocols/models/todo.md index 76d56f0..71077d5 100644 --- a/docs/protocols/models/todo.md +++ b/docs/protocols/models/todo.md @@ -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`. diff --git a/docs/protocols/models/users.md b/docs/protocols/models/users.md index 6290001..d334590 100644 --- a/docs/protocols/models/users.md +++ b/docs/protocols/models/users.md @@ -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}` 获取指定用户信息。