feat: Implement Isolate Storage & Lifecycle Handling
Flutter CI / analyze-and-test (push) Waiting to run Details

- Implement IsolateEventStorage for background persistence
- Add Analytics.bindLifecycleObserver for background flushing
- Enable useIsolateStorage by default in AnalyticsConfig
- Fix 25+ lint issues across the codebase
- Add comprehensive tests for Isolate storage and lifecycle behaviors
This commit is contained in:
Max 2026-01-28 11:07:01 +08:00
parent d58eeede2f
commit e7326cb9f9
44 changed files with 4118 additions and 690 deletions

View File

@ -1,4 +1,8 @@
include: package:flutter_lints/flutter.yaml include: package:very_good_analysis/analysis_options.7.0.0.yaml
analyzer:
exclude:
- coverage/**
# Additional information about this file can be found at # Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options # https://dart.dev/guides/language/analysis-options

View File

@ -2,24 +2,7 @@ import 'dart:core';
/// SDK /// SDK
class AnalyticsConfig { class AnalyticsConfig {
final String systemCode; /// SDK
final String endpointBaseUrl;
final int clientType;
final bool enableDebug;
final int batchSize;
final int flushInterval;
final int maxCacheSize;
final int maxRetryCount;
final Duration connectTimeout;
final Duration readTimeout;
final bool enableMetrics;
final Duration metricsReportInterval;
/// Debug
final bool blockOnValidationError;
const AnalyticsConfig({ const AnalyticsConfig({
required this.systemCode, required this.systemCode,
required this.endpointBaseUrl, required this.endpointBaseUrl,
@ -31,11 +14,58 @@ class AnalyticsConfig {
this.maxRetryCount = 3, this.maxRetryCount = 3,
this.connectTimeout = const Duration(seconds: 5), this.connectTimeout = const Duration(seconds: 5),
this.readTimeout = const Duration(seconds: 5), this.readTimeout = const Duration(seconds: 5),
this.maxEventAge = const Duration(days: 7),
this.useIsolateStorage = true,
this.enableMetrics = true, this.enableMetrics = true,
this.metricsReportInterval = const Duration(minutes: 10), this.metricsReportInterval = const Duration(minutes: 10),
this.blockOnValidationError = false, this.blockOnValidationError = false,
}); });
/// system_code
final String systemCode;
/// HTTPS
final String endpointBaseUrl;
/// client_type
final int clientType;
/// Debug
final bool enableDebug;
///
final int batchSize;
/// flush
final int flushInterval;
///
final int maxCacheSize;
/// 0
final int maxRetryCount;
///
final Duration connectTimeout;
///
final Duration readTimeout;
/// 0
final Duration maxEventAge;
/// 使 Isolate
final bool useIsolateStorage;
/// SDK
final bool enableMetrics;
///
final Duration metricsReportInterval;
/// Debug
final bool blockOnValidationError;
/// ///
/// ///
/// Phase 1 endpoint 使 HTTPS /// Phase 1 endpoint 使 HTTPS
@ -61,25 +91,60 @@ class AnalyticsConfig {
} }
if (clientType <= 0) { if (clientType <= 0) {
throw ArgumentError.value(clientType, 'clientType', 'clientType 必须为正整数'); throw ArgumentError.value(
clientType,
'clientType',
'clientType 必须为正整数',
);
} }
if (batchSize <= 0) { if (batchSize <= 0) {
throw ArgumentError.value(batchSize, 'batchSize', 'batchSize 必须为正整数'); throw ArgumentError.value(
batchSize,
'batchSize',
'batchSize 必须为正整数',
);
} }
if (flushInterval <= 0) { if (flushInterval <= 0) {
throw ArgumentError.value(flushInterval, 'flushInterval', 'flushInterval 必须为正整数(秒)'); throw ArgumentError.value(
flushInterval,
'flushInterval',
'flushInterval 必须为正整数(秒)',
);
} }
if (maxCacheSize <= 0) { if (maxCacheSize <= 0) {
throw ArgumentError.value(maxCacheSize, 'maxCacheSize', 'maxCacheSize 必须为正整数'); throw ArgumentError.value(
maxCacheSize,
'maxCacheSize',
'maxCacheSize 必须为正整数',
);
} }
if (maxRetryCount < 0) { if (maxRetryCount < 0) {
throw ArgumentError.value(maxRetryCount, 'maxRetryCount', 'maxRetryCount 不能为负数'); throw ArgumentError.value(
maxRetryCount,
'maxRetryCount',
'maxRetryCount 不能为负数',
);
} }
if (connectTimeout <= Duration.zero) { if (connectTimeout <= Duration.zero) {
throw ArgumentError.value(connectTimeout, 'connectTimeout', 'connectTimeout 必须大于 0'); throw ArgumentError.value(
connectTimeout,
'connectTimeout',
'connectTimeout 必须大于 0',
);
} }
if (readTimeout <= Duration.zero) { if (readTimeout <= Duration.zero) {
throw ArgumentError.value(readTimeout, 'readTimeout', 'readTimeout 必须大于 0'); throw ArgumentError.value(
readTimeout,
'readTimeout',
'readTimeout 必须大于 0',
);
}
if (maxEventAge < Duration.zero) {
throw ArgumentError.value(
maxEventAge,
'maxEventAge',
'maxEventAge 不能为负数',
);
} }
if (enableMetrics && metricsReportInterval <= Duration.zero) { if (enableMetrics && metricsReportInterval <= Duration.zero) {
throw ArgumentError.value( throw ArgumentError.value(
@ -104,9 +169,8 @@ class AnalyticsConfig {
final normalizedBasePath = base.path.endsWith('/') final normalizedBasePath = base.path.endsWith('/')
? base.path.substring(0, base.path.length - 1) ? base.path.substring(0, base.path.length - 1)
: base.path; : base.path;
final nextPath = normalizedBasePath.isEmpty final nextPath =
? '/$leaf' normalizedBasePath.isEmpty ? '/$leaf' : '$normalizedBasePath/$leaf';
: '$normalizedBasePath/$leaf';
return base.replace(path: nextPath); return base.replace(path: nextPath);
} }

View File

@ -1,22 +1,15 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../model/system_dim_info.dart'; import 'package:yx_tracking_flutter/src/config/analytics_config.dart';
import '../network/http_client.dart'; import 'package:yx_tracking_flutter/src/model/system_dim_info.dart';
import '../storage/config_storage.dart'; import 'package:yx_tracking_flutter/src/network/http_client.dart';
import '../storage/sqflite_config_storage.dart'; import 'package:yx_tracking_flutter/src/storage/config_storage.dart';
import '../util/logger.dart'; import 'package:yx_tracking_flutter/src/storage/sqflite_config_storage.dart';
import 'analytics_config.dart'; import 'package:yx_tracking_flutter/src/util/logger.dart';
/// Phase 2 /// Phase 2
class ConfigManager { class ConfigManager {
final AnalyticsConfig config; ///
final Duration refreshInterval;
final HttpClient _httpClient;
final ConfigStorage _storage;
SystemDimInfo? _current;
ConfigManager({ ConfigManager({
required this.config, required this.config,
this.refreshInterval = const Duration(hours: 12), this.refreshInterval = const Duration(hours: 12),
@ -25,18 +18,34 @@ class ConfigManager {
}) : _httpClient = httpClient ?? HttpClient(config), }) : _httpClient = httpClient ?? HttpClient(config),
_storage = storage ?? SqfliteConfigStorage(); _storage = storage ?? SqfliteConfigStorage();
/// SDK
final AnalyticsConfig config;
///
final Duration refreshInterval;
final HttpClient _httpClient;
final ConfigStorage _storage;
SystemDimInfo? _current;
///
SystemDimInfo? get currentConfig => _current; SystemDimInfo? get currentConfig => _current;
///
Future<void> init() async { Future<void> init() async {
await _storage.init(); await _storage.init();
_current = await _storage.loadSystemDimInfo(); _current = await _storage.loadSystemDimInfo();
if (_current != null) { if (_current != null) {
final eventCount = _current!.eventDefinitions.length;
final tagCount = _current!.tagDefinitions.length;
Logger.info( Logger.info(
'已加载本地配置缓存: events=${_current!.eventDefinitions.length}, tags=${_current!.tagDefinitions.length}', '已加载本地配置缓存: events=$eventCount, tags=$tagCount',
); );
} }
} }
///
Future<void> fetchAndCacheConfig({bool force = false}) async { Future<void> fetchAndCacheConfig({bool force = false}) async {
if (!force && _shouldSkipFetch()) { if (!force && _shouldSkipFetch()) {
final last = _current?.lastFetchedAt; final last = _current?.lastFetchedAt;
@ -63,18 +72,22 @@ class ConfigManager {
await _storage.saveSystemDimInfo(info); await _storage.saveSystemDimInfo(info);
_current = info; _current = info;
final eventCount = info.eventDefinitions.length;
final tagCount = info.tagDefinitions.length;
Logger.info( Logger.info(
'配置拉取并缓存成功: events=${info.eventDefinitions.length}, tags=${info.tagDefinitions.length}', '配置拉取并缓存成功: events=$eventCount, tags=$tagCount',
); );
} on DioException catch (e, st) { } on DioException catch (e, st) {
Logger.error('配置拉取失败DioException', e, st); Logger.error('配置拉取失败DioException', e, st);
} catch (e, st) { } on Object catch (e, st) {
Logger.error('配置拉取失败(未知异常)', e, st); Logger.error('配置拉取失败(未知异常)', e, st);
} }
} }
///
Future<void> forceRefresh() => fetchAndCacheConfig(force: true); Future<void> forceRefresh() => fetchAndCacheConfig(force: true);
///
Future<void> dispose() => _storage.dispose(); Future<void> dispose() => _storage.dispose();
bool _shouldSkipFetch() { bool _shouldSkipFetch() {

View File

@ -1,35 +1,26 @@
import 'dart:async'; import 'dart:async';
import 'dart:math'; import 'dart:math';
import '../config/analytics_config.dart'; import 'package:yx_tracking_flutter/src/config/analytics_config.dart';
import '../config/config_manager.dart'; import 'package:yx_tracking_flutter/src/config/config_manager.dart';
import '../model/device_info.dart'; import 'package:yx_tracking_flutter/src/core/interceptors.dart';
import '../model/event.dart'; import 'package:yx_tracking_flutter/src/core/scheduler.dart';
import '../model/recent_event_summary.dart'; import 'package:yx_tracking_flutter/src/core/validator.dart';
import '../model/user_info.dart'; import 'package:yx_tracking_flutter/src/model/device_info.dart';
import '../network/api_client.dart'; import 'package:yx_tracking_flutter/src/model/event.dart';
import '../storage/event_storage.dart'; import 'package:yx_tracking_flutter/src/model/recent_event_summary.dart';
import '../storage/sqflite_event_storage.dart'; import 'package:yx_tracking_flutter/src/model/user_info.dart';
import '../util/device_util.dart'; import 'package:yx_tracking_flutter/src/network/api_client.dart';
import '../util/logger.dart'; import 'package:yx_tracking_flutter/src/storage/event_storage.dart';
import '../util/time_util.dart'; import 'package:yx_tracking_flutter/src/storage/isolate_event_storage.dart';
import 'interceptors.dart'; import 'package:yx_tracking_flutter/src/storage/sqflite_event_storage.dart';
import 'scheduler.dart'; import 'package:yx_tracking_flutter/src/util/device_util.dart';
import 'validator.dart'; import 'package:yx_tracking_flutter/src/util/logger.dart';
import 'package:yx_tracking_flutter/src/util/time_util.dart';
/// SDK /// SDK
class AnalyticsCore { class AnalyticsCore {
final EventStorage Function() _storageFactory; ///
final ApiClient Function(AnalyticsConfig config) _apiClientFactory;
final ConfigManager Function(AnalyticsConfig config) _configManagerFactory;
final Future<DeviceInfo> Function() _deviceInfoCollector;
final Scheduler Function(Duration interval, Future<void> Function() onTick)
_schedulerFactory;
final double Function() _randomDouble;
final DateTime Function() _now;
final bool _includeCommonTagsInterceptor;
final List<AnalyticsInterceptor> _initialInterceptors;
AnalyticsCore({ AnalyticsCore({
EventStorage Function()? storageFactory, EventStorage Function()? storageFactory,
ApiClient Function(AnalyticsConfig config)? apiClientFactory, ApiClient Function(AnalyticsConfig config)? apiClientFactory,
@ -41,19 +32,38 @@ class AnalyticsCore {
DateTime Function()? now, DateTime Function()? now,
bool includeCommonTagsInterceptor = true, bool includeCommonTagsInterceptor = true,
List<AnalyticsInterceptor> interceptors = const <AnalyticsInterceptor>[], List<AnalyticsInterceptor> interceptors = const <AnalyticsInterceptor>[],
}) : _storageFactory = storageFactory ?? (() => SqfliteEventStorage()), }) : _storageFactory = storageFactory ?? SqfliteEventStorage.new,
_apiClientFactory = apiClientFactory ?? ((c) => ApiClient(c)), _useDefaultStorageFactory = storageFactory == null,
_apiClientFactory = apiClientFactory ?? ApiClient.new,
_configManagerFactory = _configManagerFactory =
configManagerFactory ?? ((c) => ConfigManager(config: c)), configManagerFactory ??
_deviceInfoCollector = deviceInfoCollector ?? DeviceUtil.collectDeviceInfo, ((c) => ConfigManager(config: c)), // coverage:ignore-line
_deviceInfoCollector =
deviceInfoCollector ?? DeviceUtil.collectDeviceInfo,
// coverage:ignore-start
_schedulerFactory = _schedulerFactory =
schedulerFactory ?? schedulerFactory ??
((interval, onTick) => Scheduler(interval: interval, onTick: onTick)), ((interval, onTick) =>
_randomDouble = randomDouble ?? Random().nextDouble, Scheduler(interval: interval, onTick: onTick)),
// coverage:ignore-end
_randomDouble =
randomDouble ?? (Random().nextDouble), // coverage:ignore-line
_now = now ?? DateTime.now, _now = now ?? DateTime.now,
_includeCommonTagsInterceptor = includeCommonTagsInterceptor, _includeCommonTagsInterceptor = includeCommonTagsInterceptor,
_initialInterceptors = interceptors; _initialInterceptors = interceptors;
final EventStorage Function() _storageFactory;
final bool _useDefaultStorageFactory;
final ApiClient Function(AnalyticsConfig config) _apiClientFactory;
final ConfigManager Function(AnalyticsConfig config) _configManagerFactory;
final Future<DeviceInfo> Function() _deviceInfoCollector;
final Scheduler Function(Duration interval, Future<void> Function() onTick)
_schedulerFactory;
final double Function() _randomDouble;
final DateTime Function() _now;
final bool _includeCommonTagsInterceptor;
final List<AnalyticsInterceptor> _initialInterceptors;
AnalyticsConfig? _config; AnalyticsConfig? _config;
UserInfo? _user; UserInfo? _user;
DeviceInfo? _deviceInfo; DeviceInfo? _deviceInfo;
@ -70,6 +80,7 @@ class AnalyticsCore {
bool _initialized = false; bool _initialized = false;
bool _isFlushing = false; bool _isFlushing = false;
DateTime? _nextAllowedFlushTime; DateTime? _nextAllowedFlushTime;
DateTime? _lastExpirationSweep;
// Phase 3SDK // Phase 3SDK
int _sentCount = 0; int _sentCount = 0;
@ -86,9 +97,12 @@ class AnalyticsCore {
_metricsSendEventType, _metricsSendEventType,
_metricsQueueEventType, _metricsQueueEventType,
}; };
static const Duration _expirationSweepInterval = Duration(hours: 1);
///
bool get isInitialized => _initialized; bool get isInitialized => _initialized;
/// SDK
Future<void> init(AnalyticsConfig config) async { Future<void> init(AnalyticsConfig config) async {
// init // init
if (_initialized) { if (_initialized) {
@ -96,11 +110,13 @@ class AnalyticsCore {
} }
config.validate(); config.validate();
Logger.setDebug(config.enableDebug); Logger.debugEnabled = config.enableDebug;
Logger.info('AnalyticsCore 初始化开始'); Logger.info('AnalyticsCore 初始化开始');
final deviceInfo = await _deviceInfoCollector(); final deviceInfo = await _deviceInfoCollector();
final storage = _storageFactory(); final storage = _useDefaultStorageFactory
? _buildDefaultStorage(config)
: _storageFactory();
await storage.init(); await storage.init();
final configManager = _configManagerFactory(config); final configManager = _configManagerFactory(config);
await configManager.init(); await configManager.init();
@ -115,34 +131,39 @@ class AnalyticsCore {
final scheduler = _schedulerFactory( final scheduler = _schedulerFactory(
Duration(seconds: config.flushInterval), Duration(seconds: config.flushInterval),
() => flush(), flush,
); )..start();
scheduler.start();
_scheduler = scheduler; _scheduler = scheduler;
_initialized = true; _initialized = true;
Logger.info('AnalyticsCore 初始化完成'); Logger.info('AnalyticsCore 初始化完成');
await _maybePurgeExpired(reason: 'init');
// Phase 2 // Phase 2
unawaited(configManager.fetchAndCacheConfig()); unawaited(configManager.fetchAndCacheConfig());
_startConfigRefreshTimer(configManager); _startConfigRefreshTimer(configManager);
_startMetricsTimer(config); _startMetricsTimer(config);
} }
///
void addInterceptor(AnalyticsInterceptor interceptor) { void addInterceptor(AnalyticsInterceptor interceptor) {
_interceptors.add(interceptor); _interceptors.add(interceptor);
} }
///
Future<void> setUser(UserInfo? userInfo) async { Future<void> setUser(UserInfo? userInfo) async {
_user = userInfo; _user = userInfo;
Logger.info('用户信息已更新'); Logger.info('用户信息已更新');
} }
///
Future<void> setDeviceInfo(DeviceInfo deviceInfo) async { Future<void> setDeviceInfo(DeviceInfo deviceInfo) async {
_deviceInfo = deviceInfo; _deviceInfo = deviceInfo;
Logger.info('设备信息已覆盖'); Logger.info('设备信息已覆盖');
} }
///
Future<void> track( Future<void> track(
String eventType, { String eventType, {
Map<String, dynamic>? eventParams, Map<String, dynamic>? eventParams,
@ -160,16 +181,19 @@ class AnalyticsCore {
Future<void> _track( Future<void> _track(
String eventType, { String eventType, {
required bool internal,
Map<String, dynamic>? eventParams, Map<String, dynamic>? eventParams,
Map<String, dynamic>? customTags, Map<String, dynamic>? customTags,
int? timestamp, int? timestamp,
required bool internal,
}) async { }) async {
final config = _config; final config = _config;
final storage = _storage; final storage = _storage;
final deviceInfo = _deviceInfo; final deviceInfo = _deviceInfo;
if (!_initialized || config == null || storage == null || deviceInfo == null) { if (!_initialized ||
config == null ||
storage == null ||
deviceInfo == null) {
Logger.warn('track 被调用但 SDK 尚未初始化,已忽略: $eventType'); Logger.warn('track 被调用但 SDK 尚未初始化,已忽略: $eventType');
return; return;
} }
@ -199,7 +223,8 @@ class AnalyticsCore {
createTime: now, createTime: now,
); );
final validatedEvent = internal ? event : _validateAndDecorate(event, config); final validatedEvent =
internal ? event : _validateAndDecorate(event, config);
if (validatedEvent == null) { if (validatedEvent == null) {
_droppedCount += 1; _droppedCount += 1;
return; return;
@ -222,6 +247,7 @@ class AnalyticsCore {
} }
} }
///
Future<int> cachedEventCount() async { Future<int> cachedEventCount() async {
final storage = _storage; final storage = _storage;
if (!_initialized || storage == null) { if (!_initialized || storage == null) {
@ -230,6 +256,7 @@ class AnalyticsCore {
return storage.count(); return storage.count();
} }
///
Future<List<RecentEventSummary>> cachedRecentEvents({int limit = 20}) async { Future<List<RecentEventSummary>> cachedRecentEvents({int limit = 20}) async {
final storage = _storage; final storage = _storage;
if (!_initialized || storage == null || limit <= 0) { if (!_initialized || storage == null || limit <= 0) {
@ -248,6 +275,7 @@ class AnalyticsCore {
.toList(growable: false); .toList(growable: false);
} }
///
Future<void> refreshConfig({bool force = true}) async { Future<void> refreshConfig({bool force = true}) async {
final manager = _configManager; final manager = _configManager;
if (!_initialized || manager == null) { if (!_initialized || manager == null) {
@ -260,14 +288,19 @@ class AnalyticsCore {
await manager.fetchAndCacheConfig(); await manager.fetchAndCacheConfig();
} }
/// SDK
Future<void> reportMetricsNow() => _reportMetrics(); Future<void> reportMetricsNow() => _reportMetrics();
/// flush
Future<void> flush({bool force = false}) async { Future<void> flush({bool force = false}) async {
final config = _config; final config = _config;
final storage = _storage; final storage = _storage;
final apiClient = _apiClient; final apiClient = _apiClient;
if (!_initialized || config == null || storage == null || apiClient == null) { if (!_initialized ||
config == null ||
storage == null ||
apiClient == null) {
Logger.warn('flush 被调用但 SDK 尚未初始化,已忽略'); Logger.warn('flush 被调用但 SDK 尚未初始化,已忽略');
return; return;
} }
@ -286,6 +319,7 @@ class AnalyticsCore {
_isFlushing = true; _isFlushing = true;
try { try {
await _maybePurgeExpired(reason: 'flush');
final flushStart = _now(); final flushStart = _now();
final batch = await storage.fetchBatch(config.batchSize); final batch = await storage.fetchBatch(config.batchSize);
if (batch.isEmpty) { if (batch.isEmpty) {
@ -296,10 +330,12 @@ class AnalyticsCore {
if (prepared.sendable.isEmpty) { if (prepared.sendable.isEmpty) {
return; return;
} }
final events = final events = prepared.sendable
prepared.sendable.map((item) => item.event).toList(growable: false); .map((item) => item.event)
final ids = .toList(growable: false);
prepared.sendable.map((item) => item.stored.id).toList(growable: false); final ids = prepared.sendable
.map((item) => item.stored.id)
.toList(growable: false);
try { try {
await apiClient.sendBatch(events); await apiClient.sendBatch(events);
@ -328,7 +364,7 @@ class AnalyticsCore {
await storage.deleteByIds(ids); await storage.deleteByIds(ids);
_droppedCount += _countNonInternalEvents(events); _droppedCount += _countNonInternalEvents(events);
} }
} catch (e, st) { } on Object catch (e, st) {
Logger.error('flush 发生未知异常,按可重试处理', e, st); Logger.error('flush 发生未知异常,按可重试处理', e, st);
_recordFailureMetrics(events); _recordFailureMetrics(events);
await _runAfterSend( await _runAfterSend(
@ -359,7 +395,8 @@ class AnalyticsCore {
} }
if (dropped.isNotEmpty) { if (dropped.isNotEmpty) {
final droppedIds = dropped.map((item) => item.stored.id).toList(); final droppedIds =
dropped.map((item) => item.stored.id).toList(growable: false);
await storage.deleteByIds(droppedIds); await storage.deleteByIds(droppedIds);
final droppedEvents = final droppedEvents =
dropped.map((item) => item.event).toList(growable: false); dropped.map((item) => item.event).toList(growable: false);
@ -378,7 +415,7 @@ class AnalyticsCore {
return null; return null;
} }
current = next; current = next;
} catch (e, st) { } on Object catch (e, st) {
Logger.error('beforeSend 拦截器异常,已忽略并继续', e, st); Logger.error('beforeSend 拦截器异常,已忽略并继续', e, st);
} }
} }
@ -396,7 +433,7 @@ class AnalyticsCore {
for (final item in events) { for (final item in events) {
try { try {
await interceptor.afterSend(item.event, result); await interceptor.afterSend(item.event, result);
} catch (e, st) { } on Object catch (e, st) {
Logger.error('afterSend 拦截器异常,已忽略并继续', e, st); Logger.error('afterSend 拦截器异常,已忽略并继续', e, st);
} }
} }
@ -428,7 +465,8 @@ class AnalyticsCore {
return !sampledIn; return !sampledIn;
} }
bool _isInternalEvent(String eventType) => _internalEventTypes.contains(eventType); bool _isInternalEvent(String eventType) =>
_internalEventTypes.contains(eventType);
int _countNonInternalEvents(List<Event> events) { int _countNonInternalEvents(List<Event> events) {
var count = 0; var count = 0;
@ -480,7 +518,10 @@ class AnalyticsCore {
Future<void> _reportMetrics() async { Future<void> _reportMetrics() async {
final config = _config; final config = _config;
final storage = _storage; final storage = _storage;
if (!_initialized || config == null || storage == null || !config.enableMetrics) { if (!_initialized ||
config == null ||
storage == null ||
!config.enableMetrics) {
return; return;
} }
@ -488,7 +529,8 @@ class AnalyticsCore {
final windowStart = _lastMetricsReportTime ?? now; final windowStart = _lastMetricsReportTime ?? now;
final windowMs = now.difference(windowStart).inMilliseconds; final windowMs = now.difference(windowStart).inMilliseconds;
final queueSize = await storage.count(); final queueSize = await storage.count();
final avgLatencyMs = _latencySamples == 0 ? 0 : _totalLatencyMs ~/ _latencySamples; final avgLatencyMs =
_latencySamples == 0 ? 0 : _totalLatencyMs ~/ _latencySamples;
final sendParams = <String, dynamic>{ final sendParams = <String, dynamic>{
'sentCount': _sentCount, 'sentCount': _sentCount,
@ -504,8 +546,16 @@ class AnalyticsCore {
'windowMs': windowMs, 'windowMs': windowMs,
}; };
await _track(_metricsSendEventType, eventParams: sendParams, internal: true); await _track(
await _track(_metricsQueueEventType, eventParams: queueParams, internal: true); _metricsSendEventType,
eventParams: sendParams,
internal: true,
);
await _track(
_metricsQueueEventType,
eventParams: queueParams,
internal: true,
);
_resetMetricsWindow(startAt: now); _resetMetricsWindow(startAt: now);
} }
@ -576,10 +626,12 @@ class AnalyticsCore {
return Duration(seconds: seconds); return Duration(seconds: seconds);
} }
Future<void> setDebug(bool enabled) async { /// Debug
Logger.setDebug(enabled); Future<void> setDebug({required bool enabled}) async {
Logger.debugEnabled = enabled;
} }
///
Future<void> dispose() async { Future<void> dispose() async {
_scheduler?.dispose(); _scheduler?.dispose();
_scheduler = null; _scheduler = null;
@ -605,6 +657,7 @@ class AnalyticsCore {
_nextAllowedFlushTime = null; _nextAllowedFlushTime = null;
_interceptors.clear(); _interceptors.clear();
_resetMetricsWindow(); _resetMetricsWindow();
_lastExpirationSweep = null;
Logger.info('AnalyticsCore 已释放资源'); Logger.info('AnalyticsCore 已释放资源');
} }
@ -655,7 +708,8 @@ class AnalyticsCore {
final typeMismatchFields = result.warnings final typeMismatchFields = result.warnings
.where( .where(
(w) => (w) =>
w.code == Validator.typeMismatch && (w.field?.isNotEmpty ?? false), w.code == Validator.typeMismatch &&
(w.field?.isNotEmpty ?? false),
) )
.map((w) => w.field!) .map((w) => w.field!)
.toList(growable: false); .toList(growable: false);
@ -693,20 +747,53 @@ class AnalyticsCore {
_interceptors.insert(0, CommonTagsInterceptor()); _interceptors.insert(0, CommonTagsInterceptor());
} }
} }
EventStorage _buildDefaultStorage(AnalyticsConfig config) {
if (config.useIsolateStorage) {
return IsolateEventStorage();
}
return SqfliteEventStorage();
}
Future<void> _maybePurgeExpired({required String reason}) async {
final config = _config;
final storage = _storage;
if (!_initialized || config == null || storage == null) {
return;
}
if (config.maxEventAge <= Duration.zero) {
return;
}
final now = _now();
final lastSweep = _lastExpirationSweep;
if (lastSweep != null &&
now.difference(lastSweep) < _expirationSweepInterval) {
return;
}
_lastExpirationSweep = now;
final cutoff = now.subtract(config.maxEventAge);
final removed = await storage.deleteExpired(cutoff);
if (removed > 0) {
_droppedCount += removed;
Logger.info(
'已清理过期事件: count=$removed cutoff=$cutoff reason=$reason',
);
}
}
} }
class _PreparedBatch { class _PreparedBatch {
final List<_SendableStoredEvent> sendable;
const _PreparedBatch({required this.sendable}); const _PreparedBatch({required this.sendable});
final List<_SendableStoredEvent> sendable;
} }
class _SendableStoredEvent { class _SendableStoredEvent {
final StoredEvent stored;
final Event event;
const _SendableStoredEvent({ const _SendableStoredEvent({
required this.stored, required this.stored,
required this.event, required this.event,
}); });
final StoredEvent stored;
final Event event;
} }

View File

@ -1,27 +1,37 @@
import 'dart:async'; import 'dart:async';
import '../model/event.dart'; import 'package:yx_tracking_flutter/src/model/event.dart';
import '../util/sdk_info.dart'; import 'package:yx_tracking_flutter/src/util/sdk_info.dart';
/// ///
class SendResult { class SendResult {
final bool success; ///
final int? statusCode;
final bool retryable;
final Object? error;
const SendResult({ const SendResult({
required this.success, required this.success,
required this.retryable, required this.retryable,
this.statusCode, this.statusCode,
this.error, this.error,
}); });
///
final bool success;
/// HTTP
final int? statusCode;
///
final bool retryable;
///
final Object? error;
} }
/// ///
abstract class AnalyticsInterceptor { abstract class AnalyticsInterceptor {
/// null
FutureOr<Event?> beforeSend(Event event) => event; FutureOr<Event?> beforeSend(Event event) => event;
///
FutureOr<void> afterSend(Event event, SendResult result) {} FutureOr<void> afterSend(Event event, SendResult result) {}
} }
@ -32,8 +42,8 @@ class CommonTagsInterceptor extends AnalyticsInterceptor {
final tags = Map<String, dynamic>.from( final tags = Map<String, dynamic>.from(
event.customTags ?? const <String, dynamic>{}, event.customTags ?? const <String, dynamic>{},
); );
tags.putIfAbsent('_sdk_version', () => SdkInfo.sdkVersion); tags['_sdk_version'] ??= SdkInfo.sdkVersion;
tags.putIfAbsent('_platform', () => SdkInfo.platform); tags['_platform'] ??= SdkInfo.platform;
return event.copyWith(customTags: tags); return event.copyWith(customTags: tags);
} }
} }

View File

@ -1,21 +1,27 @@
import 'dart:async'; import 'dart:async';
import '../util/logger.dart'; import 'package:yx_tracking_flutter/src/util/logger.dart';
/// ///
class Scheduler { class Scheduler {
final Duration interval; ///
final Future<void> Function() onTick;
Timer? _timer;
Scheduler({ Scheduler({
required this.interval, required this.interval,
required this.onTick, required this.onTick,
}); });
///
final Duration interval;
/// tick
final Future<void> Function() onTick;
Timer? _timer;
///
bool get isRunning => _timer?.isActive ?? false; bool get isRunning => _timer?.isActive ?? false;
///
void start() { void start() {
if (isRunning) { if (isRunning) {
return; return;
@ -23,18 +29,21 @@ class Scheduler {
_timer = Timer.periodic(interval, (_) async { _timer = Timer.periodic(interval, (_) async {
try { try {
await onTick(); await onTick();
} catch (e, st) { } on Object catch (e, st) {
Logger.error('Scheduler tick 执行失败', e, st); Logger.error('Scheduler tick 执行失败', e, st);
} }
}); });
Logger.info('Scheduler 已启动: interval=${interval.inSeconds}s'); final seconds = interval.inSeconds;
Logger.info('Scheduler 已启动: interval=${seconds}s');
} }
///
void stop() { void stop() {
_timer?.cancel(); _timer?.cancel();
_timer = null; _timer = null;
Logger.info('Scheduler 已停止'); Logger.info('Scheduler 已停止');
} }
///
void dispose() => stop(); void dispose() => stop();
} }

View File

@ -1,52 +1,72 @@
import '../config/config_manager.dart'; import 'package:yx_tracking_flutter/src/config/config_manager.dart';
import '../model/event.dart'; import 'package:yx_tracking_flutter/src/model/event.dart';
/// ///
class ValidationResult { class ValidationResult {
final List<ValidationIssue> errors; ///
final List<ValidationIssue> warnings;
const ValidationResult({ const ValidationResult({
this.errors = const <ValidationIssue>[], this.errors = const <ValidationIssue>[],
this.warnings = const <ValidationIssue>[], this.warnings = const <ValidationIssue>[],
}); });
///
final List<ValidationIssue> errors;
///
final List<ValidationIssue> warnings;
///
bool get hasErrors => errors.isNotEmpty; bool get hasErrors => errors.isNotEmpty;
///
bool get hasWarnings => warnings.isNotEmpty; bool get hasWarnings => warnings.isNotEmpty;
///
bool get isEmpty => !hasErrors && !hasWarnings; bool get isEmpty => !hasErrors && !hasWarnings;
} }
/// ///
class ValidationIssue { class ValidationIssue {
final String code; ///
final String message;
final String? field;
const ValidationIssue({ const ValidationIssue({
required this.code, required this.code,
required this.message, required this.message,
this.field, this.field,
}); });
///
final String code;
///
final String message;
///
final String? field;
@override @override
String toString() { String toString() =>
if (field == null || field!.isEmpty) { field == null || field!.isEmpty
return '$code: $message'; ? '$code: $message'
} : '$code($field): $message';
return '$code($field): $message';
}
} }
/// Phase 2 /// Phase 2
class Validator { class Validator {
///
const Validator(this._configManager);
///
static const String unknownEventType = 'UNKNOWN_EVENT_TYPE'; static const String unknownEventType = 'UNKNOWN_EVENT_TYPE';
///
static const String missingRequiredTag = 'MISSING_REQUIRED_TAG'; static const String missingRequiredTag = 'MISSING_REQUIRED_TAG';
///
static const String typeMismatch = 'TYPE_MISMATCH'; static const String typeMismatch = 'TYPE_MISMATCH';
final ConfigManager _configManager; final ConfigManager _configManager;
const Validator(this._configManager); ///
ValidationResult validate(Event event) { ValidationResult validate(Event event) {
final config = _configManager.currentConfig; final config = _configManager.currentConfig;
if (config == null) { if (config == null) {

View File

@ -1,20 +1,25 @@
/// ///
class DeviceInfo { class DeviceInfo {
final String os; ///
final String model;
final String screenResolution;
const DeviceInfo({ const DeviceInfo({
required this.os, required this.os,
required this.model, required this.model,
required this.screenResolution, required this.screenResolution,
}); });
Map<String, dynamic> toJson() { ///
return <String, dynamic>{ final String os;
///
final String model;
///
final String screenResolution;
/// JSON
Map<String, dynamic> toJson() => <String, dynamic>{
'os': os, 'os': os,
'model': model, 'model': model,
'screenResolution': screenResolution, 'screenResolution': screenResolution,
}; };
} }
}

View File

@ -1,22 +1,11 @@
import 'dart:convert'; import 'dart:convert';
import 'device_info.dart'; import 'package:yx_tracking_flutter/src/model/device_info.dart';
import 'user_info.dart'; import 'package:yx_tracking_flutter/src/model/user_info.dart';
/// ///
class Event { class Event {
final String systemCode; ///
final String eventType;
final UserInfo? userInfo;
final int clientType;
final int clientTimestamp;
final String timestamp;
final DeviceInfo deviceInfo;
final Map<String, dynamic>? eventParams;
final Map<String, dynamic>? customTags;
final DateTime createTime;
final int retryCount;
const Event({ const Event({
required this.systemCode, required this.systemCode,
required this.eventType, required this.eventType,
@ -31,46 +20,8 @@ class Event {
this.retryCount = 0, this.retryCount = 0,
}); });
Event copyWith({ /// payload
UserInfo? userInfo, factory Event.fromPayload(
DeviceInfo? deviceInfo,
Map<String, dynamic>? eventParams,
Map<String, dynamic>? customTags,
DateTime? createTime,
int? retryCount,
}) {
return Event(
systemCode: systemCode,
eventType: eventType,
userInfo: userInfo ?? this.userInfo,
clientType: clientType,
clientTimestamp: clientTimestamp,
timestamp: timestamp,
deviceInfo: deviceInfo ?? this.deviceInfo,
eventParams: eventParams ?? this.eventParams,
customTags: customTags ?? this.customTags,
createTime: createTime ?? this.createTime,
retryCount: retryCount ?? this.retryCount,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'system_code': systemCode,
'eventType': eventType,
'userInfo': userInfo?.toJson(),
'clientType': clientType,
'clientTimestamp': clientTimestamp,
'timestamp': timestamp,
'deviceInfo': deviceInfo.toJson(),
'eventParams': eventParams,
'customTags': customTags,
};
}
String toPayload() => jsonEncode(toJson());
static Event fromPayload(
String payload, { String payload, {
required DateTime createTime, required DateTime createTime,
required int retryCount, required int retryCount,
@ -79,10 +30,15 @@ class Event {
if (decoded is! Map<String, dynamic>) { if (decoded is! Map<String, dynamic>) {
throw const FormatException('事件 payload 不是合法的 JSON 对象'); throw const FormatException('事件 payload 不是合法的 JSON 对象');
} }
return fromJson(decoded, createTime: createTime, retryCount: retryCount); return Event.fromJson(
decoded,
createTime: createTime,
retryCount: retryCount,
);
} }
static Event fromJson( /// JSON
factory Event.fromJson(
Map<String, dynamic> json, { Map<String, dynamic> json, {
required DateTime createTime, required DateTime createTime,
required int retryCount, required int retryCount,
@ -110,7 +66,8 @@ class Event {
deviceInfo: DeviceInfo( deviceInfo: DeviceInfo(
os: deviceJson['os']?.toString() ?? 'unknown', os: deviceJson['os']?.toString() ?? 'unknown',
model: deviceJson['model']?.toString() ?? 'unknown', model: deviceJson['model']?.toString() ?? 'unknown',
screenResolution: deviceJson['screenResolution']?.toString() ?? 'unknown', screenResolution:
deviceJson['screenResolution']?.toString() ?? 'unknown',
), ),
eventParams: _toMap(json['eventParams']), eventParams: _toMap(json['eventParams']),
customTags: _toMap(json['customTags']), customTags: _toMap(json['customTags']),
@ -119,6 +76,78 @@ class Event {
); );
} }
/// system_code
final String systemCode;
///
final String eventType;
///
final UserInfo? userInfo;
/// client_type
final int clientType;
///
final int clientTimestamp;
///
final String timestamp;
///
final DeviceInfo deviceInfo;
///
final Map<String, dynamic>? eventParams;
///
final Map<String, dynamic>? customTags;
///
final DateTime createTime;
///
final int retryCount;
///
Event copyWith({
UserInfo? userInfo,
DeviceInfo? deviceInfo,
Map<String, dynamic>? eventParams,
Map<String, dynamic>? customTags,
DateTime? createTime,
int? retryCount,
}) =>
Event(
systemCode: systemCode,
eventType: eventType,
userInfo: userInfo ?? this.userInfo,
clientType: clientType,
clientTimestamp: clientTimestamp,
timestamp: timestamp,
deviceInfo: deviceInfo ?? this.deviceInfo,
eventParams: eventParams ?? this.eventParams,
customTags: customTags ?? this.customTags,
createTime: createTime ?? this.createTime,
retryCount: retryCount ?? this.retryCount,
);
/// JSON
Map<String, dynamic> toJson() => <String, dynamic>{
'system_code': systemCode,
'eventType': eventType,
'userInfo': userInfo?.toJson(),
'clientType': clientType,
'clientTimestamp': clientTimestamp,
'timestamp': timestamp,
'deviceInfo': deviceInfo.toJson(),
'eventParams': eventParams,
'customTags': customTags,
};
/// payload
String toPayload() => jsonEncode(toJson());
static int? _toInt(dynamic value) { static int? _toInt(dynamic value) {
if (value is int) { if (value is int) {
return value; return value;
@ -150,11 +179,7 @@ class Event {
/// ///
class StoredEvent { class StoredEvent {
final int id; ///
final Event event;
final int retryCount;
final DateTime createTime;
const StoredEvent({ const StoredEvent({
required this.id, required this.id,
required this.event, required this.event,
@ -162,17 +187,29 @@ class StoredEvent {
required this.createTime, required this.createTime,
}); });
/// ID
final int id;
///
final Event event;
///
final int retryCount;
///
final DateTime createTime;
///
StoredEvent copyWith({ StoredEvent copyWith({
int? id, int? id,
Event? event, Event? event,
int? retryCount, int? retryCount,
DateTime? createTime, DateTime? createTime,
}) { }) =>
return StoredEvent( StoredEvent(
id: id ?? this.id, id: id ?? this.id,
event: event ?? this.event, event: event ?? this.event,
retryCount: retryCount ?? this.retryCount, retryCount: retryCount ?? this.retryCount,
createTime: createTime ?? this.createTime, createTime: createTime ?? this.createTime,
); );
} }
}

View File

@ -1,14 +1,22 @@
/// ///
class RecentEventSummary { class RecentEventSummary {
final int id; ///
final String eventType;
final DateTime createTime;
final int retryCount;
const RecentEventSummary({ const RecentEventSummary({
required this.id, required this.id,
required this.eventType, required this.eventType,
required this.createTime, required this.createTime,
required this.retryCount, required this.retryCount,
}); });
/// ID
final int id;
///
final String eventType;
///
final DateTime createTime;
///
final int retryCount;
} }

View File

@ -1,12 +1,6 @@
/// Phase 2 /// Phase 2
class SystemDimInfo { class SystemDimInfo {
final SystemInfo? systemInfo; ///
final List<EventDefinition> eventDefinitions;
final List<TagDefinition> tagDefinitions;
final SdkStrategy? sdkStrategy;
final DateTime lastFetchedAt;
final String? version;
const SystemDimInfo({ const SystemDimInfo({
required this.systemInfo, required this.systemInfo,
required this.eventDefinitions, required this.eventDefinitions,
@ -16,57 +10,8 @@ class SystemDimInfo {
this.version, this.version,
}); });
bool hasEvent(String eventType) { /// JSON
return eventDefinitions.any((e) => e.eventCode == eventType); factory SystemDimInfo.fromCacheJson(Map<String, dynamic> json) {
}
List<TagDefinition> get requiredTags {
return tagDefinitions.where((t) => t.isRequired).toList(growable: false);
}
bool get isStrategyEnabled => sdkStrategy?.enabled ?? true;
EventStrategy? strategyFor(String eventType) {
final strategy = sdkStrategy;
if (strategy == null) {
return null;
}
return strategy.eventSettings[eventType];
}
double sampleRateFor(String eventType) {
final strategy = sdkStrategy;
if (strategy == null) {
return 1.0;
}
final eventStrategy = strategy.eventSettings[eventType];
return eventStrategy?.sampleRate ?? strategy.defaultSampleRate;
}
bool isEventEnabledByStrategy(String eventType) {
final strategy = sdkStrategy;
if (strategy == null) {
return true;
}
if (!strategy.enabled) {
return false;
}
final eventStrategy = strategy.eventSettings[eventType];
return eventStrategy?.enabled ?? true;
}
Map<String, dynamic> toCacheJson() {
return <String, dynamic>{
'systemInfo': systemInfo?.toJson(),
'eventDefinitions': eventDefinitions.map((e) => e.toJson()).toList(),
'tagDefinitions': tagDefinitions.map((t) => t.toJson()).toList(),
'sdkStrategy': sdkStrategy?.toJson(),
'lastFetchedAt': lastFetchedAt.millisecondsSinceEpoch,
'version': version,
};
}
static SystemDimInfo fromCacheJson(Map<String, dynamic> json) {
final lastFetchedMs = _toInt(json['lastFetchedAt']) ?? 0; final lastFetchedMs = _toInt(json['lastFetchedAt']) ?? 0;
final fetchedAt = DateTime.fromMillisecondsSinceEpoch(lastFetchedMs); final fetchedAt = DateTime.fromMillisecondsSinceEpoch(lastFetchedMs);
@ -90,7 +35,7 @@ class SystemDimInfo {
/// ///
/// ///
/// systemCustonTas /// systemCustonTas
static SystemDimInfo fromResponse( factory SystemDimInfo.fromResponse(
Map<String, dynamic> json, { Map<String, dynamic> json, {
DateTime? fetchedAt, DateTime? fetchedAt,
String? version, String? version,
@ -114,6 +59,80 @@ class SystemDimInfo {
); );
} }
///
final SystemInfo? systemInfo;
///
final List<EventDefinition> eventDefinitions;
///
final List<TagDefinition> tagDefinitions;
/// SDK
final SdkStrategy? sdkStrategy;
///
final DateTime lastFetchedAt;
///
final String? version;
///
bool hasEvent(String eventType) =>
eventDefinitions.any((e) => e.eventCode == eventType);
///
List<TagDefinition> get requiredTags =>
tagDefinitions.where((t) => t.isRequired).toList(growable: false);
///
bool get isStrategyEnabled => sdkStrategy?.enabled ?? true;
///
EventStrategy? strategyFor(String eventType) {
final strategy = sdkStrategy;
if (strategy == null) {
return null;
}
return strategy.eventSettings[eventType];
}
///
double sampleRateFor(String eventType) {
final strategy = sdkStrategy;
if (strategy == null) {
return 1;
}
final eventStrategy = strategy.eventSettings[eventType];
return eventStrategy?.sampleRate ?? strategy.defaultSampleRate;
}
///
bool isEventEnabledByStrategy(String eventType) {
final strategy = sdkStrategy;
if (strategy == null) {
return true;
}
if (!strategy.enabled) {
return false;
}
final eventStrategy = strategy.eventSettings[eventType];
return eventStrategy?.enabled ?? true;
}
/// JSON
Map<String, dynamic> toCacheJson() => <String, dynamic>{
'systemInfo': systemInfo?.toJson(),
'eventDefinitions':
eventDefinitions.map((e) => e.toJson()).toList(growable: false),
'tagDefinitions':
tagDefinitions.map((t) => t.toJson()).toList(growable: false),
'sdkStrategy': sdkStrategy?.toJson(),
'lastFetchedAt': lastFetchedAt.millisecondsSinceEpoch,
'version': version,
};
///
static List<EventDefinition> _parseEventDefinitions(dynamic value) { static List<EventDefinition> _parseEventDefinitions(dynamic value) {
if (value is! List) { if (value is! List) {
return const <EventDefinition>[]; return const <EventDefinition>[];
@ -150,6 +169,7 @@ class SystemDimInfo {
return results; return results;
} }
///
static List<TagDefinition> _parseTagDefinitions(dynamic value) { static List<TagDefinition> _parseTagDefinitions(dynamic value) {
if (value is! List) { if (value is! List) {
return const <TagDefinition>[]; return const <TagDefinition>[];
@ -172,7 +192,8 @@ class SystemDimInfo {
const <String>['tagType', 'tag_type', 'type'], const <String>['tagType', 'tag_type', 'type'],
) ?? ) ??
'string'; 'string';
final required = _toBool(map['isRequired']) ?? _toBool(map['is_required']) ?? false; final required =
_toBool(map['isRequired']) ?? _toBool(map['is_required']) ?? false;
final desc = _stringFirst(map, const <String>['description', 'desc']); final desc = _stringFirst(map, const <String>['description', 'desc']);
results.add( results.add(
@ -187,6 +208,7 @@ class SystemDimInfo {
return results; return results;
} }
/// SDK
static SdkStrategy? _parseSdkStrategy(dynamic value) { static SdkStrategy? _parseSdkStrategy(dynamic value) {
if (value is! Map) { if (value is! Map) {
return null; return null;
@ -194,7 +216,7 @@ class SystemDimInfo {
final map = value.map((k, v) => MapEntry(k.toString(), v)); final map = value.map((k, v) => MapEntry(k.toString(), v));
final enabled = _toBool(map['enabled']) ?? true; final enabled = _toBool(map['enabled']) ?? true;
final defaultSampleRate = final defaultSampleRate =
_clampSampleRate(_toDouble(map['defaultSampleRate']) ?? 1.0); _clampSampleRate(_toDouble(map['defaultSampleRate']) ?? 1);
final eventSettingsRaw = map['eventSettings']; final eventSettingsRaw = map['eventSettings'];
final eventSettings = <String, EventStrategy>{}; final eventSettings = <String, EventStrategy>{};
@ -206,7 +228,9 @@ class SystemDimInfo {
final rawMap = rawValue.map((k, v) => MapEntry(k.toString(), v)); final rawMap = rawValue.map((k, v) => MapEntry(k.toString(), v));
final eventEnabled = _toBool(rawMap['enabled']) ?? true; final eventEnabled = _toBool(rawMap['enabled']) ?? true;
final sampleRate = final sampleRate =
_clampSampleRate(_toDouble(rawMap['sampleRate']) ?? defaultSampleRate); _clampSampleRate(
_toDouble(rawMap['sampleRate']) ?? defaultSampleRate,
);
eventSettings[key.toString()] = EventStrategy( eventSettings[key.toString()] = EventStrategy(
enabled: eventEnabled, enabled: eventEnabled,
sampleRate: sampleRate, sampleRate: sampleRate,
@ -221,6 +245,7 @@ class SystemDimInfo {
); );
} }
/// key
static String? _stringFirst(Map<String, dynamic> map, List<String> keys) { static String? _stringFirst(Map<String, dynamic> map, List<String> keys) {
for (final key in keys) { for (final key in keys) {
final value = map[key]; final value = map[key];
@ -235,6 +260,7 @@ class SystemDimInfo {
return null; return null;
} }
/// int
static int? _toInt(dynamic value) { static int? _toInt(dynamic value) {
if (value is int) { if (value is int) {
return value; return value;
@ -248,6 +274,7 @@ class SystemDimInfo {
return null; return null;
} }
/// double
static double? _toDouble(dynamic value) { static double? _toDouble(dynamic value) {
if (value is double) { if (value is double) {
return value; return value;
@ -261,9 +288,10 @@ class SystemDimInfo {
return null; return null;
} }
/// [0, 1]
static double _clampSampleRate(double value) { static double _clampSampleRate(double value) {
if (value.isNaN || value.isInfinite) { if (value.isNaN || value.isInfinite) {
return 1.0; return 1;
} }
if (value < 0) { if (value < 0) {
return 0; return 0;
@ -274,6 +302,7 @@ class SystemDimInfo {
return value; return value;
} }
/// bool
static bool? _toBool(dynamic value) { static bool? _toBool(dynamic value) {
if (value is bool) { if (value is bool) {
return value; return value;
@ -296,43 +325,49 @@ class SystemDimInfo {
/// systemInfo /// systemInfo
class SystemInfo { class SystemInfo {
final Map<String, dynamic> raw; ///
const SystemInfo({required this.raw}); const SystemInfo({required this.raw});
/// JSON
factory SystemInfo.fromJson(Map<String, dynamic> json) =>
SystemInfo(raw: json);
///
final Map<String, dynamic> raw;
/// JSON
Map<String, dynamic> toJson() => raw; Map<String, dynamic> toJson() => raw;
static SystemInfo fromJson(Map<String, dynamic> json) {
return SystemInfo(raw: json);
}
} }
///
class EventDefinition { class EventDefinition {
final String eventCode; ///
final String? eventName;
final String? description;
const EventDefinition({ const EventDefinition({
required this.eventCode, required this.eventCode,
this.eventName, this.eventName,
this.description, this.description,
}); });
Map<String, dynamic> toJson() { ///
return <String, dynamic>{ final String eventCode;
///
final String? eventName;
///
final String? description;
/// JSON
Map<String, dynamic> toJson() => <String, dynamic>{
'eventCode': eventCode, 'eventCode': eventCode,
'eventName': eventName, 'eventName': eventName,
'description': description, 'description': description,
}; };
} }
}
///
class TagDefinition { class TagDefinition {
final String tagName; ///
final String tagType;
final bool isRequired;
final String? description;
const TagDefinition({ const TagDefinition({
required this.tagName, required this.tagName,
required this.tagType, required this.tagType,
@ -340,30 +375,47 @@ class TagDefinition {
this.description, this.description,
}); });
Map<String, dynamic> toJson() { ///
return <String, dynamic>{ final String tagName;
///
final String tagType;
///
final bool isRequired;
///
final String? description;
/// JSON
Map<String, dynamic> toJson() => <String, dynamic>{
'tagName': tagName, 'tagName': tagName,
'tagType': tagType, 'tagType': tagType,
'isRequired': isRequired, 'isRequired': isRequired,
'description': description, 'description': description,
}; };
} }
}
/// Phase 3SDK /// Phase 3SDK
class SdkStrategy { class SdkStrategy {
final bool enabled; /// SDK
final double defaultSampleRate;
final Map<String, EventStrategy> eventSettings;
const SdkStrategy({ const SdkStrategy({
required this.enabled, required this.enabled,
required this.defaultSampleRate, required this.defaultSampleRate,
required this.eventSettings, required this.eventSettings,
}); });
Map<String, dynamic> toJson() { ///
return <String, dynamic>{ final bool enabled;
///
final double defaultSampleRate;
///
final Map<String, EventStrategy> eventSettings;
/// JSON
Map<String, dynamic> toJson() => <String, dynamic>{
'enabled': enabled, 'enabled': enabled,
'defaultSampleRate': defaultSampleRate, 'defaultSampleRate': defaultSampleRate,
'eventSettings': eventSettings.map( 'eventSettings': eventSettings.map(
@ -371,22 +423,24 @@ class SdkStrategy {
), ),
}; };
} }
}
/// Phase 3 /// Phase 3
class EventStrategy { class EventStrategy {
final bool enabled; ///
final double sampleRate;
const EventStrategy({ const EventStrategy({
required this.enabled, required this.enabled,
required this.sampleRate, required this.sampleRate,
}); });
Map<String, dynamic> toJson() { ///
return <String, dynamic>{ final bool enabled;
///
final double sampleRate;
/// JSON
Map<String, dynamic> toJson() => <String, dynamic>{
'enabled': enabled, 'enabled': enabled,
'sampleRate': sampleRate, 'sampleRate': sampleRate,
}; };
} }
}

View File

@ -1,22 +1,25 @@
/// ///
class UserInfo { class UserInfo {
final int? userId; ///
final String? userName;
final String? account;
const UserInfo({ const UserInfo({
this.userId, this.userId,
this.userName, this.userName,
this.account, this.account,
}); });
Map<String, dynamic> toJson() { /// ID
final map = <String, dynamic>{ final int? userId;
///
final String? userName;
///
final String? account;
/// JSON
Map<String, dynamic> toJson() => <String, dynamic>{
'userId': userId, 'userId': userId,
'userName': userName, 'userName': userName,
'account': account, 'account': account,
}; }..removeWhere((_, value) => value == null);
map.removeWhere((_, value) => value == null);
return map;
}
} }

View File

@ -1,17 +1,13 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../config/analytics_config.dart'; import 'package:yx_tracking_flutter/src/config/analytics_config.dart';
import '../model/event.dart'; import 'package:yx_tracking_flutter/src/model/event.dart';
import '../util/logger.dart'; import 'package:yx_tracking_flutter/src/network/http_client.dart';
import 'http_client.dart'; import 'package:yx_tracking_flutter/src/util/logger.dart';
/// API /// API
class ApiException implements Exception { class ApiException implements Exception {
final int? statusCode; /// API
final String message;
final bool retryable;
final Object? data;
const ApiException({ const ApiException({
required this.message, required this.message,
required this.retryable, required this.retryable,
@ -19,20 +15,36 @@ class ApiException implements Exception {
this.data, this.data,
}); });
/// HTTP
final int? statusCode;
///
final String message;
///
final bool retryable;
///
final Object? data;
@override @override
String toString() { String toString() =>
return 'ApiException(statusCode: $statusCode, retryable: $retryable, message: $message)'; 'ApiException(statusCode: $statusCode, retryable: $retryable, '
} 'message: $message)';
} }
/// ///
class ApiClient { class ApiClient {
/// API
ApiClient(
AnalyticsConfig config, {
HttpClient? httpClient,
}) : _httpClient = httpClient ?? HttpClient(config);
static const String _addEventListLogPath = 'AddEventListLog'; static const String _addEventListLogPath = 'AddEventListLog';
final HttpClient _httpClient; final HttpClient _httpClient;
ApiClient(AnalyticsConfig config) : _httpClient = HttpClient(config);
/// ///
/// ///
/// [ApiException] /// [ApiException]
@ -59,12 +71,13 @@ class ApiClient {
); );
} }
Logger.info('批量上报成功: size=${events.length}, status=$statusCode'); final size = events.length;
Logger.info('批量上报成功: size=$size, status=$statusCode');
} on DioException catch (e, st) { } on DioException catch (e, st) {
final apiException = _mapDioException(e); final apiException = _mapDioException(e);
Logger.error('批量上报异常: ${apiException.message}', apiException, st); Logger.error('批量上报异常: ${apiException.message}', apiException, st);
throw apiException; throw apiException;
} catch (e, st) { } on Object catch (e, st) {
Logger.error('批量上报未知异常', e, st); Logger.error('批量上报未知异常', e, st);
rethrow; rethrow;
} }
@ -86,7 +99,6 @@ class ApiClient {
// //
return ApiException( return ApiException(
statusCode: null,
retryable: true, retryable: true,
message: e.message ?? '网络异常或请求失败', message: e.message ?? '网络异常或请求失败',
data: e.error, data: e.error,

View File

@ -1,12 +1,15 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:meta/meta.dart';
import '../config/analytics_config.dart'; import 'package:yx_tracking_flutter/src/config/analytics_config.dart';
/// Dio /// Dio
class HttpClient { class HttpClient {
final Dio _dio; /// HTTP
HttpClient(
HttpClient(AnalyticsConfig config) AnalyticsConfig config, {
HttpClientAdapter? httpClientAdapter,
})
: _dio = Dio( : _dio = Dio(
BaseOptions( BaseOptions(
baseUrl: _normalizeBaseUrl(config.endpointBaseUrl), baseUrl: _normalizeBaseUrl(config.endpointBaseUrl),
@ -17,39 +20,47 @@ class HttpClient {
Headers.contentTypeHeader: Headers.jsonContentType, Headers.contentTypeHeader: Headers.jsonContentType,
}, },
), ),
); ) {
if (httpClientAdapter != null) {
_dio.httpClientAdapter = httpClientAdapter;
}
}
final Dio _dio;
/// Dio使
@visibleForTesting
Dio get dio => _dio; Dio get dio => _dio;
/// GET
Future<Response<T>> get<T>( Future<Response<T>> get<T>(
String path, { String path, {
Map<String, dynamic>? queryParameters, Map<String, dynamic>? queryParameters,
Map<String, Object?>? headers, Map<String, Object?>? headers,
CancelToken? cancelToken, CancelToken? cancelToken,
}) { }) =>
return _dio.get<T>( _dio.get<T>(
path, path,
queryParameters: queryParameters, queryParameters: queryParameters,
options: _withHeaders(headers), options: _withHeaders(headers),
cancelToken: cancelToken, cancelToken: cancelToken,
); );
}
/// POST
Future<Response<T>> post<T>( Future<Response<T>> post<T>(
String path, { String path, {
Object? data, Object? data,
Map<String, dynamic>? queryParameters, Map<String, dynamic>? queryParameters,
Map<String, Object?>? headers, Map<String, Object?>? headers,
CancelToken? cancelToken, CancelToken? cancelToken,
}) { }) =>
return _dio.post<T>( _dio.post<T>(
path, path,
data: data, data: data,
queryParameters: queryParameters, queryParameters: queryParameters,
options: _withHeaders(headers), options: _withHeaders(headers),
cancelToken: cancelToken, cancelToken: cancelToken,
); );
}
Options? _withHeaders(Map<String, Object?>? headers) { Options? _withHeaders(Map<String, Object?>? headers) {
if (headers == null || headers.isEmpty) { if (headers == null || headers.isEmpty) {

View File

@ -1,14 +1,19 @@
import '../model/system_dim_info.dart'; import 'package:yx_tracking_flutter/src/model/system_dim_info.dart';
/// ///
abstract class ConfigStorage { abstract class ConfigStorage {
///
Future<void> init(); Future<void> init();
///
Future<void> saveSystemDimInfo(SystemDimInfo info); Future<void> saveSystemDimInfo(SystemDimInfo info);
///
Future<SystemDimInfo?> loadSystemDimInfo(); Future<SystemDimInfo?> loadSystemDimInfo();
///
Future<void> clear(); Future<void> clear();
///
Future<void> dispose(); Future<void> dispose();
} }

View File

@ -1,7 +1,14 @@
/// SQLite /// SQLite
class DbConstants { class DbConstants {
static const String dbName = 'yx_tracking_flutter.db'; ///
static const int dbVersion = 1; DbConstants._();
const DbConstants._(); /// lint
factory DbConstants.instance() => DbConstants._();
///
static const String dbName = 'yx_tracking_flutter.db';
///
static const int dbVersion = 1;
} }

View File

@ -1,7 +1,8 @@
import '../model/event.dart'; import 'package:yx_tracking_flutter/src/model/event.dart';
/// ///
abstract class EventStorage { abstract class EventStorage {
///
Future<void> init(); Future<void> init();
/// ID /// ID
@ -13,13 +14,18 @@ abstract class EventStorage {
/// create_time /// create_time
Future<List<StoredEvent>> fetchRecent(int limit); Future<List<StoredEvent>> fetchRecent(int limit);
/// ID
Future<void> deleteByIds(List<int> ids); Future<void> deleteByIds(List<int> ids);
///
Future<int> count(); Future<int> count();
/// ///
Future<int> trimToMaxSize(int maxSize); Future<int> trimToMaxSize(int maxSize);
///
Future<int> deleteExpired(DateTime cutoff);
/// ///
Future<void> updateRetryCount(int id, int retryCount); Future<void> updateRetryCount(int id, int retryCount);

View File

@ -0,0 +1,494 @@
import 'dart:async';
import 'dart:isolate';
import 'package:flutter/services.dart';
import 'package:yx_tracking_flutter/src/model/event.dart';
import 'package:yx_tracking_flutter/src/storage/event_storage.dart';
import 'package:yx_tracking_flutter/src/storage/sqflite_event_storage.dart';
import 'package:yx_tracking_flutter/src/util/logger.dart';
/// Isolate
enum IsolateStorageBackend {
/// SQLite
sqlite,
///
memory,
}
/// 使 Isolate isolate
class IsolateEventStorage implements EventStorage {
/// Isolate
IsolateEventStorage({this.backend = IsolateStorageBackend.sqlite});
///
final IsolateStorageBackend backend;
Isolate? _isolate;
ReceivePort? _responsePort;
SendPort? _commandPort;
StreamSubscription<dynamic>? _responseSub;
final Map<int, Completer<dynamic>> _pending = <int, Completer<dynamic>>{};
int _seq = 0;
bool _initialized = false;
@override
Future<void> init() async {
if (_initialized) {
return;
}
final responsePort = ReceivePort();
_responsePort = responsePort;
final ready = Completer<SendPort>();
_responseSub = responsePort.listen((message) {
if (message is Map && message['type'] == _msgReady) {
final port = message['sendPort'];
if (port is SendPort && !ready.isCompleted) {
_commandPort = port;
ready.complete(port);
}
return;
}
if (message is Map && message['id'] is int) {
final id = message['id'] as int;
final completer = _pending.remove(id);
if (completer == null) {
return;
}
final ok = message['ok'] == true;
if (ok) {
completer.complete(message['result']);
} else {
completer.completeError(
StateError((message['error'] ?? 'unknown error').toString()),
);
}
}
});
final rootToken = RootIsolateToken.instance;
_isolate = await Isolate.spawn<_WorkerInit>(
_storageWorkerEntry,
_WorkerInit(
responsePort: responsePort.sendPort,
rootToken: rootToken,
backend: backend,
),
debugName: 'yx_tracking_storage',
);
await ready.future;
_initialized = true;
}
void _ensureReady() {
if (!_initialized || _commandPort == null) {
throw StateError('IsolateEventStorage 尚未初始化');
}
}
Future<T> _call<T>(String type, Map<String, dynamic>? payload) async {
_ensureReady();
final id = ++_seq;
final completer = Completer<dynamic>();
_pending[id] = completer;
_commandPort!.send(<String, Object?>{
'id': id,
'type': type,
'payload': payload,
});
final result = await completer.future;
return result as T;
}
@override
Future<int> insert(Event event) {
return _call<int>(_msgInsert, <String, dynamic>{
'payload': event.toPayload(),
'createTimeMs': event.createTime.millisecondsSinceEpoch,
'retryCount': event.retryCount,
});
}
@override
Future<List<StoredEvent>> fetchBatch(int limit) async {
if (limit <= 0) {
return const <StoredEvent>[];
}
final rows = await _call<List<dynamic>>(
_msgFetchBatch,
<String, dynamic>{'limit': limit},
);
return _decodeStoredEvents(rows);
}
@override
Future<List<StoredEvent>> fetchRecent(int limit) async {
if (limit <= 0) {
return const <StoredEvent>[];
}
final rows = await _call<List<dynamic>>(
_msgFetchRecent,
<String, dynamic>{'limit': limit},
);
return _decodeStoredEvents(rows);
}
@override
Future<void> deleteByIds(List<int> ids) async {
if (ids.isEmpty) {
return;
}
await _call<void>(_msgDeleteByIds, <String, dynamic>{'ids': ids});
}
@override
Future<int> count() => _call<int>(_msgCount, null);
@override
Future<int> trimToMaxSize(int maxSize) async {
if (maxSize <= 0) {
return 0;
}
return _call<int>(_msgTrimToMaxSize, <String, dynamic>{'maxSize': maxSize});
}
@override
Future<int> deleteExpired(DateTime cutoff) {
return _call<int>(
_msgDeleteExpired,
<String, dynamic>{'cutoffMs': cutoff.millisecondsSinceEpoch},
);
}
@override
Future<void> updateRetryCount(int id, int retryCount) async {
await _call<void>(
_msgUpdateRetry,
<String, dynamic>{'id': id, 'retryCount': retryCount},
);
}
@override
Future<void> dispose() async {
if (!_initialized) {
return;
}
try {
await _call<void>(_msgDispose, null);
} on Object catch (e, st) {
Logger.error('IsolateEventStorage dispose 失败,强制退出', e, st);
} finally {
await _responseSub?.cancel();
_responseSub = null;
_responsePort?.close();
_responsePort = null;
_pending.clear();
_commandPort = null;
_isolate?.kill(priority: Isolate.immediate);
_isolate = null;
_initialized = false;
}
}
List<StoredEvent> _decodeStoredEvents(List<dynamic> rows) {
final results = <StoredEvent>[];
for (final row in rows) {
if (row is! Map) {
continue;
}
final id = row['id'];
final payload = row['payload'];
final retryCount = row['retryCount'];
final createTimeMs = row['createTimeMs'];
if (id is! int || payload is! String || createTimeMs is! int) {
continue;
}
final retry = retryCount is int ? retryCount : 0;
final event = Event.fromPayload(
payload,
createTime: DateTime.fromMillisecondsSinceEpoch(createTimeMs),
retryCount: retry,
);
results.add(
StoredEvent(
id: id,
event: event,
retryCount: retry,
createTime: event.createTime,
),
);
}
return results;
}
}
class _WorkerInit {
const _WorkerInit({
required this.responsePort,
required this.rootToken,
required this.backend,
});
final SendPort responsePort;
final RootIsolateToken? rootToken;
final IsolateStorageBackend backend;
}
const String _msgReady = 'ready';
const String _msgInsert = 'insert';
const String _msgFetchBatch = 'fetchBatch';
const String _msgFetchRecent = 'fetchRecent';
const String _msgDeleteByIds = 'deleteByIds';
const String _msgCount = 'count';
const String _msgTrimToMaxSize = 'trimToMaxSize';
const String _msgDeleteExpired = 'deleteExpired';
const String _msgUpdateRetry = 'updateRetryCount';
const String _msgDispose = 'dispose';
Future<void> _storageWorkerEntry(_WorkerInit init) async {
final rootToken = init.rootToken;
if (rootToken != null) {
BackgroundIsolateBinaryMessenger.ensureInitialized(rootToken);
}
final commandPort = ReceivePort();
init.responsePort.send(<String, Object?>{
'type': _msgReady,
'sendPort': commandPort.sendPort,
});
final storage = switch (init.backend) {
IsolateStorageBackend.sqlite => SqfliteEventStorage(),
IsolateStorageBackend.memory => _MemoryEventStorage(),
};
await storage.init();
await for (final message in commandPort) {
if (message is! Map) {
continue;
}
final id = message['id'];
final type = message['type'];
if (id is! int || type is! String) {
continue;
}
try {
final payload = message['payload'] as Map?;
switch (type) {
case _msgInsert:
final eventPayload = payload?['payload'];
final createTimeMs = payload?['createTimeMs'];
final retryCount = payload?['retryCount'];
if (eventPayload is! String || createTimeMs is! int) {
throw const FormatException('insert payload invalid');
}
final retry = retryCount is int ? retryCount : 0;
final event = Event.fromPayload(
eventPayload,
createTime: DateTime.fromMillisecondsSinceEpoch(createTimeMs),
retryCount: retry,
);
final inserted = await storage.insert(event);
init.responsePort.send(<String, Object?>{
'id': id,
'ok': true,
'result': inserted,
});
case _msgFetchBatch:
final limit = payload?['limit'];
if (limit is! int) {
throw const FormatException('fetchBatch payload invalid');
}
final batch = await storage.fetchBatch(limit);
init.responsePort.send(<String, Object?>{
'id': id,
'ok': true,
'result': _encodeStoredEvents(batch),
});
case _msgFetchRecent:
final limit = payload?['limit'];
if (limit is! int) {
throw const FormatException('fetchRecent payload invalid');
}
final recent = await storage.fetchRecent(limit);
init.responsePort.send(<String, Object?>{
'id': id,
'ok': true,
'result': _encodeStoredEvents(recent),
});
case _msgDeleteByIds:
final ids = payload?['ids'];
if (ids is! List) {
throw const FormatException('deleteByIds payload invalid');
}
await storage.deleteByIds(ids.cast<int>());
init.responsePort.send(<String, Object?>{
'result': null,
});
case _msgCount:
final total = await storage.count();
init.responsePort.send(<String, Object?>{
'id': id,
'ok': true,
'result': total,
});
case _msgTrimToMaxSize:
final maxSize = payload?['maxSize'];
if (maxSize is! int) {
throw const FormatException('trimToMaxSize payload invalid');
}
final trimmed = await storage.trimToMaxSize(maxSize);
init.responsePort.send(<String, Object?>{
'id': id,
'ok': true,
'result': trimmed,
});
case _msgDeleteExpired:
final cutoffMs = payload?['cutoffMs'];
if (cutoffMs is! int) {
throw const FormatException('deleteExpired payload invalid');
}
final removed = await storage.deleteExpired(
DateTime.fromMillisecondsSinceEpoch(cutoffMs),
);
init.responsePort.send(<String, Object?>{
'id': id,
'ok': true,
'result': removed,
});
case _msgUpdateRetry:
final idValue = payload?['id'];
final retry = payload?['retryCount'];
if (idValue is! int || retry is! int) {
throw const FormatException('updateRetryCount payload invalid');
}
await storage.updateRetryCount(idValue, retry);
init.responsePort.send(<String, Object?>{
'result': null,
});
case _msgDispose:
await storage.dispose();
init.responsePort.send(<String, Object?>{
'id': id,
'ok': true,
'result': null,
});
commandPort.close();
return;
default:
throw UnsupportedError('unknown message: $type');
}
} on Object catch (e) {
init.responsePort.send(<String, Object?>{
'id': id,
'ok': false,
'error': e.toString(),
});
}
}
}
List<Map<String, Object?>> _encodeStoredEvents(List<StoredEvent> events) {
return events
.map(
(stored) => <String, Object?>{
'id': stored.id,
'payload': stored.event.toPayload(),
'retryCount': stored.retryCount,
'createTimeMs': stored.createTime.millisecondsSinceEpoch,
},
)
.toList(growable: false);
}
class _MemoryEventStorage implements EventStorage {
int _nextId = 1;
final List<StoredEvent> _items = <StoredEvent>[];
@override
Future<void> init() async {}
@override
Future<int> insert(Event event) async {
final stored = StoredEvent(
id: _nextId++,
event: event,
retryCount: event.retryCount,
createTime: event.createTime,
);
_items.add(stored);
return stored.id;
}
@override
Future<List<StoredEvent>> fetchBatch(int limit) async {
if (limit <= 0) {
return const <StoredEvent>[];
}
final copy = List<StoredEvent>.from(_items)
..sort((a, b) => a.createTime.compareTo(b.createTime));
return copy.take(limit).toList(growable: false);
}
@override
Future<List<StoredEvent>> fetchRecent(int limit) async {
if (limit <= 0) {
return const <StoredEvent>[];
}
final copy = List<StoredEvent>.from(_items)
..sort((a, b) => b.createTime.compareTo(a.createTime));
return copy.take(limit).toList(growable: false);
}
@override
Future<void> deleteByIds(List<int> ids) async {
if (ids.isEmpty) {
return;
}
_items.removeWhere((event) => ids.contains(event.id));
}
@override
Future<int> count() async => _items.length;
@override
Future<int> trimToMaxSize(int maxSize) async {
if (maxSize <= 0 || _items.length <= maxSize) {
return 0;
}
final overflow = _items.length - maxSize;
_items
..sort((a, b) => a.createTime.compareTo(b.createTime))
..removeRange(0, overflow);
return overflow;
}
@override
Future<int> deleteExpired(DateTime cutoff) async {
final before = _items.length;
_items.removeWhere((event) => !event.createTime.isAfter(cutoff));
return before - _items.length;
}
@override
Future<void> updateRetryCount(int id, int retryCount) async {
final index = _items.indexWhere((e) => e.id == id);
if (index < 0) {
return;
}
final current = _items[index];
_items[index] = current.copyWith(
retryCount: retryCount,
event: current.event.copyWith(retryCount: retryCount),
);
}
@override
Future<void> dispose() async {
_items.clear();
}
}

View File

@ -1,39 +1,59 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:meta/meta.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
import 'package:yx_tracking_flutter/src/model/system_dim_info.dart';
import '../model/system_dim_info.dart'; import 'package:yx_tracking_flutter/src/storage/config_storage.dart';
import '../util/logger.dart'; import 'package:yx_tracking_flutter/src/storage/db_constants.dart';
import 'config_storage.dart'; import 'package:yx_tracking_flutter/src/util/logger.dart';
import 'db_constants.dart';
/// sqflite /// sqflite
class SqfliteConfigStorage implements ConfigStorage { class SqfliteConfigStorage implements ConfigStorage {
///
SqfliteConfigStorage({
DatabaseFactory? databaseFactory,
Future<Directory> Function()? documentsDirectoryProvider,
}) : _databaseFactory = databaseFactory,
_documentsDirectoryProvider = documentsDirectoryProvider;
static const String _tableConfigCache = 'config_cache'; static const String _tableConfigCache = 'config_cache';
static const String _systemDimInfoKey = 'system_dim_info'; static const String _systemDimInfoKey = 'system_dim_info';
final DatabaseFactory? _databaseFactory;
final Future<Directory> Function()? _documentsDirectoryProvider;
Database? _db; Database? _db;
/// 使
@visibleForTesting
Database? get debugDb => _db;
@override @override
Future<void> init() async { Future<void> init() async {
if (_db != null) { if (_db != null) {
return; return;
} }
final directory = await getApplicationDocumentsDirectory(); final directory =
await (_documentsDirectoryProvider?.call() ??
getApplicationDocumentsDirectory());
final dbPath = p.join(directory.path, DbConstants.dbName); final dbPath = p.join(directory.path, DbConstants.dbName);
_db = await openDatabase( final factory = _databaseFactory ?? databaseFactory;
_db = await factory.openDatabase(
dbPath, dbPath,
options: OpenDatabaseOptions(
version: DbConstants.dbVersion, version: DbConstants.dbVersion,
onCreate: (db, version) async { onCreate: (Database db, int version) async {
await _createTables(db); await _createTables(db);
}, },
onOpen: (db) async { onOpen: (Database db) async {
await _createTables(db); await _createTables(db);
}, },
),
); );
Logger.info('SqfliteConfigStorage 初始化完成: $dbPath'); Logger.info('SqfliteConfigStorage 初始化完成: $dbPath');
@ -91,25 +111,37 @@ class SqfliteConfigStorage implements ConfigStorage {
final row = rows.first; final row = rows.first;
final payload = row['payload']; final payload = row['payload'];
if (payload is! String || payload.isEmpty) { if (payload is! String || payload.isEmpty) {
await _deleteCachedConfig(db);
return null; return null;
} }
try { try {
final decoded = jsonDecode(payload); final decoded = jsonDecode(payload);
if (decoded is! Map) { if (decoded is! Map) {
await _deleteCachedConfig(db);
return null; return null;
} }
final map = decoded.map((k, v) => MapEntry(k.toString(), v)); final map = decoded.map((k, v) => MapEntry(k.toString(), v));
// 使 payload lastFetchedAt last_fetched_at // 使 payload lastFetchedAt last_fetched_at
map.putIfAbsent('lastFetchedAt', () => row['last_fetched_at']); return SystemDimInfo.fromCacheJson(
return SystemDimInfo.fromCacheJson(map); map..putIfAbsent('lastFetchedAt', () => row['last_fetched_at']),
} catch (e, st) { );
} on Object catch (e, st) {
Logger.error('读取配置缓存失败', e, st); Logger.error('读取配置缓存失败', e, st);
await _deleteCachedConfig(db);
return null; return null;
} }
} }
Future<void> _deleteCachedConfig(Database db) async {
await db.delete(
_tableConfigCache,
where: 'key = ?',
whereArgs: const <Object>[_systemDimInfoKey],
);
}
@override @override
Future<void> clear() async { Future<void> clear() async {
final db = _requireDb(); final db = _requireDb();

View File

@ -1,40 +1,62 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:meta/meta.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
import 'package:yx_tracking_flutter/src/model/event.dart';
import '../model/event.dart'; import 'package:yx_tracking_flutter/src/storage/db_constants.dart';
import '../util/logger.dart'; import 'package:yx_tracking_flutter/src/storage/event_storage.dart';
import 'db_constants.dart'; import 'package:yx_tracking_flutter/src/util/logger.dart';
import 'event_storage.dart';
/// sqflite /// sqflite
class SqfliteEventStorage implements EventStorage { class SqfliteEventStorage implements EventStorage {
///
SqfliteEventStorage({
DatabaseFactory? databaseFactory,
Future<Directory> Function()? documentsDirectoryProvider,
}) : _databaseFactory = databaseFactory,
_documentsDirectoryProvider = documentsDirectoryProvider;
static const String _tableEvents = 'events'; static const String _tableEvents = 'events';
final DatabaseFactory? _databaseFactory;
final Future<Directory> Function()? _documentsDirectoryProvider;
Database? _db; Database? _db;
/// 使
@visibleForTesting
Database? get debugDb => _db;
@override @override
Future<void> init() async { Future<void> init() async {
if (_db != null) { if (_db != null) {
return; return;
} }
final directory = await getApplicationDocumentsDirectory(); final directory =
await (_documentsDirectoryProvider?.call() ??
getApplicationDocumentsDirectory());
final dbPath = p.join(directory.path, DbConstants.dbName); final dbPath = p.join(directory.path, DbConstants.dbName);
_db = await openDatabase( final factory = _databaseFactory ?? databaseFactory;
_db = await factory.openDatabase(
dbPath, dbPath,
options: OpenDatabaseOptions(
version: DbConstants.dbVersion, version: DbConstants.dbVersion,
onCreate: (db, version) async { onCreate: (Database db, int version) async {
await _createTables(db); await _createTables(db);
}, },
onUpgrade: (db, oldVersion, newVersion) async { // coverage:ignore-start
onUpgrade: (Database db, int oldVersion, int newVersion) async {
if (oldVersion < 1) { if (oldVersion < 1) {
await _createTables(db); await _createTables(db);
} }
}, },
// coverage:ignore-end
),
); );
Logger.info('SqfliteEventStorage 初始化完成: $dbPath'); Logger.info('SqfliteEventStorage 初始化完成: $dbPath');
@ -50,9 +72,10 @@ class SqfliteEventStorage implements EventStorage {
); );
'''); ''');
await db.execute( const createIndexSql =
'CREATE INDEX IF NOT EXISTS idx_events_create_time ON $_tableEvents(create_time);', 'CREATE INDEX IF NOT EXISTS idx_events_create_time '
); 'ON $_tableEvents(create_time);';
await db.execute(createIndexSql);
} }
Database _requireDb() { Database _requireDb() {
@ -159,6 +182,18 @@ class SqfliteEventStorage implements EventStorage {
return overflow; return overflow;
} }
@override
Future<int> deleteExpired(DateTime cutoff) async {
final db = _requireDb();
final cutoffMs = cutoff.millisecondsSinceEpoch;
final deleted = await db.delete(
_tableEvents,
where: 'create_time <= ?',
whereArgs: <Object>[cutoffMs],
);
return deleted;
}
@override @override
Future<void> updateRetryCount(int id, int retryCount) async { Future<void> updateRetryCount(int id, int retryCount) async {
final db = _requireDb(); final db = _requireDb();
@ -232,7 +267,7 @@ class SqfliteEventStorage implements EventStorage {
createTime: createdAt, createTime: createdAt,
), ),
); );
} catch (e, st) { } on Object catch (e, st) {
Logger.error('解析存储事件失败,已跳过该条', e, st); Logger.error('解析存储事件失败,已跳过该条', e, st);
final id = row['id']; final id = row['id'];
if (id is int) { if (id is int) {
@ -246,11 +281,11 @@ class SqfliteEventStorage implements EventStorage {
} }
class _ParsedRows { class _ParsedRows {
final List<StoredEvent> events;
final List<int> invalidIds;
const _ParsedRows({ const _ParsedRows({
required this.events, required this.events,
required this.invalidIds, required this.invalidIds,
}); });
final List<StoredEvent> events;
final List<int> invalidIds;
} }

View File

@ -3,12 +3,17 @@ import 'dart:ui';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import '../model/device_info.dart'; import 'package:yx_tracking_flutter/src/model/device_info.dart';
/// ///
class DeviceUtil { class DeviceUtil {
const DeviceUtil._(); ///
DeviceUtil._();
/// lint
factory DeviceUtil.instance() => DeviceUtil._();
///
static Future<DeviceInfo> collectDeviceInfo() async { static Future<DeviceInfo> collectDeviceInfo() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@ -23,22 +28,47 @@ class DeviceUtil {
); );
} }
static String _screenResolution() { @visibleForTesting
/// view
static String screenResolutionForTesting({
Iterable<FlutterView> Function()? viewsProvider,
}) {
return _screenResolution(viewsProvider: viewsProvider);
}
@visibleForTesting
/// size
static String resolutionFromSizeForTesting(Size size) {
return _resolutionFromSize(size);
}
static String _screenResolution({
Iterable<FlutterView> Function()? viewsProvider,
}) {
try { try {
final views = WidgetsBinding.instance.platformDispatcher.views; final views = (viewsProvider ?? _defaultViewsProvider)
.call()
.toList(growable: false);
if (views.isEmpty) { if (views.isEmpty) {
return 'unknown'; return 'unknown';
} }
final FlutterView view = views.first; final view = views.first;
final Size size = view.physicalSize; final size = view.physicalSize;
final int width = size.width.round(); return _resolutionFromSize(size);
final int height = size.height.round(); } on Object {
return 'unknown';
}
}
static Iterable<FlutterView> _defaultViewsProvider() =>
WidgetsBinding.instance.platformDispatcher.views;
static String _resolutionFromSize(Size size) {
final width = size.width.round();
final height = size.height.round();
if (width <= 0 || height <= 0) { if (width <= 0 || height <= 0) {
return 'unknown'; return 'unknown';
} }
return '${width}x$height'; return '${width}x$height';
} catch (_) {
return 'unknown';
}
} }
} }

View File

@ -10,8 +10,8 @@ class IdGenerator {
/// ID /// ID
static String nextId() { static String nextId() {
_counter = (_counter + 1) & 0x7fffffff; _counter = (_counter + 1) & 0x7fffffff;
final int ts = DateTime.now().microsecondsSinceEpoch; final ts = DateTime.now().microsecondsSinceEpoch;
final int rand = _random.nextInt(1 << 32); final rand = _random.nextInt(1 << 32);
return '$ts-${_counter.toRadixString(16)}-${rand.toRadixString(16)}'; return '$ts-${_counter.toRadixString(16)}-${rand.toRadixString(16)}';
} }
} }

View File

@ -3,13 +3,19 @@ class Logger {
static const String _prefix = '[AnalyticsSDK]'; static const String _prefix = '[AnalyticsSDK]';
static bool _debugEnabled = false; static bool _debugEnabled = false;
/// Debug
static bool get isDebugEnabled => _debugEnabled; static bool get isDebugEnabled => _debugEnabled;
static void setDebug(bool enabled) { /// Debug getter setter
static bool get debugEnabled => _debugEnabled;
/// Debug
static set debugEnabled(bool enabled) {
_debugEnabled = enabled; _debugEnabled = enabled;
debug('Debug 日志已${enabled ? '开启' : '关闭'}'); debug('Debug 日志已${enabled ? '开启' : '关闭'}');
} }
/// Debug
static void debug(String message) { static void debug(String message) {
if (!_debugEnabled) { if (!_debugEnabled) {
return; return;
@ -17,6 +23,7 @@ class Logger {
_print('DEBUG', message); _print('DEBUG', message);
} }
/// Info Debug
static void info(String message) { static void info(String message) {
if (!_debugEnabled) { if (!_debugEnabled) {
return; return;
@ -24,6 +31,7 @@ class Logger {
_print('INFO', message); _print('INFO', message);
} }
/// Warn Debug
static void warn(String message) { static void warn(String message) {
if (!_debugEnabled) { if (!_debugEnabled) {
return; return;
@ -31,6 +39,7 @@ class Logger {
_print('WARN', message); _print('WARN', message);
} }
/// Error
static void error(String message, [Object? error, StackTrace? stackTrace]) { static void error(String message, [Object? error, StackTrace? stackTrace]) {
final buffer = StringBuffer(message); final buffer = StringBuffer(message);
if (error != null) { if (error != null) {
@ -42,8 +51,9 @@ class Logger {
_print('ERROR', buffer.toString()); _print('ERROR', buffer.toString());
} }
///
static void _print(String level, String message) { static void _print(String level, String message) {
// ignore: avoid_print // ignore: avoid_print, reason: SDK
print('$_prefix[$level] $message'); print('$_prefix[$level] $message');
} }
} }

View File

@ -1,7 +1,14 @@
/// SDK /// SDK
class SdkInfo { class SdkInfo {
static const String sdkVersion = '0.1.0'; ///
static const String platform = 'flutter'; SdkInfo._();
const SdkInfo._(); /// lint
factory SdkInfo.instance() => SdkInfo._();
/// SDK
static const String sdkVersion = '0.1.0';
///
static const String platform = 'flutter';
} }

View File

@ -1,6 +1,10 @@
/// ///
class TimeUtil { class TimeUtil {
const TimeUtil._(); ///
TimeUtil._();
/// lint
factory TimeUtil.instance() => TimeUtil._();
/// ///
static int nowMs() => DateTime.now().millisecondsSinceEpoch; static int nowMs() => DateTime.now().millisecondsSinceEpoch;

View File

@ -1,11 +1,13 @@
import 'dart:async'; import 'dart:async';
import 'src/config/analytics_config.dart'; import 'package:flutter/widgets.dart';
import 'src/core/analytics_core.dart';
import 'src/core/interceptors.dart'; import 'package:yx_tracking_flutter/src/config/analytics_config.dart';
import 'src/model/device_info.dart'; import 'package:yx_tracking_flutter/src/core/analytics_core.dart';
import 'src/model/recent_event_summary.dart'; import 'package:yx_tracking_flutter/src/core/interceptors.dart';
import 'src/model/user_info.dart'; import 'package:yx_tracking_flutter/src/model/device_info.dart';
import 'package:yx_tracking_flutter/src/model/recent_event_summary.dart';
import 'package:yx_tracking_flutter/src/model/user_info.dart';
export 'src/config/analytics_config.dart'; export 'src/config/analytics_config.dart';
export 'src/core/interceptors.dart'; export 'src/core/interceptors.dart';
@ -16,12 +18,29 @@ export 'src/model/user_info.dart';
/// Facade /// Facade
class Analytics { class Analytics {
///
Analytics._(); Analytics._();
static final AnalyticsCore _core = AnalyticsCore(); /// lint
factory Analytics.instance() => Analytics._();
static AnalyticsCore _core = AnalyticsCore();
static _AnalyticsLifecycleObserver? _lifecycleObserver;
@visibleForTesting
///
static AnalyticsCore get coreForTesting => _core;
@visibleForTesting
///
static set coreForTesting(AnalyticsCore core) => _core = core;
/// SDK
static Future<void> init(AnalyticsConfig config) => _core.init(config); static Future<void> init(AnalyticsConfig config) => _core.init(config);
///
static Future<void> track( static Future<void> track(
String eventType, { String eventType, {
Map<String, dynamic>? eventParams, Map<String, dynamic>? eventParams,
@ -36,18 +55,23 @@ class Analytics {
); );
} }
///
static Future<void> setUser(UserInfo? userInfo) => _core.setUser(userInfo); static Future<void> setUser(UserInfo? userInfo) => _core.setUser(userInfo);
///
static Future<void> setDeviceInfo(DeviceInfo deviceInfo) => static Future<void> setDeviceInfo(DeviceInfo deviceInfo) =>
_core.setDeviceInfo(deviceInfo); _core.setDeviceInfo(deviceInfo);
///
static Future<void> flush({bool force = false}) => _core.flush(force: force); static Future<void> flush({bool force = false}) => _core.flush(force: force);
/// / /// /
static Future<int> cachedEventCount() => _core.cachedEventCount(); static Future<int> cachedEventCount() => _core.cachedEventCount();
/// ///
static Future<List<RecentEventSummary>> cachedRecentEvents({int limit = 20}) => static Future<List<RecentEventSummary>> cachedRecentEvents({
int limit = 20,
}) =>
_core.cachedRecentEvents(limit: limit); _core.cachedRecentEvents(limit: limit);
/// Phase 2 /// Phase 2
@ -62,10 +86,74 @@ class Analytics {
_core.addInterceptor(interceptor); _core.addInterceptor(interceptor);
} }
static void setDebug(bool enabled) { /// Debug
unawaited(_core.setDebug(enabled)); static void setDebug({required bool enabled}) {
unawaited(_core.setDebug(enabled: enabled));
} }
/// 宿 SDK /// 宿 SDK
static Future<void> dispose() => _core.dispose(); static Future<void> dispose() async {
unbindLifecycleObserver();
await _core.dispose();
}
/// flush
static void bindLifecycleObserver({
bool flushOnBackground = true,
bool flushOnDetached = true,
}) {
if (_lifecycleObserver != null) {
return;
}
WidgetsFlutterBinding.ensureInitialized();
final observer = _AnalyticsLifecycleObserver(
flushOnBackground: flushOnBackground,
flushOnDetached: flushOnDetached,
onFlush: () {
unawaited(_core.flush(force: true));
},
);
_lifecycleObserver = observer;
WidgetsBinding.instance.addObserver(observer);
}
///
static void unbindLifecycleObserver() {
final observer = _lifecycleObserver;
if (observer == null) {
return;
}
WidgetsBinding.instance.removeObserver(observer);
_lifecycleObserver = null;
}
}
class _AnalyticsLifecycleObserver extends WidgetsBindingObserver {
_AnalyticsLifecycleObserver({
required this.flushOnBackground,
required this.flushOnDetached,
required this.onFlush,
});
final bool flushOnBackground;
final bool flushOnDetached;
final VoidCallback onFlush;
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.inactive:
case AppLifecycleState.paused:
case AppLifecycleState.hidden:
if (flushOnBackground) {
onFlush();
}
case AppLifecycleState.detached:
if (flushOnDetached) {
onFlush();
}
case AppLifecycleState.resumed:
break;
}
}
} }

View File

@ -8,16 +8,20 @@ environment:
flutter: ">=3.22.0" flutter: ">=3.22.0"
dependencies: dependencies:
dio: ^5.4.3+1
flutter: flutter:
sdk: flutter sdk: flutter
sqflite: ^2.3.3 meta: ^1.12.0
path_provider: ^2.1.4
path: ^1.9.0 path: ^1.9.0
dio: ^5.4.3+1 path_provider: ^2.1.4
sqflite: ^2.3.3
dev_dependencies: dev_dependencies:
flutter_lints: ^5.0.0
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_lints: ^5.0.0 path_provider_platform_interface: ^2.1.2
sqflite_common_ffi: ^2.3.3
very_good_analysis: ^7.0.0
flutter: flutter:

View File

@ -0,0 +1,129 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:yx_tracking_flutter/src/config/analytics_config.dart';
AnalyticsConfig _base({
String? systemCode,
String? endpointBaseUrl,
int? clientType,
int? batchSize,
int? flushInterval,
int? maxCacheSize,
int? maxRetryCount,
Duration? connectTimeout,
Duration? readTimeout,
Duration? maxEventAge,
bool? useIsolateStorage,
bool? enableMetrics,
Duration? metricsReportInterval,
}) {
return AnalyticsConfig(
systemCode: systemCode ?? 'SYS',
endpointBaseUrl: endpointBaseUrl ?? 'https://example.com/api',
clientType: clientType ?? 3,
batchSize: batchSize ?? 20,
flushInterval: flushInterval ?? 15,
maxCacheSize: maxCacheSize ?? 100,
maxRetryCount: maxRetryCount ?? 3,
connectTimeout: connectTimeout ?? const Duration(seconds: 1),
readTimeout: readTimeout ?? const Duration(seconds: 1),
maxEventAge: maxEventAge ?? const Duration(days: 7),
useIsolateStorage: useIsolateStorage ?? false,
enableMetrics: enableMetrics ?? true,
metricsReportInterval:
metricsReportInterval ?? const Duration(minutes: 1),
);
}
void main() {
group('AnalyticsConfig.validate 全分支', () {
test('非法 systemCode 会抛错', () {
expect(() => _base(systemCode: ' ').validate(), throwsArgumentError);
});
test('非法 URL 会抛错', () {
expect(
() => _base(endpointBaseUrl: 'not-a-url').validate(),
throwsArgumentError,
);
});
test('非 https 会抛错', () {
expect(
() => _base(endpointBaseUrl: 'http://example.com').validate(),
throwsArgumentError,
);
});
test('clientType 必须为正数', () {
expect(() => _base(clientType: 0).validate(), throwsArgumentError);
});
test('batchSize 必须为正数', () {
expect(() => _base(batchSize: 0).validate(), throwsArgumentError);
});
test('flushInterval 必须为正数', () {
expect(() => _base(flushInterval: 0).validate(), throwsArgumentError);
});
test('maxCacheSize 必须为正数', () {
expect(() => _base(maxCacheSize: 0).validate(), throwsArgumentError);
});
test('maxRetryCount 不能为负数', () {
expect(() => _base(maxRetryCount: -1).validate(), throwsArgumentError);
});
test('connectTimeout 必须大于 0', () {
expect(
() => _base(connectTimeout: Duration.zero).validate(),
throwsArgumentError,
);
});
test('readTimeout 必须大于 0', () {
expect(
() => _base(readTimeout: Duration.zero).validate(),
throwsArgumentError,
);
});
test('maxEventAge 不能为负数', () {
expect(
() => _base(maxEventAge: const Duration(seconds: -1)).validate(),
throwsArgumentError,
);
});
test('enableMetrics 时 metricsReportInterval 必须大于 0', () {
expect(
() => _base(metricsReportInterval: Duration.zero).validate(),
throwsArgumentError,
);
});
test('关闭 metrics 时允许 0 间隔', () {
expect(
() => _base(enableMetrics: false, metricsReportInterval: Duration.zero)
.validate(),
returnsNormally,
);
});
});
group('AnalyticsConfig.uri 组装', () {
test('basePath 为空时会补 /leaf', () {
final config = _base(endpointBaseUrl: 'https://example.com');
expect(config.addEventListLogUri.path, '/AddEventListLog');
expect(config.addEventLogUri.path, '/AddEventLog');
expect(config.getSystemAllDimInfoUri.path, '/GetSystemAllDimInfo');
});
test('basePath 有值且带斜杠时会规范化', () {
final config = _base(endpointBaseUrl: 'https://example.com/api/');
expect(config.addEventListLogUri.path, '/api/AddEventListLog');
expect(config.addEventLogUri.path, '/api/AddEventLog');
expect(config.getSystemAllDimInfoUri.path, '/api/GetSystemAllDimInfo');
});
});
}

File diff suppressed because it is too large Load Diff

178
test/api_client_test.dart Normal file
View File

@ -0,0 +1,178 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:yx_tracking_flutter/src/config/analytics_config.dart';
import 'package:yx_tracking_flutter/src/model/device_info.dart';
import 'package:yx_tracking_flutter/src/model/event.dart';
import 'package:yx_tracking_flutter/src/network/api_client.dart';
import 'package:yx_tracking_flutter/src/network/http_client.dart';
AnalyticsConfig _config() {
return const AnalyticsConfig(
systemCode: 'SYS',
endpointBaseUrl: 'https://example.com',
clientType: 3,
enableDebug: true,
);
}
Event _event(String type) {
return Event(
systemCode: 'SYS',
eventType: type,
userInfo: null,
clientType: 3,
clientTimestamp: 1,
timestamp: '2026-01-01T00:00:00.000Z',
deviceInfo: const DeviceInfo(
os: 'test-os',
model: 'test-model',
screenResolution: '1x1',
),
eventParams: const <String, Object?>{'k': 'v'},
customTags: const <String, Object?>{'t': 1},
createTime: DateTime.fromMillisecondsSinceEpoch(1),
);
}
class _FakeHttpClient extends HttpClient {
_FakeHttpClient() : super(_config());
int callCount = 0;
Response<dynamic>? response;
Object? error;
@override
Future<Response<T>> post<T>(
String path, {
Object? data,
Map<String, dynamic>? queryParameters,
Map<String, Object?>? headers,
CancelToken? cancelToken,
}) async {
callCount += 1;
if (error != null) {
final err = error!;
if (err is Exception) {
throw err;
}
if (err is Error) {
throw err;
}
throw StateError('Unsupported error type: $err');
}
final res = response ??
Response<dynamic>(
requestOptions: RequestOptions(path: path),
statusCode: 200,
data: const <String, Object?>{'ok': true},
);
return res as Response<T>;
}
}
Response<dynamic> _response(int statusCode) {
return Response<dynamic>(
requestOptions: RequestOptions(path: '/AddEventListLog'),
statusCode: statusCode,
data: <String, Object?>{'status': statusCode},
);
}
void main() {
group('ApiClient', () {
test('空事件列表直接返回', () async {
final fake = _FakeHttpClient();
final client = ApiClient(_config(), httpClient: fake);
await client.sendBatch(const <Event>[]);
expect(fake.callCount, 0);
});
test('2xx 视为成功', () async {
final fake = _FakeHttpClient()..response = _response(204);
final client = ApiClient(_config(), httpClient: fake);
await client.sendBatch(<Event>[_event('OK')]);
expect(fake.callCount, 1);
});
test('4xx 抛出不可重试 ApiException', () async {
final fake = _FakeHttpClient()..response = _response(400);
final client = ApiClient(_config(), httpClient: fake);
expect(
() => client.sendBatch(<Event>[_event('BAD')]),
throwsA(
isA<ApiException>()
.having((e) => e.statusCode, 'statusCode', 400)
.having((e) => e.retryable, 'retryable', isFalse),
),
);
});
test('5xx 抛出可重试 ApiException', () async {
final fake = _FakeHttpClient()..response = _response(503);
final client = ApiClient(_config(), httpClient: fake);
expect(
() => client.sendBatch(<Event>[_event('RETRY')]),
throwsA(
isA<ApiException>()
.having((e) => e.statusCode, 'statusCode', 503)
.having((e) => e.retryable, 'retryable', isTrue),
),
);
});
test('DioException(HTTP) 会被映射为 ApiException', () async {
final dioError = DioException(
requestOptions: RequestOptions(path: '/x'),
response: _response(500),
type: DioExceptionType.badResponse,
);
final fake = _FakeHttpClient()..error = dioError;
final client = ApiClient(_config(), httpClient: fake);
expect(
() => client.sendBatch(<Event>[_event('HTTP_ERR')]),
throwsA(
isA<ApiException>()
.having((e) => e.statusCode, 'statusCode', 500)
.having((e) => e.retryable, 'retryable', isTrue),
),
);
});
test('DioException(网络错误) 视为可重试', () async {
final dioError = DioException(
requestOptions: RequestOptions(path: '/x'),
type: DioExceptionType.connectionTimeout,
error: StateError('timeout'),
message: 'timeout',
);
final fake = _FakeHttpClient()..error = dioError;
final client = ApiClient(_config(), httpClient: fake);
expect(
() => client.sendBatch(<Event>[_event('NET_ERR')]),
throwsA(
isA<ApiException>()
.having((e) => e.statusCode, 'statusCode', isNull)
.having((e) => e.retryable, 'retryable', isTrue),
),
);
});
test('未知异常会透传', () async {
final fake = _FakeHttpClient()..error = StateError('boom');
final client = ApiClient(_config(), httpClient: fake);
expect(
() => client.sendBatch(<Event>[_event('UNKNOWN_ERR')]),
throwsA(isA<StateError>()),
);
});
});
}

View File

@ -53,11 +53,28 @@ class FakeHttpClient extends HttpClient {
data: responseData as T?, data: responseData as T?,
statusCode: 200, statusCode: 200,
headers: responseHeaders, headers: responseHeaders,
requestOptions: RequestOptions(path: path, queryParameters: queryParameters), requestOptions: RequestOptions(
path: path,
queryParameters: queryParameters,
),
); );
} }
} }
class ThrowingHttpClient extends HttpClient {
ThrowingHttpClient(super.config);
@override
Future<Response<T>> get<T>(
String path, {
Map<String, dynamic>? queryParameters,
Map<String, Object?>? headers,
CancelToken? cancelToken,
}) async {
throw StateError('boom');
}
}
AnalyticsConfig _config() { AnalyticsConfig _config() {
return const AnalyticsConfig( return const AnalyticsConfig(
systemCode: 'TEST_APP', systemCode: 'TEST_APP',
@ -81,8 +98,8 @@ void main() {
group('ConfigManager', () { group('ConfigManager', () {
test('fetchAndCacheConfig 会解析配置并缓存', () async { test('fetchAndCacheConfig 会解析配置并缓存', () async {
final storage = InMemoryConfigStorage(); final storage = InMemoryConfigStorage();
final httpClient = FakeHttpClient(_config()); final httpClient = FakeHttpClient(_config())
httpClient.responseData = <String, dynamic>{ ..responseData = <String, dynamic>{
'data': <String, dynamic>{ 'data': <String, dynamic>{
'systemEventTypes': <Map<String, dynamic>>[ 'systemEventTypes': <Map<String, dynamic>>[
<String, dynamic>{'eventCode': 'EVENT_A', 'eventName': 'A'}, <String, dynamic>{'eventCode': 'EVENT_A', 'eventName': 'A'},
@ -96,7 +113,7 @@ void main() {
], ],
'sdkStrategy': <String, dynamic>{ 'sdkStrategy': <String, dynamic>{
'enabled': true, 'enabled': true,
'defaultSampleRate': 1.0, 'defaultSampleRate': 1,
'eventSettings': <String, dynamic>{ 'eventSettings': <String, dynamic>{
'EVENT_A': <String, dynamic>{ 'EVENT_A': <String, dynamic>{
'enabled': true, 'enabled': true,
@ -105,8 +122,8 @@ void main() {
}, },
}, },
}, },
}; }
httpClient.responseHeaders = Headers.fromMap(<String, List<String>>{ ..responseHeaders = Headers.fromMap(<String, List<String>>{
'x-config-version': <String>['v1'], 'x-config-version': <String>['v1'],
}); });
@ -136,13 +153,12 @@ void main() {
final manager = ConfigManager( final manager = ConfigManager(
config: _config(), config: _config(),
refreshInterval: const Duration(hours: 12),
storage: storage, storage: storage,
httpClient: httpClient, httpClient: httpClient,
); );
await manager.init(); await manager.init();
await manager.fetchAndCacheConfig(force: false); await manager.fetchAndCacheConfig();
expect(httpClient.getCallCount, 0); expect(httpClient.getCallCount, 0);
expect(manager.currentConfig, isNotNull); expect(manager.currentConfig, isNotNull);
@ -151,7 +167,8 @@ void main() {
test('响应结构不可解析时保持现有配置', () async { test('响应结构不可解析时保持现有配置', () async {
final storage = InMemoryConfigStorage(); final storage = InMemoryConfigStorage();
final httpClient = FakeHttpClient(_config()); final httpClient = FakeHttpClient(_config());
final existing = _storedConfig(DateTime.now().subtract(const Duration(days: 1))); final existing =
_storedConfig(DateTime.now().subtract(const Duration(days: 1)));
await storage.saveSystemDimInfo(existing); await storage.saveSystemDimInfo(existing);
httpClient.responseData = 'not-a-map'; httpClient.responseData = 'not-a-map';
@ -168,5 +185,91 @@ void main() {
expect(manager.currentConfig, isNotNull); expect(manager.currentConfig, isNotNull);
expect(manager.currentConfig!.lastFetchedAt, existing.lastFetchedAt); expect(manager.currentConfig!.lastFetchedAt, existing.lastFetchedAt);
}); });
test('DioException 会被捕获且不抛出', () async {
final storage = InMemoryConfigStorage();
final httpClient = FakeHttpClient(_config())
..exceptionToThrow = DioException(
requestOptions: RequestOptions(path: '/GetSystemAllDimInfo'),
type: DioExceptionType.connectionTimeout,
message: 'timeout',
);
final manager = ConfigManager(
config: _config(),
refreshInterval: Duration.zero,
storage: storage,
httpClient: httpClient,
);
await manager.init();
await manager.fetchAndCacheConfig(force: true);
expect(httpClient.getCallCount, 1);
expect(manager.currentConfig, isNull);
});
test('未知异常会被捕获且不抛出', () async {
final storage = InMemoryConfigStorage();
final httpClient = ThrowingHttpClient(_config());
final manager = ConfigManager(
config: _config(),
refreshInterval: Duration.zero,
storage: storage,
httpClient: httpClient,
);
await manager.init();
await manager.fetchAndCacheConfig(force: true);
expect(manager.currentConfig, isNull);
});
test('Map 非字符串 key remembers 配置结构', () async {
final storage = InMemoryConfigStorage();
final httpClient = FakeHttpClient(_config())
..responseData = <Object?, Object?>{
'data': <Object?, Object?>{
1: 'ignored',
'systemEventTypes': const <Object?>[],
'systemCustonTas': const <Object?>[],
},
};
final manager = ConfigManager(
config: _config(),
refreshInterval: Duration.zero,
storage: storage,
httpClient: httpClient,
);
await manager.init();
await manager.fetchAndCacheConfig(force: true);
expect(manager.currentConfig, isNotNull);
});
test('forceRefresh 与 dispose 可调用', () async {
final storage = InMemoryConfigStorage();
final httpClient = FakeHttpClient(_config())
..responseData = <String, Object?>{
'systemEventTypes': const <Object?>[],
'systemCustonTas': const <Object?>[],
};
final manager = ConfigManager(
config: _config(),
refreshInterval: Duration.zero,
storage: storage,
httpClient: httpClient,
);
await manager.init();
await manager.forceRefresh();
await manager.dispose();
expect(httpClient.getCallCount, greaterThanOrEqualTo(1));
});
}); });
} }

View File

@ -0,0 +1,46 @@
import 'dart:ui';
import 'package:flutter_test/flutter_test.dart';
import 'package:yx_tracking_flutter/src/util/device_util.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('DeviceUtil', () {
test('collectDeviceInfo 返回基础信息', () async {
expect(DeviceUtil.instance(), isA<DeviceUtil>());
final info = await DeviceUtil.collectDeviceInfo();
expect(info.os, isNotEmpty);
expect(info.model, isNotEmpty);
expect(info.screenResolution, isNotEmpty);
});
test('views 为空时返回 unknown', () {
final resolution = DeviceUtil.screenResolutionForTesting(
viewsProvider: () => const [],
);
expect(resolution, 'unknown');
});
test('viewsProvider 抛错时返回 unknown', () {
final resolution = DeviceUtil.screenResolutionForTesting(
viewsProvider: () => throw StateError('boom'),
);
expect(resolution, 'unknown');
});
test('尺寸为 0 时返回 unknown', () {
final resolution = DeviceUtil.resolutionFromSizeForTesting(Size.zero);
expect(resolution, 'unknown');
});
test('正尺寸会格式化为 WxH', () {
const size = Size(1080, 1920);
final resolution = DeviceUtil.resolutionFromSizeForTesting(size);
expect(resolution, '1080x1920');
});
});
}

121
test/event_extra_test.dart Normal file
View File

@ -0,0 +1,121 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:yx_tracking_flutter/yx_tracking_flutter.dart';
void main() {
group('Event.fromJson 分支覆盖', () {
test('clientType/clientTimestamp 支持 num 与 string', () {
final event = Event.fromJson(
<String, dynamic>{
'system_code': 'SYS',
'eventType': 'E_NUM',
'clientType': 3.9,
'clientTimestamp': '123',
'timestamp': '2026-01-01T00:00:00.000Z',
'deviceInfo': <String, dynamic>{
'os': 'o',
'model': 'm',
'screenResolution': '1x1',
},
},
createTime: DateTime.fromMillisecondsSinceEpoch(0),
retryCount: 0,
);
expect(event.clientType, 3);
expect(event.clientTimestamp, 123);
});
test('eventParams/customTags 支持非 string key 的 Map', () {
final event = Event.fromJson(
<String, dynamic>{
'system_code': 'SYS',
'eventType': 'E_MAP',
'clientType': 3,
'clientTimestamp': 1,
'timestamp': '2026-01-01T00:00:00.000Z',
'deviceInfo': <String, dynamic>{
'os': 'o',
'model': 'm',
'screenResolution': '1x1',
},
'eventParams': <Object?, Object?>{1: 'v'},
'customTags': <Object?, Object?>{'k': 1},
},
createTime: DateTime.fromMillisecondsSinceEpoch(0),
retryCount: 0,
);
expect(event.eventParams?['1'], 'v');
expect(event.customTags?['k'], 1);
});
});
test('Event.fromPayload 非 Map JSON 会抛 FormatException', () {
expect(
() => Event.fromPayload(
'[]',
createTime: DateTime.fromMillisecondsSinceEpoch(0),
retryCount: 0,
),
throwsFormatException,
);
});
test('StoredEvent.copyWith 可覆盖所有字段', () {
final baseEvent = Event(
systemCode: 'SYS',
eventType: 'BASE',
userInfo: null,
clientType: 3,
clientTimestamp: 1,
timestamp: 't',
deviceInfo: const DeviceInfo(
os: 'o',
model: 'm',
screenResolution: '1x1',
),
eventParams: null,
customTags: null,
createTime: DateTime.fromMillisecondsSinceEpoch(1),
);
final stored = StoredEvent(
id: 1,
event: baseEvent,
retryCount: 2,
createTime: DateTime.fromMillisecondsSinceEpoch(2),
);
final newEvent = Event(
systemCode: baseEvent.systemCode,
eventType: 'NEW',
userInfo: baseEvent.userInfo,
clientType: baseEvent.clientType,
clientTimestamp: baseEvent.clientTimestamp,
timestamp: baseEvent.timestamp,
deviceInfo: baseEvent.deviceInfo,
eventParams: baseEvent.eventParams,
customTags: baseEvent.customTags,
createTime: baseEvent.createTime,
retryCount: baseEvent.retryCount,
);
final replaced = stored.copyWith(
id: 9,
event: newEvent,
retryCount: 7,
createTime: DateTime.fromMillisecondsSinceEpoch(9),
);
expect(replaced.id, 9);
expect(replaced.event.eventType, 'NEW');
expect(replaced.retryCount, 7);
expect(replaced.createTime.millisecondsSinceEpoch, 9);
final unchanged = stored.copyWith();
expect(unchanged.id, stored.id);
expect(unchanged.event, same(stored.event));
expect(unchanged.retryCount, stored.retryCount);
expect(unchanged.createTime, stored.createTime);
});
}

250
test/facade_test.dart Normal file
View File

@ -0,0 +1,250 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:yx_tracking_flutter/src/config/config_manager.dart';
import 'package:yx_tracking_flutter/src/core/analytics_core.dart';
import 'package:yx_tracking_flutter/src/core/scheduler.dart';
import 'package:yx_tracking_flutter/src/model/system_dim_info.dart';
import 'package:yx_tracking_flutter/src/network/api_client.dart';
import 'package:yx_tracking_flutter/src/storage/config_storage.dart';
import 'package:yx_tracking_flutter/src/storage/event_storage.dart';
import 'package:yx_tracking_flutter/yx_tracking_flutter.dart';
class _NoopInterceptor implements AnalyticsInterceptor {
@override
FutureOr<Event?> beforeSend(Event event) async => event;
@override
FutureOr<void> afterSend(Event event, SendResult result) async {}
}
class _MemoryEventStorage implements EventStorage {
final _items = <StoredEvent>[];
var _nextId = 1;
@override
Future<void> init() async {}
@override
Future<int> insert(Event event) async {
final stored = StoredEvent(
id: _nextId,
event: event,
createTime: event.createTime,
retryCount: event.retryCount,
);
_items.add(stored);
_nextId += 1;
return stored.id;
}
@override
Future<List<StoredEvent>> fetchBatch(int limit) async {
if (limit <= 0) return const <StoredEvent>[];
final copy = List<StoredEvent>.from(_items)
..sort((a, b) => a.createTime.compareTo(b.createTime));
return copy.take(limit).toList(growable: false);
}
@override
Future<List<StoredEvent>> fetchRecent(int limit) async {
if (limit <= 0) return const <StoredEvent>[];
final copy = List<StoredEvent>.from(_items)
..sort((a, b) => b.createTime.compareTo(a.createTime));
return copy.take(limit).toList(growable: false);
}
@override
Future<void> deleteByIds(List<int> ids) async {
if (ids.isEmpty) return;
_items.removeWhere((e) => ids.contains(e.id));
}
@override
Future<int> count() async => _items.length;
@override
Future<int> trimToMaxSize(int maxSize) async {
if (maxSize <= 0) {
final removed = _items.length;
_items.clear();
return removed;
}
final overflow = _items.length - maxSize;
if (overflow <= 0) return 0;
_items
..sort((a, b) => a.createTime.compareTo(b.createTime))
..removeRange(0, overflow);
return overflow;
}
@override
Future<int> deleteExpired(DateTime cutoff) async {
final before = _items.length;
_items.removeWhere((e) => !e.createTime.isAfter(cutoff));
return before - _items.length;
}
@override
Future<void> updateRetryCount(int id, int retryCount) async {
final index = _items.indexWhere((e) => e.id == id);
if (index < 0) return;
final current = _items[index];
_items[index] = current.copyWith(
retryCount: retryCount,
event: current.event.copyWith(retryCount: retryCount),
);
}
@override
Future<void> dispose() async {}
}
class _FakeApiClient extends ApiClient {
_FakeApiClient(super.config);
int sent = 0;
@override
Future<void> sendBatch(List<Event> events) async {
sent += events.length;
}
}
class _MemoryConfigStorage implements ConfigStorage {
SystemDimInfo? _value;
@override
Future<void> init() async {}
@override
Future<void> saveSystemDimInfo(SystemDimInfo info) async {
_value = info;
}
@override
Future<SystemDimInfo?> loadSystemDimInfo() async => _value;
@override
Future<void> clear() async {
_value = null;
}
@override
Future<void> dispose() async {}
}
class _TestConfigManager extends ConfigManager {
_TestConfigManager({required super.config})
: super(
storage: _MemoryConfigStorage(),
refreshInterval: Duration.zero,
httpClient: null,
);
SystemDimInfo? _current;
@override
SystemDimInfo? get currentConfig => _current;
@override
Future<void> init() async {
_current ??= SystemDimInfo(
systemInfo: const SystemInfo(raw: <String, Object?>{}),
eventDefinitions: const <EventDefinition>[
EventDefinition(eventCode: 'FACADE_EVENT'),
EventDefinition(eventCode: 'SDK_METRICS_SEND'),
EventDefinition(eventCode: 'SDK_METRICS_QUEUE'),
],
tagDefinitions: const <TagDefinition>[],
sdkStrategy: const SdkStrategy(
enabled: true,
defaultSampleRate: 1,
eventSettings: <String, EventStrategy>{},
),
lastFetchedAt: DateTime.fromMillisecondsSinceEpoch(1),
);
}
@override
Future<void> fetchAndCacheConfig({bool force = false}) async {}
@override
Future<void> forceRefresh() async {}
@override
Future<void> dispose() async {}
}
class _NoopScheduler extends Scheduler {
_NoopScheduler({required super.interval, required super.onTick});
@override
void start() {}
@override
void stop() {}
}
AnalyticsConfig _config() {
return const AnalyticsConfig(
systemCode: 'TEST_APP',
endpointBaseUrl: 'https://example.com/api/ExternalEventlogs',
clientType: 3,
enableDebug: true,
metricsReportInterval: Duration(days: 1),
);
}
void main() {
group('Analytics Facade', () {
test('可注入 core 并走通关键路径', () async {
final memoryStorage = _MemoryEventStorage();
final fakeApiClient = _FakeApiClient(_config());
final core = AnalyticsCore(
storageFactory: () => memoryStorage,
apiClientFactory: (_) => fakeApiClient,
configManagerFactory: (config) => _TestConfigManager(config: config),
deviceInfoCollector: () async => const DeviceInfo(
os: 'test-os',
model: 'test-model',
screenResolution: '100x200',
),
schedulerFactory: (interval, onTick) =>
_NoopScheduler(interval: interval, onTick: onTick),
randomDouble: () => 0,
now: () => DateTime.fromMillisecondsSinceEpoch(10),
);
Analytics.coreForTesting = core;
expect(Analytics.instance(), isA<Analytics>());
expect(Analytics.coreForTesting, same(core));
await Analytics.init(_config());
await Analytics.setUser(const UserInfo(userId: 1, userName: 'u'));
await Analytics.setDeviceInfo(
const DeviceInfo(os: 'o', model: 'm', screenResolution: '1x1'),
);
await Analytics.track(
'FACADE_EVENT',
eventParams: const <String, Object?>{'k': 1},
);
expect(await Analytics.cachedEventCount(), greaterThanOrEqualTo(1));
expect(await Analytics.cachedRecentEvents(limit: 5), isNotEmpty);
await Analytics.flush(force: true);
await Analytics.refreshConfig(force: false);
await Analytics.reportMetricsNow();
Analytics.addInterceptor(_NoopInterceptor());
Analytics.setDebug(enabled: true);
expect(fakeApiClient.sent, greaterThanOrEqualTo(1));
await Analytics.dispose();
});
});
}

View File

@ -0,0 +1,88 @@
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:yx_tracking_flutter/src/config/analytics_config.dart';
import 'package:yx_tracking_flutter/src/network/http_client.dart';
class _FakeAdapter implements HttpClientAdapter {
RequestOptions? lastOptions;
Object? lastData;
@override
void close({bool force = false}) {}
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<List<int>>? requestStream,
Future<void>? cancelFuture,
) async {
lastOptions = options;
lastData = options.data;
return ResponseBody.fromString(
jsonEncode(<String, Object?>{'ok': true}),
200,
headers: <String, List<String>>{
Headers.contentTypeHeader: <String>[Headers.jsonContentType],
},
);
}
}
AnalyticsConfig _config(String baseUrl) {
return AnalyticsConfig(
systemCode: 'SYS',
endpointBaseUrl: baseUrl,
clientType: 3,
);
}
void main() {
group('HttpClient', () {
test('会规范化 baseUrl 并使用自定义 adapter', () async {
final adapter = _FakeAdapter();
final client = HttpClient(
_config('https://example.com/'),
httpClientAdapter: adapter,
);
expect(client.dio.options.baseUrl, 'https://example.com');
final response = await client.get<Map<String, dynamic>>('/ping');
expect(response.statusCode, 200);
expect(adapter.lastOptions?.path, '/ping');
});
test('headers 为空时不会创建 Options', () async {
final adapter = _FakeAdapter();
final client = HttpClient(
_config('https://example.com/api'),
httpClientAdapter: adapter,
);
await client.post<Object?>('/no-headers', data: <String, Object?>{});
expect(adapter.lastOptions?.headers, isNot(contains('x-test')));
});
test('会把 headers 透传到请求', () async {
final adapter = _FakeAdapter();
final client = HttpClient(
_config('https://example.com/api'),
httpClientAdapter: adapter,
);
await client.post<Object?>(
'/with-headers',
data: <String, Object?>{'a': 1},
headers: <String, Object?>{'x-test': '1'},
);
expect(adapter.lastOptions?.headers['x-test'], '1');
expect(adapter.lastData, <String, Object?>{'a': 1});
});
});
}

View File

@ -0,0 +1,32 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:yx_tracking_flutter/yx_tracking_flutter.dart';
class _PassThroughInterceptor extends AnalyticsInterceptor {}
Event _event() {
final now = DateTime.fromMillisecondsSinceEpoch(1);
return Event(
systemCode: 'SYS',
eventType: 'E',
userInfo: null,
clientType: 3,
clientTimestamp: 1,
timestamp: now.toUtc().toIso8601String(),
deviceInfo: const DeviceInfo(os: 'o', model: 'm', screenResolution: '1x1'),
eventParams: null,
customTags: null,
createTime: now,
);
}
void main() {
test('AnalyticsInterceptor.afterSend 默认实现可调用', () {
final interceptor = _PassThroughInterceptor()
..beforeSend(_event())
..afterSend(
_event(),
const SendResult(success: true, retryable: false, statusCode: 200),
);
expect(interceptor, isA<AnalyticsInterceptor>());
});
}

View File

@ -0,0 +1,121 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:yx_tracking_flutter/src/model/device_info.dart';
import 'package:yx_tracking_flutter/src/model/event.dart';
import 'package:yx_tracking_flutter/src/storage/isolate_event_storage.dart';
Event _buildEvent(String type, DateTime createTime) {
final ts = createTime.millisecondsSinceEpoch;
return Event(
systemCode: 'SYS',
eventType: type,
userInfo: null,
clientType: 3,
clientTimestamp: ts,
timestamp: createTime.toUtc().toIso8601String(),
deviceInfo: const DeviceInfo(
os: 'os',
model: 'model',
screenResolution: '1x1',
),
eventParams: null,
customTags: null,
createTime: createTime,
);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('IsolateEventStorage (memory backend)', () {
late IsolateEventStorage storage;
setUp(() async {
storage = IsolateEventStorage(backend: IsolateStorageBackend.memory);
await storage.init();
});
tearDown(() async {
await storage.dispose();
});
test('insert/count/fetchBatch 基础流程', () async {
final now = DateTime.now();
await storage.insert(
_buildEvent('A', now.subtract(const Duration(seconds: 2))),
);
await storage.insert(
_buildEvent('B', now.subtract(const Duration(seconds: 1))),
);
expect(await storage.count(), 2);
final batch = await storage.fetchBatch(10);
expect(batch.map((e) => e.event.eventType), <String>['A', 'B']);
});
test('fetchRecent 按时间降序返回', () async {
final now = DateTime.now();
await storage.insert(
_buildEvent('OLD', now.subtract(const Duration(seconds: 2))),
);
await storage.insert(_buildEvent('NEW', now));
final recent = await storage.fetchRecent(1);
expect(recent.single.event.eventType, 'NEW');
});
test('deleteByIds 删除指定事件', () async {
final now = DateTime.now();
final id1 = await storage.insert(_buildEvent('A', now));
final id2 = await storage.insert(
_buildEvent('B', now.add(const Duration(seconds: 1))),
);
await storage.deleteByIds(<int>[id1]);
final left = await storage.fetchBatch(10);
expect(left.map((e) => e.id), <int>[id2]);
});
test('trimToMaxSize 会删除最旧事件', () async {
final now = DateTime.now();
await storage.insert(
_buildEvent('A', now.subtract(const Duration(seconds: 3))),
);
await storage.insert(
_buildEvent('B', now.subtract(const Duration(seconds: 2))),
);
await storage.insert(
_buildEvent('C', now.subtract(const Duration(seconds: 1))),
);
final trimmed = await storage.trimToMaxSize(2);
expect(trimmed, 1);
final left = await storage.fetchBatch(10);
expect(left.map((e) => e.event.eventType), <String>['B', 'C']);
});
test('deleteExpired 会清理过期事件', () async {
final now = DateTime.now();
await storage.insert(
_buildEvent('OLD', now.subtract(const Duration(days: 8))),
);
await storage.insert(_buildEvent('KEEP', now));
final removed = await storage.deleteExpired(
now.subtract(const Duration(days: 7)),
);
expect(removed, 1);
final left = await storage.fetchBatch(10);
expect(left.single.event.eventType, 'KEEP');
});
test('updateRetryCount 会更新 retryCount', () async {
final now = DateTime.now();
final id = await storage.insert(_buildEvent('RETRY', now));
await storage.updateRetryCount(id, 2);
final stored = await storage.fetchBatch(1);
expect(stored.single.retryCount, 2);
expect(stored.single.event.retryCount, 2);
});
});
}

12
test/logger_test.dart Normal file
View File

@ -0,0 +1,12 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:yx_tracking_flutter/src/util/logger.dart';
void main() {
test('Logger debugEnabled getter/setter 可用', () {
Logger.debugEnabled = false;
expect(Logger.debugEnabled, isFalse);
Logger.debugEnabled = true;
expect(Logger.debugEnabled, isTrue);
});
}

43
test/scheduler_test.dart Normal file
View File

@ -0,0 +1,43 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:yx_tracking_flutter/src/core/scheduler.dart';
void main() {
group('Scheduler', () {
test('start/stop 生命周期可工作且重复 start 不会重建', () async {
var ticks = 0;
final scheduler = Scheduler(
interval: const Duration(milliseconds: 10),
onTick: () async {
ticks += 1;
},
)
..start()
..start();
await Future<void>.delayed(const Duration(milliseconds: 35));
expect(scheduler.isRunning, isTrue);
expect(ticks, greaterThanOrEqualTo(1));
scheduler.stop();
expect(scheduler.isRunning, isFalse);
scheduler.dispose();
});
test('onTick 抛错会被捕获', () async {
var ticks = 0;
final scheduler = Scheduler(
interval: const Duration(milliseconds: 10),
onTick: () async {
ticks += 1;
throw StateError('tick failed');
},
)..start();
await Future<void>.delayed(const Duration(milliseconds: 25));
scheduler.stop();
expect(ticks, greaterThanOrEqualTo(1));
});
});
}

View File

@ -0,0 +1,28 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:yx_tracking_flutter/src/model/recent_event_summary.dart';
import 'package:yx_tracking_flutter/src/storage/db_constants.dart';
import 'package:yx_tracking_flutter/src/util/sdk_info.dart';
import 'package:yx_tracking_flutter/src/util/time_util.dart';
void main() {
test('small models/constants are accessible', () {
final summary = RecentEventSummary(
id: 1,
eventType: 'E',
createTime: DateTime.fromMillisecondsSinceEpoch(1),
retryCount: 0,
);
expect(summary.id, 1);
expect(DbConstants.dbName, isNotEmpty);
expect(DbConstants.dbVersion, greaterThan(0));
expect(DbConstants.instance(), isA<DbConstants>());
expect(SdkInfo.sdkVersion, isNotEmpty);
expect(SdkInfo.platform, 'flutter');
expect(SdkInfo.instance(), isA<SdkInfo>());
expect(TimeUtil.instance(), isA<TimeUtil>());
expect(TimeUtil.nowMs(), greaterThan(0));
expect(TimeUtil.nowIso8601Utc(), contains('T'));
expect(TimeUtil.iso8601FromMs(0), '1970-01-01T00:00:00.000Z');
});
}

View File

@ -0,0 +1,372 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:yx_tracking_flutter/src/model/device_info.dart';
import 'package:yx_tracking_flutter/src/model/event.dart';
import 'package:yx_tracking_flutter/src/model/system_dim_info.dart';
import 'package:yx_tracking_flutter/src/storage/db_constants.dart';
import 'package:yx_tracking_flutter/src/storage/sqflite_config_storage.dart';
import 'package:yx_tracking_flutter/src/storage/sqflite_event_storage.dart';
class FakePathProviderPlatform extends PathProviderPlatform {
FakePathProviderPlatform(this.documentsPath);
final String documentsPath;
@override
Future<String?> getApplicationDocumentsPath() async => documentsPath;
}
void main() {
sqfliteFfiInit();
final ffiFactory = databaseFactoryFfi;
group('SqfliteEventStorage (ffi)', () {
late Directory tempDir;
late PathProviderPlatform originalPathProvider;
setUp(() {
originalPathProvider = PathProviderPlatform.instance;
tempDir = Directory.systemTemp.createTempSync('yx_tracking_event_');
});
tearDown(() {
PathProviderPlatform.instance = originalPathProvider;
if (tempDir.existsSync()) {
tempDir.deleteSync(recursive: true);
}
});
SqfliteEventStorage makeStorage() {
return SqfliteEventStorage(
databaseFactory: ffiFactory,
documentsDirectoryProvider: () async => tempDir,
);
}
Event makeEvent(String type, int ts) {
return Event(
systemCode: 'SYS',
eventType: type,
userInfo: null,
clientType: 3,
clientTimestamp: ts,
timestamp: '2026-01-01T00:00:00.000Z',
deviceInfo: const DeviceInfo(
os: 'os',
model: 'model',
screenResolution: '1x1',
),
eventParams: <String, Object?>{'i': ts},
customTags: const <String, Object?>{'t': 1},
createTime: DateTime.fromMillisecondsSinceEpoch(ts),
);
}
test('未 init 时会抛 StateError', () {
final storage = makeStorage();
expect(storage.count, throwsStateError);
});
test('基础 CRUD + limit<=0 分支 + init 幂等', () async {
final storage = makeStorage();
await storage.init();
await storage.init();
expect(await storage.fetchBatch(0), isEmpty);
expect(await storage.fetchRecent(0), isEmpty);
final id1 = await storage.insert(makeEvent('A', 1));
final id2 = await storage.insert(makeEvent('B', 2));
expect(id1, greaterThan(0));
expect(id2, greaterThan(id1));
final batch = await storage.fetchBatch(10);
expect(batch.map((e) => e.event.eventType), <String>['A', 'B']);
final recent = await storage.fetchRecent(10);
expect(recent.map((e) => e.event.eventType), <String>['B', 'A']);
await storage.deleteByIds(const <int>[]);
await storage.updateRetryCount(id1, 1);
await storage.dispose();
await storage.dispose();
});
test('trimToMaxSize 会删除最旧事件并返回删除数', () async {
final storage = makeStorage();
await storage.init();
await storage.insert(makeEvent('A', 1));
await storage.insert(makeEvent('B', 2));
await storage.insert(makeEvent('C', 3));
final trimmed = await storage.trimToMaxSize(2);
expect(trimmed, 1);
final left = await storage.fetchBatch(10);
expect(left.map((e) => e.event.eventType), <String>['B', 'C']);
});
test('deleteExpired 会删除截止时间之前的事件', () async {
final storage = makeStorage();
await storage.init();
await storage.insert(makeEvent('OLD', 1));
await storage.insert(makeEvent('NEW', 100));
final removed = await storage.deleteExpired(
DateTime.fromMillisecondsSinceEpoch(50),
);
expect(removed, 1);
final left = await storage.fetchBatch(10);
expect(left.map((e) => e.event.eventType), <String>['NEW']);
});
test('updateRetryCount 会同时更新 stored 与 event.retryCount', () async {
final storage = makeStorage();
await storage.init();
final id = await storage.insert(makeEvent('R', 10));
final stored = (await storage.fetchBatch(1)).single;
expect(stored.retryCount, 0);
expect(stored.event.retryCount, 0);
await storage.updateRetryCount(id, 2);
final updated = (await storage.fetchBatch(1)).single;
expect(updated.retryCount, 2);
expect(updated.event.retryCount, 2);
});
test('坏数据会被清理避免卡死队列', () async {
final storage = makeStorage();
await storage.init();
await storage.insert(makeEvent('GOOD', 1));
final db = storage.debugDb!;
await db.insert(
'events',
<String, Object?>{
'payload': 'not-json',
'retry_count': 0,
'create_time': 2,
},
);
final before = await storage.count();
final batch = await storage.fetchBatch(10);
final after = await storage.count();
expect(before, 2);
expect(batch.map((e) => e.event.eventType), <String>['GOOD']);
expect(after, 1);
});
test('默认 documents provider 分支可工作(使用 fake 平台)', () async {
PathProviderPlatform.instance = FakePathProviderPlatform(tempDir.path);
final storage = SqfliteEventStorage(databaseFactory: ffiFactory);
await storage.init();
expect(await storage.count(), 0);
});
test('onUpgrade(oldVersion<1) 会建表', () async {
final dbPath = p.join(tempDir.path, DbConstants.dbName);
final db = await ffiFactory.openDatabase(
dbPath,
options: OpenDatabaseOptions(version: 1),
);
await db.execute('PRAGMA user_version = 0;');
await db.close();
final storage = makeStorage();
await storage.init();
expect(await storage.count(), 0);
});
test('deleteByIds 多 id 会走占位符分支', () async {
final storage = makeStorage();
await storage.init();
final id1 = await storage.insert(makeEvent('D1', 1));
final id2 = await storage.insert(makeEvent('D2', 2));
await storage.deleteByIds(<int>[id1, id2]);
expect(await storage.count(), 0);
});
test('行字段类型异常会被清理create_time 非 int', () async {
final storage = makeStorage();
await storage.init();
final db = storage.debugDb!;
await db.insert(
'events',
<String, Object?>{
'payload': '{}',
'retry_count': 0,
'create_time': 'bad-int',
},
);
final before = await storage.count();
final batch = await storage.fetchBatch(10);
final after = await storage.count();
expect(before, 1);
expect(batch, isEmpty);
expect(after, 0);
});
});
group('SqfliteConfigStorage (ffi)', () {
late Directory tempDir;
late PathProviderPlatform originalPathProvider;
setUp(() {
originalPathProvider = PathProviderPlatform.instance;
tempDir = Directory.systemTemp.createTempSync('yx_tracking_config_');
});
tearDown(() {
PathProviderPlatform.instance = originalPathProvider;
if (tempDir.existsSync()) {
tempDir.deleteSync(recursive: true);
}
});
SqfliteConfigStorage makeStorage() {
return SqfliteConfigStorage(
databaseFactory: ffiFactory,
documentsDirectoryProvider: () async => tempDir,
);
}
SystemDimInfo makeInfo(int ts) {
return SystemDimInfo(
systemInfo: const SystemInfo(raw: <String, Object?>{'a': 1}),
eventDefinitions: const <EventDefinition>[
EventDefinition(eventCode: 'E'),
],
tagDefinitions: const <TagDefinition>[
TagDefinition(tagName: 't', tagType: 'string', isRequired: true),
],
sdkStrategy: const SdkStrategy(
enabled: true,
defaultSampleRate: 1,
eventSettings: <String, EventStrategy>{},
),
lastFetchedAt: DateTime.fromMillisecondsSinceEpoch(ts),
version: 'v',
);
}
test('未 init 时会抛 StateError', () {
final storage = makeStorage();
expect(storage.loadSystemDimInfo, throwsStateError);
});
test('save/load/clear/lastFetchedAt 正常工作', () async {
final storage = makeStorage();
await storage.init();
expect(await storage.loadSystemDimInfo(), isNull);
final info = makeInfo(123);
await storage.saveSystemDimInfo(info);
final loaded = await storage.loadSystemDimInfo();
expect(loaded?.systemInfo?.raw['a'], 1);
expect(loaded?.eventDefinitions.single.eventCode, 'E');
final rows = await storage.debugDb!.query('config_cache');
expect(
rows.single['last_fetched_at'],
info.lastFetchedAt.millisecondsSinceEpoch,
);
await storage.clear();
expect(await storage.loadSystemDimInfo(), isNull);
await storage.dispose();
await storage.dispose();
});
test('默认 documents provider 分支可工作(使用 fake 平台)', () async {
PathProviderPlatform.instance = FakePathProviderPlatform(tempDir.path);
final storage = SqfliteConfigStorage(databaseFactory: ffiFactory);
await storage.init();
expect(await storage.loadSystemDimInfo(), isNull);
});
test('payload 为空字符串会被清理', () async {
final storage = makeStorage();
await storage.init();
final db = storage.debugDb!;
await db.insert(
'config_cache',
<String, Object?>{
'key': 'system_dim_info',
'payload': '',
'last_fetched_at': 1,
},
);
final loaded = await storage.loadSystemDimInfo();
expect(loaded, isNull);
expect(await db.query('config_cache'), isEmpty);
});
test('payload 为非 Map JSON 会被清理', () async {
final storage = makeStorage();
await storage.init();
final db = storage.debugDb!;
await db.insert(
'config_cache',
<String, Object?>{
'key': 'system_dim_info',
'payload': '[]',
'last_fetched_at': 1,
},
);
final loaded = await storage.loadSystemDimInfo();
expect(loaded, isNull);
expect(await db.query('config_cache'), isEmpty);
});
test('坏配置会被删除并返回 null', () async {
final storage = makeStorage();
await storage.init();
final dbPath = p.join(tempDir.path, DbConstants.dbName);
final db = storage.debugDb!;
await db.insert(
'config_cache',
<String, Object?>{
'key': 'system_dim_info',
'payload': 'not-json',
'last_fetched_at': 1,
},
);
final loaded = await storage.loadSystemDimInfo();
expect(loaded, isNull);
final rows = await db.query('config_cache');
expect(rows, isEmpty, reason: '坏数据应被清理: $dbPath');
});
});
}

View File

@ -0,0 +1,252 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:yx_tracking_flutter/src/model/system_dim_info.dart';
SystemDimInfo _emptyInfo({SdkStrategy? strategy}) {
return SystemDimInfo(
systemInfo: const SystemInfo(raw: <String, Object?>{'k': 'v'}),
eventDefinitions: const <EventDefinition>[],
tagDefinitions: const <TagDefinition>[],
sdkStrategy: strategy,
lastFetchedAt: DateTime.fromMillisecondsSinceEpoch(1),
version: 'v1',
);
}
String _eventCodeOf(EventDefinition event) => event.eventCode;
String _tagNameOf(TagDefinition tag) => tag.tagName;
void main() {
group('SystemDimInfo helpers', () {
test('hasEvent/requiredTags/strategy helpers', () {
final info = SystemDimInfo(
systemInfo: const SystemInfo(raw: <String, Object?>{'k': 'v'}),
eventDefinitions: const <EventDefinition>[
EventDefinition(eventCode: 'A', eventName: 'A1'),
],
tagDefinitions: const <TagDefinition>[
TagDefinition(tagName: 't1', tagType: 'string', isRequired: true),
TagDefinition(tagName: 't2', tagType: 'int', isRequired: false),
],
sdkStrategy: const SdkStrategy(
enabled: true,
defaultSampleRate: 0.5,
eventSettings: <String, EventStrategy>{
'A': EventStrategy(enabled: false, sampleRate: 0.1),
},
),
lastFetchedAt: DateTime.fromMillisecondsSinceEpoch(2),
);
expect(info.hasEvent('A'), isTrue);
expect(info.hasEvent('B'), isFalse);
expect(info.requiredTags.map((e) => e.tagName), <String>['t1']);
expect(info.isStrategyEnabled, isTrue);
expect(info.strategyFor('A')?.enabled, isFalse);
expect(info.strategyFor('B'), isNull);
expect(info.sampleRateFor('A'), 0.1);
expect(info.sampleRateFor('B'), 0.5);
expect(info.isEventEnabledByStrategy('A'), isFalse);
expect(info.isEventEnabledByStrategy('B'), isTrue);
});
test('无策略时走默认值', () {
final info = _emptyInfo();
expect(info.isStrategyEnabled, isTrue);
expect(info.strategyFor('X'), isNull);
expect(info.sampleRateFor('X'), 1);
expect(info.isEventEnabledByStrategy('X'), isTrue);
});
});
group('SystemDimInfo cache/response 解析', () {
test('toCacheJson/fromCacheJson 往返', () {
final original = SystemDimInfo(
systemInfo: const SystemInfo(raw: <String, Object?>{'a': 1}),
eventDefinitions: const <EventDefinition>[
EventDefinition(eventCode: 'E', eventName: 'Event'),
],
tagDefinitions: const <TagDefinition>[
TagDefinition(tagName: 'tag', tagType: 'string', isRequired: true),
],
sdkStrategy: const SdkStrategy(
enabled: false,
defaultSampleRate: 0.3,
eventSettings: <String, EventStrategy>{
'E': EventStrategy(enabled: true, sampleRate: 1),
},
),
lastFetchedAt: DateTime.fromMillisecondsSinceEpoch(10),
version: 'ver',
);
final cached = original.toCacheJson();
final parsed = SystemDimInfo.fromCacheJson(cached);
expect(parsed.systemInfo?.raw['a'], 1);
expect(parsed.eventDefinitions.single.eventCode, 'E');
expect(parsed.tagDefinitions.single.tagName, 'tag');
expect(parsed.sdkStrategy?.enabled, isFalse);
expect(parsed.sdkStrategy?.eventSettings['E']?.sampleRate, 1);
expect(parsed.version, 'ver');
expect(parsed.lastFetchedAt.millisecondsSinceEpoch, 10);
});
test('fromResponse 兼容字段差异与异常输入', () {
final fetchedAt = DateTime.fromMillisecondsSinceEpoch(99);
final info = SystemDimInfo.fromResponse(
<String, Object?>{
'systemInfo': <String, Object?>{'sys': true},
'systemEventTypes': <Object?>[
<String, Object?>{'event_code': 'E1', 'event_name': 'Name'},
<String, Object?>{'eventType': 'E2'},
<String, Object?>{'eventCode': ''},
123,
],
// 使
'systemCustonTas': <Object?>[
<String, Object?>{
'tag_name': 'req',
'tag_type': 'int',
'is_required': '1',
'desc': 'd',
},
<String, Object?>{'name': 'opt', 'type': 'string'},
<String, Object?>{'tagName': ''},
'bad',
],
// 使 sdk_strategy
'sdk_strategy': <String, Object?>{
'enabled': 'false',
'defaultSampleRate': 2,
'eventSettings': <String, Object?>{
'E1': <String, Object?>{'enabled': 0, 'sampleRate': -1},
'E2': <String, Object?>{
'enabled': true,
'sampleRate': double.infinity,
},
'bad': 1,
},
},
},
fetchedAt: fetchedAt,
version: 'v2',
);
expect(info.systemInfo?.raw['sys'], isTrue);
expect(
info.eventDefinitions.map(_eventCodeOf).toList(growable: false),
containsAll(<String>['E1', 'E2']),
);
expect(
info.tagDefinitions.map(_tagNameOf).toList(growable: false),
containsAll(<String>['req', 'opt']),
);
expect(info.tagDefinitions.first.isRequired, isTrue);
// defaultSampleRate=2 clamp 1
expect(info.sdkStrategy?.defaultSampleRate, 1);
// -1 clamp 0
expect(info.sdkStrategy?.eventSettings['E1']?.sampleRate, 0);
// infinity clamp 1
expect(info.sdkStrategy?.eventSettings['E2']?.sampleRate, 1);
expect(info.isStrategyEnabled, isFalse);
expect(info.lastFetchedAt, fetchedAt);
expect(info.version, 'v2');
});
test('异常类型输入会返回空集合/空策略', () {
final info = SystemDimInfo.fromResponse(
<String, Object?>{
'systemEventTypes': 'not-list',
'systemCustomTags': 123,
'sdkStrategy': 'bad',
},
fetchedAt: DateTime.fromMillisecondsSinceEpoch(1),
);
expect(info.eventDefinitions, isEmpty);
expect(info.tagDefinitions, isEmpty);
expect(info.sdkStrategy, isNull);
});
test('fromCacheJson 支持 num 与 string 数值', () {
final info = SystemDimInfo.fromCacheJson(
<String, Object?>{
'systemInfo': <String, Object?>{'k': 'v'},
'eventDefinitions': const <Object?>[],
'tagDefinitions': const <Object?>[],
'sdkStrategy': <String, Object?>{
'enabled': true,
'defaultSampleRate': '0.25',
'eventSettings': <String, Object?>{
'E': <String, Object?>{'enabled': true, 'sampleRate': '0.75'},
},
},
'lastFetchedAt': 1.5,
},
);
expect(info.lastFetchedAt.millisecondsSinceEpoch, 1);
expect(info.sdkStrategy?.defaultSampleRate, 0.25);
expect(info.sdkStrategy?.eventSettings['E']?.sampleRate, 0.75);
});
test('fromCacheJson 的 lastFetchedAt 支持字符串整数', () {
final info = SystemDimInfo.fromCacheJson(
<String, Object?>{
'systemInfo': const <String, Object?>{},
'eventDefinitions': const <Object?>[],
'tagDefinitions': const <Object?>[],
'lastFetchedAt': '2',
},
);
expect(info.lastFetchedAt.millisecondsSinceEpoch, 2);
});
});
group('Model toJson', () {
test('SystemInfo/EventDefinition/TagDefinition/SdkStrategy/EventStrategy', () {
const system = SystemInfo(raw: <String, Object?>{'k': 'v'});
const event = EventDefinition(eventCode: 'E', eventName: 'N');
const tag = TagDefinition(
tagName: 't',
tagType: 'string',
isRequired: true,
);
const strategy = SdkStrategy(
enabled: true,
defaultSampleRate: 0.7,
eventSettings: <String, EventStrategy>{
'E': EventStrategy(enabled: true, sampleRate: 0.7),
},
);
expect(SystemInfo.fromJson(system.toJson()).raw['k'], 'v');
expect(event.toJson()['eventCode'], 'E');
expect(tag.toJson()['isRequired'], isTrue);
expect(strategy.toJson()['eventSettings'], contains('E'));
});
test('defaultSampleRate 为 NaN 时会回落到 1', () {
final info = SystemDimInfo.fromResponse(
<String, Object?>{
'sdkStrategy': <String, Object?>{
'enabled': true,
'defaultSampleRate': double.nan,
'eventSettings': const <String, Object?>{},
},
},
fetchedAt: DateTime.fromMillisecondsSinceEpoch(1),
);
expect(info.sdkStrategy?.defaultSampleRate, 1);
});
});
}

View File

@ -81,21 +81,54 @@ SystemDimInfo _dimInfo() {
); );
} }
SystemDimInfo _dimInfoWithTypes() {
return SystemDimInfo(
systemInfo: const SystemInfo(raw: <String, dynamic>{}),
eventDefinitions: const <EventDefinition>[
EventDefinition(eventCode: 'TYPED'),
],
tagDefinitions: const <TagDefinition>[
TagDefinition(tagName: 'intNum', tagType: 'int', isRequired: true),
TagDefinition(tagName: 'intStr', tagType: 'integer', isRequired: true),
TagDefinition(tagName: 'boolNum', tagType: 'bool', isRequired: true),
TagDefinition(tagName: 'boolStr', tagType: 'boolean', isRequired: true),
TagDefinition(tagName: 'doubleStr', tagType: 'double', isRequired: true),
TagDefinition(tagName: 'floatStr', tagType: 'float', isRequired: true),
TagDefinition(tagName: 'numStr', tagType: 'num', isRequired: true),
TagDefinition(tagName: 'unknown', tagType: 'mystery', isRequired: false),
],
sdkStrategy: null,
lastFetchedAt: DateTime.now(),
);
}
bool _isTypeMismatch(ValidationIssue issue) =>
issue.code == Validator.typeMismatch;
void main() { void main() {
group('Validator', () { group('Validator', () {
test('已配置事件且必填 tag 满足时无告警', () { test('已配置事件且必填 tag 满足时无告警', () {
final manager = StaticConfigManager(config: _config(), configValue: _dimInfo()); final manager = StaticConfigManager(
config: _config(),
configValue: _dimInfo(),
);
final validator = Validator(manager); final validator = Validator(manager);
final result = validator.validate( final result = validator.validate(
_event(type: 'KNOWN', customTags: const <String, dynamic>{'tenantId': 't1'}), _event(
type: 'KNOWN',
customTags: const <String, dynamic>{'tenantId': 't1'},
),
); );
expect(result.isEmpty, true); expect(result.isEmpty, true);
}); });
test('未知事件会产生 error', () { test('未知事件会产生 error', () {
final manager = StaticConfigManager(config: _config(), configValue: _dimInfo()); final manager = StaticConfigManager(
config: _config(),
configValue: _dimInfo(),
);
final validator = Validator(manager); final validator = Validator(manager);
final result = validator.validate(_event(type: 'UNKNOWN')); final result = validator.validate(_event(type: 'UNKNOWN'));
@ -105,7 +138,10 @@ void main() {
}); });
test('缺失必填 tag 会产生 warning', () { test('缺失必填 tag 会产生 warning', () {
final manager = StaticConfigManager(config: _config(), configValue: _dimInfo()); final manager = StaticConfigManager(
config: _config(),
configValue: _dimInfo(),
);
final validator = Validator(manager); final validator = Validator(manager);
final result = validator.validate(_event(type: 'KNOWN')); final result = validator.validate(_event(type: 'KNOWN'));
@ -115,7 +151,10 @@ void main() {
}); });
test('类型不匹配会产生 warning', () { test('类型不匹配会产生 warning', () {
final manager = StaticConfigManager(config: _config(), configValue: _dimInfo()); final manager = StaticConfigManager(
config: _config(),
configValue: _dimInfo(),
);
final validator = Validator(manager); final validator = Validator(manager);
final result = validator.validate( final result = validator.validate(
@ -129,7 +168,37 @@ void main() {
); );
expect(result.hasWarnings, true); expect(result.hasWarnings, true);
expect(result.warnings.any((w) => w.code == Validator.typeMismatch), true); expect(
result.warnings.any(_isTypeMismatch),
true,
);
});
test('多类型匹配路径均可通过', () {
final manager = StaticConfigManager(
config: _config(),
configValue: _dimInfoWithTypes(),
);
final validator = Validator(manager);
final result = validator.validate(
_event(
type: 'TYPED',
customTags: const <String, dynamic>{
'intNum': 1.0,
'intStr': '2',
'boolNum': 0,
'boolStr': 'no',
'doubleStr': '3.14',
'floatStr': '2.72',
'numStr': '42',
'unknown': <String, dynamic>{'any': 'value'},
},
),
);
expect(result.hasErrors, false);
expect(result.hasWarnings, false);
}); });
}); });
} }

View File

@ -1,6 +1,6 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:yx_tracking_flutter/yx_tracking_flutter.dart';
import 'package:yx_tracking_flutter/src/util/time_util.dart'; import 'package:yx_tracking_flutter/src/util/time_util.dart';
import 'package:yx_tracking_flutter/yx_tracking_flutter.dart';
void main() { void main() {
group('AnalyticsConfig.validate', () { group('AnalyticsConfig.validate', () {
@ -26,12 +26,12 @@ void main() {
}); });
test('Event payload 序列化/反序列化', () { test('Event payload 序列化/反序列化', () {
final device = const DeviceInfo( const device = DeviceInfo(
os: 'android', os: 'android',
model: 'pixel', model: 'pixel',
screenResolution: '1080x1920', screenResolution: '1080x1920',
); );
final user = const UserInfo(userId: 1, userName: 'max', account: 'max'); const user = UserInfo(userId: 1, userName: 'max', account: 'max');
final now = TimeUtil.nowMs(); final now = TimeUtil.nowMs();
final event = Event( final event = Event(