feat: 添加 Analytics 分析功能(行为追踪、错误码、协议更新)

This commit is contained in:
qzl
2026-04-02 11:52:23 +08:00
parent b101826de5
commit 7b6dbe72c3
24 changed files with 682 additions and 52 deletions
+4
View File
@@ -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
View File
@@ -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();
});
}
+10
View File
@@ -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,
+3 -3
View File
@@ -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(
+133 -3
View File
@@ -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")
+2
View File
@@ -52,3 +52,5 @@ class AnalyticsLoginRequest(BaseModel):
class AnalyticsLoginResponse(BaseModel):
success: bool
data_base_url: str
token: str
+22 -1
View File
@@ -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();
}
+25
View File
@@ -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 时需指定后端地址:
+2
View File
@@ -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}
+22
View File
@@ -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"
}
]
}
+8 -6
View File
@@ -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 Token5分钟有效),前端存 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"
}
```
+7 -7
View File
@@ -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": [],
+146
View File
@@ -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` 内部字段)。
- 不允许移除现有必填字段或修改既有字段语义;若必须变更,需新增版本化协议文档并提供迁移说明。
+15 -21
View File
@@ -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
+1
View File
@@ -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[]
+1 -2
View File
@@ -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",
+30
View File
@@ -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`.
+26 -1
View File
@@ -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}`
获取指定用户信息。