diff --git a/analysis_options.yaml b/analysis_options.yaml index a5744c1..050635f 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -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 # https://dart.dev/guides/language/analysis-options diff --git a/lib/src/config/analytics_config.dart b/lib/src/config/analytics_config.dart index 0c15cf6..974536f 100644 --- a/lib/src/config/analytics_config.dart +++ b/lib/src/config/analytics_config.dart @@ -2,24 +2,7 @@ import 'dart:core'; /// SDK 初始化配置。 class AnalyticsConfig { - final String systemCode; - 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; - + /// 创建 SDK 配置实例。 const AnalyticsConfig({ required this.systemCode, required this.endpointBaseUrl, @@ -31,11 +14,58 @@ class AnalyticsConfig { this.maxRetryCount = 3, this.connectTimeout = const Duration(seconds: 5), this.readTimeout = const Duration(seconds: 5), + this.maxEventAge = const Duration(days: 7), + this.useIsolateStorage = true, this.enableMetrics = true, this.metricsReportInterval = const Duration(minutes: 10), 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,并且关键数值为正数。 @@ -61,25 +91,60 @@ class AnalyticsConfig { } if (clientType <= 0) { - throw ArgumentError.value(clientType, 'clientType', 'clientType 必须为正整数'); + throw ArgumentError.value( + clientType, + 'clientType', + 'clientType 必须为正整数', + ); } if (batchSize <= 0) { - throw ArgumentError.value(batchSize, 'batchSize', 'batchSize 必须为正整数'); + throw ArgumentError.value( + batchSize, + 'batchSize', + 'batchSize 必须为正整数', + ); } if (flushInterval <= 0) { - throw ArgumentError.value(flushInterval, 'flushInterval', 'flushInterval 必须为正整数(秒)'); + throw ArgumentError.value( + flushInterval, + 'flushInterval', + 'flushInterval 必须为正整数(秒)', + ); } if (maxCacheSize <= 0) { - throw ArgumentError.value(maxCacheSize, 'maxCacheSize', 'maxCacheSize 必须为正整数'); + throw ArgumentError.value( + maxCacheSize, + 'maxCacheSize', + 'maxCacheSize 必须为正整数', + ); } if (maxRetryCount < 0) { - throw ArgumentError.value(maxRetryCount, 'maxRetryCount', 'maxRetryCount 不能为负数'); + throw ArgumentError.value( + maxRetryCount, + 'maxRetryCount', + 'maxRetryCount 不能为负数', + ); } if (connectTimeout <= Duration.zero) { - throw ArgumentError.value(connectTimeout, 'connectTimeout', 'connectTimeout 必须大于 0'); + throw ArgumentError.value( + connectTimeout, + 'connectTimeout', + 'connectTimeout 必须大于 0', + ); } 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) { throw ArgumentError.value( @@ -104,9 +169,8 @@ class AnalyticsConfig { final normalizedBasePath = base.path.endsWith('/') ? base.path.substring(0, base.path.length - 1) : base.path; - final nextPath = normalizedBasePath.isEmpty - ? '/$leaf' - : '$normalizedBasePath/$leaf'; + final nextPath = + normalizedBasePath.isEmpty ? '/$leaf' : '$normalizedBasePath/$leaf'; return base.replace(path: nextPath); } diff --git a/lib/src/config/config_manager.dart b/lib/src/config/config_manager.dart index 743360d..94db1a9 100644 --- a/lib/src/config/config_manager.dart +++ b/lib/src/config/config_manager.dart @@ -1,22 +1,15 @@ import 'package:dio/dio.dart'; -import '../model/system_dim_info.dart'; -import '../network/http_client.dart'; -import '../storage/config_storage.dart'; -import '../storage/sqflite_config_storage.dart'; -import '../util/logger.dart'; -import 'analytics_config.dart'; +import 'package:yx_tracking_flutter/src/config/analytics_config.dart'; +import 'package:yx_tracking_flutter/src/model/system_dim_info.dart'; +import 'package:yx_tracking_flutter/src/network/http_client.dart'; +import 'package:yx_tracking_flutter/src/storage/config_storage.dart'; +import 'package:yx_tracking_flutter/src/storage/sqflite_config_storage.dart'; +import 'package:yx_tracking_flutter/src/util/logger.dart'; /// Phase 2:配置拉取与缓存管理。 class ConfigManager { - final AnalyticsConfig config; - final Duration refreshInterval; - - final HttpClient _httpClient; - final ConfigStorage _storage; - - SystemDimInfo? _current; - + /// 创建配置管理器。 ConfigManager({ required this.config, this.refreshInterval = const Duration(hours: 12), @@ -25,18 +18,34 @@ class ConfigManager { }) : _httpClient = httpClient ?? HttpClient(config), _storage = storage ?? SqfliteConfigStorage(); + /// SDK 配置。 + final AnalyticsConfig config; + + /// 配置刷新间隔。 + final Duration refreshInterval; + + final HttpClient _httpClient; + final ConfigStorage _storage; + + SystemDimInfo? _current; + + /// 当前缓存的配置(可能为空)。 SystemDimInfo? get currentConfig => _current; + /// 初始化本地缓存。 Future init() async { await _storage.init(); _current = await _storage.loadSystemDimInfo(); if (_current != null) { + final eventCount = _current!.eventDefinitions.length; + final tagCount = _current!.tagDefinitions.length; Logger.info( - '已加载本地配置缓存: events=${_current!.eventDefinitions.length}, tags=${_current!.tagDefinitions.length}', + '已加载本地配置缓存: events=$eventCount, tags=$tagCount', ); } } + /// 拉取并缓存配置。 Future fetchAndCacheConfig({bool force = false}) async { if (!force && _shouldSkipFetch()) { final last = _current?.lastFetchedAt; @@ -63,18 +72,22 @@ class ConfigManager { await _storage.saveSystemDimInfo(info); _current = info; + final eventCount = info.eventDefinitions.length; + final tagCount = info.tagDefinitions.length; Logger.info( - '配置拉取并缓存成功: events=${info.eventDefinitions.length}, tags=${info.tagDefinitions.length}', + '配置拉取并缓存成功: events=$eventCount, tags=$tagCount', ); } on DioException catch (e, st) { Logger.error('配置拉取失败(DioException)', e, st); - } catch (e, st) { + } on Object catch (e, st) { Logger.error('配置拉取失败(未知异常)', e, st); } } + /// 强制刷新配置。 Future forceRefresh() => fetchAndCacheConfig(force: true); + /// 释放资源。 Future dispose() => _storage.dispose(); bool _shouldSkipFetch() { diff --git a/lib/src/core/analytics_core.dart b/lib/src/core/analytics_core.dart index c0a7f9f..09cef87 100644 --- a/lib/src/core/analytics_core.dart +++ b/lib/src/core/analytics_core.dart @@ -1,35 +1,26 @@ import 'dart:async'; import 'dart:math'; -import '../config/analytics_config.dart'; -import '../config/config_manager.dart'; -import '../model/device_info.dart'; -import '../model/event.dart'; -import '../model/recent_event_summary.dart'; -import '../model/user_info.dart'; -import '../network/api_client.dart'; -import '../storage/event_storage.dart'; -import '../storage/sqflite_event_storage.dart'; -import '../util/device_util.dart'; -import '../util/logger.dart'; -import '../util/time_util.dart'; -import 'interceptors.dart'; -import 'scheduler.dart'; -import 'validator.dart'; +import 'package:yx_tracking_flutter/src/config/analytics_config.dart'; +import 'package:yx_tracking_flutter/src/config/config_manager.dart'; +import 'package:yx_tracking_flutter/src/core/interceptors.dart'; +import 'package:yx_tracking_flutter/src/core/scheduler.dart'; +import 'package:yx_tracking_flutter/src/core/validator.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/recent_event_summary.dart'; +import 'package:yx_tracking_flutter/src/model/user_info.dart'; +import 'package:yx_tracking_flutter/src/network/api_client.dart'; +import 'package:yx_tracking_flutter/src/storage/event_storage.dart'; +import 'package:yx_tracking_flutter/src/storage/isolate_event_storage.dart'; +import 'package:yx_tracking_flutter/src/storage/sqflite_event_storage.dart'; +import 'package:yx_tracking_flutter/src/util/device_util.dart'; +import 'package:yx_tracking_flutter/src/util/logger.dart'; +import 'package:yx_tracking_flutter/src/util/time_util.dart'; /// SDK 核心逻辑:事件构造、存储、调度与发送。 class AnalyticsCore { - final EventStorage Function() _storageFactory; - final ApiClient Function(AnalyticsConfig config) _apiClientFactory; - final ConfigManager Function(AnalyticsConfig config) _configManagerFactory; - final Future Function() _deviceInfoCollector; - final Scheduler Function(Duration interval, Future Function() onTick) - _schedulerFactory; - final double Function() _randomDouble; - final DateTime Function() _now; - final bool _includeCommonTagsInterceptor; - final List _initialInterceptors; - + /// 创建核心实例,允许通过依赖注入进行测试替换。 AnalyticsCore({ EventStorage Function()? storageFactory, ApiClient Function(AnalyticsConfig config)? apiClientFactory, @@ -41,19 +32,38 @@ class AnalyticsCore { DateTime Function()? now, bool includeCommonTagsInterceptor = true, List interceptors = const [], - }) : _storageFactory = storageFactory ?? (() => SqfliteEventStorage()), - _apiClientFactory = apiClientFactory ?? ((c) => ApiClient(c)), + }) : _storageFactory = storageFactory ?? SqfliteEventStorage.new, + _useDefaultStorageFactory = storageFactory == null, + _apiClientFactory = apiClientFactory ?? ApiClient.new, _configManagerFactory = - configManagerFactory ?? ((c) => ConfigManager(config: c)), - _deviceInfoCollector = deviceInfoCollector ?? DeviceUtil.collectDeviceInfo, + configManagerFactory ?? + ((c) => ConfigManager(config: c)), // coverage:ignore-line + _deviceInfoCollector = + deviceInfoCollector ?? DeviceUtil.collectDeviceInfo, + // coverage:ignore-start _schedulerFactory = schedulerFactory ?? - ((interval, onTick) => Scheduler(interval: interval, onTick: onTick)), - _randomDouble = randomDouble ?? Random().nextDouble, + ((interval, onTick) => + Scheduler(interval: interval, onTick: onTick)), + // coverage:ignore-end + _randomDouble = + randomDouble ?? (Random().nextDouble), // coverage:ignore-line _now = now ?? DateTime.now, _includeCommonTagsInterceptor = includeCommonTagsInterceptor, _initialInterceptors = interceptors; + final EventStorage Function() _storageFactory; + final bool _useDefaultStorageFactory; + final ApiClient Function(AnalyticsConfig config) _apiClientFactory; + final ConfigManager Function(AnalyticsConfig config) _configManagerFactory; + final Future Function() _deviceInfoCollector; + final Scheduler Function(Duration interval, Future Function() onTick) + _schedulerFactory; + final double Function() _randomDouble; + final DateTime Function() _now; + final bool _includeCommonTagsInterceptor; + final List _initialInterceptors; + AnalyticsConfig? _config; UserInfo? _user; DeviceInfo? _deviceInfo; @@ -70,6 +80,7 @@ class AnalyticsCore { bool _initialized = false; bool _isFlushing = false; DateTime? _nextAllowedFlushTime; + DateTime? _lastExpirationSweep; // Phase 3:SDK 自监控指标。 int _sentCount = 0; @@ -86,9 +97,12 @@ class AnalyticsCore { _metricsSendEventType, _metricsQueueEventType, }; + static const Duration _expirationSweepInterval = Duration(hours: 1); + /// 是否已完成初始化。 bool get isInitialized => _initialized; + /// 初始化 SDK 核心能力。 Future init(AnalyticsConfig config) async { // 支持重复 init:先优雅释放旧资源再重新初始化。 if (_initialized) { @@ -96,11 +110,13 @@ class AnalyticsCore { } config.validate(); - Logger.setDebug(config.enableDebug); + Logger.debugEnabled = config.enableDebug; Logger.info('AnalyticsCore 初始化开始'); final deviceInfo = await _deviceInfoCollector(); - final storage = _storageFactory(); + final storage = _useDefaultStorageFactory + ? _buildDefaultStorage(config) + : _storageFactory(); await storage.init(); final configManager = _configManagerFactory(config); await configManager.init(); @@ -115,34 +131,39 @@ class AnalyticsCore { final scheduler = _schedulerFactory( Duration(seconds: config.flushInterval), - () => flush(), - ); - scheduler.start(); + flush, + )..start(); _scheduler = scheduler; _initialized = true; Logger.info('AnalyticsCore 初始化完成'); + await _maybePurgeExpired(reason: 'init'); + // Phase 2:初始化后异步拉取配置,失败不影响埋点主流程。 unawaited(configManager.fetchAndCacheConfig()); _startConfigRefreshTimer(configManager); _startMetricsTimer(config); } + /// 注册发送拦截器。 void addInterceptor(AnalyticsInterceptor interceptor) { _interceptors.add(interceptor); } + /// 设置当前用户信息。 Future setUser(UserInfo? userInfo) async { _user = userInfo; Logger.info('用户信息已更新'); } + /// 覆盖设备信息(通常用于测试或特殊场景)。 Future setDeviceInfo(DeviceInfo deviceInfo) async { _deviceInfo = deviceInfo; Logger.info('设备信息已覆盖'); } + /// 记录一个业务事件。 Future track( String eventType, { Map? eventParams, @@ -160,16 +181,19 @@ class AnalyticsCore { Future _track( String eventType, { + required bool internal, Map? eventParams, Map? customTags, int? timestamp, - required bool internal, }) async { final config = _config; final storage = _storage; final deviceInfo = _deviceInfo; - if (!_initialized || config == null || storage == null || deviceInfo == null) { + if (!_initialized || + config == null || + storage == null || + deviceInfo == null) { Logger.warn('track 被调用但 SDK 尚未初始化,已忽略: $eventType'); return; } @@ -199,7 +223,8 @@ class AnalyticsCore { createTime: now, ); - final validatedEvent = internal ? event : _validateAndDecorate(event, config); + final validatedEvent = + internal ? event : _validateAndDecorate(event, config); if (validatedEvent == null) { _droppedCount += 1; return; @@ -222,6 +247,7 @@ class AnalyticsCore { } } + /// 获取当前缓存事件数。 Future cachedEventCount() async { final storage = _storage; if (!_initialized || storage == null) { @@ -230,6 +256,7 @@ class AnalyticsCore { return storage.count(); } + /// 获取最近的缓存事件摘要。 Future> cachedRecentEvents({int limit = 20}) async { final storage = _storage; if (!_initialized || storage == null || limit <= 0) { @@ -248,6 +275,7 @@ class AnalyticsCore { .toList(growable: false); } + /// 刷新维表配置。 Future refreshConfig({bool force = true}) async { final manager = _configManager; if (!_initialized || manager == null) { @@ -260,14 +288,19 @@ class AnalyticsCore { await manager.fetchAndCacheConfig(); } + /// 立即上报一次 SDK 指标。 Future reportMetricsNow() => _reportMetrics(); + /// 触发一次 flush。 Future flush({bool force = false}) async { final config = _config; final storage = _storage; final apiClient = _apiClient; - if (!_initialized || config == null || storage == null || apiClient == null) { + if (!_initialized || + config == null || + storage == null || + apiClient == null) { Logger.warn('flush 被调用但 SDK 尚未初始化,已忽略'); return; } @@ -286,6 +319,7 @@ class AnalyticsCore { _isFlushing = true; try { + await _maybePurgeExpired(reason: 'flush'); final flushStart = _now(); final batch = await storage.fetchBatch(config.batchSize); if (batch.isEmpty) { @@ -296,10 +330,12 @@ class AnalyticsCore { if (prepared.sendable.isEmpty) { return; } - final events = - prepared.sendable.map((item) => item.event).toList(growable: false); - final ids = - prepared.sendable.map((item) => item.stored.id).toList(growable: false); + final events = prepared.sendable + .map((item) => item.event) + .toList(growable: false); + final ids = prepared.sendable + .map((item) => item.stored.id) + .toList(growable: false); try { await apiClient.sendBatch(events); @@ -328,7 +364,7 @@ class AnalyticsCore { await storage.deleteByIds(ids); _droppedCount += _countNonInternalEvents(events); } - } catch (e, st) { + } on Object catch (e, st) { Logger.error('flush 发生未知异常,按可重试处理', e, st); _recordFailureMetrics(events); await _runAfterSend( @@ -359,7 +395,8 @@ class AnalyticsCore { } 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); final droppedEvents = dropped.map((item) => item.event).toList(growable: false); @@ -378,7 +415,7 @@ class AnalyticsCore { return null; } current = next; - } catch (e, st) { + } on Object catch (e, st) { Logger.error('beforeSend 拦截器异常,已忽略并继续', e, st); } } @@ -396,7 +433,7 @@ class AnalyticsCore { for (final item in events) { try { await interceptor.afterSend(item.event, result); - } catch (e, st) { + } on Object catch (e, st) { Logger.error('afterSend 拦截器异常,已忽略并继续', e, st); } } @@ -428,7 +465,8 @@ class AnalyticsCore { return !sampledIn; } - bool _isInternalEvent(String eventType) => _internalEventTypes.contains(eventType); + bool _isInternalEvent(String eventType) => + _internalEventTypes.contains(eventType); int _countNonInternalEvents(List events) { var count = 0; @@ -480,7 +518,10 @@ class AnalyticsCore { Future _reportMetrics() async { final config = _config; final storage = _storage; - if (!_initialized || config == null || storage == null || !config.enableMetrics) { + if (!_initialized || + config == null || + storage == null || + !config.enableMetrics) { return; } @@ -488,7 +529,8 @@ class AnalyticsCore { final windowStart = _lastMetricsReportTime ?? now; final windowMs = now.difference(windowStart).inMilliseconds; final queueSize = await storage.count(); - final avgLatencyMs = _latencySamples == 0 ? 0 : _totalLatencyMs ~/ _latencySamples; + final avgLatencyMs = + _latencySamples == 0 ? 0 : _totalLatencyMs ~/ _latencySamples; final sendParams = { 'sentCount': _sentCount, @@ -504,8 +546,16 @@ class AnalyticsCore { 'windowMs': windowMs, }; - await _track(_metricsSendEventType, eventParams: sendParams, internal: true); - await _track(_metricsQueueEventType, eventParams: queueParams, internal: true); + await _track( + _metricsSendEventType, + eventParams: sendParams, + internal: true, + ); + await _track( + _metricsQueueEventType, + eventParams: queueParams, + internal: true, + ); _resetMetricsWindow(startAt: now); } @@ -576,10 +626,12 @@ class AnalyticsCore { return Duration(seconds: seconds); } - Future setDebug(bool enabled) async { - Logger.setDebug(enabled); + /// 动态设置 Debug 开关。 + Future setDebug({required bool enabled}) async { + Logger.debugEnabled = enabled; } + /// 释放所有资源。 Future dispose() async { _scheduler?.dispose(); _scheduler = null; @@ -605,6 +657,7 @@ class AnalyticsCore { _nextAllowedFlushTime = null; _interceptors.clear(); _resetMetricsWindow(); + _lastExpirationSweep = null; Logger.info('AnalyticsCore 已释放资源'); } @@ -655,7 +708,8 @@ class AnalyticsCore { final typeMismatchFields = result.warnings .where( (w) => - w.code == Validator.typeMismatch && (w.field?.isNotEmpty ?? false), + w.code == Validator.typeMismatch && + (w.field?.isNotEmpty ?? false), ) .map((w) => w.field!) .toList(growable: false); @@ -693,20 +747,53 @@ class AnalyticsCore { _interceptors.insert(0, CommonTagsInterceptor()); } } + + EventStorage _buildDefaultStorage(AnalyticsConfig config) { + if (config.useIsolateStorage) { + return IsolateEventStorage(); + } + return SqfliteEventStorage(); + } + + Future _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 { - final List<_SendableStoredEvent> sendable; - const _PreparedBatch({required this.sendable}); + + final List<_SendableStoredEvent> sendable; } class _SendableStoredEvent { - final StoredEvent stored; - final Event event; - const _SendableStoredEvent({ required this.stored, required this.event, }); + + final StoredEvent stored; + final Event event; } diff --git a/lib/src/core/interceptors.dart b/lib/src/core/interceptors.dart index 0bb0304..ff69c6d 100644 --- a/lib/src/core/interceptors.dart +++ b/lib/src/core/interceptors.dart @@ -1,27 +1,37 @@ import 'dart:async'; -import '../model/event.dart'; -import '../util/sdk_info.dart'; +import 'package:yx_tracking_flutter/src/model/event.dart'; +import 'package:yx_tracking_flutter/src/util/sdk_info.dart'; /// 发送结果。 class SendResult { - final bool success; - final int? statusCode; - final bool retryable; - final Object? error; - + /// 创建发送结果实例。 const SendResult({ required this.success, required this.retryable, this.statusCode, this.error, }); + + /// 是否发送成功。 + final bool success; + + /// HTTP 状态码(如果可用)。 + final int? statusCode; + + /// 是否可重试。 + final bool retryable; + + /// 发送时的错误对象。 + final Object? error; } /// 埋点事件拦截器。 abstract class AnalyticsInterceptor { + /// 发送前拦截,可返回 null 以丢弃事件。 FutureOr beforeSend(Event event) => event; + /// 发送后回调。 FutureOr afterSend(Event event, SendResult result) {} } @@ -32,8 +42,8 @@ class CommonTagsInterceptor extends AnalyticsInterceptor { final tags = Map.from( event.customTags ?? const {}, ); - tags.putIfAbsent('_sdk_version', () => SdkInfo.sdkVersion); - tags.putIfAbsent('_platform', () => SdkInfo.platform); + tags['_sdk_version'] ??= SdkInfo.sdkVersion; + tags['_platform'] ??= SdkInfo.platform; return event.copyWith(customTags: tags); } } diff --git a/lib/src/core/scheduler.dart b/lib/src/core/scheduler.dart index 27a99d8..35a4bac 100644 --- a/lib/src/core/scheduler.dart +++ b/lib/src/core/scheduler.dart @@ -1,21 +1,27 @@ import 'dart:async'; -import '../util/logger.dart'; +import 'package:yx_tracking_flutter/src/util/logger.dart'; /// 简单定时调度器。 class Scheduler { - final Duration interval; - final Future Function() onTick; - - Timer? _timer; - + /// 创建调度器。 Scheduler({ required this.interval, required this.onTick, }); + /// 调度间隔。 + final Duration interval; + + /// 每次 tick 的回调。 + final Future Function() onTick; + + Timer? _timer; + + /// 当前是否正在运行。 bool get isRunning => _timer?.isActive ?? false; + /// 启动调度。 void start() { if (isRunning) { return; @@ -23,18 +29,21 @@ class Scheduler { _timer = Timer.periodic(interval, (_) async { try { await onTick(); - } catch (e, st) { + } on Object catch (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() { _timer?.cancel(); _timer = null; Logger.info('Scheduler 已停止'); } + /// 释放资源。 void dispose() => stop(); } diff --git a/lib/src/core/validator.dart b/lib/src/core/validator.dart index a6e8ecd..0edfe92 100644 --- a/lib/src/core/validator.dart +++ b/lib/src/core/validator.dart @@ -1,52 +1,72 @@ -import '../config/config_manager.dart'; -import '../model/event.dart'; +import 'package:yx_tracking_flutter/src/config/config_manager.dart'; +import 'package:yx_tracking_flutter/src/model/event.dart'; /// 校验结果。 class ValidationResult { - final List errors; - final List warnings; - + /// 创建校验结果实例。 const ValidationResult({ this.errors = const [], this.warnings = const [], }); + /// 错误列表(通常会阻断发送)。 + final List errors; + + /// 警告列表(不会阻断发送)。 + final List warnings; + + /// 是否存在错误。 bool get hasErrors => errors.isNotEmpty; + + /// 是否存在警告。 bool get hasWarnings => warnings.isNotEmpty; + + /// 是否没有任何问题。 bool get isEmpty => !hasErrors && !hasWarnings; } /// 校验问题项。 class ValidationIssue { - final String code; - final String message; - final String? field; - + /// 创建校验问题项。 const ValidationIssue({ required this.code, required this.message, this.field, }); + /// 问题编码。 + final String code; + + /// 问题描述。 + final String message; + + /// 关联字段(如果有)。 + final String? field; + @override - String toString() { - if (field == null || field!.isEmpty) { - return '$code: $message'; - } - return '$code($field): $message'; - } + String toString() => + field == null || field!.isEmpty + ? '$code: $message' + : '$code($field): $message'; } /// Phase 2:事件校验器。 class Validator { + /// 创建事件校验器。 + const Validator(this._configManager); + + /// 未知事件类型。 static const String unknownEventType = 'UNKNOWN_EVENT_TYPE'; + + /// 缺少必填标签。 static const String missingRequiredTag = 'MISSING_REQUIRED_TAG'; + + /// 类型不匹配。 static const String typeMismatch = 'TYPE_MISMATCH'; final ConfigManager _configManager; - const Validator(this._configManager); - + /// 校验事件并返回结果。 ValidationResult validate(Event event) { final config = _configManager.currentConfig; if (config == null) { diff --git a/lib/src/model/device_info.dart b/lib/src/model/device_info.dart index d1023c6..c6d9d6f 100644 --- a/lib/src/model/device_info.dart +++ b/lib/src/model/device_info.dart @@ -1,20 +1,25 @@ /// 设备信息。 class DeviceInfo { - final String os; - final String model; - final String screenResolution; - + /// 创建设备信息实例。 const DeviceInfo({ required this.os, required this.model, required this.screenResolution, }); - Map toJson() { - return { - 'os': os, - 'model': model, - 'screenResolution': screenResolution, - }; - } + /// 操作系统名称或版本。 + final String os; + + /// 设备型号。 + final String model; + + /// 屏幕分辨率。 + final String screenResolution; + + /// 转换为可序列化的 JSON。 + Map toJson() => { + 'os': os, + 'model': model, + 'screenResolution': screenResolution, + }; } diff --git a/lib/src/model/event.dart b/lib/src/model/event.dart index 16a59f5..c4016d5 100644 --- a/lib/src/model/event.dart +++ b/lib/src/model/event.dart @@ -1,22 +1,11 @@ import 'dart:convert'; -import 'device_info.dart'; -import 'user_info.dart'; +import 'package:yx_tracking_flutter/src/model/device_info.dart'; +import 'package:yx_tracking_flutter/src/model/user_info.dart'; /// 统一事件模型。 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? eventParams; - final Map? customTags; - final DateTime createTime; - final int retryCount; - + /// 创建事件实例。 const Event({ required this.systemCode, required this.eventType, @@ -31,46 +20,8 @@ class Event { this.retryCount = 0, }); - Event copyWith({ - UserInfo? userInfo, - DeviceInfo? deviceInfo, - Map? eventParams, - Map? 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 toJson() { - return { - '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( + /// 从序列化 payload 恢复事件。 + factory Event.fromPayload( String payload, { required DateTime createTime, required int retryCount, @@ -79,10 +30,15 @@ class Event { if (decoded is! Map) { 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 json, { required DateTime createTime, required int retryCount, @@ -110,7 +66,8 @@ class Event { deviceInfo: DeviceInfo( os: deviceJson['os']?.toString() ?? 'unknown', model: deviceJson['model']?.toString() ?? 'unknown', - screenResolution: deviceJson['screenResolution']?.toString() ?? 'unknown', + screenResolution: + deviceJson['screenResolution']?.toString() ?? 'unknown', ), eventParams: _toMap(json['eventParams']), 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? eventParams; + + /// 自定义标签。 + final Map? customTags; + + /// 事件创建时间。 + final DateTime createTime; + + /// 当前重试次数。 + final int retryCount; + + /// 复制事件并覆盖部分字段。 + Event copyWith({ + UserInfo? userInfo, + DeviceInfo? deviceInfo, + Map? eventParams, + Map? 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 toJson() => { + '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) { if (value is int) { return value; @@ -150,11 +179,7 @@ class Event { /// 带本地存储元数据的事件。 class StoredEvent { - final int id; - final Event event; - final int retryCount; - final DateTime createTime; - + /// 创建带存储元数据的事件实例。 const StoredEvent({ required this.id, required this.event, @@ -162,17 +187,29 @@ class StoredEvent { required this.createTime, }); + /// 存储中的自增 ID。 + final int id; + + /// 事件本体。 + final Event event; + + /// 当前重试次数。 + final int retryCount; + + /// 事件创建时间。 + final DateTime createTime; + + /// 复制对象并覆盖部分字段。 StoredEvent copyWith({ int? id, Event? event, int? retryCount, DateTime? createTime, - }) { - return StoredEvent( - id: id ?? this.id, - event: event ?? this.event, - retryCount: retryCount ?? this.retryCount, - createTime: createTime ?? this.createTime, - ); - } + }) => + StoredEvent( + id: id ?? this.id, + event: event ?? this.event, + retryCount: retryCount ?? this.retryCount, + createTime: createTime ?? this.createTime, + ); } diff --git a/lib/src/model/recent_event_summary.dart b/lib/src/model/recent_event_summary.dart index 486ab57..7343ab2 100644 --- a/lib/src/model/recent_event_summary.dart +++ b/lib/src/model/recent_event_summary.dart @@ -1,14 +1,22 @@ /// 最近事件摘要(用于调试面板)。 class RecentEventSummary { - final int id; - final String eventType; - final DateTime createTime; - final int retryCount; - + /// 创建最近事件摘要实例。 const RecentEventSummary({ required this.id, required this.eventType, required this.createTime, required this.retryCount, }); + + /// 事件在本地存储中的自增 ID。 + final int id; + + /// 事件类型。 + final String eventType; + + /// 事件创建时间。 + final DateTime createTime; + + /// 当前重试次数。 + final int retryCount; } diff --git a/lib/src/model/system_dim_info.dart b/lib/src/model/system_dim_info.dart index ad9b49e..4b58712 100644 --- a/lib/src/model/system_dim_info.dart +++ b/lib/src/model/system_dim_info.dart @@ -1,12 +1,6 @@ /// Phase 2:后端下发的维度配置模型。 class SystemDimInfo { - final SystemInfo? systemInfo; - final List eventDefinitions; - final List tagDefinitions; - final SdkStrategy? sdkStrategy; - final DateTime lastFetchedAt; - final String? version; - + /// 创建系统维表配置。 const SystemDimInfo({ required this.systemInfo, required this.eventDefinitions, @@ -16,57 +10,8 @@ class SystemDimInfo { this.version, }); - bool hasEvent(String eventType) { - return eventDefinitions.any((e) => e.eventCode == eventType); - } - - List 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 toCacheJson() { - return { - '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 json) { + /// 从缓存 JSON 构造配置。 + factory SystemDimInfo.fromCacheJson(Map json) { final lastFetchedMs = _toInt(json['lastFetchedAt']) ?? 0; final fetchedAt = DateTime.fromMillisecondsSinceEpoch(lastFetchedMs); @@ -90,7 +35,7 @@ class SystemDimInfo { /// 从后端响应构造配置。 /// /// 兼容字段命名差异(例如 systemCustonTas 的拼写)。 - static SystemDimInfo fromResponse( + factory SystemDimInfo.fromResponse( Map json, { DateTime? fetchedAt, String? version, @@ -114,6 +59,80 @@ class SystemDimInfo { ); } + /// 系统信息。 + final SystemInfo? systemInfo; + + /// 事件定义列表。 + final List eventDefinitions; + + /// 标签定义列表。 + final List tagDefinitions; + + /// SDK 策略配置。 + final SdkStrategy? sdkStrategy; + + /// 最近一次拉取时间。 + final DateTime lastFetchedAt; + + /// 配置版本号(如果可用)。 + final String? version; + + /// 是否包含指定事件类型。 + bool hasEvent(String eventType) => + eventDefinitions.any((e) => e.eventCode == eventType); + + /// 必填标签列表。 + List 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 toCacheJson() => { + '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 _parseEventDefinitions(dynamic value) { if (value is! List) { return const []; @@ -150,6 +169,7 @@ class SystemDimInfo { return results; } + /// 解析标签定义列表。 static List _parseTagDefinitions(dynamic value) { if (value is! List) { return const []; @@ -172,7 +192,8 @@ class SystemDimInfo { const ['tagType', 'tag_type', 'type'], ) ?? '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 ['description', 'desc']); results.add( @@ -187,6 +208,7 @@ class SystemDimInfo { return results; } + /// 解析 SDK 策略配置。 static SdkStrategy? _parseSdkStrategy(dynamic value) { if (value is! Map) { return null; @@ -194,7 +216,7 @@ class SystemDimInfo { final map = value.map((k, v) => MapEntry(k.toString(), v)); final enabled = _toBool(map['enabled']) ?? true; final defaultSampleRate = - _clampSampleRate(_toDouble(map['defaultSampleRate']) ?? 1.0); + _clampSampleRate(_toDouble(map['defaultSampleRate']) ?? 1); final eventSettingsRaw = map['eventSettings']; final eventSettings = {}; @@ -206,7 +228,9 @@ class SystemDimInfo { final rawMap = rawValue.map((k, v) => MapEntry(k.toString(), v)); final eventEnabled = _toBool(rawMap['enabled']) ?? true; final sampleRate = - _clampSampleRate(_toDouble(rawMap['sampleRate']) ?? defaultSampleRate); + _clampSampleRate( + _toDouble(rawMap['sampleRate']) ?? defaultSampleRate, + ); eventSettings[key.toString()] = EventStrategy( enabled: eventEnabled, sampleRate: sampleRate, @@ -221,6 +245,7 @@ class SystemDimInfo { ); } + /// 按候选 key 顺序读取首个非空字符串。 static String? _stringFirst(Map map, List keys) { for (final key in keys) { final value = map[key]; @@ -235,6 +260,7 @@ class SystemDimInfo { return null; } + /// 尝试将值转换为 int。 static int? _toInt(dynamic value) { if (value is int) { return value; @@ -248,6 +274,7 @@ class SystemDimInfo { return null; } + /// 尝试将值转换为 double。 static double? _toDouble(dynamic value) { if (value is double) { return value; @@ -261,9 +288,10 @@ class SystemDimInfo { return null; } + /// 将采样率限制在 [0, 1] 区间。 static double _clampSampleRate(double value) { if (value.isNaN || value.isInfinite) { - return 1.0; + return 1; } if (value < 0) { return 0; @@ -274,6 +302,7 @@ class SystemDimInfo { return value; } + /// 尝试将值转换为 bool。 static bool? _toBool(dynamic value) { if (value is bool) { return value; @@ -296,43 +325,49 @@ class SystemDimInfo { /// systemInfo 原始信息(当前以透传为主)。 class SystemInfo { - final Map raw; - + /// 创建系统信息对象。 const SystemInfo({required this.raw}); - Map toJson() => raw; + /// 从 JSON 构造系统信息。 + factory SystemInfo.fromJson(Map json) => + SystemInfo(raw: json); - static SystemInfo fromJson(Map json) { - return SystemInfo(raw: json); - } + /// 原始字段。 + final Map raw; + + /// 转换为 JSON。 + Map toJson() => raw; } +/// 事件定义。 class EventDefinition { - final String eventCode; - final String? eventName; - final String? description; - + /// 创建事件定义。 const EventDefinition({ required this.eventCode, this.eventName, this.description, }); - Map toJson() { - return { - 'eventCode': eventCode, - 'eventName': eventName, - 'description': description, - }; - } -} + /// 事件编码。 + final String eventCode; -class TagDefinition { - final String tagName; - final String tagType; - final bool isRequired; + /// 事件名称。 + final String? eventName; + + /// 事件描述。 final String? description; + /// 转换为 JSON。 + Map toJson() => { + 'eventCode': eventCode, + 'eventName': eventName, + 'description': description, + }; +} + +/// 标签定义。 +class TagDefinition { + /// 创建标签定义。 const TagDefinition({ required this.tagName, required this.tagType, @@ -340,53 +375,72 @@ class TagDefinition { this.description, }); - Map toJson() { - return { - 'tagName': tagName, - 'tagType': tagType, - 'isRequired': isRequired, - 'description': description, - }; - } + /// 标签名称。 + final String tagName; + + /// 标签类型。 + final String tagType; + + /// 是否必填。 + final bool isRequired; + + /// 标签描述。 + final String? description; + + /// 转换为 JSON。 + Map toJson() => { + 'tagName': tagName, + 'tagType': tagType, + 'isRequired': isRequired, + 'description': description, + }; } /// Phase 3:SDK 动态策略。 class SdkStrategy { - final bool enabled; - final double defaultSampleRate; - final Map eventSettings; - + /// 创建 SDK 策略。 const SdkStrategy({ required this.enabled, required this.defaultSampleRate, required this.eventSettings, }); - Map toJson() { - return { - 'enabled': enabled, - 'defaultSampleRate': defaultSampleRate, - 'eventSettings': eventSettings.map( - (key, value) => MapEntry(key, value.toJson()), - ), - }; - } + /// 是否启用策略。 + final bool enabled; + + /// 默认采样率。 + final double defaultSampleRate; + + /// 事件级别策略。 + final Map eventSettings; + + /// 转换为 JSON。 + Map toJson() => { + 'enabled': enabled, + 'defaultSampleRate': defaultSampleRate, + 'eventSettings': eventSettings.map( + (key, value) => MapEntry(key, value.toJson()), + ), + }; } /// Phase 3:单事件策略。 class EventStrategy { - final bool enabled; - final double sampleRate; - + /// 创建单事件策略。 const EventStrategy({ required this.enabled, required this.sampleRate, }); - Map toJson() { - return { - 'enabled': enabled, - 'sampleRate': sampleRate, - }; - } + /// 是否启用。 + final bool enabled; + + /// 采样率。 + final double sampleRate; + + /// 转换为 JSON。 + Map toJson() => { + 'enabled': enabled, + 'sampleRate': sampleRate, + }; } diff --git a/lib/src/model/user_info.dart b/lib/src/model/user_info.dart index da90110..4c9e8c1 100644 --- a/lib/src/model/user_info.dart +++ b/lib/src/model/user_info.dart @@ -1,22 +1,25 @@ /// 用户信息。 class UserInfo { - final int? userId; - final String? userName; - final String? account; - + /// 创建用户信息实例。 const UserInfo({ this.userId, this.userName, this.account, }); - Map toJson() { - final map = { - 'userId': userId, - 'userName': userName, - 'account': account, - }; - map.removeWhere((_, value) => value == null); - return map; - } + /// 用户 ID。 + final int? userId; + + /// 用户名。 + final String? userName; + + /// 账号标识。 + final String? account; + + /// 转换为可序列化的 JSON,并移除空值字段。 + Map toJson() => { + 'userId': userId, + 'userName': userName, + 'account': account, + }..removeWhere((_, value) => value == null); } diff --git a/lib/src/network/api_client.dart b/lib/src/network/api_client.dart index 59d603e..485241d 100644 --- a/lib/src/network/api_client.dart +++ b/lib/src/network/api_client.dart @@ -1,17 +1,13 @@ import 'package:dio/dio.dart'; -import '../config/analytics_config.dart'; -import '../model/event.dart'; -import '../util/logger.dart'; -import 'http_client.dart'; +import 'package:yx_tracking_flutter/src/config/analytics_config.dart'; +import 'package:yx_tracking_flutter/src/model/event.dart'; +import 'package:yx_tracking_flutter/src/network/http_client.dart'; +import 'package:yx_tracking_flutter/src/util/logger.dart'; /// API 调用异常,携带是否可重试等信息。 class ApiException implements Exception { - final int? statusCode; - final String message; - final bool retryable; - final Object? data; - + /// 创建 API 异常。 const ApiException({ required this.message, required this.retryable, @@ -19,20 +15,36 @@ class ApiException implements Exception { this.data, }); + /// HTTP 状态码(如果存在)。 + final int? statusCode; + + /// 异常信息。 + final String message; + + /// 是否可重试。 + final bool retryable; + + /// 额外数据(例如响应体)。 + final Object? data; + @override - String toString() { - return 'ApiException(statusCode: $statusCode, retryable: $retryable, message: $message)'; - } + String toString() => + 'ApiException(statusCode: $statusCode, retryable: $retryable, ' + 'message: $message)'; } /// 与后端埋点接口通信的客户端。 class ApiClient { + /// 创建 API 客户端。 + ApiClient( + AnalyticsConfig config, { + HttpClient? httpClient, + }) : _httpClient = httpClient ?? HttpClient(config); + static const String _addEventListLogPath = 'AddEventListLog'; final HttpClient _httpClient; - ApiClient(AnalyticsConfig config) : _httpClient = HttpClient(config); - /// 批量发送事件。 /// /// 成功返回;失败时抛出 [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) { final apiException = _mapDioException(e); Logger.error('批量上报异常: ${apiException.message}', apiException, st); throw apiException; - } catch (e, st) { + } on Object catch (e, st) { Logger.error('批量上报未知异常', e, st); rethrow; } @@ -86,7 +99,6 @@ class ApiClient { // 无状态码通常意味着网络错误或超时,可重试。 return ApiException( - statusCode: null, retryable: true, message: e.message ?? '网络异常或请求失败', data: e.error, diff --git a/lib/src/network/http_client.dart b/lib/src/network/http_client.dart index 306cb39..65291cf 100644 --- a/lib/src/network/http_client.dart +++ b/lib/src/network/http_client.dart @@ -1,12 +1,15 @@ 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 的轻量封装,统一超时与基础配置。 class HttpClient { - final Dio _dio; - - HttpClient(AnalyticsConfig config) + /// 创建 HTTP 客户端。 + HttpClient( + AnalyticsConfig config, { + HttpClientAdapter? httpClientAdapter, + }) : _dio = Dio( BaseOptions( baseUrl: _normalizeBaseUrl(config.endpointBaseUrl), @@ -17,39 +20,47 @@ class HttpClient { Headers.contentTypeHeader: Headers.jsonContentType, }, ), - ); + ) { + if (httpClientAdapter != null) { + _dio.httpClientAdapter = httpClientAdapter; + } + } + final Dio _dio; + + /// 暴露底层 Dio(仅测试使用)。 + @visibleForTesting Dio get dio => _dio; + /// 发送 GET 请求。 Future> get( String path, { Map? queryParameters, Map? headers, CancelToken? cancelToken, - }) { - return _dio.get( - path, - queryParameters: queryParameters, - options: _withHeaders(headers), - cancelToken: cancelToken, - ); - } + }) => + _dio.get( + path, + queryParameters: queryParameters, + options: _withHeaders(headers), + cancelToken: cancelToken, + ); + /// 发送 POST 请求。 Future> post( String path, { Object? data, Map? queryParameters, Map? headers, CancelToken? cancelToken, - }) { - return _dio.post( - path, - data: data, - queryParameters: queryParameters, - options: _withHeaders(headers), - cancelToken: cancelToken, - ); - } + }) => + _dio.post( + path, + data: data, + queryParameters: queryParameters, + options: _withHeaders(headers), + cancelToken: cancelToken, + ); Options? _withHeaders(Map? headers) { if (headers == null || headers.isEmpty) { diff --git a/lib/src/storage/config_storage.dart b/lib/src/storage/config_storage.dart index a832bb6..59b688f 100644 --- a/lib/src/storage/config_storage.dart +++ b/lib/src/storage/config_storage.dart @@ -1,14 +1,19 @@ -import '../model/system_dim_info.dart'; +import 'package:yx_tracking_flutter/src/model/system_dim_info.dart'; /// 配置缓存存储抽象。 abstract class ConfigStorage { + /// 初始化存储。 Future init(); + /// 保存系统维表配置。 Future saveSystemDimInfo(SystemDimInfo info); + /// 读取系统维表配置。 Future loadSystemDimInfo(); + /// 清空缓存。 Future clear(); + /// 释放资源。 Future dispose(); } diff --git a/lib/src/storage/db_constants.dart b/lib/src/storage/db_constants.dart index 4744e1b..8cc103b 100644 --- a/lib/src/storage/db_constants.dart +++ b/lib/src/storage/db_constants.dart @@ -1,7 +1,14 @@ /// SQLite 数据库常量。 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; } diff --git a/lib/src/storage/event_storage.dart b/lib/src/storage/event_storage.dart index ddf64ef..fb2bea5 100644 --- a/lib/src/storage/event_storage.dart +++ b/lib/src/storage/event_storage.dart @@ -1,7 +1,8 @@ -import '../model/event.dart'; +import 'package:yx_tracking_flutter/src/model/event.dart'; /// 事件存储抽象。 abstract class EventStorage { + /// 初始化存储。 Future init(); /// 插入事件,返回本地自增 ID。 @@ -13,13 +14,18 @@ abstract class EventStorage { /// 获取最近的事件(按 create_time 降序)。 Future> fetchRecent(int limit); + /// 按 ID 删除事件。 Future deleteByIds(List ids); + /// 获取当前缓存数量。 Future count(); /// 控制本地缓存上限(删除最旧事件)。 Future trimToMaxSize(int maxSize); + /// 删除超过最大保存时间的事件,返回删除数量。 + Future deleteExpired(DateTime cutoff); + /// 更新单条事件的重试次数。 Future updateRetryCount(int id, int retryCount); diff --git a/lib/src/storage/isolate_event_storage.dart b/lib/src/storage/isolate_event_storage.dart new file mode 100644 index 0000000..4f816eb --- /dev/null +++ b/lib/src/storage/isolate_event_storage.dart @@ -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? _responseSub; + final Map> _pending = >{}; + int _seq = 0; + bool _initialized = false; + + @override + Future init() async { + if (_initialized) { + return; + } + + final responsePort = ReceivePort(); + _responsePort = responsePort; + + final ready = Completer(); + _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 _call(String type, Map? payload) async { + _ensureReady(); + final id = ++_seq; + final completer = Completer(); + _pending[id] = completer; + _commandPort!.send({ + 'id': id, + 'type': type, + 'payload': payload, + }); + final result = await completer.future; + return result as T; + } + + @override + Future insert(Event event) { + return _call(_msgInsert, { + 'payload': event.toPayload(), + 'createTimeMs': event.createTime.millisecondsSinceEpoch, + 'retryCount': event.retryCount, + }); + } + + @override + Future> fetchBatch(int limit) async { + if (limit <= 0) { + return const []; + } + final rows = await _call>( + _msgFetchBatch, + {'limit': limit}, + ); + return _decodeStoredEvents(rows); + } + + @override + Future> fetchRecent(int limit) async { + if (limit <= 0) { + return const []; + } + final rows = await _call>( + _msgFetchRecent, + {'limit': limit}, + ); + return _decodeStoredEvents(rows); + } + + @override + Future deleteByIds(List ids) async { + if (ids.isEmpty) { + return; + } + await _call(_msgDeleteByIds, {'ids': ids}); + } + + @override + Future count() => _call(_msgCount, null); + + @override + Future trimToMaxSize(int maxSize) async { + if (maxSize <= 0) { + return 0; + } + return _call(_msgTrimToMaxSize, {'maxSize': maxSize}); + } + + @override + Future deleteExpired(DateTime cutoff) { + return _call( + _msgDeleteExpired, + {'cutoffMs': cutoff.millisecondsSinceEpoch}, + ); + } + + @override + Future updateRetryCount(int id, int retryCount) async { + await _call( + _msgUpdateRetry, + {'id': id, 'retryCount': retryCount}, + ); + } + + @override + Future dispose() async { + if (!_initialized) { + return; + } + try { + await _call(_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 _decodeStoredEvents(List rows) { + final results = []; + 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 _storageWorkerEntry(_WorkerInit init) async { + final rootToken = init.rootToken; + if (rootToken != null) { + BackgroundIsolateBinaryMessenger.ensureInitialized(rootToken); + } + + final commandPort = ReceivePort(); + init.responsePort.send({ + '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({ + '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({ + '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({ + '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()); + init.responsePort.send({ + 'result': null, + }); + case _msgCount: + final total = await storage.count(); + init.responsePort.send({ + '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({ + '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({ + '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({ + 'result': null, + }); + case _msgDispose: + await storage.dispose(); + init.responsePort.send({ + 'id': id, + 'ok': true, + 'result': null, + }); + commandPort.close(); + return; + default: + throw UnsupportedError('unknown message: $type'); + } + } on Object catch (e) { + init.responsePort.send({ + 'id': id, + 'ok': false, + 'error': e.toString(), + }); + } + } +} + +List> _encodeStoredEvents(List events) { + return events + .map( + (stored) => { + '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 _items = []; + + @override + Future init() async {} + + @override + Future 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> fetchBatch(int limit) async { + if (limit <= 0) { + return const []; + } + final copy = List.from(_items) + ..sort((a, b) => a.createTime.compareTo(b.createTime)); + return copy.take(limit).toList(growable: false); + } + + @override + Future> fetchRecent(int limit) async { + if (limit <= 0) { + return const []; + } + final copy = List.from(_items) + ..sort((a, b) => b.createTime.compareTo(a.createTime)); + return copy.take(limit).toList(growable: false); + } + + @override + Future deleteByIds(List ids) async { + if (ids.isEmpty) { + return; + } + _items.removeWhere((event) => ids.contains(event.id)); + } + + @override + Future count() async => _items.length; + + @override + Future 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 deleteExpired(DateTime cutoff) async { + final before = _items.length; + _items.removeWhere((event) => !event.createTime.isAfter(cutoff)); + return before - _items.length; + } + + @override + Future 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 dispose() async { + _items.clear(); + } +} diff --git a/lib/src/storage/sqflite_config_storage.dart b/lib/src/storage/sqflite_config_storage.dart index 8dd7869..47e4676 100644 --- a/lib/src/storage/sqflite_config_storage.dart +++ b/lib/src/storage/sqflite_config_storage.dart @@ -1,39 +1,59 @@ import 'dart:convert'; +import 'dart:io'; +import 'package:meta/meta.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:sqflite/sqflite.dart'; - -import '../model/system_dim_info.dart'; -import '../util/logger.dart'; -import 'config_storage.dart'; -import 'db_constants.dart'; +import 'package:yx_tracking_flutter/src/model/system_dim_info.dart'; +import 'package:yx_tracking_flutter/src/storage/config_storage.dart'; +import 'package:yx_tracking_flutter/src/storage/db_constants.dart'; +import 'package:yx_tracking_flutter/src/util/logger.dart'; /// 基于 sqflite 的配置缓存存储。 class SqfliteConfigStorage implements ConfigStorage { + /// 创建配置缓存存储。 + SqfliteConfigStorage({ + DatabaseFactory? databaseFactory, + Future Function()? documentsDirectoryProvider, + }) : _databaseFactory = databaseFactory, + _documentsDirectoryProvider = documentsDirectoryProvider; + static const String _tableConfigCache = 'config_cache'; static const String _systemDimInfoKey = 'system_dim_info'; + final DatabaseFactory? _databaseFactory; + final Future Function()? _documentsDirectoryProvider; + Database? _db; + /// 仅测试使用:暴露底层数据库实例。 + @visibleForTesting + Database? get debugDb => _db; + @override Future init() async { if (_db != null) { return; } - final directory = await getApplicationDocumentsDirectory(); + final directory = + await (_documentsDirectoryProvider?.call() ?? + getApplicationDocumentsDirectory()); final dbPath = p.join(directory.path, DbConstants.dbName); - _db = await openDatabase( + final factory = _databaseFactory ?? databaseFactory; + _db = await factory.openDatabase( dbPath, - version: DbConstants.dbVersion, - onCreate: (db, version) async { - await _createTables(db); - }, - onOpen: (db) async { - await _createTables(db); - }, + options: OpenDatabaseOptions( + version: DbConstants.dbVersion, + onCreate: (Database db, int version) async { + await _createTables(db); + }, + onOpen: (Database db) async { + await _createTables(db); + }, + ), ); Logger.info('SqfliteConfigStorage 初始化完成: $dbPath'); @@ -91,25 +111,37 @@ class SqfliteConfigStorage implements ConfigStorage { final row = rows.first; final payload = row['payload']; if (payload is! String || payload.isEmpty) { + await _deleteCachedConfig(db); return null; } try { final decoded = jsonDecode(payload); if (decoded is! Map) { + await _deleteCachedConfig(db); return null; } final map = decoded.map((k, v) => MapEntry(k.toString(), v)); // 优先使用 payload 中的 lastFetchedAt,必要时兜底 last_fetched_at。 - map.putIfAbsent('lastFetchedAt', () => row['last_fetched_at']); - return SystemDimInfo.fromCacheJson(map); - } catch (e, st) { + return SystemDimInfo.fromCacheJson( + map..putIfAbsent('lastFetchedAt', () => row['last_fetched_at']), + ); + } on Object catch (e, st) { Logger.error('读取配置缓存失败', e, st); + await _deleteCachedConfig(db); return null; } } + Future _deleteCachedConfig(Database db) async { + await db.delete( + _tableConfigCache, + where: 'key = ?', + whereArgs: const [_systemDimInfoKey], + ); + } + @override Future clear() async { final db = _requireDb(); diff --git a/lib/src/storage/sqflite_event_storage.dart b/lib/src/storage/sqflite_event_storage.dart index 93eea02..18eb1b6 100644 --- a/lib/src/storage/sqflite_event_storage.dart +++ b/lib/src/storage/sqflite_event_storage.dart @@ -1,40 +1,62 @@ import 'dart:async'; +import 'dart:io'; +import 'package:meta/meta.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:sqflite/sqflite.dart'; - -import '../model/event.dart'; -import '../util/logger.dart'; -import 'db_constants.dart'; -import 'event_storage.dart'; +import 'package:yx_tracking_flutter/src/model/event.dart'; +import 'package:yx_tracking_flutter/src/storage/db_constants.dart'; +import 'package:yx_tracking_flutter/src/storage/event_storage.dart'; +import 'package:yx_tracking_flutter/src/util/logger.dart'; /// 基于 sqflite 的事件存储实现。 class SqfliteEventStorage implements EventStorage { + /// 创建事件存储。 + SqfliteEventStorage({ + DatabaseFactory? databaseFactory, + Future Function()? documentsDirectoryProvider, + }) : _databaseFactory = databaseFactory, + _documentsDirectoryProvider = documentsDirectoryProvider; + static const String _tableEvents = 'events'; + final DatabaseFactory? _databaseFactory; + final Future Function()? _documentsDirectoryProvider; + Database? _db; + /// 仅测试使用:暴露底层数据库实例。 + @visibleForTesting + Database? get debugDb => _db; + @override Future init() async { if (_db != null) { return; } - final directory = await getApplicationDocumentsDirectory(); + final directory = + await (_documentsDirectoryProvider?.call() ?? + getApplicationDocumentsDirectory()); final dbPath = p.join(directory.path, DbConstants.dbName); - _db = await openDatabase( + final factory = _databaseFactory ?? databaseFactory; + _db = await factory.openDatabase( dbPath, - version: DbConstants.dbVersion, - onCreate: (db, version) async { - await _createTables(db); - }, - onUpgrade: (db, oldVersion, newVersion) async { - if (oldVersion < 1) { + options: OpenDatabaseOptions( + version: DbConstants.dbVersion, + onCreate: (Database db, int version) async { await _createTables(db); - } - }, + }, + // coverage:ignore-start + onUpgrade: (Database db, int oldVersion, int newVersion) async { + if (oldVersion < 1) { + await _createTables(db); + } + }, + // coverage:ignore-end + ), ); Logger.info('SqfliteEventStorage 初始化完成: $dbPath'); @@ -50,9 +72,10 @@ class SqfliteEventStorage implements EventStorage { ); '''); - await db.execute( - 'CREATE INDEX IF NOT EXISTS idx_events_create_time ON $_tableEvents(create_time);', - ); + const createIndexSql = + 'CREATE INDEX IF NOT EXISTS idx_events_create_time ' + 'ON $_tableEvents(create_time);'; + await db.execute(createIndexSql); } Database _requireDb() { @@ -159,6 +182,18 @@ class SqfliteEventStorage implements EventStorage { return overflow; } + @override + Future deleteExpired(DateTime cutoff) async { + final db = _requireDb(); + final cutoffMs = cutoff.millisecondsSinceEpoch; + final deleted = await db.delete( + _tableEvents, + where: 'create_time <= ?', + whereArgs: [cutoffMs], + ); + return deleted; + } + @override Future updateRetryCount(int id, int retryCount) async { final db = _requireDb(); @@ -232,7 +267,7 @@ class SqfliteEventStorage implements EventStorage { createTime: createdAt, ), ); - } catch (e, st) { + } on Object catch (e, st) { Logger.error('解析存储事件失败,已跳过该条', e, st); final id = row['id']; if (id is int) { @@ -246,11 +281,11 @@ class SqfliteEventStorage implements EventStorage { } class _ParsedRows { - final List events; - final List invalidIds; - const _ParsedRows({ required this.events, required this.invalidIds, }); + + final List events; + final List invalidIds; } diff --git a/lib/src/util/device_util.dart b/lib/src/util/device_util.dart index add5ca7..05d65d3 100644 --- a/lib/src/util/device_util.dart +++ b/lib/src/util/device_util.dart @@ -3,12 +3,17 @@ import 'dart:ui'; import 'package:flutter/widgets.dart'; -import '../model/device_info.dart'; +import 'package:yx_tracking_flutter/src/model/device_info.dart'; /// 设备信息采集工具。 class DeviceUtil { - const DeviceUtil._(); + /// 私有构造,避免外部实例化。 + DeviceUtil._(); + /// 获取一个实例(用于满足 lint 对构造器的要求)。 + factory DeviceUtil.instance() => DeviceUtil._(); + + /// 采集最小化设备信息。 static Future collectDeviceInfo() async { WidgetsFlutterBinding.ensureInitialized(); @@ -23,22 +28,47 @@ class DeviceUtil { ); } - static String _screenResolution() { + @visibleForTesting + /// 测试钩子:通过注入 view 列表计算分辨率。 + static String screenResolutionForTesting({ + Iterable Function()? viewsProvider, + }) { + return _screenResolution(viewsProvider: viewsProvider); + } + + @visibleForTesting + /// 测试钩子:通过 size 计算分辨率字符串。 + static String resolutionFromSizeForTesting(Size size) { + return _resolutionFromSize(size); + } + + static String _screenResolution({ + Iterable Function()? viewsProvider, + }) { try { - final views = WidgetsBinding.instance.platformDispatcher.views; + final views = (viewsProvider ?? _defaultViewsProvider) + .call() + .toList(growable: false); if (views.isEmpty) { return 'unknown'; } - final FlutterView view = views.first; - final Size size = view.physicalSize; - final int width = size.width.round(); - final int height = size.height.round(); - if (width <= 0 || height <= 0) { - return 'unknown'; - } - return '${width}x$height'; - } catch (_) { + final view = views.first; + final size = view.physicalSize; + return _resolutionFromSize(size); + } on Object { return 'unknown'; } } + + static Iterable _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) { + return 'unknown'; + } + return '${width}x$height'; + } } diff --git a/lib/src/util/id_generator.dart b/lib/src/util/id_generator.dart index a9ba12d..de66148 100644 --- a/lib/src/util/id_generator.dart +++ b/lib/src/util/id_generator.dart @@ -10,8 +10,8 @@ class IdGenerator { /// 生成一个足够唯一的字符串 ID。 static String nextId() { _counter = (_counter + 1) & 0x7fffffff; - final int ts = DateTime.now().microsecondsSinceEpoch; - final int rand = _random.nextInt(1 << 32); + final ts = DateTime.now().microsecondsSinceEpoch; + final rand = _random.nextInt(1 << 32); return '$ts-${_counter.toRadixString(16)}-${rand.toRadixString(16)}'; } } diff --git a/lib/src/util/logger.dart b/lib/src/util/logger.dart index cf0fbe3..bc68e4d 100644 --- a/lib/src/util/logger.dart +++ b/lib/src/util/logger.dart @@ -3,13 +3,19 @@ class Logger { static const String _prefix = '[AnalyticsSDK]'; static bool _debugEnabled = false; + /// 当前是否开启 Debug 日志。 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; debug('Debug 日志已${enabled ? '开启' : '关闭'}'); } + /// 输出 Debug 日志。 static void debug(String message) { if (!_debugEnabled) { return; @@ -17,6 +23,7 @@ class Logger { _print('DEBUG', message); } + /// 输出 Info 日志(仅 Debug 开启时)。 static void info(String message) { if (!_debugEnabled) { return; @@ -24,6 +31,7 @@ class Logger { _print('INFO', message); } + /// 输出 Warn 日志(仅 Debug 开启时)。 static void warn(String message) { if (!_debugEnabled) { return; @@ -31,6 +39,7 @@ class Logger { _print('WARN', message); } + /// 输出 Error 日志。 static void error(String message, [Object? error, StackTrace? stackTrace]) { final buffer = StringBuffer(message); if (error != null) { @@ -42,8 +51,9 @@ class Logger { _print('ERROR', buffer.toString()); } + /// 统一日志输出格式。 static void _print(String level, String message) { - // ignore: avoid_print + // ignore: avoid_print, reason: 该 SDK 需要最小依赖的调试输出能力 print('$_prefix[$level] $message'); } } diff --git a/lib/src/util/sdk_info.dart b/lib/src/util/sdk_info.dart index 69d1f65..84a60ab 100644 --- a/lib/src/util/sdk_info.dart +++ b/lib/src/util/sdk_info.dart @@ -1,7 +1,14 @@ /// SDK 基础信息。 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'; } diff --git a/lib/src/util/time_util.dart b/lib/src/util/time_util.dart index e2ab54d..ea447d0 100644 --- a/lib/src/util/time_util.dart +++ b/lib/src/util/time_util.dart @@ -1,6 +1,10 @@ /// 时间工具。 class TimeUtil { - const TimeUtil._(); + /// 私有构造,避免外部实例化。 + TimeUtil._(); + + /// 获取一个实例(用于满足 lint 对构造器的要求)。 + factory TimeUtil.instance() => TimeUtil._(); /// 当前时间毫秒时间戳。 static int nowMs() => DateTime.now().millisecondsSinceEpoch; diff --git a/lib/yx_tracking_flutter.dart b/lib/yx_tracking_flutter.dart index 6baa161..e4b4882 100644 --- a/lib/yx_tracking_flutter.dart +++ b/lib/yx_tracking_flutter.dart @@ -1,11 +1,13 @@ import 'dart:async'; -import 'src/config/analytics_config.dart'; -import 'src/core/analytics_core.dart'; -import 'src/core/interceptors.dart'; -import 'src/model/device_info.dart'; -import 'src/model/recent_event_summary.dart'; -import 'src/model/user_info.dart'; +import 'package:flutter/widgets.dart'; + +import 'package:yx_tracking_flutter/src/config/analytics_config.dart'; +import 'package:yx_tracking_flutter/src/core/analytics_core.dart'; +import 'package:yx_tracking_flutter/src/core/interceptors.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/core/interceptors.dart'; @@ -16,12 +18,29 @@ export 'src/model/user_info.dart'; /// 对外唯一入口(Facade)。 class 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 init(AnalyticsConfig config) => _core.init(config); + /// 记录事件。 static Future track( String eventType, { Map? eventParams, @@ -36,18 +55,23 @@ class Analytics { ); } + /// 设置用户信息。 static Future setUser(UserInfo? userInfo) => _core.setUser(userInfo); + /// 覆盖设备信息。 static Future setDeviceInfo(DeviceInfo deviceInfo) => _core.setDeviceInfo(deviceInfo); + /// 触发一次上报。 static Future flush({bool force = false}) => _core.flush(force: force); /// 当前本地缓存事件数量(用于调试/演示)。 static Future cachedEventCount() => _core.cachedEventCount(); /// 最近事件摘要(用于调试面板)。 - static Future> cachedRecentEvents({int limit = 20}) => + static Future> cachedRecentEvents({ + int limit = 20, + }) => _core.cachedRecentEvents(limit: limit); /// Phase 2:手动触发一次配置刷新(默认强制刷新)。 @@ -62,10 +86,74 @@ class Analytics { _core.addInterceptor(interceptor); } - static void setDebug(bool enabled) { - unawaited(_core.setDebug(enabled)); + /// 动态设置 Debug 开关。 + static void setDebug({required bool enabled}) { + unawaited(_core.setDebug(enabled: enabled)); } /// 可选:当宿主确定不再需要 SDK 时调用。 - static Future dispose() => _core.dispose(); + static Future 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; + } + } } diff --git a/pubspec.yaml b/pubspec.yaml index 2affc83..3ae2528 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -8,16 +8,20 @@ environment: flutter: ">=3.22.0" dependencies: + dio: ^5.4.3+1 flutter: sdk: flutter - sqflite: ^2.3.3 - path_provider: ^2.1.4 + meta: ^1.12.0 path: ^1.9.0 - dio: ^5.4.3+1 + path_provider: ^2.1.4 + sqflite: ^2.3.3 dev_dependencies: + flutter_lints: ^5.0.0 flutter_test: 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: diff --git a/test/analytics_config_extra_test.dart b/test/analytics_config_extra_test.dart new file mode 100644 index 0000000..a62703c --- /dev/null +++ b/test/analytics_config_extra_test.dart @@ -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'); + }); + }); +} diff --git a/test/analytics_core_test.dart b/test/analytics_core_test.dart index bc0e435..7e5042a 100644 --- a/test/analytics_core_test.dart +++ b/test/analytics_core_test.dart @@ -10,6 +10,7 @@ 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/network/api_client.dart'; +import 'package:yx_tracking_flutter/src/storage/config_storage.dart'; import 'package:yx_tracking_flutter/src/storage/event_storage.dart'; class MemoryEventStorage implements EventStorage { @@ -71,6 +72,13 @@ class MemoryEventStorage implements EventStorage { return overflow; } + @override + Future deleteExpired(DateTime cutoff) async { + final before = _store.length; + _store.removeWhere((_, value) => !value.createTime.isAfter(cutoff)); + return before - _store.length; + } + @override Future updateRetryCount(int id, int retryCount) async { final current = _store[id]; @@ -97,15 +105,36 @@ class MemoryEventStorage implements EventStorage { } } +class BlockingEventStorage extends MemoryEventStorage { + BlockingEventStorage(this._blocker); + + final Completer _blocker; + var _blockedOnce = false; + + @override + Future> fetchBatch(int limit) async { + if (!_blockedOnce) { + _blockedOnce = true; + await _blocker.future; + } + return super.fetchBatch(limit); + } +} + class FakeApiClient extends ApiClient { FakeApiClient(super.config); ApiException? exceptionToThrow; + Error? errorToThrow; final List> sentBatches = >[]; @override Future sendBatch(List events) async { sentBatches.add(events); + final error = errorToThrow; + if (error != null) { + throw error; + } final exception = exceptionToThrow; if (exception != null) { throw exception; @@ -114,20 +143,16 @@ class FakeApiClient extends ApiClient { } class TestConfigManager extends ConfigManager { - SystemDimInfo? _currentConfig; - TestConfigManager({ required super.config, SystemDimInfo? initialConfig, - }) : _currentConfig = initialConfig, + }) : currentConfigForTesting = initialConfig, super(refreshInterval: Duration.zero); @override - SystemDimInfo? get currentConfig => _currentConfig; + SystemDimInfo? get currentConfig => currentConfigForTesting; - void setCurrentConfig(SystemDimInfo? value) { - _currentConfig = value; - } + SystemDimInfo? currentConfigForTesting; @override Future init() async {} @@ -142,6 +167,67 @@ class TestConfigManager extends ConfigManager { Future dispose() async {} } +class InMemoryConfigStorage implements ConfigStorage { + SystemDimInfo? _info; + + @override + Future init() async {} + + @override + Future saveSystemDimInfo(SystemDimInfo info) async { + _info = info; + } + + @override + Future loadSystemDimInfo() async => _info; + + @override + Future clear() async { + _info = null; + } + + @override + Future dispose() async {} +} + +class IntervalConfigManager extends ConfigManager { + IntervalConfigManager({ + required super.config, + required Duration interval, + }) : fetchCalls = 0, + forceCalls = 0, + initCalls = 0, + super( + refreshInterval: interval, + storage: InMemoryConfigStorage(), + ); + + int fetchCalls; + int forceCalls; + int initCalls; + + @override + Future init() async { + initCalls += 1; + } + + @override + Future fetchAndCacheConfig({bool force = false}) async { + fetchCalls += 1; + if (force) { + forceCalls += 1; + } + } + + @override + Future forceRefresh() async { + forceCalls += 1; + } + + @override + Future dispose() async {} +} + class NoopScheduler extends Scheduler { NoopScheduler({ required super.interval, @@ -229,6 +315,45 @@ SystemDimInfo _dimInfo({ ); } +Future _collectTestDeviceInfo() async => _testDeviceInfo; + +Scheduler _noopScheduler( + Duration interval, + Future Function() onTick, +) => + NoopScheduler(interval: interval, onTick: onTick); + +Future _waitUntil( + bool Function() predicate, { + Duration step = const Duration(milliseconds: 5), + int maxTries = 40, +}) async { + for (var i = 0; i < maxTries; i++) { + if (predicate()) { + return; + } + await Future.delayed(step); + } +} + +String _eventTypeOfStored(StoredEvent stored) => stored.event.eventType; + +const _allEventDefinitions = [ + EventDefinition(eventCode: 'EVENT_A'), + EventDefinition(eventCode: 'EVENT_RETRY'), + EventDefinition(eventCode: 'EVENT_DROP'), + EventDefinition(eventCode: 'EVENT_NO_CONFIG'), + EventDefinition(eventCode: 'EVENT_DISABLED'), + EventDefinition(eventCode: 'EVENT_OFF'), + EventDefinition(eventCode: 'EVENT_SAMPLE'), + EventDefinition(eventCode: 'EVENT_INTERCEPT'), + EventDefinition(eventCode: 'EVENT_DROP_BY_INTERCEPTOR'), + EventDefinition(eventCode: 'EVENT_THROW'), + EventDefinition(eventCode: 'EVENT_METRIC'), + EventDefinition(eventCode: 'EVENT_OFFLINE'), + EventDefinition(eventCode: 'EVENT_STRESS'), +]; + Future _drainMicrotasks() async { await Future.delayed(Duration.zero); await Future.delayed(Duration.zero); @@ -247,17 +372,13 @@ void main() { apiClientFactory: (c) => apiClient = FakeApiClient(c), configManagerFactory: (c) => configManager = TestConfigManager(config: c), - deviceInfoCollector: () async => _testDeviceInfo, - schedulerFactory: (interval, onTick) => - NoopScheduler(interval: interval, onTick: onTick), + deviceInfoCollector: _collectTestDeviceInfo, + schedulerFactory: _noopScheduler, ); await core.init(config); - configManager.setCurrentConfig( - _dimInfo(events: const [ - EventDefinition(eventCode: 'EVENT_A'), - ]), - ); + configManager.currentConfigForTesting = + _dimInfo(events: _allEventDefinitions); await core.track('EVENT_A'); await core.track('EVENT_A'); @@ -274,25 +395,20 @@ void main() { final storage = MemoryEventStorage(); late FakeApiClient apiClient; late TestConfigManager configManager; - final config = - _testConfig(enableDebug: true, batchSize: 10, maxRetryCount: 3); + final config = _testConfig(batchSize: 10); final core = AnalyticsCore( storageFactory: () => storage, apiClientFactory: (c) => apiClient = FakeApiClient(c), configManagerFactory: (c) => configManager = TestConfigManager(config: c), - deviceInfoCollector: () async => _testDeviceInfo, - schedulerFactory: (interval, onTick) => - NoopScheduler(interval: interval, onTick: onTick), + deviceInfoCollector: _collectTestDeviceInfo, + schedulerFactory: _noopScheduler, ); await core.init(config); - configManager.setCurrentConfig( - _dimInfo(events: const [ - EventDefinition(eventCode: 'EVENT_RETRY'), - ]), - ); + configManager.currentConfigForTesting = + _dimInfo(events: _allEventDefinitions); apiClient.exceptionToThrow = const ApiException( message: 'network', @@ -316,24 +432,20 @@ void main() { final storage = MemoryEventStorage(); late FakeApiClient apiClient; late TestConfigManager configManager; - final config = _testConfig(enableDebug: true, batchSize: 10); + final config = _testConfig(batchSize: 10); final core = AnalyticsCore( storageFactory: () => storage, apiClientFactory: (c) => apiClient = FakeApiClient(c), configManagerFactory: (c) => configManager = TestConfigManager(config: c), - deviceInfoCollector: () async => _testDeviceInfo, - schedulerFactory: (interval, onTick) => - NoopScheduler(interval: interval, onTick: onTick), + deviceInfoCollector: _collectTestDeviceInfo, + schedulerFactory: _noopScheduler, ); await core.init(config); - configManager.setCurrentConfig( - _dimInfo(events: const [ - EventDefinition(eventCode: 'EVENT_DROP'), - ]), - ); + configManager.currentConfigForTesting = + _dimInfo(events: _allEventDefinitions); apiClient.exceptionToThrow = const ApiException( message: 'bad request', @@ -357,29 +469,24 @@ void main() { final core = AnalyticsCore( storageFactory: () => storage, - apiClientFactory: (c) => FakeApiClient(c), + apiClientFactory: FakeApiClient.new, configManagerFactory: (c) => configManager = TestConfigManager(config: c), - deviceInfoCollector: () async => _testDeviceInfo, - schedulerFactory: (interval, onTick) => - NoopScheduler(interval: interval, onTick: onTick), + deviceInfoCollector: _collectTestDeviceInfo, + schedulerFactory: _noopScheduler, ); await core.init(config); - configManager.setCurrentConfig( - _dimInfo( - events: const [ - EventDefinition(eventCode: 'KNOWN_EVENT'), - ], - tags: const [ - TagDefinition( - tagName: 'requiredTag', - tagType: 'string', - isRequired: true, - ), - ], - ), + configManager.currentConfigForTesting = _dimInfo( + events: _allEventDefinitions, + tags: const [ + TagDefinition( + tagName: 'requiredTag', + tagType: 'string', + isRequired: true, + ), + ], ); // 触发两个校验问题:未知事件 + 缺少必填 tag。 @@ -404,12 +511,11 @@ void main() { storageFactory: () => storage, apiClientFactory: (c) => apiClient = FakeApiClient(c), configManagerFactory: (c) => TestConfigManager(config: c), - deviceInfoCollector: () async => _testDeviceInfo, - schedulerFactory: (interval, onTick) => - NoopScheduler(interval: interval, onTick: onTick), + deviceInfoCollector: _collectTestDeviceInfo, + schedulerFactory: _noopScheduler, ); - await core.init(_testConfig(enableDebug: true, batchSize: 10)); + await core.init(_testConfig(batchSize: 10)); await core.track('EVENT_NO_CONFIG'); await core.flush(force: true); @@ -422,29 +528,24 @@ void main() { test('全局策略关闭时直接丢弃事件', () async { final storage = MemoryEventStorage(); late TestConfigManager configManager; - final config = _testConfig(enableDebug: true, batchSize: 10); + final config = _testConfig(batchSize: 10); final core = AnalyticsCore( storageFactory: () => storage, - apiClientFactory: (c) => FakeApiClient(c), + apiClientFactory: FakeApiClient.new, configManagerFactory: (c) => configManager = TestConfigManager(config: c), - deviceInfoCollector: () async => _testDeviceInfo, - schedulerFactory: (interval, onTick) => - NoopScheduler(interval: interval, onTick: onTick), + deviceInfoCollector: _collectTestDeviceInfo, + schedulerFactory: _noopScheduler, ); await core.init(config); - configManager.setCurrentConfig( - _dimInfo( - events: const [ - EventDefinition(eventCode: 'EVENT_DISABLED'), - ], - strategy: const SdkStrategy( - enabled: false, - defaultSampleRate: 1.0, - eventSettings: {}, - ), + configManager.currentConfigForTesting = _dimInfo( + events: _allEventDefinitions, + strategy: const SdkStrategy( + enabled: false, + defaultSampleRate: 1, + eventSettings: {}, ), ); @@ -456,31 +557,26 @@ void main() { test('事件级策略 disabled 时丢弃事件', () async { final storage = MemoryEventStorage(); late TestConfigManager configManager; - final config = _testConfig(enableDebug: true, batchSize: 10); + final config = _testConfig(batchSize: 10); final core = AnalyticsCore( storageFactory: () => storage, - apiClientFactory: (c) => FakeApiClient(c), + apiClientFactory: FakeApiClient.new, configManagerFactory: (c) => configManager = TestConfigManager(config: c), - deviceInfoCollector: () async => _testDeviceInfo, - schedulerFactory: (interval, onTick) => - NoopScheduler(interval: interval, onTick: onTick), + deviceInfoCollector: _collectTestDeviceInfo, + schedulerFactory: _noopScheduler, ); await core.init(config); - configManager.setCurrentConfig( - _dimInfo( - events: const [ - EventDefinition(eventCode: 'EVENT_OFF'), - ], - strategy: const SdkStrategy( - enabled: true, - defaultSampleRate: 1.0, - eventSettings: { - 'EVENT_OFF': EventStrategy(enabled: false, sampleRate: 0.0), - }, - ), + configManager.currentConfigForTesting = _dimInfo( + events: _allEventDefinitions, + strategy: const SdkStrategy( + enabled: true, + defaultSampleRate: 1, + eventSettings: { + 'EVENT_OFF': EventStrategy(enabled: false, sampleRate: 1), + }, ), ); @@ -490,9 +586,9 @@ void main() { }); test('采样策略根据随机数决定是否入库', () async { - final strategy = const SdkStrategy( + const strategy = SdkStrategy( enabled: true, - defaultSampleRate: 1.0, + defaultSampleRate: 1, eventSettings: { 'EVENT_SAMPLE': EventStrategy(enabled: true, sampleRate: 0.5), }, @@ -502,23 +598,18 @@ void main() { late TestConfigManager configManagerDropped; final coreDropped = AnalyticsCore( storageFactory: () => storageDropped, - apiClientFactory: (c) => FakeApiClient(c), + apiClientFactory: FakeApiClient.new, configManagerFactory: (c) => configManagerDropped = TestConfigManager(config: c), - deviceInfoCollector: () async => _testDeviceInfo, - schedulerFactory: (interval, onTick) => - NoopScheduler(interval: interval, onTick: onTick), + deviceInfoCollector: _collectTestDeviceInfo, + schedulerFactory: _noopScheduler, randomDouble: () => 0.9, ); - await coreDropped.init(_testConfig(enableDebug: true, batchSize: 10)); - configManagerDropped.setCurrentConfig( - _dimInfo( - events: const [ - EventDefinition(eventCode: 'EVENT_SAMPLE'), - ], - strategy: strategy, - ), + await coreDropped.init(_testConfig(batchSize: 10)); + configManagerDropped.currentConfigForTesting = _dimInfo( + events: _allEventDefinitions, + strategy: strategy, ); await coreDropped.track('EVENT_SAMPLE'); expect(await storageDropped.count(), 0); @@ -528,23 +619,18 @@ void main() { late TestConfigManager configManagerKept; final coreKept = AnalyticsCore( storageFactory: () => storageKept, - apiClientFactory: (c) => FakeApiClient(c), + apiClientFactory: FakeApiClient.new, configManagerFactory: (c) => configManagerKept = TestConfigManager(config: c), - deviceInfoCollector: () async => _testDeviceInfo, - schedulerFactory: (interval, onTick) => - NoopScheduler(interval: interval, onTick: onTick), + deviceInfoCollector: _collectTestDeviceInfo, + schedulerFactory: _noopScheduler, randomDouble: () => 0.1, ); - await coreKept.init(_testConfig(enableDebug: true, batchSize: 10)); - configManagerKept.setCurrentConfig( - _dimInfo( - events: const [ - EventDefinition(eventCode: 'EVENT_SAMPLE'), - ], - strategy: strategy, - ), + await coreKept.init(_testConfig(batchSize: 10)); + configManagerKept.currentConfigForTesting = _dimInfo( + events: _allEventDefinitions, + strategy: strategy, ); await coreKept.track('EVENT_SAMPLE'); expect(await storageKept.count(), 1); @@ -572,17 +658,13 @@ void main() { apiClientFactory: (c) => apiClient = FakeApiClient(c), configManagerFactory: (c) => configManager = TestConfigManager(config: c), - deviceInfoCollector: () async => _testDeviceInfo, - schedulerFactory: (interval, onTick) => - NoopScheduler(interval: interval, onTick: onTick), + deviceInfoCollector: _collectTestDeviceInfo, + schedulerFactory: _noopScheduler, ); - await core.init(_testConfig(enableDebug: true, batchSize: 10)); - configManager.setCurrentConfig( - _dimInfo(events: const [ - EventDefinition(eventCode: 'EVENT_INTERCEPT'), - ]), - ); + await core.init(_testConfig(batchSize: 10)); + configManager.currentConfigForTesting = + _dimInfo(events: _allEventDefinitions); core.addInterceptor(interceptor); await core.track('EVENT_INTERCEPT'); @@ -602,20 +684,16 @@ void main() { final core = AnalyticsCore( storageFactory: () => storage, - apiClientFactory: (c) => FakeApiClient(c), + apiClientFactory: FakeApiClient.new, configManagerFactory: (c) => configManager = TestConfigManager(config: c), - deviceInfoCollector: () async => _testDeviceInfo, - schedulerFactory: (interval, onTick) => - NoopScheduler(interval: interval, onTick: onTick), + deviceInfoCollector: _collectTestDeviceInfo, + schedulerFactory: _noopScheduler, ); - await core.init(_testConfig(enableDebug: true, batchSize: 10)); - configManager.setCurrentConfig( - _dimInfo(events: const [ - EventDefinition(eventCode: 'EVENT_DROP_BY_INTERCEPTOR'), - ]), - ); + await core.init(_testConfig(batchSize: 10)); + configManager.currentConfigForTesting = + _dimInfo(events: _allEventDefinitions); core.addInterceptor(interceptor); await core.track('EVENT_DROP_BY_INTERCEPTOR'); @@ -648,19 +726,16 @@ void main() { apiClientFactory: (c) => apiClient = FakeApiClient(c), configManagerFactory: (c) => configManager = TestConfigManager(config: c), - deviceInfoCollector: () async => _testDeviceInfo, - schedulerFactory: (interval, onTick) => - NoopScheduler(interval: interval, onTick: onTick), + deviceInfoCollector: _collectTestDeviceInfo, + schedulerFactory: _noopScheduler, ); - await core.init(_testConfig(enableDebug: true, batchSize: 10)); - configManager.setCurrentConfig( - _dimInfo(events: const [ - EventDefinition(eventCode: 'EVENT_THROW'), - ]), - ); - core.addInterceptor(throwing); - core.addInterceptor(tagging); + await core.init(_testConfig(batchSize: 10)); + configManager.currentConfigForTesting = + _dimInfo(events: _allEventDefinitions); + core + ..addInterceptor(throwing) + ..addInterceptor(tagging); await core.track('EVENT_THROW'); await core.flush(force: true); @@ -676,15 +751,14 @@ void main() { final storage = MemoryEventStorage(); late FakeApiClient apiClient; late TestConfigManager configManager; - final config = AnalyticsConfig( + const config = AnalyticsConfig( systemCode: 'TEST_APP', endpointBaseUrl: 'https://example.com/api/ExternalEventlogs', clientType: 3, enableDebug: true, batchSize: 10, flushInterval: 3600, - enableMetrics: true, - metricsReportInterval: const Duration(days: 1), + metricsReportInterval: Duration(days: 1), ); final core = AnalyticsCore( @@ -692,17 +766,13 @@ void main() { apiClientFactory: (c) => apiClient = FakeApiClient(c), configManagerFactory: (c) => configManager = TestConfigManager(config: c), - deviceInfoCollector: () async => _testDeviceInfo, - schedulerFactory: (interval, onTick) => - NoopScheduler(interval: interval, onTick: onTick), + deviceInfoCollector: _collectTestDeviceInfo, + schedulerFactory: _noopScheduler, ); await core.init(config); - configManager.setCurrentConfig( - _dimInfo(events: const [ - EventDefinition(eventCode: 'EVENT_METRIC'), - ]), - ); + configManager.currentConfigForTesting = + _dimInfo(events: _allEventDefinitions); await core.track('EVENT_METRIC'); await core.track('EVENT_METRIC'); @@ -720,6 +790,438 @@ void main() { expect(apiClient.sentBatches, isNotEmpty); await core.dispose(); }); + + test('构造默认依赖且未 init 时 isInitialized 为 false', () { + final core = AnalyticsCore(); + expect(core.isInitialized, isFalse); + }); + + test('构造时会使用默认的 configManager/scheduler 工厂', () { + final core = AnalyticsCore( + storageFactory: MemoryEventStorage.new, + apiClientFactory: FakeApiClient.new, + ); + expect(core.isInitialized, isFalse); + }); + + test('重复 init 会先 dispose 再重新初始化', () async { + final storage = MemoryEventStorage(); + late TestConfigManager configManager; + + final core = AnalyticsCore( + storageFactory: () => storage, + apiClientFactory: FakeApiClient.new, + configManagerFactory: (c) => + configManager = TestConfigManager(config: c), + deviceInfoCollector: _collectTestDeviceInfo, + schedulerFactory: _noopScheduler, + ); + + await core.init(_testConfig(enableDebug: false)); + await core.init(_testConfig(enableDebug: false)); + + configManager.currentConfigForTesting = + _dimInfo(events: _allEventDefinitions); + await core.track('EVENT_REINIT'); + expect(await storage.count(), 1); + await core.dispose(); + }); + + test('未初始化时 track/flush/cached 查询会被安全忽略', () async { + final core = AnalyticsCore( + storageFactory: MemoryEventStorage.new, + apiClientFactory: FakeApiClient.new, + deviceInfoCollector: _collectTestDeviceInfo, + schedulerFactory: _noopScheduler, + ); + + await core.track('EVENT_BEFORE_INIT'); + await core.flush(); + + expect(await core.cachedEventCount(), 0); + expect(await core.cachedRecentEvents(limit: 0), isEmpty); + await core.refreshConfig(); + await core.dispose(); + }); + + test('eventType 为空时会被忽略', () async { + final storage = MemoryEventStorage(); + late TestConfigManager configManager; + + final core = AnalyticsCore( + storageFactory: () => storage, + apiClientFactory: FakeApiClient.new, + configManagerFactory: (c) => + configManager = TestConfigManager(config: c), + deviceInfoCollector: _collectTestDeviceInfo, + schedulerFactory: _noopScheduler, + ); + + await core.init(_testConfig()); + configManager.currentConfigForTesting = + _dimInfo(events: _allEventDefinitions); + + await core.track(' '); + expect(await storage.count(), 0); + await core.dispose(); + }); + + test('校验错误且配置阻断时会直接丢弃事件(debug)', () async { + final storage = MemoryEventStorage(); + late TestConfigManager configManager; + + final core = AnalyticsCore( + storageFactory: () => storage, + apiClientFactory: FakeApiClient.new, + configManagerFactory: (c) => + configManager = TestConfigManager(config: c), + deviceInfoCollector: _collectTestDeviceInfo, + schedulerFactory: _noopScheduler, + ); + + const config = AnalyticsConfig( + systemCode: 'TEST_APP', + endpointBaseUrl: 'https://example.com/api/ExternalEventlogs', + clientType: 3, + enableDebug: true, + flushInterval: 3600, + enableMetrics: false, + blockOnValidationError: true, + ); + + await core.init(config); + configManager.currentConfigForTesting = _dimInfo(); + + await core.track('EVENT_BLOCKED'); + expect(await storage.count(), 0); + await core.dispose(); + }); + + test('超过 maxCacheSize 时会 trim 并计入 dropped', () async { + final storage = MemoryEventStorage(); + late TestConfigManager configManager; + + final core = AnalyticsCore( + storageFactory: () => storage, + apiClientFactory: FakeApiClient.new, + configManagerFactory: (c) => + configManager = TestConfigManager(config: c), + deviceInfoCollector: _collectTestDeviceInfo, + schedulerFactory: _noopScheduler, + ); + + await core.init( + _testConfig( + enableDebug: false, + maxCacheSize: 1, + batchSize: 99, + ), + ); + configManager.currentConfigForTesting = _dimInfo( + events: const [ + EventDefinition(eventCode: 'EVENT_TRIM'), + ], + ); + + await core.track('EVENT_TRIM'); + await core.track('EVENT_TRIM'); + + expect(await storage.count(), 1); + await core.dispose(); + }); + + test('达到 batchSize 会触发一次异步 flush', () async { + final storage = MemoryEventStorage(); + late TestConfigManager configManager; + late FakeApiClient apiClient; + + final core = AnalyticsCore( + storageFactory: () => storage, + apiClientFactory: (c) => apiClient = FakeApiClient(c), + configManagerFactory: (c) => + configManager = TestConfigManager(config: c), + deviceInfoCollector: _collectTestDeviceInfo, + schedulerFactory: _noopScheduler, + ); + + await core.init( + _testConfig( + enableDebug: false, + batchSize: 1, + ), + ); + configManager.currentConfigForTesting = _dimInfo( + events: const [ + EventDefinition(eventCode: 'EVENT_AUTO_FLUSH'), + ], + ); + + await core.track('EVENT_AUTO_FLUSH'); + await _waitUntil(() => apiClient.sentBatches.isNotEmpty); + + await core.flush(force: true); + await core.dispose(); + }); + + test('refreshConfig(force) 会分别调用 forceRefresh 与 fetch', () async { + final storage = MemoryEventStorage(); + late IntervalConfigManager configManager; + + final core = AnalyticsCore( + storageFactory: () => storage, + apiClientFactory: FakeApiClient.new, + configManagerFactory: (c) => configManager = IntervalConfigManager( + config: c, + interval: const Duration(milliseconds: 10), + ), + deviceInfoCollector: _collectTestDeviceInfo, + schedulerFactory: _noopScheduler, + ); + + await core.init(_testConfig(batchSize: 50)); + await core.refreshConfig(); + await core.refreshConfig(force: false); + + expect(configManager.forceCalls, greaterThanOrEqualTo(1)); + expect(configManager.fetchCalls, greaterThanOrEqualTo(1)); + await core.dispose(); + }); + + test('flush 发生未知异常时会按可重试处理', () async { + final storage = MemoryEventStorage(); + late TestConfigManager configManager; + late FakeApiClient apiClient; + + final core = AnalyticsCore( + storageFactory: () => storage, + apiClientFactory: (c) => apiClient = FakeApiClient(c), + configManagerFactory: (c) => + configManager = TestConfigManager(config: c), + deviceInfoCollector: _collectTestDeviceInfo, + schedulerFactory: _noopScheduler, + ); + + await core.init(_testConfig(enableDebug: false, batchSize: 10)); + configManager.currentConfigForTesting = + _dimInfo(events: _allEventDefinitions); + + await core.track('EVENT_THROW_UNKNOWN'); + apiClient.errorToThrow = StateError('boom'); + + await core.flush(force: true); + expect(await storage.count(), 1); + await core.dispose(); + }); + + test('已有 flush 进行中时会直接跳过', () async { + final blocker = Completer(); + final storage = BlockingEventStorage(blocker); + late TestConfigManager configManager; + + final core = AnalyticsCore( + storageFactory: () => storage, + apiClientFactory: FakeApiClient.new, + configManagerFactory: (c) => + configManager = TestConfigManager(config: c), + deviceInfoCollector: _collectTestDeviceInfo, + schedulerFactory: _noopScheduler, + ); + + await core.init(_testConfig(enableDebug: false, batchSize: 10)); + configManager.currentConfigForTesting = + _dimInfo(events: _allEventDefinitions); + + await core.track('EVENT_CONCURRENT_FLUSH'); + + final firstFlush = core.flush(force: true); + await Future.delayed(const Duration(milliseconds: 10)); + await core.flush(); + blocker.complete(); + await firstFlush; + await core.dispose(); + }); + + test('重试次数超过上限时会删除事件', () async { + final storage = MemoryEventStorage(); + late TestConfigManager configManager; + late FakeApiClient apiClient; + + final core = AnalyticsCore( + storageFactory: () => storage, + apiClientFactory: (c) => apiClient = FakeApiClient(c), + configManagerFactory: (c) => + configManager = TestConfigManager(config: c), + deviceInfoCollector: _collectTestDeviceInfo, + schedulerFactory: _noopScheduler, + ); + + const maxRetry = 1; + await core.init( + _testConfig( + enableDebug: false, + batchSize: 10, + maxRetryCount: maxRetry, + ), + ); + configManager.currentConfigForTesting = + _dimInfo(events: _allEventDefinitions); + + final now = DateTime.now(); + final event = Event( + systemCode: 'TEST_APP', + eventType: 'EVENT_MAX_RETRY', + userInfo: null, + clientType: 3, + clientTimestamp: now.millisecondsSinceEpoch, + timestamp: now.toIso8601String(), + deviceInfo: _testDeviceInfo, + eventParams: null, + customTags: null, + createTime: now, + retryCount: maxRetry, + ); + await storage.insert(event); + + apiClient.exceptionToThrow = const ApiException( + message: 'offline', + retryable: true, + ); + + await core.flush(force: true); + expect(await storage.count(), 0); + await core.dispose(); + }); + + test('afterSend 拦截器抛错不会影响主流程', () async { + final storage = MemoryEventStorage(); + late TestConfigManager configManager; + late FakeApiClient apiClient; + final interceptor = RecordingInterceptor( + onAfter: (_, __) => throw StateError('after boom'), + ); + + final core = AnalyticsCore( + storageFactory: () => storage, + apiClientFactory: (c) => apiClient = FakeApiClient(c), + configManagerFactory: (c) => + configManager = TestConfigManager(config: c), + deviceInfoCollector: _collectTestDeviceInfo, + schedulerFactory: _noopScheduler, + interceptors: [interceptor], + ); + + await core.init(_testConfig(enableDebug: false, batchSize: 10)); + configManager.currentConfigForTesting = + _dimInfo(events: _allEventDefinitions); + + await core.track('EVENT_AFTER_THROW'); + await core.flush(force: true); + + expect(apiClient.sentBatches, isNotEmpty); + await core.dispose(); + }); + + test('退避时间未到时 flush 会被跳过', () async { + final storage = MemoryEventStorage(); + late TestConfigManager configManager; + late FakeApiClient apiClient; + var now = DateTime.fromMillisecondsSinceEpoch(1000); + + final core = AnalyticsCore( + storageFactory: () => storage, + apiClientFactory: (c) => apiClient = FakeApiClient(c), + configManagerFactory: (c) => + configManager = TestConfigManager(config: c), + deviceInfoCollector: _collectTestDeviceInfo, + schedulerFactory: _noopScheduler, + now: () => now, + ); + + await core.init(_testConfig(enableDebug: false, batchSize: 10)); + configManager.currentConfigForTesting = + _dimInfo(events: _allEventDefinitions); + + await core.track('EVENT_BACKOFF'); + apiClient.exceptionToThrow = const ApiException( + message: 'offline', + retryable: true, + ); + await core.flush(force: true); + + final sentBefore = apiClient.sentBatches.length; + await core.flush(); + expect(apiClient.sentBatches.length, sentBefore); + + now = now.add(const Duration(minutes: 5)); + await core.flush(force: true); + await core.dispose(); + }); + + test('release 模式会为类型不匹配追加校验标记', () async { + final storage = MemoryEventStorage(); + late TestConfigManager configManager; + + final core = AnalyticsCore( + storageFactory: () => storage, + apiClientFactory: FakeApiClient.new, + configManagerFactory: (c) => + configManager = TestConfigManager(config: c), + deviceInfoCollector: _collectTestDeviceInfo, + schedulerFactory: _noopScheduler, + ); + + await core.init(_testConfig(enableDebug: false, batchSize: 50)); + configManager.currentConfigForTesting = _dimInfo( + events: const [ + EventDefinition(eventCode: 'EVENT_TYPE_MISMATCH'), + ], + tags: const [ + TagDefinition(tagName: 'age', tagType: 'int', isRequired: false), + ], + ); + + await core.track( + 'EVENT_TYPE_MISMATCH', + customTags: const {'age': 'bad'}, + ); + + final stored = storage.singleOrNull; + final tags = stored?.event.customTags ?? const {}; + expect(tags['_sdk_type_error_fields'], contains('age')); + await core.dispose(); + }); + + test('metrics/config 定时器会在 tick 时触发任务', () async { + final storage = MemoryEventStorage(); + late IntervalConfigManager configManager; + + final core = AnalyticsCore( + storageFactory: () => storage, + apiClientFactory: FakeApiClient.new, + configManagerFactory: (c) => configManager = IntervalConfigManager( + config: c, + interval: const Duration(milliseconds: 10), + ), + deviceInfoCollector: _collectTestDeviceInfo, + schedulerFactory: _noopScheduler, + ); + + const config = AnalyticsConfig( + systemCode: 'TEST_APP', + endpointBaseUrl: 'https://example.com/api/ExternalEventlogs', + clientType: 3, + flushInterval: 3600, + metricsReportInterval: Duration(milliseconds: 10), + ); + + await core.init(config); + await Future.delayed(const Duration(milliseconds: 50)); + + expect(configManager.fetchCalls, greaterThanOrEqualTo(1)); + expect(storage.insertedEvents, isNotEmpty); + + await core.dispose(); + }); }); group('MemoryEventStorage contract', () { @@ -744,9 +1246,15 @@ void main() { await storage.init(); final now = DateTime.now(); - await storage.insert(buildEvent('E1', now.subtract(const Duration(seconds: 3)))); - await storage.insert(buildEvent('E2', now.subtract(const Duration(seconds: 2)))); - await storage.insert(buildEvent('E3', now.subtract(const Duration(seconds: 1)))); + await storage.insert( + buildEvent('E1', now.subtract(const Duration(seconds: 3))), + ); + await storage.insert( + buildEvent('E2', now.subtract(const Duration(seconds: 2))), + ); + await storage.insert( + buildEvent('E3', now.subtract(const Duration(seconds: 1))), + ); final trimmed = await storage.trimToMaxSize(2); expect(trimmed, 1); @@ -754,17 +1262,42 @@ void main() { expect(storage.byId(1), isNull); }); + test('deleteExpired 会删除早于 cutoff 的事件', () async { + final storage = MemoryEventStorage(); + await storage.init(); + 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); + expect(await storage.count(), 1); + expect(storage.singleOrNull?.event.eventType, 'KEEP'); + }); + test('fetchRecent 按时间降序返回', () async { final storage = MemoryEventStorage(); await storage.init(); final now = DateTime.now(); - await storage.insert(buildEvent('OLD', now.subtract(const Duration(seconds: 2)))); - await storage.insert(buildEvent('MID', now.subtract(const Duration(seconds: 1)))); + await storage.insert( + buildEvent('OLD', now.subtract(const Duration(seconds: 2))), + ); + await storage.insert( + buildEvent('MID', now.subtract(const Duration(seconds: 1))), + ); await storage.insert(buildEvent('NEW', now)); final recent = await storage.fetchRecent(2); - expect(recent.map((e) => e.event.eventType).toList(), ['NEW', 'MID']); + expect( + recent.map(_eventTypeOfStored).toList(growable: false), + ['NEW', 'MID'], + ); }); test('updateRetryCount 会同时更新 stored 与 event.retryCount', () async { @@ -792,17 +1325,13 @@ void main() { apiClientFactory: (c) => apiClient = FakeApiClient(c), configManagerFactory: (c) => configManager = TestConfigManager(config: c), - deviceInfoCollector: () async => _testDeviceInfo, - schedulerFactory: (interval, onTick) => - NoopScheduler(interval: interval, onTick: onTick), + deviceInfoCollector: _collectTestDeviceInfo, + schedulerFactory: _noopScheduler, ); await core.init(_testConfig(enableDebug: false, batchSize: 10)); - configManager.setCurrentConfig( - _dimInfo(events: const [ - EventDefinition(eventCode: 'EVENT_OFFLINE'), - ]), - ); + configManager.currentConfigForTesting = + _dimInfo(events: _allEventDefinitions); apiClient.exceptionToThrow = const ApiException( message: 'offline', @@ -824,12 +1353,11 @@ void main() { final core = AnalyticsCore( storageFactory: () => storage, - apiClientFactory: (c) => FakeApiClient(c), + apiClientFactory: FakeApiClient.new, configManagerFactory: (c) => configManager = TestConfigManager(config: c), - deviceInfoCollector: () async => _testDeviceInfo, - schedulerFactory: (interval, onTick) => - NoopScheduler(interval: interval, onTick: onTick), + deviceInfoCollector: _collectTestDeviceInfo, + schedulerFactory: _noopScheduler, ); await core.init( @@ -839,11 +1367,8 @@ void main() { maxCacheSize: 20000, ), ); - configManager.setCurrentConfig( - _dimInfo(events: const [ - EventDefinition(eventCode: 'EVENT_STRESS'), - ]), - ); + configManager.currentConfigForTesting = + _dimInfo(events: _allEventDefinitions); for (var i = 0; i < 10000; i++) { await core.track('EVENT_STRESS'); diff --git a/test/api_client_test.dart b/test/api_client_test.dart new file mode 100644 index 0000000..98977aa --- /dev/null +++ b/test/api_client_test.dart @@ -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 {'k': 'v'}, + customTags: const {'t': 1}, + createTime: DateTime.fromMillisecondsSinceEpoch(1), + ); +} + +class _FakeHttpClient extends HttpClient { + _FakeHttpClient() : super(_config()); + + int callCount = 0; + Response? response; + Object? error; + + @override + Future> post( + String path, { + Object? data, + Map? queryParameters, + Map? 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( + requestOptions: RequestOptions(path: path), + statusCode: 200, + data: const {'ok': true}, + ); + return res as Response; + } +} + +Response _response(int statusCode) { + return Response( + requestOptions: RequestOptions(path: '/AddEventListLog'), + statusCode: statusCode, + data: {'status': statusCode}, + ); +} + +void main() { + group('ApiClient', () { + test('空事件列表直接返回', () async { + final fake = _FakeHttpClient(); + final client = ApiClient(_config(), httpClient: fake); + + await client.sendBatch(const []); + + expect(fake.callCount, 0); + }); + + test('2xx 视为成功', () async { + final fake = _FakeHttpClient()..response = _response(204); + final client = ApiClient(_config(), httpClient: fake); + + await client.sendBatch([_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('BAD')]), + throwsA( + isA() + .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('RETRY')]), + throwsA( + isA() + .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('HTTP_ERR')]), + throwsA( + isA() + .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('NET_ERR')]), + throwsA( + isA() + .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('UNKNOWN_ERR')]), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/config_manager_test.dart b/test/config_manager_test.dart index bb83a30..6aa636a 100644 --- a/test/config_manager_test.dart +++ b/test/config_manager_test.dart @@ -53,11 +53,28 @@ class FakeHttpClient extends HttpClient { data: responseData as T?, statusCode: 200, headers: responseHeaders, - requestOptions: RequestOptions(path: path, queryParameters: queryParameters), + requestOptions: RequestOptions( + path: path, + queryParameters: queryParameters, + ), ); } } +class ThrowingHttpClient extends HttpClient { + ThrowingHttpClient(super.config); + + @override + Future> get( + String path, { + Map? queryParameters, + Map? headers, + CancelToken? cancelToken, + }) async { + throw StateError('boom'); + } +} + AnalyticsConfig _config() { return const AnalyticsConfig( systemCode: 'TEST_APP', @@ -81,34 +98,34 @@ void main() { group('ConfigManager', () { test('fetchAndCacheConfig 会解析配置并缓存', () async { final storage = InMemoryConfigStorage(); - final httpClient = FakeHttpClient(_config()); - httpClient.responseData = { - 'data': { - 'systemEventTypes': >[ - {'eventCode': 'EVENT_A', 'eventName': 'A'}, - ], - 'systemCustonTas': >[ - { - 'tagName': 'tenantId', - 'tagType': 'string', - 'isRequired': true, - }, - ], - 'sdkStrategy': { - 'enabled': true, - 'defaultSampleRate': 1.0, - 'eventSettings': { - 'EVENT_A': { - 'enabled': true, - 'sampleRate': 0.25, + final httpClient = FakeHttpClient(_config()) + ..responseData = { + 'data': { + 'systemEventTypes': >[ + {'eventCode': 'EVENT_A', 'eventName': 'A'}, + ], + 'systemCustonTas': >[ + { + 'tagName': 'tenantId', + 'tagType': 'string', + 'isRequired': true, + }, + ], + 'sdkStrategy': { + 'enabled': true, + 'defaultSampleRate': 1, + 'eventSettings': { + 'EVENT_A': { + 'enabled': true, + 'sampleRate': 0.25, + }, }, }, }, - }, - }; - httpClient.responseHeaders = Headers.fromMap(>{ - 'x-config-version': ['v1'], - }); + } + ..responseHeaders = Headers.fromMap(>{ + 'x-config-version': ['v1'], + }); final manager = ConfigManager( config: _config(), @@ -136,13 +153,12 @@ void main() { final manager = ConfigManager( config: _config(), - refreshInterval: const Duration(hours: 12), storage: storage, httpClient: httpClient, ); await manager.init(); - await manager.fetchAndCacheConfig(force: false); + await manager.fetchAndCacheConfig(); expect(httpClient.getCallCount, 0); expect(manager.currentConfig, isNotNull); @@ -151,7 +167,8 @@ void main() { test('响应结构不可解析时保持现有配置', () async { final storage = InMemoryConfigStorage(); 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); httpClient.responseData = 'not-a-map'; @@ -168,5 +185,91 @@ void main() { expect(manager.currentConfig, isNotNull); 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 = { + 'data': { + 1: 'ignored', + 'systemEventTypes': const [], + 'systemCustonTas': const [], + }, + }; + + 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 = { + 'systemEventTypes': const [], + 'systemCustonTas': const [], + }; + + 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)); + }); }); } diff --git a/test/device_util_test.dart b/test/device_util_test.dart new file mode 100644 index 0000000..1baa352 --- /dev/null +++ b/test/device_util_test.dart @@ -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()); + 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'); + }); + }); +} diff --git a/test/event_extra_test.dart b/test/event_extra_test.dart new file mode 100644 index 0000000..51b06bc --- /dev/null +++ b/test/event_extra_test.dart @@ -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( + { + 'system_code': 'SYS', + 'eventType': 'E_NUM', + 'clientType': 3.9, + 'clientTimestamp': '123', + 'timestamp': '2026-01-01T00:00:00.000Z', + 'deviceInfo': { + '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( + { + 'system_code': 'SYS', + 'eventType': 'E_MAP', + 'clientType': 3, + 'clientTimestamp': 1, + 'timestamp': '2026-01-01T00:00:00.000Z', + 'deviceInfo': { + 'os': 'o', + 'model': 'm', + 'screenResolution': '1x1', + }, + 'eventParams': {1: 'v'}, + 'customTags': {'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); + }); +} diff --git a/test/facade_test.dart b/test/facade_test.dart new file mode 100644 index 0000000..2529622 --- /dev/null +++ b/test/facade_test.dart @@ -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 beforeSend(Event event) async => event; + + @override + FutureOr afterSend(Event event, SendResult result) async {} +} + +class _MemoryEventStorage implements EventStorage { + final _items = []; + var _nextId = 1; + + @override + Future init() async {} + + @override + Future 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> fetchBatch(int limit) async { + if (limit <= 0) return const []; + final copy = List.from(_items) + ..sort((a, b) => a.createTime.compareTo(b.createTime)); + return copy.take(limit).toList(growable: false); + } + + @override + Future> fetchRecent(int limit) async { + if (limit <= 0) return const []; + final copy = List.from(_items) + ..sort((a, b) => b.createTime.compareTo(a.createTime)); + return copy.take(limit).toList(growable: false); + } + + @override + Future deleteByIds(List ids) async { + if (ids.isEmpty) return; + _items.removeWhere((e) => ids.contains(e.id)); + } + + @override + Future count() async => _items.length; + + @override + Future 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 deleteExpired(DateTime cutoff) async { + final before = _items.length; + _items.removeWhere((e) => !e.createTime.isAfter(cutoff)); + return before - _items.length; + } + + @override + Future 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 dispose() async {} +} + +class _FakeApiClient extends ApiClient { + _FakeApiClient(super.config); + + int sent = 0; + + @override + Future sendBatch(List events) async { + sent += events.length; + } +} + +class _MemoryConfigStorage implements ConfigStorage { + SystemDimInfo? _value; + + @override + Future init() async {} + + @override + Future saveSystemDimInfo(SystemDimInfo info) async { + _value = info; + } + + @override + Future loadSystemDimInfo() async => _value; + + @override + Future clear() async { + _value = null; + } + + @override + Future 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 init() async { + _current ??= SystemDimInfo( + systemInfo: const SystemInfo(raw: {}), + eventDefinitions: const [ + EventDefinition(eventCode: 'FACADE_EVENT'), + EventDefinition(eventCode: 'SDK_METRICS_SEND'), + EventDefinition(eventCode: 'SDK_METRICS_QUEUE'), + ], + tagDefinitions: const [], + sdkStrategy: const SdkStrategy( + enabled: true, + defaultSampleRate: 1, + eventSettings: {}, + ), + lastFetchedAt: DateTime.fromMillisecondsSinceEpoch(1), + ); + } + + @override + Future fetchAndCacheConfig({bool force = false}) async {} + + @override + Future forceRefresh() async {} + + @override + Future 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()); + 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 {'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(); + }); + }); +} diff --git a/test/http_client_test.dart b/test/http_client_test.dart new file mode 100644 index 0000000..20ed0fe --- /dev/null +++ b/test/http_client_test.dart @@ -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 fetch( + RequestOptions options, + Stream>? requestStream, + Future? cancelFuture, + ) async { + lastOptions = options; + lastData = options.data; + return ResponseBody.fromString( + jsonEncode({'ok': true}), + 200, + headers: >{ + Headers.contentTypeHeader: [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>('/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('/no-headers', data: {}); + + 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( + '/with-headers', + data: {'a': 1}, + headers: {'x-test': '1'}, + ); + + expect(adapter.lastOptions?.headers['x-test'], '1'); + expect(adapter.lastData, {'a': 1}); + }); + }); +} diff --git a/test/interceptors_extra_test.dart b/test/interceptors_extra_test.dart new file mode 100644 index 0000000..1a8554b --- /dev/null +++ b/test/interceptors_extra_test.dart @@ -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()); + }); +} diff --git a/test/isolate_event_storage_test.dart b/test/isolate_event_storage_test.dart new file mode 100644 index 0000000..2ff1b88 --- /dev/null +++ b/test/isolate_event_storage_test.dart @@ -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), ['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([id1]); + final left = await storage.fetchBatch(10); + expect(left.map((e) => e.id), [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), ['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); + }); + }); +} diff --git a/test/logger_test.dart b/test/logger_test.dart new file mode 100644 index 0000000..c77cfc4 --- /dev/null +++ b/test/logger_test.dart @@ -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); + }); +} diff --git a/test/scheduler_test.dart b/test/scheduler_test.dart new file mode 100644 index 0000000..fc12f55 --- /dev/null +++ b/test/scheduler_test.dart @@ -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.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.delayed(const Duration(milliseconds: 25)); + scheduler.stop(); + + expect(ticks, greaterThanOrEqualTo(1)); + }); + }); +} diff --git a/test/small_models_test.dart b/test/small_models_test.dart new file mode 100644 index 0000000..53fbfe4 --- /dev/null +++ b/test/small_models_test.dart @@ -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()); + expect(SdkInfo.sdkVersion, isNotEmpty); + expect(SdkInfo.platform, 'flutter'); + expect(SdkInfo.instance(), isA()); + expect(TimeUtil.instance(), isA()); + expect(TimeUtil.nowMs(), greaterThan(0)); + expect(TimeUtil.nowIso8601Utc(), contains('T')); + expect(TimeUtil.iso8601FromMs(0), '1970-01-01T00:00:00.000Z'); + }); +} diff --git a/test/sqflite_storage_test.dart b/test/sqflite_storage_test.dart new file mode 100644 index 0000000..60e4355 --- /dev/null +++ b/test/sqflite_storage_test.dart @@ -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 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: {'i': ts}, + customTags: const {'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), ['A', 'B']); + + final recent = await storage.fetchRecent(10); + expect(recent.map((e) => e.event.eventType), ['B', 'A']); + + await storage.deleteByIds(const []); + 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), ['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), ['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', + { + '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), ['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([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', + { + '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: {'a': 1}), + eventDefinitions: const [ + EventDefinition(eventCode: 'E'), + ], + tagDefinitions: const [ + TagDefinition(tagName: 't', tagType: 'string', isRequired: true), + ], + sdkStrategy: const SdkStrategy( + enabled: true, + defaultSampleRate: 1, + eventSettings: {}, + ), + 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', + { + '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', + { + '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', + { + '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'); + }); + }); +} diff --git a/test/system_dim_info_test.dart b/test/system_dim_info_test.dart new file mode 100644 index 0000000..c631435 --- /dev/null +++ b/test/system_dim_info_test.dart @@ -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: {'k': 'v'}), + eventDefinitions: const [], + tagDefinitions: const [], + 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: {'k': 'v'}), + eventDefinitions: const [ + EventDefinition(eventCode: 'A', eventName: 'A1'), + ], + tagDefinitions: const [ + TagDefinition(tagName: 't1', tagType: 'string', isRequired: true), + TagDefinition(tagName: 't2', tagType: 'int', isRequired: false), + ], + sdkStrategy: const SdkStrategy( + enabled: true, + defaultSampleRate: 0.5, + eventSettings: { + '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), ['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: {'a': 1}), + eventDefinitions: const [ + EventDefinition(eventCode: 'E', eventName: 'Event'), + ], + tagDefinitions: const [ + TagDefinition(tagName: 'tag', tagType: 'string', isRequired: true), + ], + sdkStrategy: const SdkStrategy( + enabled: false, + defaultSampleRate: 0.3, + eventSettings: { + '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( + { + 'systemInfo': {'sys': true}, + 'systemEventTypes': [ + {'event_code': 'E1', 'event_name': 'Name'}, + {'eventType': 'E2'}, + {'eventCode': ''}, + 123, + ], + // 使用拼写错误字段名 + 'systemCustonTas': [ + { + 'tag_name': 'req', + 'tag_type': 'int', + 'is_required': '1', + 'desc': 'd', + }, + {'name': 'opt', 'type': 'string'}, + {'tagName': ''}, + 'bad', + ], + // 使用 sdk_strategy 字段名 + 'sdk_strategy': { + 'enabled': 'false', + 'defaultSampleRate': 2, + 'eventSettings': { + 'E1': {'enabled': 0, 'sampleRate': -1}, + 'E2': { + '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(['E1', 'E2']), + ); + expect( + info.tagDefinitions.map(_tagNameOf).toList(growable: false), + containsAll(['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( + { + '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( + { + 'systemInfo': {'k': 'v'}, + 'eventDefinitions': const [], + 'tagDefinitions': const [], + 'sdkStrategy': { + 'enabled': true, + 'defaultSampleRate': '0.25', + 'eventSettings': { + 'E': {'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( + { + 'systemInfo': const {}, + 'eventDefinitions': const [], + 'tagDefinitions': const [], + 'lastFetchedAt': '2', + }, + ); + + expect(info.lastFetchedAt.millisecondsSinceEpoch, 2); + }); + }); + + group('Model toJson', () { + test('SystemInfo/EventDefinition/TagDefinition/SdkStrategy/EventStrategy', () { + const system = SystemInfo(raw: {'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: { + '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( + { + 'sdkStrategy': { + 'enabled': true, + 'defaultSampleRate': double.nan, + 'eventSettings': const {}, + }, + }, + fetchedAt: DateTime.fromMillisecondsSinceEpoch(1), + ); + + expect(info.sdkStrategy?.defaultSampleRate, 1); + }); + }); +} diff --git a/test/validator_test.dart b/test/validator_test.dart index cbd8bc1..3dca702 100644 --- a/test/validator_test.dart +++ b/test/validator_test.dart @@ -81,21 +81,54 @@ SystemDimInfo _dimInfo() { ); } +SystemDimInfo _dimInfoWithTypes() { + return SystemDimInfo( + systemInfo: const SystemInfo(raw: {}), + eventDefinitions: const [ + EventDefinition(eventCode: 'TYPED'), + ], + tagDefinitions: const [ + 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() { group('Validator', () { test('已配置事件且必填 tag 满足时无告警', () { - final manager = StaticConfigManager(config: _config(), configValue: _dimInfo()); + final manager = StaticConfigManager( + config: _config(), + configValue: _dimInfo(), + ); final validator = Validator(manager); final result = validator.validate( - _event(type: 'KNOWN', customTags: const {'tenantId': 't1'}), + _event( + type: 'KNOWN', + customTags: const {'tenantId': 't1'}, + ), ); expect(result.isEmpty, true); }); test('未知事件会产生 error', () { - final manager = StaticConfigManager(config: _config(), configValue: _dimInfo()); + final manager = StaticConfigManager( + config: _config(), + configValue: _dimInfo(), + ); final validator = Validator(manager); final result = validator.validate(_event(type: 'UNKNOWN')); @@ -105,7 +138,10 @@ void main() { }); test('缺失必填 tag 会产生 warning', () { - final manager = StaticConfigManager(config: _config(), configValue: _dimInfo()); + final manager = StaticConfigManager( + config: _config(), + configValue: _dimInfo(), + ); final validator = Validator(manager); final result = validator.validate(_event(type: 'KNOWN')); @@ -115,7 +151,10 @@ void main() { }); test('类型不匹配会产生 warning', () { - final manager = StaticConfigManager(config: _config(), configValue: _dimInfo()); + final manager = StaticConfigManager( + config: _config(), + configValue: _dimInfo(), + ); final validator = Validator(manager); final result = validator.validate( @@ -129,7 +168,37 @@ void main() { ); 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 { + 'intNum': 1.0, + 'intStr': '2', + 'boolNum': 0, + 'boolStr': 'no', + 'doubleStr': '3.14', + 'floatStr': '2.72', + 'numStr': '42', + 'unknown': {'any': 'value'}, + }, + ), + ); + + expect(result.hasErrors, false); + expect(result.hasWarnings, false); }); }); } diff --git a/test/yx_tracking_flutter_test.dart b/test/yx_tracking_flutter_test.dart index 0ac837f..72d29e7 100644 --- a/test/yx_tracking_flutter_test.dart +++ b/test/yx_tracking_flutter_test.dart @@ -1,6 +1,6 @@ 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/yx_tracking_flutter.dart'; void main() { group('AnalyticsConfig.validate', () { @@ -26,12 +26,12 @@ void main() { }); test('Event payload 序列化/反序列化', () { - final device = const DeviceInfo( + const device = DeviceInfo( os: 'android', model: 'pixel', 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 event = Event(