feat: 添加用户行为分析功能

This commit is contained in:
qzl
2026-04-01 18:35:32 +08:00
parent 24eda6ff51
commit 2cdf075e92
25 changed files with 1321 additions and 5 deletions
+6
View File
@@ -91,6 +91,12 @@ SOCIAL_APP_VERSION__MANIFEST_PATH=deploy/static/releases/manifest.json
SOCIAL_APP_VERSION__RELEASE_PATH_PREFIX=releases
SOCIAL_APP_VERSION__DOWNLOAD_BASE_URL=
############
# Analytics 配置
############
SOCIAL_ANALYTICS__DATA_PATH=backend/data/analytics
SOCIAL_ANALYTICS__PASSWORD=analytics-secret-change-me
############
# Test相关
############
@@ -0,0 +1,111 @@
import 'package:equatable/equatable.dart';
class AnalyticsContext extends Equatable {
final String? networkType;
final String? osVersion;
final String? deviceModel;
final String? locale;
final String? timezone;
const AnalyticsContext({
this.networkType,
this.osVersion,
this.deviceModel,
this.locale,
this.timezone,
});
Map<String, dynamic> toJson() => {
'network_type': networkType,
'os_version': osVersion,
'device_model': deviceModel,
'locale': locale,
'timezone': timezone,
};
@override
List<Object?> get props => [
networkType,
osVersion,
deviceModel,
locale,
timezone,
];
}
class BaseAnalyticsEvent extends Equatable {
final String eventId;
final String eventType;
final DateTime timestamp;
final String userId;
final String deviceId;
final String sessionId;
final String platform;
final String appVersion;
final String? appBuild;
final String env;
final String? pageName;
final String? traceId;
final String? requestId;
final Map<String, dynamic> attributes;
final Map<String, num> metrics;
final AnalyticsContext? context;
const BaseAnalyticsEvent({
required this.eventId,
required this.eventType,
required this.timestamp,
required this.userId,
required this.deviceId,
required this.sessionId,
required this.platform,
required this.appVersion,
this.appBuild,
required this.env,
this.pageName,
this.traceId,
this.requestId,
this.attributes = const {},
this.metrics = const {},
this.context,
});
Map<String, dynamic> toJson() => {
'event_id': eventId,
'event_type': eventType,
'timestamp': timestamp.toUtc().toIso8601String(),
'user_id': userId,
'device_id': deviceId,
'session_id': sessionId,
'platform': platform,
'app_version': appVersion,
'app_build': appBuild,
'env': env,
'page_name': pageName,
'trace_id': traceId,
'request_id': requestId,
'attributes': attributes,
'metrics': metrics,
'context': context?.toJson(),
};
@override
List<Object?> get props => [
eventId,
eventType,
timestamp,
userId,
deviceId,
sessionId,
platform,
appVersion,
appBuild,
env,
pageName,
traceId,
requestId,
attributes,
metrics,
context,
];
}
@@ -0,0 +1,27 @@
import 'base_event.dart';
class UiClickEvent extends BaseAnalyticsEvent {
UiClickEvent({
required super.eventId,
required super.timestamp,
required super.userId,
required super.deviceId,
required super.sessionId,
required super.platform,
required super.appVersion,
super.appBuild,
required super.env,
required super.pageName,
super.traceId,
super.requestId,
required String elementId,
String? elementType,
super.context,
}) : super(
eventType: 'ui.click',
attributes: {
'element_id': elementId,
if (elementType != null) 'element_type': elementType,
},
);
}
@@ -0,0 +1,34 @@
import 'base_event.dart';
class AgentChatCompletedEvent extends BaseAnalyticsEvent {
AgentChatCompletedEvent({
required super.eventId,
required super.timestamp,
required super.userId,
required super.deviceId,
required super.sessionId,
required super.platform,
required super.appVersion,
super.appBuild,
required super.env,
super.pageName,
super.traceId,
super.requestId,
required String conversationId,
String? scenario,
int? messageCount,
int? responseTimeMs,
AnalyticsContext? context,
}) : super(
eventType: 'agent.chat_completed',
attributes: {
'conversation_id': conversationId,
if (scenario != null) 'scenario': scenario,
},
metrics: {
if (messageCount != null) 'message_count': messageCount,
if (responseTimeMs != null) 'response_time_ms': responseTimeMs,
},
context: context,
);
}
@@ -0,0 +1,6 @@
export 'base_event.dart';
export 'login_event.dart';
export 'logout_event.dart';
export 'conversation_event.dart';
export 'page_view_event.dart';
export 'click_event.dart';
@@ -0,0 +1,24 @@
import 'base_event.dart';
class SessionLoginEvent extends BaseAnalyticsEvent {
SessionLoginEvent({
required super.eventId,
required super.timestamp,
required super.userId,
required super.deviceId,
required super.sessionId,
required super.platform,
required super.appVersion,
super.appBuild,
required super.env,
super.pageName,
super.traceId,
super.requestId,
required String method,
AnalyticsContext? context,
}) : super(
eventType: 'session.login',
attributes: {'method': method},
context: context,
);
}
@@ -0,0 +1,28 @@
import 'base_event.dart';
class SessionLogoutEvent extends BaseAnalyticsEvent {
SessionLogoutEvent({
required super.eventId,
required super.timestamp,
required super.userId,
required super.deviceId,
required super.sessionId,
required super.platform,
required super.appVersion,
super.appBuild,
required super.env,
super.pageName,
super.traceId,
super.requestId,
String? reason,
int? sessionDurationS,
AnalyticsContext? context,
}) : super(
eventType: 'session.logout',
attributes: reason != null ? {'reason': reason} : const {},
metrics: sessionDurationS != null
? {'session_duration_s': sessionDurationS}
: const {},
context: context,
);
}
@@ -0,0 +1,29 @@
import 'base_event.dart';
class PageViewEvent extends BaseAnalyticsEvent {
PageViewEvent({
required super.eventId,
required super.timestamp,
required super.userId,
required super.deviceId,
required super.sessionId,
required super.platform,
required super.appVersion,
super.appBuild,
required super.env,
required super.pageName,
super.traceId,
super.requestId,
String? pageFrom,
int? stayDurationMs,
int? clickCount,
super.context,
}) : super(
eventType: 'page.view',
attributes: pageFrom != null ? {'page_from': pageFrom} : const {},
metrics: {
if (stayDurationMs != null) 'stay_duration_ms': stayDurationMs,
if (clickCount != null) 'click_count': clickCount,
},
);
}
@@ -0,0 +1,46 @@
import 'dart:async';
import '../events/events.dart';
class EventQueue {
final List<BaseAnalyticsEvent> _queue = [];
final int maxSize;
final Duration flushInterval;
final void Function(List<BaseAnalyticsEvent>) onFlush;
Timer? _timer;
EventQueue({
this.maxSize = 50,
this.flushInterval = const Duration(seconds: 30),
required this.onFlush,
});
void start() {
_timer?.cancel();
_timer = Timer.periodic(flushInterval, (_) => _tryFlush());
}
void stop() {
_timer?.cancel();
_timer = null;
}
void add(BaseAnalyticsEvent event) {
_queue.add(event);
if (_queue.length >= maxSize) {
_tryFlush();
}
}
void _tryFlush() {
if (_queue.isEmpty) return;
final events = List<BaseAnalyticsEvent>.from(_queue);
_queue.clear();
onFlush(events);
}
List<BaseAnalyticsEvent> get pendingEvents => List.unmodifiable(_queue);
int get pendingCount => _queue.length;
}
+45
View File
@@ -0,0 +1,45 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'events/events.dart';
class AnalyticsSender {
final Dio _dio;
final String endpoint;
AnalyticsSender({required this.endpoint, Dio? dio}) : _dio = dio ?? Dio();
Future<void> send(List<BaseAnalyticsEvent> events) async {
if (events.isEmpty) return;
final body = {'events': events.map((e) => e.toJson()).toList()};
try {
await _dio.post(
endpoint,
data: jsonEncode(body),
options: Options(
headers: {'Content-Type': 'application/json'},
sendTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
),
);
} on DioException catch (e) {
throw AnalyticsSendException(
'Failed to send analytics events: ${e.message}',
events: events,
);
}
}
}
class AnalyticsSendException implements Exception {
final String message;
final List<BaseAnalyticsEvent> events;
AnalyticsSendException(this.message, {required this.events});
@override
String toString() => 'AnalyticsSendException: $message';
}
+247
View File
@@ -0,0 +1,247 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart';
import 'events/events.dart';
import 'queue/event_queue.dart';
import 'sender.dart';
class AnalyticsTracker {
static AnalyticsTracker? _instance;
late final AnalyticsSender _sender;
late final EventQueue _queue;
late final String _deviceId;
late final String _sessionId;
late final String _platform;
late final String _appVersion;
late final String? _appBuild;
late final String _env;
String? _userId;
AnalyticsTracker._();
static AnalyticsTracker get instance {
if (_instance == null) {
throw StateError('AnalyticsTracker not initialized. Call init() first.');
}
return _instance!;
}
static Future<void> init({
required String endpoint,
required String deviceId,
}) async {
if (_instance != null) return;
final packageInfo = await PackageInfo.fromPlatform();
final sessionId = await _getOrCreateSessionId();
final platform = Platform.isAndroid ? 'android' : 'ios';
final env = kDebugMode ? 'dev' : 'prod';
final tracker = AnalyticsTracker._();
tracker._sender = AnalyticsSender(endpoint: endpoint);
tracker._queue = EventQueue(
maxSize: 50,
flushInterval: const Duration(seconds: 30),
onFlush: tracker._handleFlush,
);
tracker._deviceId = deviceId;
tracker._sessionId = sessionId;
tracker._platform = platform;
tracker._appVersion = packageInfo.version;
tracker._appBuild = packageInfo.buildNumber.isNotEmpty
? packageInfo.buildNumber
: null;
tracker._env = env;
tracker._queue.start();
_instance = tracker;
}
static Future<String> _getOrCreateSessionId() async {
const uuid = Uuid();
final prefs = await SharedPreferences.getInstance();
var sessionId = prefs.getString('_analytics_session_id');
if (sessionId == null) {
sessionId = 'sess_${uuid.v4()}';
await prefs.setString('_analytics_session_id', sessionId);
}
return sessionId;
}
void setUserId(String? userId) {
_userId = userId;
}
String get userId => _userId ?? 'anonymous';
String get sessionId => _sessionId;
void track(BaseAnalyticsEvent event) {
_queue.add(event);
}
void trackLogin({
required String method,
String? traceId,
String? requestId,
AnalyticsContext? context,
}) {
track(
SessionLoginEvent(
eventId: _generateEventId(),
timestamp: DateTime.now(),
userId: userId,
deviceId: _deviceId,
sessionId: _sessionId,
platform: _platform,
appVersion: _appVersion,
appBuild: _appBuild,
env: _env,
pageName: 'login',
traceId: traceId,
requestId: requestId,
method: method,
context: context,
),
);
}
void trackLogout({
String? reason,
int? sessionDurationS,
String? pageName,
String? traceId,
AnalyticsContext? context,
}) {
track(
SessionLogoutEvent(
eventId: _generateEventId(),
timestamp: DateTime.now(),
userId: userId,
deviceId: _deviceId,
sessionId: _sessionId,
platform: _platform,
appVersion: _appVersion,
appBuild: _appBuild,
env: _env,
pageName: pageName,
traceId: traceId,
reason: reason,
sessionDurationS: sessionDurationS,
context: context,
),
);
}
void trackAgentChatCompleted({
required String conversationId,
String? scenario,
int? messageCount,
int? responseTimeMs,
String? traceId,
String? requestId,
AnalyticsContext? context,
}) {
track(
AgentChatCompletedEvent(
eventId: _generateEventId(),
timestamp: DateTime.now(),
userId: userId,
deviceId: _deviceId,
sessionId: _sessionId,
platform: _platform,
appVersion: _appVersion,
appBuild: _appBuild,
env: _env,
pageName: 'chat',
traceId: traceId,
requestId: requestId,
conversationId: conversationId,
scenario: scenario,
messageCount: messageCount,
responseTimeMs: responseTimeMs,
context: context,
),
);
}
void trackPageView({
required String pageName,
String? pageFrom,
int? stayDurationMs,
int? clickCount,
String? traceId,
AnalyticsContext? context,
}) {
track(
PageViewEvent(
eventId: _generateEventId(),
timestamp: DateTime.now(),
userId: userId,
deviceId: _deviceId,
sessionId: _sessionId,
platform: _platform,
appVersion: _appVersion,
appBuild: _appBuild,
env: _env,
pageName: pageName,
pageFrom: pageFrom,
stayDurationMs: stayDurationMs,
clickCount: clickCount,
traceId: traceId,
context: context,
),
);
}
void trackClick({
required String pageName,
required String elementId,
String? elementType,
String? traceId,
AnalyticsContext? context,
}) {
track(
UiClickEvent(
eventId: _generateEventId(),
timestamp: DateTime.now(),
userId: userId,
deviceId: _deviceId,
sessionId: _sessionId,
platform: _platform,
appVersion: _appVersion,
appBuild: _appBuild,
env: _env,
pageName: pageName,
elementId: elementId,
elementType: elementType,
traceId: traceId,
context: context,
),
);
}
String _generateEventId() {
return const Uuid().v4();
}
Future<void> _handleFlush(List<BaseAnalyticsEvent> events) async {
try {
await _sender.send(events);
} catch (e) {
// TODO: 失败时落盘本地,下次启动重试
debugPrint('Analytics send failed: $e');
}
}
void dispose() {
_queue.stop();
}
}
+22
View File
@@ -1,6 +1,8 @@
import 'dart:io';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class Env {
static String get apiUrl {
@@ -14,13 +16,33 @@ class Env {
return 'http://localhost:5775';
}
static String get analyticsEndpoint => '$apiUrl/api/v1/analytics/events';
static String version = '0.1.0';
static int build = 1;
static String deviceId = '';
static Future<void> init() async {
final info = await PackageInfo.fromPlatform();
version = info.version;
final buildStr = info.buildNumber.isEmpty ? '1' : info.buildNumber;
build = int.tryParse(buildStr) ?? 1;
deviceId = await _getOrCreateDeviceId();
}
static Future<String> _getOrCreateDeviceId() async {
const storage = FlutterSecureStorage();
var deviceId = await storage.read(key: 'device_id');
if (deviceId == null || deviceId.isEmpty) {
final prefs = await SharedPreferences.getInstance();
deviceId = prefs.getString('device_id') ?? '';
if (deviceId.isEmpty) {
deviceId = 'install_${DateTime.now().millisecondsSinceEpoch}';
await prefs.setString('device_id', deviceId);
}
await storage.write(key: 'device_id', value: deviceId);
}
return deviceId;
}
}
@@ -1,4 +1,5 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/analytics/tracker.dart';
import '../../../../core/logging/logger.dart';
import '../../data/repositories/auth_repository.dart';
import 'auth_event.dart';
@@ -7,6 +8,7 @@ import 'auth_state.dart';
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final AuthRepository _repository;
final Logger _logger = getLogger('features.auth.bloc');
DateTime? _loginTime;
AuthBloc(this._repository) : super(AuthInitial()) {
on<AuthStarted>(_onStarted);
@@ -21,6 +23,8 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
final refreshToken = await _repository.getRefreshToken();
if (refreshToken != null) {
final response = await _repository.refreshSession(refreshToken);
_loginTime = DateTime.now();
AnalyticsTracker.instance.setUserId(response.user.id);
emit(
AuthAuthenticated(
user: AuthUser(id: response.user.id, phone: response.user.phone),
@@ -56,6 +60,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
}
void _onLoggedIn(AuthLoggedIn event, Emitter<AuthState> emit) {
_loginTime = DateTime.now();
_logger.info(message: 'User logged in', extra: {'user_id': event.user.id});
emit(AuthAuthenticated(user: event.user));
}
@@ -64,6 +69,9 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
AuthLoggedOut event,
Emitter<AuthState> emit,
) async {
final sessionDuration = _loginTime != null
? DateTime.now().difference(_loginTime!).inSeconds
: null;
try {
await _repository.deleteSession();
_logger.info(message: 'User logged out');
@@ -74,6 +82,11 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
stackTrace: stackTrace,
);
} finally {
AnalyticsTracker.instance.trackLogout(
reason: 'manual',
sessionDurationS: sessionDuration,
);
_loginTime = null;
emit(
const AuthUnauthenticated(reason: AuthUnauthenticatedReason.signedOut),
);
@@ -84,6 +97,9 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
AuthSessionInvalidated event,
Emitter<AuthState> emit,
) async {
final sessionDuration = _loginTime != null
? DateTime.now().difference(_loginTime!).inSeconds
: null;
_logger.warning(message: 'Session invalidated by server');
try {
await _repository.clearSessionLocalOnly();
@@ -94,6 +110,11 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
stackTrace: stackTrace,
);
} finally {
AnalyticsTracker.instance.trackLogout(
reason: 'expired',
sessionDurationS: sessionDuration,
);
_loginTime = null;
emit(
const AuthUnauthenticated(reason: AuthUnauthenticatedReason.expired),
);
@@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart';
import '../../../../app/di/injection.dart';
import '../../../../app/router/app_routes.dart';
import '../../../../core/analytics/tracker.dart';
import '../../../../core/l10n/l10n.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_button.dart';
@@ -69,6 +70,8 @@ class _LoginViewState extends State<LoginView> {
final response = await cubit.submit();
if (response != null && mounted) {
AnalyticsTracker.instance.trackLogin(method: 'phone_code');
AnalyticsTracker.instance.setUserId(response.user.id);
context.read<AuthBloc>().add(AuthLoggedIn(user: response.user));
context.go(AppRoutes.homeMain);
}
+6
View File
@@ -3,6 +3,7 @@ import 'core/config/env.dart';
import 'core/logging/logger.dart';
import 'core/logging/log_service.dart';
import 'core/logging/error_handler.dart';
import 'core/analytics/tracker.dart';
import 'app/di/injection.dart';
import 'app/app.dart';
@@ -17,6 +18,11 @@ void main() async {
await configureDependencies();
await Env.init();
await AnalyticsTracker.init(
endpoint: Env.analyticsEndpoint,
deviceId: Env.deviceId,
);
getLogger(
'app',
).info(message: 'App starting...', extra: {'version': Env.version});
+1
View File
@@ -29,6 +29,7 @@ dependencies:
package_info_plus: ^8.0.3
url_launcher: ^6.3.1
path_provider: ^2.1.2
uuid: ^4.5.1
dev_dependencies:
flutter_test:
+6
View File
@@ -89,6 +89,11 @@ class RuntimeSettings(BaseModel):
return self
class AnalyticsSettings(BaseModel):
data_path: str = "backend/data/analytics"
password: str = "analytics-secret"
class TaskiqSettings(BaseModel):
broker_url: str | None = None
result_backend_url: str | None = None
@@ -274,6 +279,7 @@ class Settings(BaseSettings):
taskiq: TaskiqSettings = TaskiqSettings()
database: DatabaseSettings = DatabaseSettings()
app_version: AppVersionSettings = AppVersionSettings()
analytics: AnalyticsSettings = AnalyticsSettings()
test: TestSettings = Field(default_factory=TestSettings)
@computed_field
+44
View File
@@ -0,0 +1,44 @@
from fastapi import APIRouter, HTTPException, status
from core.config.settings import config
from core.logging import get_logger
from v1.analytics.schemas import (
AnalyticsBatchRequest,
AnalyticsBatchResponse,
AnalyticsLoginRequest,
AnalyticsLoginResponse,
)
from v1.analytics.service import get_analytics_service
from v1.analytics.tasks import write_analytics_events
logger = get_logger("v1.analytics.router")
router = APIRouter(prefix="/analytics", tags=["analytics"])
@router.post("/events", response_model=AnalyticsBatchResponse)
async def receive_events(request: AnalyticsBatchRequest) -> AnalyticsBatchResponse:
"""接收埋点事件批次"""
service = get_analytics_service()
received = await service.enqueue_events(request)
events, date = service.get_and_clear_buffer()
if events:
await write_analytics_events(batch=events, date=date)
return AnalyticsBatchResponse(received=received, queued=True)
@router.post("/login", response_model=AnalyticsLoginResponse)
async def login(request: AnalyticsLoginRequest) -> AnalyticsLoginResponse:
"""Analytics Dashboard 登录"""
if request.password != config.analytics.password:
logger.warning("Analytics login failed: invalid password")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid password",
)
logger.info("Analytics login success")
return AnalyticsLoginResponse(success=True)
+54
View File
@@ -0,0 +1,54 @@
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, Field
class AnalyticsContext(BaseModel):
network_type: str | None = None
os_version: str | None = None
device_model: str | None = None
locale: str | None = None
timezone: str | None = None
class AnalyticsEvent(BaseModel):
event_id: str
event_type: str
timestamp: datetime
user_id: str
device_id: str
session_id: str
platform: Literal["android", "ios", "web"]
app_version: str
app_build: str | None = None
env: Literal["dev", "staging", "prod"]
page_name: str | None = None
trace_id: str | None = None
request_id: str | None = None
attributes: dict[str, Any] = Field(default_factory=dict)
metrics: dict[str, int | float] = Field(default_factory=dict)
context: AnalyticsContext | None = None
class AnalyticsBatchRequest(BaseModel):
client_time: datetime | None = None
sdk_version: str | None = None
events: list[AnalyticsEvent]
class AnalyticsBatchResponse(BaseModel):
received: int
queued: bool = True
class AnalyticsLoginRequest(BaseModel):
password: str
class AnalyticsLoginResponse(BaseModel):
success: bool
+60
View File
@@ -0,0 +1,60 @@
from datetime import datetime, timezone
from typing import Any
from core.logging import get_logger
from v1.analytics.schemas import AnalyticsBatchRequest
logger = get_logger("v1.analytics.service")
class AnalyticsService:
def __init__(self) -> None:
self._buffer: list[dict[str, Any]] = []
self._buffer_date: str | None = None
async def enqueue_events(self, request: AnalyticsBatchRequest) -> int:
"""接收事件并放入内存缓冲,返回接收数量"""
now = datetime.now(timezone.utc)
received_count = 0
for event in request.events:
event_dict = event.model_dump(mode="json")
self._buffer.append(event_dict)
received_count += 1
if self._buffer_date is None:
self._buffer_date = now.strftime("%Y-%m-%d")
logger.info(
"Analytics events received",
count=received_count,
buffer_size=len(self._buffer),
)
return received_count
def get_and_clear_buffer(self) -> tuple[list[dict[str, Any]], str]:
"""获取当前缓冲并清空,返回 (events, date)"""
if not self._buffer:
return [], self._buffer_date or datetime.now(timezone.utc).strftime(
"%Y-%m-%d"
)
events = self._buffer.copy()
date = self._buffer_date or datetime.now(timezone.utc).strftime("%Y-%m-%d")
self._buffer.clear()
self._buffer_date = None
return events, date
_analytics_service: AnalyticsService | None = None
def get_analytics_service() -> AnalyticsService:
global _analytics_service
if _analytics_service is None:
_analytics_service = AnalyticsService()
return _analytics_service
+43
View File
@@ -0,0 +1,43 @@
import json
from pathlib import Path
from core.config.settings import config
from core.logging import get_logger
from core.taskiq.app import worker_general_broker
logger = get_logger("v1.analytics.tasks")
def _get_analytics_data_path() -> Path:
return Path(config.analytics.data_path)
@worker_general_broker.task(task_name="v1.analytics.write_events")
async def write_analytics_events(batch: list[dict], date: str) -> dict:
"""批量写入事件到 JSONL 文件"""
data_path = _get_analytics_data_path()
data_path.mkdir(parents=True, exist_ok=True)
events_by_type: dict[str, list[str]] = {}
for event_dict in batch:
event_type = event_dict.get("event_type", "unknown")
if event_type not in events_by_type:
events_by_type[event_type] = []
events_by_type[event_type].append(json.dumps(event_dict, ensure_ascii=False))
for event_type, lines in events_by_type.items():
file_path = data_path / f"{date}.jsonl"
with open(file_path, "a", encoding="utf-8") as f:
for line in lines:
f.write(line + "\n")
logger.info(
"Analytics events written",
date=date,
total_count=len(batch),
types=list(events_by_type.keys()),
)
return {"written": len(batch), "date": date}
+445
View File
@@ -0,0 +1,445 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Analytics Dashboard</title>
<style>
:root {
--bg: #f3f4f6;
--card: #ffffff;
--text: #111827;
--muted: #6b7280;
--line: #e5e7eb;
--primary: #0f766e;
--danger: #b91c1c;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
color: var(--text);
background: linear-gradient(180deg, #e6fffb 0%, var(--bg) 240px);
min-height: 100vh;
}
.container {
width: min(1100px, 92vw);
margin: 24px auto 40px;
}
.card {
background: var(--card);
border: 1px solid var(--line);
border-radius: 12px;
padding: 16px;
box-shadow: 0 6px 24px rgba(15, 118, 110, 0.08);
}
.grid {
display: grid;
gap: 12px;
}
.stats {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: end;
margin-bottom: 12px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 170px;
}
input {
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 8px 10px;
font-size: 14px;
}
button {
border: 0;
border-radius: 8px;
padding: 9px 14px;
font-size: 14px;
cursor: pointer;
color: #fff;
background: var(--primary);
}
button[disabled] { opacity: 0.6; cursor: not-allowed; }
.btn-ghost {
background: #374151;
}
.value {
margin-top: 8px;
font-size: 26px;
font-weight: 700;
}
.muted { color: var(--muted); }
.danger { color: var(--danger); }
table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
font-size: 14px;
}
th, td {
border-bottom: 1px solid var(--line);
padding: 8px;
text-align: left;
white-space: nowrap;
}
.chart-row {
display: flex;
align-items: center;
gap: 10px;
margin: 8px 0;
}
.bar {
height: 10px;
background: linear-gradient(90deg, #14b8a6, #0f766e);
border-radius: 999px;
min-width: 2px;
}
.hidden { display: none; }
</style>
</head>
<body>
<div class="container">
<div id="loginCard" class="card" style="max-width: 420px; margin: 100px auto;">
<h2 style="margin: 0 0 12px;">Analytics 登录</h2>
<p class="muted" style="margin-top: 0;">输入密码进入聚合分析页面</p>
<form id="loginForm">
<div class="field">
<label for="password">密码</label>
<input id="password" type="password" required />
</div>
<div style="margin-top: 12px; display: flex; gap: 10px;">
<button id="loginBtn" type="submit">登录</button>
</div>
<p id="loginError" class="danger" style="margin-bottom: 0;"></p>
</form>
</div>
<div id="dashboard" class="hidden">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<h1 style="margin: 0;">Analytics 聚合看板</h1>
<button id="logoutBtn" class="btn-ghost">退出</button>
</div>
<div class="card" style="margin-bottom: 12px;">
<div class="toolbar">
<div class="field">
<label for="startDate">开始日期</label>
<input id="startDate" type="date" />
</div>
<div class="field">
<label for="endDate">结束日期</label>
<input id="endDate" type="date" />
</div>
<button id="loadBtn" type="button">加载数据</button>
</div>
<div id="status" class="muted"></div>
</div>
<div class="grid stats" id="summaryCards"></div>
<div class="card" style="margin-top: 12px;">
<h3 style="margin-top: 0;">按天趋势</h3>
<div id="dailyBars"></div>
</div>
<div class="card" style="margin-top: 12px; overflow-x: auto;">
<h3 style="margin-top: 0;">按天明细</h3>
<table>
<thead>
<tr>
<th>日期</th>
<th>DAU</th>
<th>登录数</th>
<th>对话完成数</th>
<th>平均停留(ms)</th>
</tr>
</thead>
<tbody id="dailyTable"></tbody>
</table>
</div>
</div>
</div>
<script>
const loginCard = document.getElementById("loginCard");
const dashboard = document.getElementById("dashboard");
const loginForm = document.getElementById("loginForm");
const loginBtn = document.getElementById("loginBtn");
const loginError = document.getElementById("loginError");
const logoutBtn = document.getElementById("logoutBtn");
const loadBtn = document.getElementById("loadBtn");
const startDateInput = document.getElementById("startDate");
const endDateInput = document.getElementById("endDate");
const statusEl = document.getElementById("status");
const summaryCards = document.getElementById("summaryCards");
const dailyBars = document.getElementById("dailyBars");
const dailyTable = document.getElementById("dailyTable");
const AUTH_KEY = "analytics_logged_in";
function formatDate(date) {
const y = date.getUTCFullYear();
const m = String(date.getUTCMonth() + 1).padStart(2, "0");
const d = String(date.getUTCDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
function dateRange(startDate, endDate) {
const list = [];
const cursor = new Date(`${startDate}T00:00:00Z`);
const end = new Date(`${endDate}T00:00:00Z`);
while (cursor <= end) {
list.push(formatDate(cursor));
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
return list;
}
function parseJsonl(text) {
if (!text.trim()) return [];
return text
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line);
} catch {
return null;
}
})
.filter(Boolean);
}
async function fetchDayEvents(date) {
const res = await fetch(`/analytics-data/${date}.jsonl`, {
method: "GET",
});
if (res.status === 404) {
return [];
}
if (!res.ok) {
throw new Error(`读取 ${date} 失败: ${res.status}`);
}
return parseJsonl(await res.text());
}
function aggregateDay(events) {
const users = new Set();
let loginCount = 0;
let chatCount = 0;
let staySum = 0;
let stayCnt = 0;
for (const event of events) {
if (event.user_id) users.add(event.user_id);
if (event.event_type === "session.login") loginCount += 1;
if (event.event_type === "agent.chat_completed") chatCount += 1;
if (event.event_type === "page.view") {
const stay = event.metrics && event.metrics.stay_duration_ms;
if (typeof stay === "number") {
staySum += stay;
stayCnt += 1;
}
}
}
return {
dau: users.size,
loginCount,
chatCount,
avgStay: stayCnt ? staySum / stayCnt : 0,
};
}
function renderSummary(rows) {
const allUsers = new Set();
let totalLogins = 0;
let totalChats = 0;
let staySum = 0;
let stayCnt = 0;
rows.forEach((row) => {
row.users.forEach((u) => allUsers.add(u));
totalLogins += row.loginCount;
totalChats += row.chatCount;
staySum += row.staySum;
stayCnt += row.stayCnt;
});
const cards = [
{ label: "DAU(区间去重)", value: allUsers.size },
{ label: "总登录次数", value: totalLogins },
{ label: "总对话完成数", value: totalChats },
{ label: "平均停留(ms)", value: Math.round(stayCnt ? staySum / stayCnt : 0) },
];
summaryCards.innerHTML = cards
.map((card) => `<div class="card"><div class="muted">${card.label}</div><div class="value">${card.value}</div></div>`)
.join("");
}
function renderDaily(rows) {
const maxLogin = Math.max(1, ...rows.map((r) => r.loginCount));
dailyBars.innerHTML = rows
.map((r) => {
const width = Math.max(2, Math.round((r.loginCount / maxLogin) * 100));
return `<div class="chart-row"><div style="width:92px">${r.date}</div><div class="bar" style="width:${width}%"></div><div class="muted">登录 ${r.loginCount}</div></div>`;
})
.join("");
dailyTable.innerHTML = rows
.map(
(r) => `<tr>
<td>${r.date}</td>
<td>${r.dau}</td>
<td>${r.loginCount}</td>
<td>${r.chatCount}</td>
<td>${Math.round(r.avgStay)}</td>
</tr>`,
)
.join("");
}
async function loadData() {
const startDate = startDateInput.value;
const endDate = endDateInput.value;
if (!startDate || !endDate || startDate > endDate) {
statusEl.textContent = "请选择有效日期区间";
return;
}
loadBtn.disabled = true;
statusEl.textContent = "正在读取并聚合数据...";
try {
const dates = dateRange(startDate, endDate);
const rows = [];
for (const date of dates) {
const events = await fetchDayEvents(date);
const users = new Set();
let loginCount = 0;
let chatCount = 0;
let staySum = 0;
let stayCnt = 0;
for (const event of events) {
if (event.user_id) users.add(event.user_id);
if (event.event_type === "session.login") loginCount += 1;
if (event.event_type === "agent.chat_completed") chatCount += 1;
if (event.event_type === "page.view") {
const stay = event.metrics && event.metrics.stay_duration_ms;
if (typeof stay === "number") {
staySum += stay;
stayCnt += 1;
}
}
}
rows.push({
date,
users,
dau: users.size,
loginCount,
chatCount,
staySum,
stayCnt,
avgStay: stayCnt ? staySum / stayCnt : 0,
});
}
renderSummary(rows);
renderDaily(rows);
statusEl.textContent = `加载完成,共 ${rows.length}`;
} catch (err) {
statusEl.textContent = err.message || "加载失败";
} finally {
loadBtn.disabled = false;
}
}
async function login(password) {
const res = await fetch("/api/v1/analytics/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
if (!res.ok) {
throw new Error("密码错误");
}
sessionStorage.setItem(AUTH_KEY, "1");
}
function enterDashboard() {
loginCard.classList.add("hidden");
dashboard.classList.remove("hidden");
}
function exitDashboard() {
sessionStorage.removeItem(AUTH_KEY);
dashboard.classList.add("hidden");
loginCard.classList.remove("hidden");
}
loginForm.addEventListener("submit", async (event) => {
event.preventDefault();
loginError.textContent = "";
loginBtn.disabled = true;
try {
const password = document.getElementById("password").value;
await login(password);
enterDashboard();
await loadData();
} catch (err) {
loginError.textContent = err.message || "登录失败";
} finally {
loginBtn.disabled = false;
}
});
loadBtn.addEventListener("click", loadData);
logoutBtn.addEventListener("click", exitDashboard);
(function init() {
const today = new Date();
const start = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);
startDateInput.value = formatDate(start);
endDateInput.value = formatDate(today);
if (sessionStorage.getItem(AUTH_KEY) === "1") {
enterDashboard();
loadData();
}
})();
</script>
</body>
</html>
+2
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from fastapi import APIRouter
from v1.agent.router import router as agent_router
from v1.analytics.router import router as analytics_router
from v1.app.router import router as app_router
from v1.automation_jobs.router import router as automation_jobs_router
from v1.auth.router import router as auth_router
@@ -18,6 +19,7 @@ router = APIRouter(prefix="/api/v1")
router.include_router(app_router)
router.include_router(auth_router)
router.include_router(agent_router)
router.include_router(analytics_router)
router.include_router(automation_jobs_router)
router.include_router(friendships_router)
router.include_router(memories_router)
+6
View File
@@ -89,3 +89,9 @@ SOCIAL_LLM__PROVIDER_KEYS__ZAI=
SOCIAL_APP_VERSION__MANIFEST_PATH=deploy/static/releases/manifest.json
SOCIAL_APP_VERSION__RELEASE_PATH_PREFIX=releases
SOCIAL_APP_VERSION__DOWNLOAD_BASE_URL=
############
# Analytics 配置
############
SOCIAL_ANALYTICS__DATA_PATH=backend/data/analytics
SOCIAL_ANALYTICS__PASSWORD=analytics-secret-change-me
+5 -5
View File
@@ -540,12 +540,12 @@ app.mount("/analytics", StaticFiles(directory="web/dist", html=True), name="anal
### 6.4 认证方式
**推荐:后端验证密码,返回 JWT Token**
**后端验证密码,返回简单 TokenHMAC**
1. 前端登录页输入密码
2. 调用 `POST /api/v1/analytics/login` 验证
3. 后端读取 `.env``ANALYTICS_PASSWORD` 验证
4. 验证成功返回 JWT Token,前端存 sessionStorage
4. 验证成功返回 HMAC Token5分钟有效),前端存 sessionStorage
5. 后续请求带 Token,后端验证
### 6.5 页面设计
@@ -581,10 +581,10 @@ app.mount("/analytics", StaticFiles(directory="web/dist", html=True), name="anal
```env
# Analytics 数据存储路径
ANALYTICS_DATA_PATH=backend/data/analytics
SOCIAL_ANALYTICS__DATA_PATH=backend/data/analytics
# 可视化网站密码(用于 /api/v1/analytics/login 验证)
ANALYTICS_PASSWORD=your-secure-password
# 可视化网站密码
SOCIAL_ANALYTICS__PASSWORD=your-secure-password
```
### 7.2 后端登录验证 API