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
@@ -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;
}
}