feat: Implement Isolate Storage & Lifecycle Handling
Flutter CI / analyze-and-test (push) Waiting to run
Details
Flutter CI / analyze-and-test (push) Waiting to run
Details
- Implement IsolateEventStorage for background persistence - Add Analytics.bindLifecycleObserver for background flushing - Enable useIsolateStorage by default in AnalyticsConfig - Fix 25+ lint issues across the codebase - Add comprehensive tests for Isolate storage and lifecycle behaviors
This commit is contained in:
parent
d58eeede2f
commit
e7326cb9f9
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> 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<void> 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<void> forceRefresh() => fetchAndCacheConfig(force: true);
|
||||
|
||||
/// 释放资源。
|
||||
Future<void> dispose() => _storage.dispose();
|
||||
|
||||
bool _shouldSkipFetch() {
|
||||
|
|
|
|||
|
|
@ -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<DeviceInfo> Function() _deviceInfoCollector;
|
||||
final Scheduler Function(Duration interval, Future<void> Function() onTick)
|
||||
_schedulerFactory;
|
||||
final double Function() _randomDouble;
|
||||
final DateTime Function() _now;
|
||||
final bool _includeCommonTagsInterceptor;
|
||||
final List<AnalyticsInterceptor> _initialInterceptors;
|
||||
|
||||
/// 创建核心实例,允许通过依赖注入进行测试替换。
|
||||
AnalyticsCore({
|
||||
EventStorage Function()? storageFactory,
|
||||
ApiClient Function(AnalyticsConfig config)? apiClientFactory,
|
||||
|
|
@ -41,19 +32,38 @@ class AnalyticsCore {
|
|||
DateTime Function()? now,
|
||||
bool includeCommonTagsInterceptor = true,
|
||||
List<AnalyticsInterceptor> interceptors = const <AnalyticsInterceptor>[],
|
||||
}) : _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<DeviceInfo> Function() _deviceInfoCollector;
|
||||
final Scheduler Function(Duration interval, Future<void> Function() onTick)
|
||||
_schedulerFactory;
|
||||
final double Function() _randomDouble;
|
||||
final DateTime Function() _now;
|
||||
final bool _includeCommonTagsInterceptor;
|
||||
final List<AnalyticsInterceptor> _initialInterceptors;
|
||||
|
||||
AnalyticsConfig? _config;
|
||||
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<void> 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<void> setUser(UserInfo? userInfo) async {
|
||||
_user = userInfo;
|
||||
Logger.info('用户信息已更新');
|
||||
}
|
||||
|
||||
/// 覆盖设备信息(通常用于测试或特殊场景)。
|
||||
Future<void> setDeviceInfo(DeviceInfo deviceInfo) async {
|
||||
_deviceInfo = deviceInfo;
|
||||
Logger.info('设备信息已覆盖');
|
||||
}
|
||||
|
||||
/// 记录一个业务事件。
|
||||
Future<void> track(
|
||||
String eventType, {
|
||||
Map<String, dynamic>? eventParams,
|
||||
|
|
@ -160,16 +181,19 @@ class AnalyticsCore {
|
|||
|
||||
Future<void> _track(
|
||||
String eventType, {
|
||||
required bool internal,
|
||||
Map<String, dynamic>? eventParams,
|
||||
Map<String, dynamic>? 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<int> cachedEventCount() async {
|
||||
final storage = _storage;
|
||||
if (!_initialized || storage == null) {
|
||||
|
|
@ -230,6 +256,7 @@ class AnalyticsCore {
|
|||
return storage.count();
|
||||
}
|
||||
|
||||
/// 获取最近的缓存事件摘要。
|
||||
Future<List<RecentEventSummary>> cachedRecentEvents({int limit = 20}) async {
|
||||
final storage = _storage;
|
||||
if (!_initialized || storage == null || limit <= 0) {
|
||||
|
|
@ -248,6 +275,7 @@ class AnalyticsCore {
|
|||
.toList(growable: false);
|
||||
}
|
||||
|
||||
/// 刷新维表配置。
|
||||
Future<void> refreshConfig({bool force = true}) async {
|
||||
final manager = _configManager;
|
||||
if (!_initialized || manager == null) {
|
||||
|
|
@ -260,14 +288,19 @@ class AnalyticsCore {
|
|||
await manager.fetchAndCacheConfig();
|
||||
}
|
||||
|
||||
/// 立即上报一次 SDK 指标。
|
||||
Future<void> reportMetricsNow() => _reportMetrics();
|
||||
|
||||
/// 触发一次 flush。
|
||||
Future<void> 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<Event> events) {
|
||||
var count = 0;
|
||||
|
|
@ -480,7 +518,10 @@ class AnalyticsCore {
|
|||
Future<void> _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 = <String, dynamic>{
|
||||
'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<void> setDebug(bool enabled) async {
|
||||
Logger.setDebug(enabled);
|
||||
/// 动态设置 Debug 开关。
|
||||
Future<void> setDebug({required bool enabled}) async {
|
||||
Logger.debugEnabled = enabled;
|
||||
}
|
||||
|
||||
/// 释放所有资源。
|
||||
Future<void> 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<void> _maybePurgeExpired({required String reason}) async {
|
||||
final config = _config;
|
||||
final storage = _storage;
|
||||
if (!_initialized || config == null || storage == null) {
|
||||
return;
|
||||
}
|
||||
if (config.maxEventAge <= Duration.zero) {
|
||||
return;
|
||||
}
|
||||
final now = _now();
|
||||
final lastSweep = _lastExpirationSweep;
|
||||
if (lastSweep != null &&
|
||||
now.difference(lastSweep) < _expirationSweepInterval) {
|
||||
return;
|
||||
}
|
||||
_lastExpirationSweep = now;
|
||||
final cutoff = now.subtract(config.maxEventAge);
|
||||
final removed = await storage.deleteExpired(cutoff);
|
||||
if (removed > 0) {
|
||||
_droppedCount += removed;
|
||||
Logger.info(
|
||||
'已清理过期事件: count=$removed cutoff=$cutoff reason=$reason',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _PreparedBatch {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Event?> beforeSend(Event event) => event;
|
||||
|
||||
/// 发送后回调。
|
||||
FutureOr<void> afterSend(Event event, SendResult result) {}
|
||||
}
|
||||
|
||||
|
|
@ -32,8 +42,8 @@ class CommonTagsInterceptor extends AnalyticsInterceptor {
|
|||
final tags = Map<String, dynamic>.from(
|
||||
event.customTags ?? const <String, dynamic>{},
|
||||
);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> Function() onTick;
|
||||
|
||||
Timer? _timer;
|
||||
|
||||
/// 创建调度器。
|
||||
Scheduler({
|
||||
required this.interval,
|
||||
required this.onTick,
|
||||
});
|
||||
|
||||
/// 调度间隔。
|
||||
final Duration interval;
|
||||
|
||||
/// 每次 tick 的回调。
|
||||
final Future<void> 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ValidationIssue> errors;
|
||||
final List<ValidationIssue> warnings;
|
||||
|
||||
/// 创建校验结果实例。
|
||||
const ValidationResult({
|
||||
this.errors = const <ValidationIssue>[],
|
||||
this.warnings = const <ValidationIssue>[],
|
||||
});
|
||||
|
||||
/// 错误列表(通常会阻断发送)。
|
||||
final List<ValidationIssue> errors;
|
||||
|
||||
/// 警告列表(不会阻断发送)。
|
||||
final List<ValidationIssue> 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) {
|
||||
|
|
|
|||
|
|
@ -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<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'os': os,
|
||||
'model': model,
|
||||
'screenResolution': screenResolution,
|
||||
};
|
||||
}
|
||||
/// 操作系统名称或版本。
|
||||
final String os;
|
||||
|
||||
/// 设备型号。
|
||||
final String model;
|
||||
|
||||
/// 屏幕分辨率。
|
||||
final String screenResolution;
|
||||
|
||||
/// 转换为可序列化的 JSON。
|
||||
Map<String, dynamic> toJson() => <String, dynamic>{
|
||||
'os': os,
|
||||
'model': model,
|
||||
'screenResolution': screenResolution,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, dynamic>? eventParams;
|
||||
final Map<String, dynamic>? 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<String, dynamic>? eventParams,
|
||||
Map<String, dynamic>? customTags,
|
||||
DateTime? createTime,
|
||||
int? retryCount,
|
||||
}) {
|
||||
return Event(
|
||||
systemCode: systemCode,
|
||||
eventType: eventType,
|
||||
userInfo: userInfo ?? this.userInfo,
|
||||
clientType: clientType,
|
||||
clientTimestamp: clientTimestamp,
|
||||
timestamp: timestamp,
|
||||
deviceInfo: deviceInfo ?? this.deviceInfo,
|
||||
eventParams: eventParams ?? this.eventParams,
|
||||
customTags: customTags ?? this.customTags,
|
||||
createTime: createTime ?? this.createTime,
|
||||
retryCount: retryCount ?? this.retryCount,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'system_code': systemCode,
|
||||
'eventType': eventType,
|
||||
'userInfo': userInfo?.toJson(),
|
||||
'clientType': clientType,
|
||||
'clientTimestamp': clientTimestamp,
|
||||
'timestamp': timestamp,
|
||||
'deviceInfo': deviceInfo.toJson(),
|
||||
'eventParams': eventParams,
|
||||
'customTags': customTags,
|
||||
};
|
||||
}
|
||||
|
||||
String toPayload() => jsonEncode(toJson());
|
||||
|
||||
static Event fromPayload(
|
||||
/// 从序列化 payload 恢复事件。
|
||||
factory Event.fromPayload(
|
||||
String payload, {
|
||||
required DateTime createTime,
|
||||
required int retryCount,
|
||||
|
|
@ -79,10 +30,15 @@ class Event {
|
|||
if (decoded is! Map<String, dynamic>) {
|
||||
throw const FormatException('事件 payload 不是合法的 JSON 对象');
|
||||
}
|
||||
return fromJson(decoded, createTime: createTime, retryCount: retryCount);
|
||||
return Event.fromJson(
|
||||
decoded,
|
||||
createTime: createTime,
|
||||
retryCount: retryCount,
|
||||
);
|
||||
}
|
||||
|
||||
static Event fromJson(
|
||||
/// 从 JSON 恢复事件。
|
||||
factory Event.fromJson(
|
||||
Map<String, dynamic> json, {
|
||||
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<String, dynamic>? eventParams;
|
||||
|
||||
/// 自定义标签。
|
||||
final Map<String, dynamic>? customTags;
|
||||
|
||||
/// 事件创建时间。
|
||||
final DateTime createTime;
|
||||
|
||||
/// 当前重试次数。
|
||||
final int retryCount;
|
||||
|
||||
/// 复制事件并覆盖部分字段。
|
||||
Event copyWith({
|
||||
UserInfo? userInfo,
|
||||
DeviceInfo? deviceInfo,
|
||||
Map<String, dynamic>? eventParams,
|
||||
Map<String, dynamic>? customTags,
|
||||
DateTime? createTime,
|
||||
int? retryCount,
|
||||
}) =>
|
||||
Event(
|
||||
systemCode: systemCode,
|
||||
eventType: eventType,
|
||||
userInfo: userInfo ?? this.userInfo,
|
||||
clientType: clientType,
|
||||
clientTimestamp: clientTimestamp,
|
||||
timestamp: timestamp,
|
||||
deviceInfo: deviceInfo ?? this.deviceInfo,
|
||||
eventParams: eventParams ?? this.eventParams,
|
||||
customTags: customTags ?? this.customTags,
|
||||
createTime: createTime ?? this.createTime,
|
||||
retryCount: retryCount ?? this.retryCount,
|
||||
);
|
||||
|
||||
/// 转换为可序列化的 JSON。
|
||||
Map<String, dynamic> toJson() => <String, dynamic>{
|
||||
'system_code': systemCode,
|
||||
'eventType': eventType,
|
||||
'userInfo': userInfo?.toJson(),
|
||||
'clientType': clientType,
|
||||
'clientTimestamp': clientTimestamp,
|
||||
'timestamp': timestamp,
|
||||
'deviceInfo': deviceInfo.toJson(),
|
||||
'eventParams': eventParams,
|
||||
'customTags': customTags,
|
||||
};
|
||||
|
||||
/// 转换为 payload 字符串。
|
||||
String toPayload() => jsonEncode(toJson());
|
||||
|
||||
static int? _toInt(dynamic value) {
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,6 @@
|
|||
/// Phase 2:后端下发的维度配置模型。
|
||||
class SystemDimInfo {
|
||||
final SystemInfo? systemInfo;
|
||||
final List<EventDefinition> eventDefinitions;
|
||||
final List<TagDefinition> 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<TagDefinition> get requiredTags {
|
||||
return tagDefinitions.where((t) => t.isRequired).toList(growable: false);
|
||||
}
|
||||
|
||||
bool get isStrategyEnabled => sdkStrategy?.enabled ?? true;
|
||||
|
||||
EventStrategy? strategyFor(String eventType) {
|
||||
final strategy = sdkStrategy;
|
||||
if (strategy == null) {
|
||||
return null;
|
||||
}
|
||||
return strategy.eventSettings[eventType];
|
||||
}
|
||||
|
||||
double sampleRateFor(String eventType) {
|
||||
final strategy = sdkStrategy;
|
||||
if (strategy == null) {
|
||||
return 1.0;
|
||||
}
|
||||
final eventStrategy = strategy.eventSettings[eventType];
|
||||
return eventStrategy?.sampleRate ?? strategy.defaultSampleRate;
|
||||
}
|
||||
|
||||
bool isEventEnabledByStrategy(String eventType) {
|
||||
final strategy = sdkStrategy;
|
||||
if (strategy == null) {
|
||||
return true;
|
||||
}
|
||||
if (!strategy.enabled) {
|
||||
return false;
|
||||
}
|
||||
final eventStrategy = strategy.eventSettings[eventType];
|
||||
return eventStrategy?.enabled ?? true;
|
||||
}
|
||||
|
||||
Map<String, dynamic> toCacheJson() {
|
||||
return <String, dynamic>{
|
||||
'systemInfo': systemInfo?.toJson(),
|
||||
'eventDefinitions': eventDefinitions.map((e) => e.toJson()).toList(),
|
||||
'tagDefinitions': tagDefinitions.map((t) => t.toJson()).toList(),
|
||||
'sdkStrategy': sdkStrategy?.toJson(),
|
||||
'lastFetchedAt': lastFetchedAt.millisecondsSinceEpoch,
|
||||
'version': version,
|
||||
};
|
||||
}
|
||||
|
||||
static SystemDimInfo fromCacheJson(Map<String, dynamic> json) {
|
||||
/// 从缓存 JSON 构造配置。
|
||||
factory SystemDimInfo.fromCacheJson(Map<String, dynamic> 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<String, dynamic> json, {
|
||||
DateTime? fetchedAt,
|
||||
String? version,
|
||||
|
|
@ -114,6 +59,80 @@ class SystemDimInfo {
|
|||
);
|
||||
}
|
||||
|
||||
/// 系统信息。
|
||||
final SystemInfo? systemInfo;
|
||||
|
||||
/// 事件定义列表。
|
||||
final List<EventDefinition> eventDefinitions;
|
||||
|
||||
/// 标签定义列表。
|
||||
final List<TagDefinition> tagDefinitions;
|
||||
|
||||
/// SDK 策略配置。
|
||||
final SdkStrategy? sdkStrategy;
|
||||
|
||||
/// 最近一次拉取时间。
|
||||
final DateTime lastFetchedAt;
|
||||
|
||||
/// 配置版本号(如果可用)。
|
||||
final String? version;
|
||||
|
||||
/// 是否包含指定事件类型。
|
||||
bool hasEvent(String eventType) =>
|
||||
eventDefinitions.any((e) => e.eventCode == eventType);
|
||||
|
||||
/// 必填标签列表。
|
||||
List<TagDefinition> get requiredTags =>
|
||||
tagDefinitions.where((t) => t.isRequired).toList(growable: false);
|
||||
|
||||
/// 是否启用策略控制。
|
||||
bool get isStrategyEnabled => sdkStrategy?.enabled ?? true;
|
||||
|
||||
/// 获取指定事件的策略配置。
|
||||
EventStrategy? strategyFor(String eventType) {
|
||||
final strategy = sdkStrategy;
|
||||
if (strategy == null) {
|
||||
return null;
|
||||
}
|
||||
return strategy.eventSettings[eventType];
|
||||
}
|
||||
|
||||
/// 获取指定事件的采样率。
|
||||
double sampleRateFor(String eventType) {
|
||||
final strategy = sdkStrategy;
|
||||
if (strategy == null) {
|
||||
return 1;
|
||||
}
|
||||
final eventStrategy = strategy.eventSettings[eventType];
|
||||
return eventStrategy?.sampleRate ?? strategy.defaultSampleRate;
|
||||
}
|
||||
|
||||
/// 判断指定事件是否被策略启用。
|
||||
bool isEventEnabledByStrategy(String eventType) {
|
||||
final strategy = sdkStrategy;
|
||||
if (strategy == null) {
|
||||
return true;
|
||||
}
|
||||
if (!strategy.enabled) {
|
||||
return false;
|
||||
}
|
||||
final eventStrategy = strategy.eventSettings[eventType];
|
||||
return eventStrategy?.enabled ?? true;
|
||||
}
|
||||
|
||||
/// 转换为缓存 JSON。
|
||||
Map<String, dynamic> toCacheJson() => <String, dynamic>{
|
||||
'systemInfo': systemInfo?.toJson(),
|
||||
'eventDefinitions':
|
||||
eventDefinitions.map((e) => e.toJson()).toList(growable: false),
|
||||
'tagDefinitions':
|
||||
tagDefinitions.map((t) => t.toJson()).toList(growable: false),
|
||||
'sdkStrategy': sdkStrategy?.toJson(),
|
||||
'lastFetchedAt': lastFetchedAt.millisecondsSinceEpoch,
|
||||
'version': version,
|
||||
};
|
||||
|
||||
/// 解析事件定义列表。
|
||||
static List<EventDefinition> _parseEventDefinitions(dynamic value) {
|
||||
if (value is! List) {
|
||||
return const <EventDefinition>[];
|
||||
|
|
@ -150,6 +169,7 @@ class SystemDimInfo {
|
|||
return results;
|
||||
}
|
||||
|
||||
/// 解析标签定义列表。
|
||||
static List<TagDefinition> _parseTagDefinitions(dynamic value) {
|
||||
if (value is! List) {
|
||||
return const <TagDefinition>[];
|
||||
|
|
@ -172,7 +192,8 @@ class SystemDimInfo {
|
|||
const <String>['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 <String>['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 = <String, EventStrategy>{};
|
||||
|
|
@ -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<String, dynamic> map, List<String> 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<String, dynamic> raw;
|
||||
|
||||
/// 创建系统信息对象。
|
||||
const SystemInfo({required this.raw});
|
||||
|
||||
Map<String, dynamic> toJson() => raw;
|
||||
/// 从 JSON 构造系统信息。
|
||||
factory SystemInfo.fromJson(Map<String, dynamic> json) =>
|
||||
SystemInfo(raw: json);
|
||||
|
||||
static SystemInfo fromJson(Map<String, dynamic> json) {
|
||||
return SystemInfo(raw: json);
|
||||
}
|
||||
/// 原始字段。
|
||||
final Map<String, dynamic> raw;
|
||||
|
||||
/// 转换为 JSON。
|
||||
Map<String, dynamic> toJson() => raw;
|
||||
}
|
||||
|
||||
/// 事件定义。
|
||||
class EventDefinition {
|
||||
final String eventCode;
|
||||
final String? eventName;
|
||||
final String? description;
|
||||
|
||||
/// 创建事件定义。
|
||||
const EventDefinition({
|
||||
required this.eventCode,
|
||||
this.eventName,
|
||||
this.description,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'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<String, dynamic> toJson() => <String, dynamic>{
|
||||
'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<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'tagName': tagName,
|
||||
'tagType': tagType,
|
||||
'isRequired': isRequired,
|
||||
'description': description,
|
||||
};
|
||||
}
|
||||
/// 标签名称。
|
||||
final String tagName;
|
||||
|
||||
/// 标签类型。
|
||||
final String tagType;
|
||||
|
||||
/// 是否必填。
|
||||
final bool isRequired;
|
||||
|
||||
/// 标签描述。
|
||||
final String? description;
|
||||
|
||||
/// 转换为 JSON。
|
||||
Map<String, dynamic> toJson() => <String, dynamic>{
|
||||
'tagName': tagName,
|
||||
'tagType': tagType,
|
||||
'isRequired': isRequired,
|
||||
'description': description,
|
||||
};
|
||||
}
|
||||
|
||||
/// Phase 3:SDK 动态策略。
|
||||
class SdkStrategy {
|
||||
final bool enabled;
|
||||
final double defaultSampleRate;
|
||||
final Map<String, EventStrategy> eventSettings;
|
||||
|
||||
/// 创建 SDK 策略。
|
||||
const SdkStrategy({
|
||||
required this.enabled,
|
||||
required this.defaultSampleRate,
|
||||
required this.eventSettings,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'enabled': enabled,
|
||||
'defaultSampleRate': defaultSampleRate,
|
||||
'eventSettings': eventSettings.map(
|
||||
(key, value) => MapEntry(key, value.toJson()),
|
||||
),
|
||||
};
|
||||
}
|
||||
/// 是否启用策略。
|
||||
final bool enabled;
|
||||
|
||||
/// 默认采样率。
|
||||
final double defaultSampleRate;
|
||||
|
||||
/// 事件级别策略。
|
||||
final Map<String, EventStrategy> eventSettings;
|
||||
|
||||
/// 转换为 JSON。
|
||||
Map<String, dynamic> toJson() => <String, dynamic>{
|
||||
'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<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'enabled': enabled,
|
||||
'sampleRate': sampleRate,
|
||||
};
|
||||
}
|
||||
/// 是否启用。
|
||||
final bool enabled;
|
||||
|
||||
/// 采样率。
|
||||
final double sampleRate;
|
||||
|
||||
/// 转换为 JSON。
|
||||
Map<String, dynamic> toJson() => <String, dynamic>{
|
||||
'enabled': enabled,
|
||||
'sampleRate': sampleRate,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,25 @@
|
|||
/// 用户信息。
|
||||
class UserInfo {
|
||||
final int? userId;
|
||||
final String? userName;
|
||||
final String? account;
|
||||
|
||||
/// 创建用户信息实例。
|
||||
const UserInfo({
|
||||
this.userId,
|
||||
this.userName,
|
||||
this.account,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final map = <String, dynamic>{
|
||||
'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<String, dynamic> toJson() => <String, dynamic>{
|
||||
'userId': userId,
|
||||
'userName': userName,
|
||||
'account': account,
|
||||
}..removeWhere((_, value) => value == null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<Response<T>> get<T>(
|
||||
String path, {
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Map<String, Object?>? headers,
|
||||
CancelToken? cancelToken,
|
||||
}) {
|
||||
return _dio.get<T>(
|
||||
path,
|
||||
queryParameters: queryParameters,
|
||||
options: _withHeaders(headers),
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
}
|
||||
}) =>
|
||||
_dio.get<T>(
|
||||
path,
|
||||
queryParameters: queryParameters,
|
||||
options: _withHeaders(headers),
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
|
||||
/// 发送 POST 请求。
|
||||
Future<Response<T>> post<T>(
|
||||
String path, {
|
||||
Object? data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Map<String, Object?>? headers,
|
||||
CancelToken? cancelToken,
|
||||
}) {
|
||||
return _dio.post<T>(
|
||||
path,
|
||||
data: data,
|
||||
queryParameters: queryParameters,
|
||||
options: _withHeaders(headers),
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
}
|
||||
}) =>
|
||||
_dio.post<T>(
|
||||
path,
|
||||
data: data,
|
||||
queryParameters: queryParameters,
|
||||
options: _withHeaders(headers),
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
|
||||
Options? _withHeaders(Map<String, Object?>? headers) {
|
||||
if (headers == null || headers.isEmpty) {
|
||||
|
|
|
|||
|
|
@ -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<void> init();
|
||||
|
||||
/// 保存系统维表配置。
|
||||
Future<void> saveSystemDimInfo(SystemDimInfo info);
|
||||
|
||||
/// 读取系统维表配置。
|
||||
Future<SystemDimInfo?> loadSystemDimInfo();
|
||||
|
||||
/// 清空缓存。
|
||||
Future<void> clear();
|
||||
|
||||
/// 释放资源。
|
||||
Future<void> dispose();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import '../model/event.dart';
|
||||
import 'package:yx_tracking_flutter/src/model/event.dart';
|
||||
|
||||
/// 事件存储抽象。
|
||||
abstract class EventStorage {
|
||||
/// 初始化存储。
|
||||
Future<void> init();
|
||||
|
||||
/// 插入事件,返回本地自增 ID。
|
||||
|
|
@ -13,13 +14,18 @@ abstract class EventStorage {
|
|||
/// 获取最近的事件(按 create_time 降序)。
|
||||
Future<List<StoredEvent>> fetchRecent(int limit);
|
||||
|
||||
/// 按 ID 删除事件。
|
||||
Future<void> deleteByIds(List<int> ids);
|
||||
|
||||
/// 获取当前缓存数量。
|
||||
Future<int> count();
|
||||
|
||||
/// 控制本地缓存上限(删除最旧事件)。
|
||||
Future<int> trimToMaxSize(int maxSize);
|
||||
|
||||
/// 删除超过最大保存时间的事件,返回删除数量。
|
||||
Future<int> deleteExpired(DateTime cutoff);
|
||||
|
||||
/// 更新单条事件的重试次数。
|
||||
Future<void> updateRetryCount(int id, int retryCount);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,494 @@
|
|||
import 'dart:async';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:yx_tracking_flutter/src/model/event.dart';
|
||||
import 'package:yx_tracking_flutter/src/storage/event_storage.dart';
|
||||
import 'package:yx_tracking_flutter/src/storage/sqflite_event_storage.dart';
|
||||
import 'package:yx_tracking_flutter/src/util/logger.dart';
|
||||
|
||||
/// Isolate 存储后端类型。
|
||||
enum IsolateStorageBackend {
|
||||
/// 默认 SQLite 存储。
|
||||
sqlite,
|
||||
|
||||
/// 内存存储(主要用于测试)。
|
||||
memory,
|
||||
}
|
||||
|
||||
/// 使用后台 Isolate 执行存储操作,降低主 isolate 阻塞风险。
|
||||
class IsolateEventStorage implements EventStorage {
|
||||
/// 创建一个 Isolate 存储实例。
|
||||
IsolateEventStorage({this.backend = IsolateStorageBackend.sqlite});
|
||||
|
||||
/// 存储后端类型。
|
||||
final IsolateStorageBackend backend;
|
||||
|
||||
Isolate? _isolate;
|
||||
ReceivePort? _responsePort;
|
||||
SendPort? _commandPort;
|
||||
StreamSubscription<dynamic>? _responseSub;
|
||||
final Map<int, Completer<dynamic>> _pending = <int, Completer<dynamic>>{};
|
||||
int _seq = 0;
|
||||
bool _initialized = false;
|
||||
|
||||
@override
|
||||
Future<void> init() async {
|
||||
if (_initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
final responsePort = ReceivePort();
|
||||
_responsePort = responsePort;
|
||||
|
||||
final ready = Completer<SendPort>();
|
||||
_responseSub = responsePort.listen((message) {
|
||||
if (message is Map && message['type'] == _msgReady) {
|
||||
final port = message['sendPort'];
|
||||
if (port is SendPort && !ready.isCompleted) {
|
||||
_commandPort = port;
|
||||
ready.complete(port);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message is Map && message['id'] is int) {
|
||||
final id = message['id'] as int;
|
||||
final completer = _pending.remove(id);
|
||||
if (completer == null) {
|
||||
return;
|
||||
}
|
||||
final ok = message['ok'] == true;
|
||||
if (ok) {
|
||||
completer.complete(message['result']);
|
||||
} else {
|
||||
completer.completeError(
|
||||
StateError((message['error'] ?? 'unknown error').toString()),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
final rootToken = RootIsolateToken.instance;
|
||||
_isolate = await Isolate.spawn<_WorkerInit>(
|
||||
_storageWorkerEntry,
|
||||
_WorkerInit(
|
||||
responsePort: responsePort.sendPort,
|
||||
rootToken: rootToken,
|
||||
backend: backend,
|
||||
),
|
||||
debugName: 'yx_tracking_storage',
|
||||
);
|
||||
|
||||
await ready.future;
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
void _ensureReady() {
|
||||
if (!_initialized || _commandPort == null) {
|
||||
throw StateError('IsolateEventStorage 尚未初始化');
|
||||
}
|
||||
}
|
||||
|
||||
Future<T> _call<T>(String type, Map<String, dynamic>? payload) async {
|
||||
_ensureReady();
|
||||
final id = ++_seq;
|
||||
final completer = Completer<dynamic>();
|
||||
_pending[id] = completer;
|
||||
_commandPort!.send(<String, Object?>{
|
||||
'id': id,
|
||||
'type': type,
|
||||
'payload': payload,
|
||||
});
|
||||
final result = await completer.future;
|
||||
return result as T;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> insert(Event event) {
|
||||
return _call<int>(_msgInsert, <String, dynamic>{
|
||||
'payload': event.toPayload(),
|
||||
'createTimeMs': event.createTime.millisecondsSinceEpoch,
|
||||
'retryCount': event.retryCount,
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<StoredEvent>> fetchBatch(int limit) async {
|
||||
if (limit <= 0) {
|
||||
return const <StoredEvent>[];
|
||||
}
|
||||
final rows = await _call<List<dynamic>>(
|
||||
_msgFetchBatch,
|
||||
<String, dynamic>{'limit': limit},
|
||||
);
|
||||
return _decodeStoredEvents(rows);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<StoredEvent>> fetchRecent(int limit) async {
|
||||
if (limit <= 0) {
|
||||
return const <StoredEvent>[];
|
||||
}
|
||||
final rows = await _call<List<dynamic>>(
|
||||
_msgFetchRecent,
|
||||
<String, dynamic>{'limit': limit},
|
||||
);
|
||||
return _decodeStoredEvents(rows);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteByIds(List<int> ids) async {
|
||||
if (ids.isEmpty) {
|
||||
return;
|
||||
}
|
||||
await _call<void>(_msgDeleteByIds, <String, dynamic>{'ids': ids});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> count() => _call<int>(_msgCount, null);
|
||||
|
||||
@override
|
||||
Future<int> trimToMaxSize(int maxSize) async {
|
||||
if (maxSize <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return _call<int>(_msgTrimToMaxSize, <String, dynamic>{'maxSize': maxSize});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> deleteExpired(DateTime cutoff) {
|
||||
return _call<int>(
|
||||
_msgDeleteExpired,
|
||||
<String, dynamic>{'cutoffMs': cutoff.millisecondsSinceEpoch},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateRetryCount(int id, int retryCount) async {
|
||||
await _call<void>(
|
||||
_msgUpdateRetry,
|
||||
<String, dynamic>{'id': id, 'retryCount': retryCount},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
if (!_initialized) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await _call<void>(_msgDispose, null);
|
||||
} on Object catch (e, st) {
|
||||
Logger.error('IsolateEventStorage dispose 失败,强制退出', e, st);
|
||||
} finally {
|
||||
await _responseSub?.cancel();
|
||||
_responseSub = null;
|
||||
_responsePort?.close();
|
||||
_responsePort = null;
|
||||
_pending.clear();
|
||||
_commandPort = null;
|
||||
_isolate?.kill(priority: Isolate.immediate);
|
||||
_isolate = null;
|
||||
_initialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
List<StoredEvent> _decodeStoredEvents(List<dynamic> rows) {
|
||||
final results = <StoredEvent>[];
|
||||
for (final row in rows) {
|
||||
if (row is! Map) {
|
||||
continue;
|
||||
}
|
||||
final id = row['id'];
|
||||
final payload = row['payload'];
|
||||
final retryCount = row['retryCount'];
|
||||
final createTimeMs = row['createTimeMs'];
|
||||
if (id is! int || payload is! String || createTimeMs is! int) {
|
||||
continue;
|
||||
}
|
||||
final retry = retryCount is int ? retryCount : 0;
|
||||
final event = Event.fromPayload(
|
||||
payload,
|
||||
createTime: DateTime.fromMillisecondsSinceEpoch(createTimeMs),
|
||||
retryCount: retry,
|
||||
);
|
||||
results.add(
|
||||
StoredEvent(
|
||||
id: id,
|
||||
event: event,
|
||||
retryCount: retry,
|
||||
createTime: event.createTime,
|
||||
),
|
||||
);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
class _WorkerInit {
|
||||
const _WorkerInit({
|
||||
required this.responsePort,
|
||||
required this.rootToken,
|
||||
required this.backend,
|
||||
});
|
||||
|
||||
final SendPort responsePort;
|
||||
final RootIsolateToken? rootToken;
|
||||
final IsolateStorageBackend backend;
|
||||
}
|
||||
|
||||
const String _msgReady = 'ready';
|
||||
const String _msgInsert = 'insert';
|
||||
const String _msgFetchBatch = 'fetchBatch';
|
||||
const String _msgFetchRecent = 'fetchRecent';
|
||||
const String _msgDeleteByIds = 'deleteByIds';
|
||||
const String _msgCount = 'count';
|
||||
const String _msgTrimToMaxSize = 'trimToMaxSize';
|
||||
const String _msgDeleteExpired = 'deleteExpired';
|
||||
const String _msgUpdateRetry = 'updateRetryCount';
|
||||
const String _msgDispose = 'dispose';
|
||||
|
||||
Future<void> _storageWorkerEntry(_WorkerInit init) async {
|
||||
final rootToken = init.rootToken;
|
||||
if (rootToken != null) {
|
||||
BackgroundIsolateBinaryMessenger.ensureInitialized(rootToken);
|
||||
}
|
||||
|
||||
final commandPort = ReceivePort();
|
||||
init.responsePort.send(<String, Object?>{
|
||||
'type': _msgReady,
|
||||
'sendPort': commandPort.sendPort,
|
||||
});
|
||||
|
||||
final storage = switch (init.backend) {
|
||||
IsolateStorageBackend.sqlite => SqfliteEventStorage(),
|
||||
IsolateStorageBackend.memory => _MemoryEventStorage(),
|
||||
};
|
||||
await storage.init();
|
||||
|
||||
await for (final message in commandPort) {
|
||||
if (message is! Map) {
|
||||
continue;
|
||||
}
|
||||
final id = message['id'];
|
||||
final type = message['type'];
|
||||
if (id is! int || type is! String) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
final payload = message['payload'] as Map?;
|
||||
switch (type) {
|
||||
case _msgInsert:
|
||||
final eventPayload = payload?['payload'];
|
||||
final createTimeMs = payload?['createTimeMs'];
|
||||
final retryCount = payload?['retryCount'];
|
||||
if (eventPayload is! String || createTimeMs is! int) {
|
||||
throw const FormatException('insert payload invalid');
|
||||
}
|
||||
final retry = retryCount is int ? retryCount : 0;
|
||||
final event = Event.fromPayload(
|
||||
eventPayload,
|
||||
createTime: DateTime.fromMillisecondsSinceEpoch(createTimeMs),
|
||||
retryCount: retry,
|
||||
);
|
||||
final inserted = await storage.insert(event);
|
||||
init.responsePort.send(<String, Object?>{
|
||||
'id': id,
|
||||
'ok': true,
|
||||
'result': inserted,
|
||||
});
|
||||
case _msgFetchBatch:
|
||||
final limit = payload?['limit'];
|
||||
if (limit is! int) {
|
||||
throw const FormatException('fetchBatch payload invalid');
|
||||
}
|
||||
final batch = await storage.fetchBatch(limit);
|
||||
init.responsePort.send(<String, Object?>{
|
||||
'id': id,
|
||||
'ok': true,
|
||||
'result': _encodeStoredEvents(batch),
|
||||
});
|
||||
case _msgFetchRecent:
|
||||
final limit = payload?['limit'];
|
||||
if (limit is! int) {
|
||||
throw const FormatException('fetchRecent payload invalid');
|
||||
}
|
||||
final recent = await storage.fetchRecent(limit);
|
||||
init.responsePort.send(<String, Object?>{
|
||||
'id': id,
|
||||
'ok': true,
|
||||
'result': _encodeStoredEvents(recent),
|
||||
});
|
||||
case _msgDeleteByIds:
|
||||
final ids = payload?['ids'];
|
||||
if (ids is! List) {
|
||||
throw const FormatException('deleteByIds payload invalid');
|
||||
}
|
||||
await storage.deleteByIds(ids.cast<int>());
|
||||
init.responsePort.send(<String, Object?>{
|
||||
'result': null,
|
||||
});
|
||||
case _msgCount:
|
||||
final total = await storage.count();
|
||||
init.responsePort.send(<String, Object?>{
|
||||
'id': id,
|
||||
'ok': true,
|
||||
'result': total,
|
||||
});
|
||||
case _msgTrimToMaxSize:
|
||||
final maxSize = payload?['maxSize'];
|
||||
if (maxSize is! int) {
|
||||
throw const FormatException('trimToMaxSize payload invalid');
|
||||
}
|
||||
final trimmed = await storage.trimToMaxSize(maxSize);
|
||||
init.responsePort.send(<String, Object?>{
|
||||
'id': id,
|
||||
'ok': true,
|
||||
'result': trimmed,
|
||||
});
|
||||
case _msgDeleteExpired:
|
||||
final cutoffMs = payload?['cutoffMs'];
|
||||
if (cutoffMs is! int) {
|
||||
throw const FormatException('deleteExpired payload invalid');
|
||||
}
|
||||
final removed = await storage.deleteExpired(
|
||||
DateTime.fromMillisecondsSinceEpoch(cutoffMs),
|
||||
);
|
||||
init.responsePort.send(<String, Object?>{
|
||||
'id': id,
|
||||
'ok': true,
|
||||
'result': removed,
|
||||
});
|
||||
case _msgUpdateRetry:
|
||||
final idValue = payload?['id'];
|
||||
final retry = payload?['retryCount'];
|
||||
if (idValue is! int || retry is! int) {
|
||||
throw const FormatException('updateRetryCount payload invalid');
|
||||
}
|
||||
await storage.updateRetryCount(idValue, retry);
|
||||
init.responsePort.send(<String, Object?>{
|
||||
'result': null,
|
||||
});
|
||||
case _msgDispose:
|
||||
await storage.dispose();
|
||||
init.responsePort.send(<String, Object?>{
|
||||
'id': id,
|
||||
'ok': true,
|
||||
'result': null,
|
||||
});
|
||||
commandPort.close();
|
||||
return;
|
||||
default:
|
||||
throw UnsupportedError('unknown message: $type');
|
||||
}
|
||||
} on Object catch (e) {
|
||||
init.responsePort.send(<String, Object?>{
|
||||
'id': id,
|
||||
'ok': false,
|
||||
'error': e.toString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, Object?>> _encodeStoredEvents(List<StoredEvent> events) {
|
||||
return events
|
||||
.map(
|
||||
(stored) => <String, Object?>{
|
||||
'id': stored.id,
|
||||
'payload': stored.event.toPayload(),
|
||||
'retryCount': stored.retryCount,
|
||||
'createTimeMs': stored.createTime.millisecondsSinceEpoch,
|
||||
},
|
||||
)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
class _MemoryEventStorage implements EventStorage {
|
||||
int _nextId = 1;
|
||||
final List<StoredEvent> _items = <StoredEvent>[];
|
||||
|
||||
@override
|
||||
Future<void> init() async {}
|
||||
|
||||
@override
|
||||
Future<int> insert(Event event) async {
|
||||
final stored = StoredEvent(
|
||||
id: _nextId++,
|
||||
event: event,
|
||||
retryCount: event.retryCount,
|
||||
createTime: event.createTime,
|
||||
);
|
||||
_items.add(stored);
|
||||
return stored.id;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<StoredEvent>> fetchBatch(int limit) async {
|
||||
if (limit <= 0) {
|
||||
return const <StoredEvent>[];
|
||||
}
|
||||
final copy = List<StoredEvent>.from(_items)
|
||||
..sort((a, b) => a.createTime.compareTo(b.createTime));
|
||||
return copy.take(limit).toList(growable: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<StoredEvent>> fetchRecent(int limit) async {
|
||||
if (limit <= 0) {
|
||||
return const <StoredEvent>[];
|
||||
}
|
||||
final copy = List<StoredEvent>.from(_items)
|
||||
..sort((a, b) => b.createTime.compareTo(a.createTime));
|
||||
return copy.take(limit).toList(growable: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteByIds(List<int> ids) async {
|
||||
if (ids.isEmpty) {
|
||||
return;
|
||||
}
|
||||
_items.removeWhere((event) => ids.contains(event.id));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> count() async => _items.length;
|
||||
|
||||
@override
|
||||
Future<int> trimToMaxSize(int maxSize) async {
|
||||
if (maxSize <= 0 || _items.length <= maxSize) {
|
||||
return 0;
|
||||
}
|
||||
final overflow = _items.length - maxSize;
|
||||
_items
|
||||
..sort((a, b) => a.createTime.compareTo(b.createTime))
|
||||
..removeRange(0, overflow);
|
||||
return overflow;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> deleteExpired(DateTime cutoff) async {
|
||||
final before = _items.length;
|
||||
_items.removeWhere((event) => !event.createTime.isAfter(cutoff));
|
||||
return before - _items.length;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateRetryCount(int id, int retryCount) async {
|
||||
final index = _items.indexWhere((e) => e.id == id);
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
final current = _items[index];
|
||||
_items[index] = current.copyWith(
|
||||
retryCount: retryCount,
|
||||
event: current.event.copyWith(retryCount: retryCount),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
_items.clear();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Directory> Function()? documentsDirectoryProvider,
|
||||
}) : _databaseFactory = databaseFactory,
|
||||
_documentsDirectoryProvider = documentsDirectoryProvider;
|
||||
|
||||
static const String _tableConfigCache = 'config_cache';
|
||||
static const String _systemDimInfoKey = 'system_dim_info';
|
||||
|
||||
final DatabaseFactory? _databaseFactory;
|
||||
final Future<Directory> Function()? _documentsDirectoryProvider;
|
||||
|
||||
Database? _db;
|
||||
|
||||
/// 仅测试使用:暴露底层数据库实例。
|
||||
@visibleForTesting
|
||||
Database? get debugDb => _db;
|
||||
|
||||
@override
|
||||
Future<void> 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<void> _deleteCachedConfig(Database db) async {
|
||||
await db.delete(
|
||||
_tableConfigCache,
|
||||
where: 'key = ?',
|
||||
whereArgs: const <Object>[_systemDimInfoKey],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clear() async {
|
||||
final db = _requireDb();
|
||||
|
|
|
|||
|
|
@ -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<Directory> Function()? documentsDirectoryProvider,
|
||||
}) : _databaseFactory = databaseFactory,
|
||||
_documentsDirectoryProvider = documentsDirectoryProvider;
|
||||
|
||||
static const String _tableEvents = 'events';
|
||||
|
||||
final DatabaseFactory? _databaseFactory;
|
||||
final Future<Directory> Function()? _documentsDirectoryProvider;
|
||||
|
||||
Database? _db;
|
||||
|
||||
/// 仅测试使用:暴露底层数据库实例。
|
||||
@visibleForTesting
|
||||
Database? get debugDb => _db;
|
||||
|
||||
@override
|
||||
Future<void> 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<int> deleteExpired(DateTime cutoff) async {
|
||||
final db = _requireDb();
|
||||
final cutoffMs = cutoff.millisecondsSinceEpoch;
|
||||
final deleted = await db.delete(
|
||||
_tableEvents,
|
||||
where: 'create_time <= ?',
|
||||
whereArgs: <Object>[cutoffMs],
|
||||
);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> 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<StoredEvent> events;
|
||||
final List<int> invalidIds;
|
||||
|
||||
const _ParsedRows({
|
||||
required this.events,
|
||||
required this.invalidIds,
|
||||
});
|
||||
|
||||
final List<StoredEvent> events;
|
||||
final List<int> invalidIds;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<DeviceInfo> collectDeviceInfo() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
|
|
@ -23,22 +28,47 @@ class DeviceUtil {
|
|||
);
|
||||
}
|
||||
|
||||
static String _screenResolution() {
|
||||
@visibleForTesting
|
||||
/// 测试钩子:通过注入 view 列表计算分辨率。
|
||||
static String screenResolutionForTesting({
|
||||
Iterable<FlutterView> Function()? viewsProvider,
|
||||
}) {
|
||||
return _screenResolution(viewsProvider: viewsProvider);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
/// 测试钩子:通过 size 计算分辨率字符串。
|
||||
static String resolutionFromSizeForTesting(Size size) {
|
||||
return _resolutionFromSize(size);
|
||||
}
|
||||
|
||||
static String _screenResolution({
|
||||
Iterable<FlutterView> Function()? viewsProvider,
|
||||
}) {
|
||||
try {
|
||||
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<FlutterView> _defaultViewsProvider() =>
|
||||
WidgetsBinding.instance.platformDispatcher.views;
|
||||
|
||||
static String _resolutionFromSize(Size size) {
|
||||
final width = size.width.round();
|
||||
final height = size.height.round();
|
||||
if (width <= 0 || height <= 0) {
|
||||
return 'unknown';
|
||||
}
|
||||
return '${width}x$height';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
/// 时间工具。
|
||||
class TimeUtil {
|
||||
const TimeUtil._();
|
||||
/// 私有构造,避免外部实例化。
|
||||
TimeUtil._();
|
||||
|
||||
/// 获取一个实例(用于满足 lint 对构造器的要求)。
|
||||
factory TimeUtil.instance() => TimeUtil._();
|
||||
|
||||
/// 当前时间毫秒时间戳。
|
||||
static int nowMs() => DateTime.now().millisecondsSinceEpoch;
|
||||
|
|
|
|||
|
|
@ -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<void> init(AnalyticsConfig config) => _core.init(config);
|
||||
|
||||
/// 记录事件。
|
||||
static Future<void> track(
|
||||
String eventType, {
|
||||
Map<String, dynamic>? eventParams,
|
||||
|
|
@ -36,18 +55,23 @@ class Analytics {
|
|||
);
|
||||
}
|
||||
|
||||
/// 设置用户信息。
|
||||
static Future<void> setUser(UserInfo? userInfo) => _core.setUser(userInfo);
|
||||
|
||||
/// 覆盖设备信息。
|
||||
static Future<void> setDeviceInfo(DeviceInfo deviceInfo) =>
|
||||
_core.setDeviceInfo(deviceInfo);
|
||||
|
||||
/// 触发一次上报。
|
||||
static Future<void> flush({bool force = false}) => _core.flush(force: force);
|
||||
|
||||
/// 当前本地缓存事件数量(用于调试/演示)。
|
||||
static Future<int> cachedEventCount() => _core.cachedEventCount();
|
||||
|
||||
/// 最近事件摘要(用于调试面板)。
|
||||
static Future<List<RecentEventSummary>> cachedRecentEvents({int limit = 20}) =>
|
||||
static Future<List<RecentEventSummary>> cachedRecentEvents({
|
||||
int limit = 20,
|
||||
}) =>
|
||||
_core.cachedRecentEvents(limit: limit);
|
||||
|
||||
/// 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<void> dispose() => _core.dispose();
|
||||
static Future<void> dispose() async {
|
||||
unbindLifecycleObserver();
|
||||
await _core.dispose();
|
||||
}
|
||||
|
||||
/// 可选:绑定应用生命周期监听,进入后台时自动 flush。
|
||||
static void bindLifecycleObserver({
|
||||
bool flushOnBackground = true,
|
||||
bool flushOnDetached = true,
|
||||
}) {
|
||||
if (_lifecycleObserver != null) {
|
||||
return;
|
||||
}
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
final observer = _AnalyticsLifecycleObserver(
|
||||
flushOnBackground: flushOnBackground,
|
||||
flushOnDetached: flushOnDetached,
|
||||
onFlush: () {
|
||||
unawaited(_core.flush(force: true));
|
||||
},
|
||||
);
|
||||
_lifecycleObserver = observer;
|
||||
WidgetsBinding.instance.addObserver(observer);
|
||||
}
|
||||
|
||||
/// 解绑生命周期监听,避免重复注册或泄漏。
|
||||
static void unbindLifecycleObserver() {
|
||||
final observer = _lifecycleObserver;
|
||||
if (observer == null) {
|
||||
return;
|
||||
}
|
||||
WidgetsBinding.instance.removeObserver(observer);
|
||||
_lifecycleObserver = null;
|
||||
}
|
||||
}
|
||||
|
||||
class _AnalyticsLifecycleObserver extends WidgetsBindingObserver {
|
||||
_AnalyticsLifecycleObserver({
|
||||
required this.flushOnBackground,
|
||||
required this.flushOnDetached,
|
||||
required this.onFlush,
|
||||
});
|
||||
|
||||
final bool flushOnBackground;
|
||||
final bool flushOnDetached;
|
||||
final VoidCallback onFlush;
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
switch (state) {
|
||||
case AppLifecycleState.inactive:
|
||||
case AppLifecycleState.paused:
|
||||
case AppLifecycleState.hidden:
|
||||
if (flushOnBackground) {
|
||||
onFlush();
|
||||
}
|
||||
case AppLifecycleState.detached:
|
||||
if (flushOnDetached) {
|
||||
onFlush();
|
||||
}
|
||||
case AppLifecycleState.resumed:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
12
pubspec.yaml
12
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:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,129 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:yx_tracking_flutter/src/config/analytics_config.dart';
|
||||
|
||||
AnalyticsConfig _base({
|
||||
String? systemCode,
|
||||
String? endpointBaseUrl,
|
||||
int? clientType,
|
||||
int? batchSize,
|
||||
int? flushInterval,
|
||||
int? maxCacheSize,
|
||||
int? maxRetryCount,
|
||||
Duration? connectTimeout,
|
||||
Duration? readTimeout,
|
||||
Duration? maxEventAge,
|
||||
bool? useIsolateStorage,
|
||||
bool? enableMetrics,
|
||||
Duration? metricsReportInterval,
|
||||
}) {
|
||||
return AnalyticsConfig(
|
||||
systemCode: systemCode ?? 'SYS',
|
||||
endpointBaseUrl: endpointBaseUrl ?? 'https://example.com/api',
|
||||
clientType: clientType ?? 3,
|
||||
batchSize: batchSize ?? 20,
|
||||
flushInterval: flushInterval ?? 15,
|
||||
maxCacheSize: maxCacheSize ?? 100,
|
||||
maxRetryCount: maxRetryCount ?? 3,
|
||||
connectTimeout: connectTimeout ?? const Duration(seconds: 1),
|
||||
readTimeout: readTimeout ?? const Duration(seconds: 1),
|
||||
maxEventAge: maxEventAge ?? const Duration(days: 7),
|
||||
useIsolateStorage: useIsolateStorage ?? false,
|
||||
enableMetrics: enableMetrics ?? true,
|
||||
metricsReportInterval:
|
||||
metricsReportInterval ?? const Duration(minutes: 1),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('AnalyticsConfig.validate 全分支', () {
|
||||
test('非法 systemCode 会抛错', () {
|
||||
expect(() => _base(systemCode: ' ').validate(), throwsArgumentError);
|
||||
});
|
||||
|
||||
test('非法 URL 会抛错', () {
|
||||
expect(
|
||||
() => _base(endpointBaseUrl: 'not-a-url').validate(),
|
||||
throwsArgumentError,
|
||||
);
|
||||
});
|
||||
|
||||
test('非 https 会抛错', () {
|
||||
expect(
|
||||
() => _base(endpointBaseUrl: 'http://example.com').validate(),
|
||||
throwsArgumentError,
|
||||
);
|
||||
});
|
||||
|
||||
test('clientType 必须为正数', () {
|
||||
expect(() => _base(clientType: 0).validate(), throwsArgumentError);
|
||||
});
|
||||
|
||||
test('batchSize 必须为正数', () {
|
||||
expect(() => _base(batchSize: 0).validate(), throwsArgumentError);
|
||||
});
|
||||
|
||||
test('flushInterval 必须为正数', () {
|
||||
expect(() => _base(flushInterval: 0).validate(), throwsArgumentError);
|
||||
});
|
||||
|
||||
test('maxCacheSize 必须为正数', () {
|
||||
expect(() => _base(maxCacheSize: 0).validate(), throwsArgumentError);
|
||||
});
|
||||
|
||||
test('maxRetryCount 不能为负数', () {
|
||||
expect(() => _base(maxRetryCount: -1).validate(), throwsArgumentError);
|
||||
});
|
||||
|
||||
test('connectTimeout 必须大于 0', () {
|
||||
expect(
|
||||
() => _base(connectTimeout: Duration.zero).validate(),
|
||||
throwsArgumentError,
|
||||
);
|
||||
});
|
||||
|
||||
test('readTimeout 必须大于 0', () {
|
||||
expect(
|
||||
() => _base(readTimeout: Duration.zero).validate(),
|
||||
throwsArgumentError,
|
||||
);
|
||||
});
|
||||
|
||||
test('maxEventAge 不能为负数', () {
|
||||
expect(
|
||||
() => _base(maxEventAge: const Duration(seconds: -1)).validate(),
|
||||
throwsArgumentError,
|
||||
);
|
||||
});
|
||||
|
||||
test('enableMetrics 时 metricsReportInterval 必须大于 0', () {
|
||||
expect(
|
||||
() => _base(metricsReportInterval: Duration.zero).validate(),
|
||||
throwsArgumentError,
|
||||
);
|
||||
});
|
||||
|
||||
test('关闭 metrics 时允许 0 间隔', () {
|
||||
expect(
|
||||
() => _base(enableMetrics: false, metricsReportInterval: Duration.zero)
|
||||
.validate(),
|
||||
returnsNormally,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('AnalyticsConfig.uri 组装', () {
|
||||
test('basePath 为空时会补 /leaf', () {
|
||||
final config = _base(endpointBaseUrl: 'https://example.com');
|
||||
expect(config.addEventListLogUri.path, '/AddEventListLog');
|
||||
expect(config.addEventLogUri.path, '/AddEventLog');
|
||||
expect(config.getSystemAllDimInfoUri.path, '/GetSystemAllDimInfo');
|
||||
});
|
||||
|
||||
test('basePath 有值且带斜杠时会规范化', () {
|
||||
final config = _base(endpointBaseUrl: 'https://example.com/api/');
|
||||
expect(config.addEventListLogUri.path, '/api/AddEventListLog');
|
||||
expect(config.addEventLogUri.path, '/api/AddEventLog');
|
||||
expect(config.getSystemAllDimInfoUri.path, '/api/GetSystemAllDimInfo');
|
||||
});
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,178 @@
|
|||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:yx_tracking_flutter/src/config/analytics_config.dart';
|
||||
import 'package:yx_tracking_flutter/src/model/device_info.dart';
|
||||
import 'package:yx_tracking_flutter/src/model/event.dart';
|
||||
import 'package:yx_tracking_flutter/src/network/api_client.dart';
|
||||
import 'package:yx_tracking_flutter/src/network/http_client.dart';
|
||||
|
||||
AnalyticsConfig _config() {
|
||||
return const AnalyticsConfig(
|
||||
systemCode: 'SYS',
|
||||
endpointBaseUrl: 'https://example.com',
|
||||
clientType: 3,
|
||||
enableDebug: true,
|
||||
);
|
||||
}
|
||||
|
||||
Event _event(String type) {
|
||||
return Event(
|
||||
systemCode: 'SYS',
|
||||
eventType: type,
|
||||
userInfo: null,
|
||||
clientType: 3,
|
||||
clientTimestamp: 1,
|
||||
timestamp: '2026-01-01T00:00:00.000Z',
|
||||
deviceInfo: const DeviceInfo(
|
||||
os: 'test-os',
|
||||
model: 'test-model',
|
||||
screenResolution: '1x1',
|
||||
),
|
||||
eventParams: const <String, Object?>{'k': 'v'},
|
||||
customTags: const <String, Object?>{'t': 1},
|
||||
createTime: DateTime.fromMillisecondsSinceEpoch(1),
|
||||
);
|
||||
}
|
||||
|
||||
class _FakeHttpClient extends HttpClient {
|
||||
_FakeHttpClient() : super(_config());
|
||||
|
||||
int callCount = 0;
|
||||
Response<dynamic>? response;
|
||||
Object? error;
|
||||
|
||||
@override
|
||||
Future<Response<T>> post<T>(
|
||||
String path, {
|
||||
Object? data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Map<String, Object?>? headers,
|
||||
CancelToken? cancelToken,
|
||||
}) async {
|
||||
callCount += 1;
|
||||
if (error != null) {
|
||||
final err = error!;
|
||||
if (err is Exception) {
|
||||
throw err;
|
||||
}
|
||||
if (err is Error) {
|
||||
throw err;
|
||||
}
|
||||
throw StateError('Unsupported error type: $err');
|
||||
}
|
||||
final res = response ??
|
||||
Response<dynamic>(
|
||||
requestOptions: RequestOptions(path: path),
|
||||
statusCode: 200,
|
||||
data: const <String, Object?>{'ok': true},
|
||||
);
|
||||
return res as Response<T>;
|
||||
}
|
||||
}
|
||||
|
||||
Response<dynamic> _response(int statusCode) {
|
||||
return Response<dynamic>(
|
||||
requestOptions: RequestOptions(path: '/AddEventListLog'),
|
||||
statusCode: statusCode,
|
||||
data: <String, Object?>{'status': statusCode},
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('ApiClient', () {
|
||||
test('空事件列表直接返回', () async {
|
||||
final fake = _FakeHttpClient();
|
||||
final client = ApiClient(_config(), httpClient: fake);
|
||||
|
||||
await client.sendBatch(const <Event>[]);
|
||||
|
||||
expect(fake.callCount, 0);
|
||||
});
|
||||
|
||||
test('2xx 视为成功', () async {
|
||||
final fake = _FakeHttpClient()..response = _response(204);
|
||||
final client = ApiClient(_config(), httpClient: fake);
|
||||
|
||||
await client.sendBatch(<Event>[_event('OK')]);
|
||||
|
||||
expect(fake.callCount, 1);
|
||||
});
|
||||
|
||||
test('4xx 抛出不可重试 ApiException', () async {
|
||||
final fake = _FakeHttpClient()..response = _response(400);
|
||||
final client = ApiClient(_config(), httpClient: fake);
|
||||
|
||||
expect(
|
||||
() => client.sendBatch(<Event>[_event('BAD')]),
|
||||
throwsA(
|
||||
isA<ApiException>()
|
||||
.having((e) => e.statusCode, 'statusCode', 400)
|
||||
.having((e) => e.retryable, 'retryable', isFalse),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('5xx 抛出可重试 ApiException', () async {
|
||||
final fake = _FakeHttpClient()..response = _response(503);
|
||||
final client = ApiClient(_config(), httpClient: fake);
|
||||
|
||||
expect(
|
||||
() => client.sendBatch(<Event>[_event('RETRY')]),
|
||||
throwsA(
|
||||
isA<ApiException>()
|
||||
.having((e) => e.statusCode, 'statusCode', 503)
|
||||
.having((e) => e.retryable, 'retryable', isTrue),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('DioException(HTTP) 会被映射为 ApiException', () async {
|
||||
final dioError = DioException(
|
||||
requestOptions: RequestOptions(path: '/x'),
|
||||
response: _response(500),
|
||||
type: DioExceptionType.badResponse,
|
||||
);
|
||||
final fake = _FakeHttpClient()..error = dioError;
|
||||
final client = ApiClient(_config(), httpClient: fake);
|
||||
|
||||
expect(
|
||||
() => client.sendBatch(<Event>[_event('HTTP_ERR')]),
|
||||
throwsA(
|
||||
isA<ApiException>()
|
||||
.having((e) => e.statusCode, 'statusCode', 500)
|
||||
.having((e) => e.retryable, 'retryable', isTrue),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('DioException(网络错误) 视为可重试', () async {
|
||||
final dioError = DioException(
|
||||
requestOptions: RequestOptions(path: '/x'),
|
||||
type: DioExceptionType.connectionTimeout,
|
||||
error: StateError('timeout'),
|
||||
message: 'timeout',
|
||||
);
|
||||
final fake = _FakeHttpClient()..error = dioError;
|
||||
final client = ApiClient(_config(), httpClient: fake);
|
||||
|
||||
expect(
|
||||
() => client.sendBatch(<Event>[_event('NET_ERR')]),
|
||||
throwsA(
|
||||
isA<ApiException>()
|
||||
.having((e) => e.statusCode, 'statusCode', isNull)
|
||||
.having((e) => e.retryable, 'retryable', isTrue),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('未知异常会透传', () async {
|
||||
final fake = _FakeHttpClient()..error = StateError('boom');
|
||||
final client = ApiClient(_config(), httpClient: fake);
|
||||
|
||||
expect(
|
||||
() => client.sendBatch(<Event>[_event('UNKNOWN_ERR')]),
|
||||
throwsA(isA<StateError>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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<Response<T>> get<T>(
|
||||
String path, {
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Map<String, Object?>? 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 = <String, dynamic>{
|
||||
'data': <String, dynamic>{
|
||||
'systemEventTypes': <Map<String, dynamic>>[
|
||||
<String, dynamic>{'eventCode': 'EVENT_A', 'eventName': 'A'},
|
||||
],
|
||||
'systemCustonTas': <Map<String, dynamic>>[
|
||||
<String, dynamic>{
|
||||
'tagName': 'tenantId',
|
||||
'tagType': 'string',
|
||||
'isRequired': true,
|
||||
},
|
||||
],
|
||||
'sdkStrategy': <String, dynamic>{
|
||||
'enabled': true,
|
||||
'defaultSampleRate': 1.0,
|
||||
'eventSettings': <String, dynamic>{
|
||||
'EVENT_A': <String, dynamic>{
|
||||
'enabled': true,
|
||||
'sampleRate': 0.25,
|
||||
final httpClient = FakeHttpClient(_config())
|
||||
..responseData = <String, dynamic>{
|
||||
'data': <String, dynamic>{
|
||||
'systemEventTypes': <Map<String, dynamic>>[
|
||||
<String, dynamic>{'eventCode': 'EVENT_A', 'eventName': 'A'},
|
||||
],
|
||||
'systemCustonTas': <Map<String, dynamic>>[
|
||||
<String, dynamic>{
|
||||
'tagName': 'tenantId',
|
||||
'tagType': 'string',
|
||||
'isRequired': true,
|
||||
},
|
||||
],
|
||||
'sdkStrategy': <String, dynamic>{
|
||||
'enabled': true,
|
||||
'defaultSampleRate': 1,
|
||||
'eventSettings': <String, dynamic>{
|
||||
'EVENT_A': <String, dynamic>{
|
||||
'enabled': true,
|
||||
'sampleRate': 0.25,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
httpClient.responseHeaders = Headers.fromMap(<String, List<String>>{
|
||||
'x-config-version': <String>['v1'],
|
||||
});
|
||||
}
|
||||
..responseHeaders = Headers.fromMap(<String, List<String>>{
|
||||
'x-config-version': <String>['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 = <Object?, Object?>{
|
||||
'data': <Object?, Object?>{
|
||||
1: 'ignored',
|
||||
'systemEventTypes': const <Object?>[],
|
||||
'systemCustonTas': const <Object?>[],
|
||||
},
|
||||
};
|
||||
|
||||
final manager = ConfigManager(
|
||||
config: _config(),
|
||||
refreshInterval: Duration.zero,
|
||||
storage: storage,
|
||||
httpClient: httpClient,
|
||||
);
|
||||
|
||||
await manager.init();
|
||||
await manager.fetchAndCacheConfig(force: true);
|
||||
|
||||
expect(manager.currentConfig, isNotNull);
|
||||
});
|
||||
|
||||
test('forceRefresh 与 dispose 可调用', () async {
|
||||
final storage = InMemoryConfigStorage();
|
||||
final httpClient = FakeHttpClient(_config())
|
||||
..responseData = <String, Object?>{
|
||||
'systemEventTypes': const <Object?>[],
|
||||
'systemCustonTas': const <Object?>[],
|
||||
};
|
||||
|
||||
final manager = ConfigManager(
|
||||
config: _config(),
|
||||
refreshInterval: Duration.zero,
|
||||
storage: storage,
|
||||
httpClient: httpClient,
|
||||
);
|
||||
|
||||
await manager.init();
|
||||
await manager.forceRefresh();
|
||||
await manager.dispose();
|
||||
|
||||
expect(httpClient.getCallCount, greaterThanOrEqualTo(1));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:yx_tracking_flutter/src/util/device_util.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('DeviceUtil', () {
|
||||
test('collectDeviceInfo 返回基础信息', () async {
|
||||
expect(DeviceUtil.instance(), isA<DeviceUtil>());
|
||||
final info = await DeviceUtil.collectDeviceInfo();
|
||||
|
||||
expect(info.os, isNotEmpty);
|
||||
expect(info.model, isNotEmpty);
|
||||
expect(info.screenResolution, isNotEmpty);
|
||||
});
|
||||
|
||||
test('views 为空时返回 unknown', () {
|
||||
final resolution = DeviceUtil.screenResolutionForTesting(
|
||||
viewsProvider: () => const [],
|
||||
);
|
||||
|
||||
expect(resolution, 'unknown');
|
||||
});
|
||||
|
||||
test('viewsProvider 抛错时返回 unknown', () {
|
||||
final resolution = DeviceUtil.screenResolutionForTesting(
|
||||
viewsProvider: () => throw StateError('boom'),
|
||||
);
|
||||
|
||||
expect(resolution, 'unknown');
|
||||
});
|
||||
|
||||
test('尺寸为 0 时返回 unknown', () {
|
||||
final resolution = DeviceUtil.resolutionFromSizeForTesting(Size.zero);
|
||||
expect(resolution, 'unknown');
|
||||
});
|
||||
|
||||
test('正尺寸会格式化为 WxH', () {
|
||||
const size = Size(1080, 1920);
|
||||
final resolution = DeviceUtil.resolutionFromSizeForTesting(size);
|
||||
expect(resolution, '1080x1920');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:yx_tracking_flutter/yx_tracking_flutter.dart';
|
||||
|
||||
void main() {
|
||||
group('Event.fromJson 分支覆盖', () {
|
||||
test('clientType/clientTimestamp 支持 num 与 string', () {
|
||||
final event = Event.fromJson(
|
||||
<String, dynamic>{
|
||||
'system_code': 'SYS',
|
||||
'eventType': 'E_NUM',
|
||||
'clientType': 3.9,
|
||||
'clientTimestamp': '123',
|
||||
'timestamp': '2026-01-01T00:00:00.000Z',
|
||||
'deviceInfo': <String, dynamic>{
|
||||
'os': 'o',
|
||||
'model': 'm',
|
||||
'screenResolution': '1x1',
|
||||
},
|
||||
},
|
||||
createTime: DateTime.fromMillisecondsSinceEpoch(0),
|
||||
retryCount: 0,
|
||||
);
|
||||
|
||||
expect(event.clientType, 3);
|
||||
expect(event.clientTimestamp, 123);
|
||||
});
|
||||
|
||||
test('eventParams/customTags 支持非 string key 的 Map', () {
|
||||
final event = Event.fromJson(
|
||||
<String, dynamic>{
|
||||
'system_code': 'SYS',
|
||||
'eventType': 'E_MAP',
|
||||
'clientType': 3,
|
||||
'clientTimestamp': 1,
|
||||
'timestamp': '2026-01-01T00:00:00.000Z',
|
||||
'deviceInfo': <String, dynamic>{
|
||||
'os': 'o',
|
||||
'model': 'm',
|
||||
'screenResolution': '1x1',
|
||||
},
|
||||
'eventParams': <Object?, Object?>{1: 'v'},
|
||||
'customTags': <Object?, Object?>{'k': 1},
|
||||
},
|
||||
createTime: DateTime.fromMillisecondsSinceEpoch(0),
|
||||
retryCount: 0,
|
||||
);
|
||||
|
||||
expect(event.eventParams?['1'], 'v');
|
||||
expect(event.customTags?['k'], 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('Event.fromPayload 非 Map JSON 会抛 FormatException', () {
|
||||
expect(
|
||||
() => Event.fromPayload(
|
||||
'[]',
|
||||
createTime: DateTime.fromMillisecondsSinceEpoch(0),
|
||||
retryCount: 0,
|
||||
),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
|
||||
test('StoredEvent.copyWith 可覆盖所有字段', () {
|
||||
final baseEvent = Event(
|
||||
systemCode: 'SYS',
|
||||
eventType: 'BASE',
|
||||
userInfo: null,
|
||||
clientType: 3,
|
||||
clientTimestamp: 1,
|
||||
timestamp: 't',
|
||||
deviceInfo: const DeviceInfo(
|
||||
os: 'o',
|
||||
model: 'm',
|
||||
screenResolution: '1x1',
|
||||
),
|
||||
eventParams: null,
|
||||
customTags: null,
|
||||
createTime: DateTime.fromMillisecondsSinceEpoch(1),
|
||||
);
|
||||
|
||||
final stored = StoredEvent(
|
||||
id: 1,
|
||||
event: baseEvent,
|
||||
retryCount: 2,
|
||||
createTime: DateTime.fromMillisecondsSinceEpoch(2),
|
||||
);
|
||||
|
||||
final newEvent = Event(
|
||||
systemCode: baseEvent.systemCode,
|
||||
eventType: 'NEW',
|
||||
userInfo: baseEvent.userInfo,
|
||||
clientType: baseEvent.clientType,
|
||||
clientTimestamp: baseEvent.clientTimestamp,
|
||||
timestamp: baseEvent.timestamp,
|
||||
deviceInfo: baseEvent.deviceInfo,
|
||||
eventParams: baseEvent.eventParams,
|
||||
customTags: baseEvent.customTags,
|
||||
createTime: baseEvent.createTime,
|
||||
retryCount: baseEvent.retryCount,
|
||||
);
|
||||
|
||||
final replaced = stored.copyWith(
|
||||
id: 9,
|
||||
event: newEvent,
|
||||
retryCount: 7,
|
||||
createTime: DateTime.fromMillisecondsSinceEpoch(9),
|
||||
);
|
||||
|
||||
expect(replaced.id, 9);
|
||||
expect(replaced.event.eventType, 'NEW');
|
||||
expect(replaced.retryCount, 7);
|
||||
expect(replaced.createTime.millisecondsSinceEpoch, 9);
|
||||
|
||||
final unchanged = stored.copyWith();
|
||||
expect(unchanged.id, stored.id);
|
||||
expect(unchanged.event, same(stored.event));
|
||||
expect(unchanged.retryCount, stored.retryCount);
|
||||
expect(unchanged.createTime, stored.createTime);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,250 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:yx_tracking_flutter/src/config/config_manager.dart';
|
||||
import 'package:yx_tracking_flutter/src/core/analytics_core.dart';
|
||||
import 'package:yx_tracking_flutter/src/core/scheduler.dart';
|
||||
import 'package:yx_tracking_flutter/src/model/system_dim_info.dart';
|
||||
import 'package:yx_tracking_flutter/src/network/api_client.dart';
|
||||
import 'package:yx_tracking_flutter/src/storage/config_storage.dart';
|
||||
import 'package:yx_tracking_flutter/src/storage/event_storage.dart';
|
||||
import 'package:yx_tracking_flutter/yx_tracking_flutter.dart';
|
||||
|
||||
class _NoopInterceptor implements AnalyticsInterceptor {
|
||||
@override
|
||||
FutureOr<Event?> beforeSend(Event event) async => event;
|
||||
|
||||
@override
|
||||
FutureOr<void> afterSend(Event event, SendResult result) async {}
|
||||
}
|
||||
|
||||
class _MemoryEventStorage implements EventStorage {
|
||||
final _items = <StoredEvent>[];
|
||||
var _nextId = 1;
|
||||
|
||||
@override
|
||||
Future<void> init() async {}
|
||||
|
||||
@override
|
||||
Future<int> insert(Event event) async {
|
||||
final stored = StoredEvent(
|
||||
id: _nextId,
|
||||
event: event,
|
||||
createTime: event.createTime,
|
||||
retryCount: event.retryCount,
|
||||
);
|
||||
_items.add(stored);
|
||||
_nextId += 1;
|
||||
return stored.id;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<StoredEvent>> fetchBatch(int limit) async {
|
||||
if (limit <= 0) return const <StoredEvent>[];
|
||||
final copy = List<StoredEvent>.from(_items)
|
||||
..sort((a, b) => a.createTime.compareTo(b.createTime));
|
||||
return copy.take(limit).toList(growable: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<StoredEvent>> fetchRecent(int limit) async {
|
||||
if (limit <= 0) return const <StoredEvent>[];
|
||||
final copy = List<StoredEvent>.from(_items)
|
||||
..sort((a, b) => b.createTime.compareTo(a.createTime));
|
||||
return copy.take(limit).toList(growable: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteByIds(List<int> ids) async {
|
||||
if (ids.isEmpty) return;
|
||||
_items.removeWhere((e) => ids.contains(e.id));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> count() async => _items.length;
|
||||
|
||||
@override
|
||||
Future<int> trimToMaxSize(int maxSize) async {
|
||||
if (maxSize <= 0) {
|
||||
final removed = _items.length;
|
||||
_items.clear();
|
||||
return removed;
|
||||
}
|
||||
final overflow = _items.length - maxSize;
|
||||
if (overflow <= 0) return 0;
|
||||
_items
|
||||
..sort((a, b) => a.createTime.compareTo(b.createTime))
|
||||
..removeRange(0, overflow);
|
||||
return overflow;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> deleteExpired(DateTime cutoff) async {
|
||||
final before = _items.length;
|
||||
_items.removeWhere((e) => !e.createTime.isAfter(cutoff));
|
||||
return before - _items.length;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateRetryCount(int id, int retryCount) async {
|
||||
final index = _items.indexWhere((e) => e.id == id);
|
||||
if (index < 0) return;
|
||||
final current = _items[index];
|
||||
_items[index] = current.copyWith(
|
||||
retryCount: retryCount,
|
||||
event: current.event.copyWith(retryCount: retryCount),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
}
|
||||
|
||||
class _FakeApiClient extends ApiClient {
|
||||
_FakeApiClient(super.config);
|
||||
|
||||
int sent = 0;
|
||||
|
||||
@override
|
||||
Future<void> sendBatch(List<Event> events) async {
|
||||
sent += events.length;
|
||||
}
|
||||
}
|
||||
|
||||
class _MemoryConfigStorage implements ConfigStorage {
|
||||
SystemDimInfo? _value;
|
||||
|
||||
@override
|
||||
Future<void> init() async {}
|
||||
|
||||
@override
|
||||
Future<void> saveSystemDimInfo(SystemDimInfo info) async {
|
||||
_value = info;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SystemDimInfo?> loadSystemDimInfo() async => _value;
|
||||
|
||||
@override
|
||||
Future<void> clear() async {
|
||||
_value = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
}
|
||||
|
||||
class _TestConfigManager extends ConfigManager {
|
||||
_TestConfigManager({required super.config})
|
||||
: super(
|
||||
storage: _MemoryConfigStorage(),
|
||||
refreshInterval: Duration.zero,
|
||||
httpClient: null,
|
||||
);
|
||||
|
||||
SystemDimInfo? _current;
|
||||
|
||||
@override
|
||||
SystemDimInfo? get currentConfig => _current;
|
||||
|
||||
@override
|
||||
Future<void> init() async {
|
||||
_current ??= SystemDimInfo(
|
||||
systemInfo: const SystemInfo(raw: <String, Object?>{}),
|
||||
eventDefinitions: const <EventDefinition>[
|
||||
EventDefinition(eventCode: 'FACADE_EVENT'),
|
||||
EventDefinition(eventCode: 'SDK_METRICS_SEND'),
|
||||
EventDefinition(eventCode: 'SDK_METRICS_QUEUE'),
|
||||
],
|
||||
tagDefinitions: const <TagDefinition>[],
|
||||
sdkStrategy: const SdkStrategy(
|
||||
enabled: true,
|
||||
defaultSampleRate: 1,
|
||||
eventSettings: <String, EventStrategy>{},
|
||||
),
|
||||
lastFetchedAt: DateTime.fromMillisecondsSinceEpoch(1),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> fetchAndCacheConfig({bool force = false}) async {}
|
||||
|
||||
@override
|
||||
Future<void> forceRefresh() async {}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
}
|
||||
|
||||
class _NoopScheduler extends Scheduler {
|
||||
_NoopScheduler({required super.interval, required super.onTick});
|
||||
|
||||
@override
|
||||
void start() {}
|
||||
|
||||
@override
|
||||
void stop() {}
|
||||
}
|
||||
|
||||
AnalyticsConfig _config() {
|
||||
return const AnalyticsConfig(
|
||||
systemCode: 'TEST_APP',
|
||||
endpointBaseUrl: 'https://example.com/api/ExternalEventlogs',
|
||||
clientType: 3,
|
||||
enableDebug: true,
|
||||
metricsReportInterval: Duration(days: 1),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('Analytics Facade', () {
|
||||
test('可注入 core 并走通关键路径', () async {
|
||||
final memoryStorage = _MemoryEventStorage();
|
||||
final fakeApiClient = _FakeApiClient(_config());
|
||||
|
||||
final core = AnalyticsCore(
|
||||
storageFactory: () => memoryStorage,
|
||||
apiClientFactory: (_) => fakeApiClient,
|
||||
configManagerFactory: (config) => _TestConfigManager(config: config),
|
||||
deviceInfoCollector: () async => const DeviceInfo(
|
||||
os: 'test-os',
|
||||
model: 'test-model',
|
||||
screenResolution: '100x200',
|
||||
),
|
||||
schedulerFactory: (interval, onTick) =>
|
||||
_NoopScheduler(interval: interval, onTick: onTick),
|
||||
randomDouble: () => 0,
|
||||
now: () => DateTime.fromMillisecondsSinceEpoch(10),
|
||||
);
|
||||
|
||||
Analytics.coreForTesting = core;
|
||||
expect(Analytics.instance(), isA<Analytics>());
|
||||
expect(Analytics.coreForTesting, same(core));
|
||||
|
||||
await Analytics.init(_config());
|
||||
await Analytics.setUser(const UserInfo(userId: 1, userName: 'u'));
|
||||
await Analytics.setDeviceInfo(
|
||||
const DeviceInfo(os: 'o', model: 'm', screenResolution: '1x1'),
|
||||
);
|
||||
|
||||
await Analytics.track(
|
||||
'FACADE_EVENT',
|
||||
eventParams: const <String, Object?>{'k': 1},
|
||||
);
|
||||
|
||||
expect(await Analytics.cachedEventCount(), greaterThanOrEqualTo(1));
|
||||
expect(await Analytics.cachedRecentEvents(limit: 5), isNotEmpty);
|
||||
|
||||
await Analytics.flush(force: true);
|
||||
await Analytics.refreshConfig(force: false);
|
||||
await Analytics.reportMetricsNow();
|
||||
|
||||
Analytics.addInterceptor(_NoopInterceptor());
|
||||
Analytics.setDebug(enabled: true);
|
||||
|
||||
expect(fakeApiClient.sent, greaterThanOrEqualTo(1));
|
||||
|
||||
await Analytics.dispose();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:yx_tracking_flutter/src/config/analytics_config.dart';
|
||||
import 'package:yx_tracking_flutter/src/network/http_client.dart';
|
||||
|
||||
class _FakeAdapter implements HttpClientAdapter {
|
||||
RequestOptions? lastOptions;
|
||||
Object? lastData;
|
||||
|
||||
@override
|
||||
void close({bool force = false}) {}
|
||||
|
||||
@override
|
||||
Future<ResponseBody> fetch(
|
||||
RequestOptions options,
|
||||
Stream<List<int>>? requestStream,
|
||||
Future<void>? cancelFuture,
|
||||
) async {
|
||||
lastOptions = options;
|
||||
lastData = options.data;
|
||||
return ResponseBody.fromString(
|
||||
jsonEncode(<String, Object?>{'ok': true}),
|
||||
200,
|
||||
headers: <String, List<String>>{
|
||||
Headers.contentTypeHeader: <String>[Headers.jsonContentType],
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AnalyticsConfig _config(String baseUrl) {
|
||||
return AnalyticsConfig(
|
||||
systemCode: 'SYS',
|
||||
endpointBaseUrl: baseUrl,
|
||||
clientType: 3,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('HttpClient', () {
|
||||
test('会规范化 baseUrl 并使用自定义 adapter', () async {
|
||||
final adapter = _FakeAdapter();
|
||||
final client = HttpClient(
|
||||
_config('https://example.com/'),
|
||||
httpClientAdapter: adapter,
|
||||
);
|
||||
|
||||
expect(client.dio.options.baseUrl, 'https://example.com');
|
||||
|
||||
final response = await client.get<Map<String, dynamic>>('/ping');
|
||||
|
||||
expect(response.statusCode, 200);
|
||||
expect(adapter.lastOptions?.path, '/ping');
|
||||
});
|
||||
|
||||
test('headers 为空时不会创建 Options', () async {
|
||||
final adapter = _FakeAdapter();
|
||||
final client = HttpClient(
|
||||
_config('https://example.com/api'),
|
||||
httpClientAdapter: adapter,
|
||||
);
|
||||
|
||||
await client.post<Object?>('/no-headers', data: <String, Object?>{});
|
||||
|
||||
expect(adapter.lastOptions?.headers, isNot(contains('x-test')));
|
||||
});
|
||||
|
||||
test('会把 headers 透传到请求', () async {
|
||||
final adapter = _FakeAdapter();
|
||||
final client = HttpClient(
|
||||
_config('https://example.com/api'),
|
||||
httpClientAdapter: adapter,
|
||||
);
|
||||
|
||||
await client.post<Object?>(
|
||||
'/with-headers',
|
||||
data: <String, Object?>{'a': 1},
|
||||
headers: <String, Object?>{'x-test': '1'},
|
||||
);
|
||||
|
||||
expect(adapter.lastOptions?.headers['x-test'], '1');
|
||||
expect(adapter.lastData, <String, Object?>{'a': 1});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:yx_tracking_flutter/yx_tracking_flutter.dart';
|
||||
|
||||
class _PassThroughInterceptor extends AnalyticsInterceptor {}
|
||||
|
||||
Event _event() {
|
||||
final now = DateTime.fromMillisecondsSinceEpoch(1);
|
||||
return Event(
|
||||
systemCode: 'SYS',
|
||||
eventType: 'E',
|
||||
userInfo: null,
|
||||
clientType: 3,
|
||||
clientTimestamp: 1,
|
||||
timestamp: now.toUtc().toIso8601String(),
|
||||
deviceInfo: const DeviceInfo(os: 'o', model: 'm', screenResolution: '1x1'),
|
||||
eventParams: null,
|
||||
customTags: null,
|
||||
createTime: now,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('AnalyticsInterceptor.afterSend 默认实现可调用', () {
|
||||
final interceptor = _PassThroughInterceptor()
|
||||
..beforeSend(_event())
|
||||
..afterSend(
|
||||
_event(),
|
||||
const SendResult(success: true, retryable: false, statusCode: 200),
|
||||
);
|
||||
expect(interceptor, isA<AnalyticsInterceptor>());
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:yx_tracking_flutter/src/model/device_info.dart';
|
||||
import 'package:yx_tracking_flutter/src/model/event.dart';
|
||||
import 'package:yx_tracking_flutter/src/storage/isolate_event_storage.dart';
|
||||
|
||||
Event _buildEvent(String type, DateTime createTime) {
|
||||
final ts = createTime.millisecondsSinceEpoch;
|
||||
return Event(
|
||||
systemCode: 'SYS',
|
||||
eventType: type,
|
||||
userInfo: null,
|
||||
clientType: 3,
|
||||
clientTimestamp: ts,
|
||||
timestamp: createTime.toUtc().toIso8601String(),
|
||||
deviceInfo: const DeviceInfo(
|
||||
os: 'os',
|
||||
model: 'model',
|
||||
screenResolution: '1x1',
|
||||
),
|
||||
eventParams: null,
|
||||
customTags: null,
|
||||
createTime: createTime,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('IsolateEventStorage (memory backend)', () {
|
||||
late IsolateEventStorage storage;
|
||||
|
||||
setUp(() async {
|
||||
storage = IsolateEventStorage(backend: IsolateStorageBackend.memory);
|
||||
await storage.init();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await storage.dispose();
|
||||
});
|
||||
|
||||
test('insert/count/fetchBatch 基础流程', () async {
|
||||
final now = DateTime.now();
|
||||
await storage.insert(
|
||||
_buildEvent('A', now.subtract(const Duration(seconds: 2))),
|
||||
);
|
||||
await storage.insert(
|
||||
_buildEvent('B', now.subtract(const Duration(seconds: 1))),
|
||||
);
|
||||
|
||||
expect(await storage.count(), 2);
|
||||
final batch = await storage.fetchBatch(10);
|
||||
expect(batch.map((e) => e.event.eventType), <String>['A', 'B']);
|
||||
});
|
||||
|
||||
test('fetchRecent 按时间降序返回', () async {
|
||||
final now = DateTime.now();
|
||||
await storage.insert(
|
||||
_buildEvent('OLD', now.subtract(const Duration(seconds: 2))),
|
||||
);
|
||||
await storage.insert(_buildEvent('NEW', now));
|
||||
|
||||
final recent = await storage.fetchRecent(1);
|
||||
expect(recent.single.event.eventType, 'NEW');
|
||||
});
|
||||
|
||||
test('deleteByIds 删除指定事件', () async {
|
||||
final now = DateTime.now();
|
||||
final id1 = await storage.insert(_buildEvent('A', now));
|
||||
final id2 = await storage.insert(
|
||||
_buildEvent('B', now.add(const Duration(seconds: 1))),
|
||||
);
|
||||
|
||||
await storage.deleteByIds(<int>[id1]);
|
||||
final left = await storage.fetchBatch(10);
|
||||
expect(left.map((e) => e.id), <int>[id2]);
|
||||
});
|
||||
|
||||
test('trimToMaxSize 会删除最旧事件', () async {
|
||||
final now = DateTime.now();
|
||||
await storage.insert(
|
||||
_buildEvent('A', now.subtract(const Duration(seconds: 3))),
|
||||
);
|
||||
await storage.insert(
|
||||
_buildEvent('B', now.subtract(const Duration(seconds: 2))),
|
||||
);
|
||||
await storage.insert(
|
||||
_buildEvent('C', now.subtract(const Duration(seconds: 1))),
|
||||
);
|
||||
|
||||
final trimmed = await storage.trimToMaxSize(2);
|
||||
expect(trimmed, 1);
|
||||
final left = await storage.fetchBatch(10);
|
||||
expect(left.map((e) => e.event.eventType), <String>['B', 'C']);
|
||||
});
|
||||
|
||||
test('deleteExpired 会清理过期事件', () async {
|
||||
final now = DateTime.now();
|
||||
await storage.insert(
|
||||
_buildEvent('OLD', now.subtract(const Duration(days: 8))),
|
||||
);
|
||||
await storage.insert(_buildEvent('KEEP', now));
|
||||
|
||||
final removed = await storage.deleteExpired(
|
||||
now.subtract(const Duration(days: 7)),
|
||||
);
|
||||
expect(removed, 1);
|
||||
final left = await storage.fetchBatch(10);
|
||||
expect(left.single.event.eventType, 'KEEP');
|
||||
});
|
||||
|
||||
test('updateRetryCount 会更新 retryCount', () async {
|
||||
final now = DateTime.now();
|
||||
final id = await storage.insert(_buildEvent('RETRY', now));
|
||||
|
||||
await storage.updateRetryCount(id, 2);
|
||||
final stored = await storage.fetchBatch(1);
|
||||
expect(stored.single.retryCount, 2);
|
||||
expect(stored.single.event.retryCount, 2);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:yx_tracking_flutter/src/core/scheduler.dart';
|
||||
|
||||
void main() {
|
||||
group('Scheduler', () {
|
||||
test('start/stop 生命周期可工作且重复 start 不会重建', () async {
|
||||
var ticks = 0;
|
||||
final scheduler = Scheduler(
|
||||
interval: const Duration(milliseconds: 10),
|
||||
onTick: () async {
|
||||
ticks += 1;
|
||||
},
|
||||
)
|
||||
..start()
|
||||
..start();
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 35));
|
||||
|
||||
expect(scheduler.isRunning, isTrue);
|
||||
expect(ticks, greaterThanOrEqualTo(1));
|
||||
|
||||
scheduler.stop();
|
||||
expect(scheduler.isRunning, isFalse);
|
||||
|
||||
scheduler.dispose();
|
||||
});
|
||||
|
||||
test('onTick 抛错会被捕获', () async {
|
||||
var ticks = 0;
|
||||
final scheduler = Scheduler(
|
||||
interval: const Duration(milliseconds: 10),
|
||||
onTick: () async {
|
||||
ticks += 1;
|
||||
throw StateError('tick failed');
|
||||
},
|
||||
)..start();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 25));
|
||||
scheduler.stop();
|
||||
|
||||
expect(ticks, greaterThanOrEqualTo(1));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:yx_tracking_flutter/src/model/recent_event_summary.dart';
|
||||
import 'package:yx_tracking_flutter/src/storage/db_constants.dart';
|
||||
import 'package:yx_tracking_flutter/src/util/sdk_info.dart';
|
||||
import 'package:yx_tracking_flutter/src/util/time_util.dart';
|
||||
|
||||
void main() {
|
||||
test('small models/constants are accessible', () {
|
||||
final summary = RecentEventSummary(
|
||||
id: 1,
|
||||
eventType: 'E',
|
||||
createTime: DateTime.fromMillisecondsSinceEpoch(1),
|
||||
retryCount: 0,
|
||||
);
|
||||
|
||||
expect(summary.id, 1);
|
||||
expect(DbConstants.dbName, isNotEmpty);
|
||||
expect(DbConstants.dbVersion, greaterThan(0));
|
||||
expect(DbConstants.instance(), isA<DbConstants>());
|
||||
expect(SdkInfo.sdkVersion, isNotEmpty);
|
||||
expect(SdkInfo.platform, 'flutter');
|
||||
expect(SdkInfo.instance(), isA<SdkInfo>());
|
||||
expect(TimeUtil.instance(), isA<TimeUtil>());
|
||||
expect(TimeUtil.nowMs(), greaterThan(0));
|
||||
expect(TimeUtil.nowIso8601Utc(), contains('T'));
|
||||
expect(TimeUtil.iso8601FromMs(0), '1970-01-01T00:00:00.000Z');
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,372 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import 'package:yx_tracking_flutter/src/model/device_info.dart';
|
||||
import 'package:yx_tracking_flutter/src/model/event.dart';
|
||||
import 'package:yx_tracking_flutter/src/model/system_dim_info.dart';
|
||||
import 'package:yx_tracking_flutter/src/storage/db_constants.dart';
|
||||
import 'package:yx_tracking_flutter/src/storage/sqflite_config_storage.dart';
|
||||
import 'package:yx_tracking_flutter/src/storage/sqflite_event_storage.dart';
|
||||
|
||||
class FakePathProviderPlatform extends PathProviderPlatform {
|
||||
FakePathProviderPlatform(this.documentsPath);
|
||||
|
||||
final String documentsPath;
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationDocumentsPath() async => documentsPath;
|
||||
}
|
||||
|
||||
void main() {
|
||||
sqfliteFfiInit();
|
||||
final ffiFactory = databaseFactoryFfi;
|
||||
|
||||
group('SqfliteEventStorage (ffi)', () {
|
||||
late Directory tempDir;
|
||||
late PathProviderPlatform originalPathProvider;
|
||||
|
||||
setUp(() {
|
||||
originalPathProvider = PathProviderPlatform.instance;
|
||||
tempDir = Directory.systemTemp.createTempSync('yx_tracking_event_');
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
PathProviderPlatform.instance = originalPathProvider;
|
||||
if (tempDir.existsSync()) {
|
||||
tempDir.deleteSync(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
SqfliteEventStorage makeStorage() {
|
||||
return SqfliteEventStorage(
|
||||
databaseFactory: ffiFactory,
|
||||
documentsDirectoryProvider: () async => tempDir,
|
||||
);
|
||||
}
|
||||
|
||||
Event makeEvent(String type, int ts) {
|
||||
return Event(
|
||||
systemCode: 'SYS',
|
||||
eventType: type,
|
||||
userInfo: null,
|
||||
clientType: 3,
|
||||
clientTimestamp: ts,
|
||||
timestamp: '2026-01-01T00:00:00.000Z',
|
||||
deviceInfo: const DeviceInfo(
|
||||
os: 'os',
|
||||
model: 'model',
|
||||
screenResolution: '1x1',
|
||||
),
|
||||
eventParams: <String, Object?>{'i': ts},
|
||||
customTags: const <String, Object?>{'t': 1},
|
||||
createTime: DateTime.fromMillisecondsSinceEpoch(ts),
|
||||
);
|
||||
}
|
||||
|
||||
test('未 init 时会抛 StateError', () {
|
||||
final storage = makeStorage();
|
||||
expect(storage.count, throwsStateError);
|
||||
});
|
||||
|
||||
test('基础 CRUD + limit<=0 分支 + init 幂等', () async {
|
||||
final storage = makeStorage();
|
||||
|
||||
await storage.init();
|
||||
await storage.init();
|
||||
|
||||
expect(await storage.fetchBatch(0), isEmpty);
|
||||
expect(await storage.fetchRecent(0), isEmpty);
|
||||
|
||||
final id1 = await storage.insert(makeEvent('A', 1));
|
||||
final id2 = await storage.insert(makeEvent('B', 2));
|
||||
|
||||
expect(id1, greaterThan(0));
|
||||
expect(id2, greaterThan(id1));
|
||||
|
||||
final batch = await storage.fetchBatch(10);
|
||||
expect(batch.map((e) => e.event.eventType), <String>['A', 'B']);
|
||||
|
||||
final recent = await storage.fetchRecent(10);
|
||||
expect(recent.map((e) => e.event.eventType), <String>['B', 'A']);
|
||||
|
||||
await storage.deleteByIds(const <int>[]);
|
||||
await storage.updateRetryCount(id1, 1);
|
||||
|
||||
await storage.dispose();
|
||||
await storage.dispose();
|
||||
});
|
||||
|
||||
test('trimToMaxSize 会删除最旧事件并返回删除数', () async {
|
||||
final storage = makeStorage();
|
||||
await storage.init();
|
||||
|
||||
await storage.insert(makeEvent('A', 1));
|
||||
await storage.insert(makeEvent('B', 2));
|
||||
await storage.insert(makeEvent('C', 3));
|
||||
|
||||
final trimmed = await storage.trimToMaxSize(2);
|
||||
expect(trimmed, 1);
|
||||
|
||||
final left = await storage.fetchBatch(10);
|
||||
expect(left.map((e) => e.event.eventType), <String>['B', 'C']);
|
||||
});
|
||||
|
||||
test('deleteExpired 会删除截止时间之前的事件', () async {
|
||||
final storage = makeStorage();
|
||||
await storage.init();
|
||||
|
||||
await storage.insert(makeEvent('OLD', 1));
|
||||
await storage.insert(makeEvent('NEW', 100));
|
||||
|
||||
final removed = await storage.deleteExpired(
|
||||
DateTime.fromMillisecondsSinceEpoch(50),
|
||||
);
|
||||
expect(removed, 1);
|
||||
|
||||
final left = await storage.fetchBatch(10);
|
||||
expect(left.map((e) => e.event.eventType), <String>['NEW']);
|
||||
});
|
||||
|
||||
test('updateRetryCount 会同时更新 stored 与 event.retryCount', () async {
|
||||
final storage = makeStorage();
|
||||
await storage.init();
|
||||
|
||||
final id = await storage.insert(makeEvent('R', 10));
|
||||
final stored = (await storage.fetchBatch(1)).single;
|
||||
expect(stored.retryCount, 0);
|
||||
expect(stored.event.retryCount, 0);
|
||||
|
||||
await storage.updateRetryCount(id, 2);
|
||||
|
||||
final updated = (await storage.fetchBatch(1)).single;
|
||||
expect(updated.retryCount, 2);
|
||||
expect(updated.event.retryCount, 2);
|
||||
});
|
||||
|
||||
test('坏数据会被清理避免卡死队列', () async {
|
||||
final storage = makeStorage();
|
||||
await storage.init();
|
||||
|
||||
await storage.insert(makeEvent('GOOD', 1));
|
||||
|
||||
final db = storage.debugDb!;
|
||||
await db.insert(
|
||||
'events',
|
||||
<String, Object?>{
|
||||
'payload': 'not-json',
|
||||
'retry_count': 0,
|
||||
'create_time': 2,
|
||||
},
|
||||
);
|
||||
|
||||
final before = await storage.count();
|
||||
final batch = await storage.fetchBatch(10);
|
||||
final after = await storage.count();
|
||||
|
||||
expect(before, 2);
|
||||
expect(batch.map((e) => e.event.eventType), <String>['GOOD']);
|
||||
expect(after, 1);
|
||||
});
|
||||
|
||||
test('默认 documents provider 分支可工作(使用 fake 平台)', () async {
|
||||
PathProviderPlatform.instance = FakePathProviderPlatform(tempDir.path);
|
||||
final storage = SqfliteEventStorage(databaseFactory: ffiFactory);
|
||||
await storage.init();
|
||||
|
||||
expect(await storage.count(), 0);
|
||||
});
|
||||
|
||||
test('onUpgrade(oldVersion<1) 会建表', () async {
|
||||
final dbPath = p.join(tempDir.path, DbConstants.dbName);
|
||||
final db = await ffiFactory.openDatabase(
|
||||
dbPath,
|
||||
options: OpenDatabaseOptions(version: 1),
|
||||
);
|
||||
await db.execute('PRAGMA user_version = 0;');
|
||||
await db.close();
|
||||
|
||||
final storage = makeStorage();
|
||||
await storage.init();
|
||||
expect(await storage.count(), 0);
|
||||
});
|
||||
|
||||
test('deleteByIds 多 id 会走占位符分支', () async {
|
||||
final storage = makeStorage();
|
||||
await storage.init();
|
||||
|
||||
final id1 = await storage.insert(makeEvent('D1', 1));
|
||||
final id2 = await storage.insert(makeEvent('D2', 2));
|
||||
|
||||
await storage.deleteByIds(<int>[id1, id2]);
|
||||
expect(await storage.count(), 0);
|
||||
});
|
||||
|
||||
test('行字段类型异常会被清理(create_time 非 int)', () async {
|
||||
final storage = makeStorage();
|
||||
await storage.init();
|
||||
|
||||
final db = storage.debugDb!;
|
||||
await db.insert(
|
||||
'events',
|
||||
<String, Object?>{
|
||||
'payload': '{}',
|
||||
'retry_count': 0,
|
||||
'create_time': 'bad-int',
|
||||
},
|
||||
);
|
||||
|
||||
final before = await storage.count();
|
||||
final batch = await storage.fetchBatch(10);
|
||||
final after = await storage.count();
|
||||
|
||||
expect(before, 1);
|
||||
expect(batch, isEmpty);
|
||||
expect(after, 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('SqfliteConfigStorage (ffi)', () {
|
||||
late Directory tempDir;
|
||||
late PathProviderPlatform originalPathProvider;
|
||||
|
||||
setUp(() {
|
||||
originalPathProvider = PathProviderPlatform.instance;
|
||||
tempDir = Directory.systemTemp.createTempSync('yx_tracking_config_');
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
PathProviderPlatform.instance = originalPathProvider;
|
||||
if (tempDir.existsSync()) {
|
||||
tempDir.deleteSync(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
SqfliteConfigStorage makeStorage() {
|
||||
return SqfliteConfigStorage(
|
||||
databaseFactory: ffiFactory,
|
||||
documentsDirectoryProvider: () async => tempDir,
|
||||
);
|
||||
}
|
||||
|
||||
SystemDimInfo makeInfo(int ts) {
|
||||
return SystemDimInfo(
|
||||
systemInfo: const SystemInfo(raw: <String, Object?>{'a': 1}),
|
||||
eventDefinitions: const <EventDefinition>[
|
||||
EventDefinition(eventCode: 'E'),
|
||||
],
|
||||
tagDefinitions: const <TagDefinition>[
|
||||
TagDefinition(tagName: 't', tagType: 'string', isRequired: true),
|
||||
],
|
||||
sdkStrategy: const SdkStrategy(
|
||||
enabled: true,
|
||||
defaultSampleRate: 1,
|
||||
eventSettings: <String, EventStrategy>{},
|
||||
),
|
||||
lastFetchedAt: DateTime.fromMillisecondsSinceEpoch(ts),
|
||||
version: 'v',
|
||||
);
|
||||
}
|
||||
|
||||
test('未 init 时会抛 StateError', () {
|
||||
final storage = makeStorage();
|
||||
expect(storage.loadSystemDimInfo, throwsStateError);
|
||||
});
|
||||
|
||||
test('save/load/clear/lastFetchedAt 正常工作', () async {
|
||||
final storage = makeStorage();
|
||||
await storage.init();
|
||||
|
||||
expect(await storage.loadSystemDimInfo(), isNull);
|
||||
|
||||
final info = makeInfo(123);
|
||||
await storage.saveSystemDimInfo(info);
|
||||
|
||||
final loaded = await storage.loadSystemDimInfo();
|
||||
expect(loaded?.systemInfo?.raw['a'], 1);
|
||||
expect(loaded?.eventDefinitions.single.eventCode, 'E');
|
||||
final rows = await storage.debugDb!.query('config_cache');
|
||||
expect(
|
||||
rows.single['last_fetched_at'],
|
||||
info.lastFetchedAt.millisecondsSinceEpoch,
|
||||
);
|
||||
|
||||
await storage.clear();
|
||||
expect(await storage.loadSystemDimInfo(), isNull);
|
||||
|
||||
await storage.dispose();
|
||||
await storage.dispose();
|
||||
});
|
||||
|
||||
test('默认 documents provider 分支可工作(使用 fake 平台)', () async {
|
||||
PathProviderPlatform.instance = FakePathProviderPlatform(tempDir.path);
|
||||
final storage = SqfliteConfigStorage(databaseFactory: ffiFactory);
|
||||
await storage.init();
|
||||
|
||||
expect(await storage.loadSystemDimInfo(), isNull);
|
||||
});
|
||||
|
||||
test('payload 为空字符串会被清理', () async {
|
||||
final storage = makeStorage();
|
||||
await storage.init();
|
||||
|
||||
final db = storage.debugDb!;
|
||||
await db.insert(
|
||||
'config_cache',
|
||||
<String, Object?>{
|
||||
'key': 'system_dim_info',
|
||||
'payload': '',
|
||||
'last_fetched_at': 1,
|
||||
},
|
||||
);
|
||||
|
||||
final loaded = await storage.loadSystemDimInfo();
|
||||
expect(loaded, isNull);
|
||||
expect(await db.query('config_cache'), isEmpty);
|
||||
});
|
||||
|
||||
test('payload 为非 Map JSON 会被清理', () async {
|
||||
final storage = makeStorage();
|
||||
await storage.init();
|
||||
|
||||
final db = storage.debugDb!;
|
||||
await db.insert(
|
||||
'config_cache',
|
||||
<String, Object?>{
|
||||
'key': 'system_dim_info',
|
||||
'payload': '[]',
|
||||
'last_fetched_at': 1,
|
||||
},
|
||||
);
|
||||
|
||||
final loaded = await storage.loadSystemDimInfo();
|
||||
expect(loaded, isNull);
|
||||
expect(await db.query('config_cache'), isEmpty);
|
||||
});
|
||||
|
||||
test('坏配置会被删除并返回 null', () async {
|
||||
final storage = makeStorage();
|
||||
await storage.init();
|
||||
|
||||
final dbPath = p.join(tempDir.path, DbConstants.dbName);
|
||||
final db = storage.debugDb!;
|
||||
|
||||
await db.insert(
|
||||
'config_cache',
|
||||
<String, Object?>{
|
||||
'key': 'system_dim_info',
|
||||
'payload': 'not-json',
|
||||
'last_fetched_at': 1,
|
||||
},
|
||||
);
|
||||
|
||||
final loaded = await storage.loadSystemDimInfo();
|
||||
expect(loaded, isNull);
|
||||
|
||||
final rows = await db.query('config_cache');
|
||||
expect(rows, isEmpty, reason: '坏数据应被清理: $dbPath');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,252 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:yx_tracking_flutter/src/model/system_dim_info.dart';
|
||||
|
||||
SystemDimInfo _emptyInfo({SdkStrategy? strategy}) {
|
||||
return SystemDimInfo(
|
||||
systemInfo: const SystemInfo(raw: <String, Object?>{'k': 'v'}),
|
||||
eventDefinitions: const <EventDefinition>[],
|
||||
tagDefinitions: const <TagDefinition>[],
|
||||
sdkStrategy: strategy,
|
||||
lastFetchedAt: DateTime.fromMillisecondsSinceEpoch(1),
|
||||
version: 'v1',
|
||||
);
|
||||
}
|
||||
|
||||
String _eventCodeOf(EventDefinition event) => event.eventCode;
|
||||
|
||||
String _tagNameOf(TagDefinition tag) => tag.tagName;
|
||||
|
||||
void main() {
|
||||
group('SystemDimInfo helpers', () {
|
||||
test('hasEvent/requiredTags/strategy helpers', () {
|
||||
final info = SystemDimInfo(
|
||||
systemInfo: const SystemInfo(raw: <String, Object?>{'k': 'v'}),
|
||||
eventDefinitions: const <EventDefinition>[
|
||||
EventDefinition(eventCode: 'A', eventName: 'A1'),
|
||||
],
|
||||
tagDefinitions: const <TagDefinition>[
|
||||
TagDefinition(tagName: 't1', tagType: 'string', isRequired: true),
|
||||
TagDefinition(tagName: 't2', tagType: 'int', isRequired: false),
|
||||
],
|
||||
sdkStrategy: const SdkStrategy(
|
||||
enabled: true,
|
||||
defaultSampleRate: 0.5,
|
||||
eventSettings: <String, EventStrategy>{
|
||||
'A': EventStrategy(enabled: false, sampleRate: 0.1),
|
||||
},
|
||||
),
|
||||
lastFetchedAt: DateTime.fromMillisecondsSinceEpoch(2),
|
||||
);
|
||||
|
||||
expect(info.hasEvent('A'), isTrue);
|
||||
expect(info.hasEvent('B'), isFalse);
|
||||
|
||||
expect(info.requiredTags.map((e) => e.tagName), <String>['t1']);
|
||||
|
||||
expect(info.isStrategyEnabled, isTrue);
|
||||
expect(info.strategyFor('A')?.enabled, isFalse);
|
||||
expect(info.strategyFor('B'), isNull);
|
||||
|
||||
expect(info.sampleRateFor('A'), 0.1);
|
||||
expect(info.sampleRateFor('B'), 0.5);
|
||||
|
||||
expect(info.isEventEnabledByStrategy('A'), isFalse);
|
||||
expect(info.isEventEnabledByStrategy('B'), isTrue);
|
||||
});
|
||||
|
||||
test('无策略时走默认值', () {
|
||||
final info = _emptyInfo();
|
||||
|
||||
expect(info.isStrategyEnabled, isTrue);
|
||||
expect(info.strategyFor('X'), isNull);
|
||||
expect(info.sampleRateFor('X'), 1);
|
||||
expect(info.isEventEnabledByStrategy('X'), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('SystemDimInfo cache/response 解析', () {
|
||||
test('toCacheJson/fromCacheJson 往返', () {
|
||||
final original = SystemDimInfo(
|
||||
systemInfo: const SystemInfo(raw: <String, Object?>{'a': 1}),
|
||||
eventDefinitions: const <EventDefinition>[
|
||||
EventDefinition(eventCode: 'E', eventName: 'Event'),
|
||||
],
|
||||
tagDefinitions: const <TagDefinition>[
|
||||
TagDefinition(tagName: 'tag', tagType: 'string', isRequired: true),
|
||||
],
|
||||
sdkStrategy: const SdkStrategy(
|
||||
enabled: false,
|
||||
defaultSampleRate: 0.3,
|
||||
eventSettings: <String, EventStrategy>{
|
||||
'E': EventStrategy(enabled: true, sampleRate: 1),
|
||||
},
|
||||
),
|
||||
lastFetchedAt: DateTime.fromMillisecondsSinceEpoch(10),
|
||||
version: 'ver',
|
||||
);
|
||||
|
||||
final cached = original.toCacheJson();
|
||||
final parsed = SystemDimInfo.fromCacheJson(cached);
|
||||
|
||||
expect(parsed.systemInfo?.raw['a'], 1);
|
||||
expect(parsed.eventDefinitions.single.eventCode, 'E');
|
||||
expect(parsed.tagDefinitions.single.tagName, 'tag');
|
||||
expect(parsed.sdkStrategy?.enabled, isFalse);
|
||||
expect(parsed.sdkStrategy?.eventSettings['E']?.sampleRate, 1);
|
||||
expect(parsed.version, 'ver');
|
||||
expect(parsed.lastFetchedAt.millisecondsSinceEpoch, 10);
|
||||
});
|
||||
|
||||
test('fromResponse 兼容字段差异与异常输入', () {
|
||||
final fetchedAt = DateTime.fromMillisecondsSinceEpoch(99);
|
||||
final info = SystemDimInfo.fromResponse(
|
||||
<String, Object?>{
|
||||
'systemInfo': <String, Object?>{'sys': true},
|
||||
'systemEventTypes': <Object?>[
|
||||
<String, Object?>{'event_code': 'E1', 'event_name': 'Name'},
|
||||
<String, Object?>{'eventType': 'E2'},
|
||||
<String, Object?>{'eventCode': ''},
|
||||
123,
|
||||
],
|
||||
// 使用拼写错误字段名
|
||||
'systemCustonTas': <Object?>[
|
||||
<String, Object?>{
|
||||
'tag_name': 'req',
|
||||
'tag_type': 'int',
|
||||
'is_required': '1',
|
||||
'desc': 'd',
|
||||
},
|
||||
<String, Object?>{'name': 'opt', 'type': 'string'},
|
||||
<String, Object?>{'tagName': ''},
|
||||
'bad',
|
||||
],
|
||||
// 使用 sdk_strategy 字段名
|
||||
'sdk_strategy': <String, Object?>{
|
||||
'enabled': 'false',
|
||||
'defaultSampleRate': 2,
|
||||
'eventSettings': <String, Object?>{
|
||||
'E1': <String, Object?>{'enabled': 0, 'sampleRate': -1},
|
||||
'E2': <String, Object?>{
|
||||
'enabled': true,
|
||||
'sampleRate': double.infinity,
|
||||
},
|
||||
'bad': 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
fetchedAt: fetchedAt,
|
||||
version: 'v2',
|
||||
);
|
||||
|
||||
expect(info.systemInfo?.raw['sys'], isTrue);
|
||||
expect(
|
||||
info.eventDefinitions.map(_eventCodeOf).toList(growable: false),
|
||||
containsAll(<String>['E1', 'E2']),
|
||||
);
|
||||
expect(
|
||||
info.tagDefinitions.map(_tagNameOf).toList(growable: false),
|
||||
containsAll(<String>['req', 'opt']),
|
||||
);
|
||||
expect(info.tagDefinitions.first.isRequired, isTrue);
|
||||
|
||||
// defaultSampleRate=2 会被 clamp 为 1
|
||||
expect(info.sdkStrategy?.defaultSampleRate, 1);
|
||||
// -1 会被 clamp 为 0
|
||||
expect(info.sdkStrategy?.eventSettings['E1']?.sampleRate, 0);
|
||||
// infinity 会被 clamp 为 1
|
||||
expect(info.sdkStrategy?.eventSettings['E2']?.sampleRate, 1);
|
||||
expect(info.isStrategyEnabled, isFalse);
|
||||
expect(info.lastFetchedAt, fetchedAt);
|
||||
expect(info.version, 'v2');
|
||||
});
|
||||
|
||||
test('异常类型输入会返回空集合/空策略', () {
|
||||
final info = SystemDimInfo.fromResponse(
|
||||
<String, Object?>{
|
||||
'systemEventTypes': 'not-list',
|
||||
'systemCustomTags': 123,
|
||||
'sdkStrategy': 'bad',
|
||||
},
|
||||
fetchedAt: DateTime.fromMillisecondsSinceEpoch(1),
|
||||
);
|
||||
|
||||
expect(info.eventDefinitions, isEmpty);
|
||||
expect(info.tagDefinitions, isEmpty);
|
||||
expect(info.sdkStrategy, isNull);
|
||||
});
|
||||
|
||||
test('fromCacheJson 支持 num 与 string 数值', () {
|
||||
final info = SystemDimInfo.fromCacheJson(
|
||||
<String, Object?>{
|
||||
'systemInfo': <String, Object?>{'k': 'v'},
|
||||
'eventDefinitions': const <Object?>[],
|
||||
'tagDefinitions': const <Object?>[],
|
||||
'sdkStrategy': <String, Object?>{
|
||||
'enabled': true,
|
||||
'defaultSampleRate': '0.25',
|
||||
'eventSettings': <String, Object?>{
|
||||
'E': <String, Object?>{'enabled': true, 'sampleRate': '0.75'},
|
||||
},
|
||||
},
|
||||
'lastFetchedAt': 1.5,
|
||||
},
|
||||
);
|
||||
|
||||
expect(info.lastFetchedAt.millisecondsSinceEpoch, 1);
|
||||
expect(info.sdkStrategy?.defaultSampleRate, 0.25);
|
||||
expect(info.sdkStrategy?.eventSettings['E']?.sampleRate, 0.75);
|
||||
});
|
||||
|
||||
test('fromCacheJson 的 lastFetchedAt 支持字符串整数', () {
|
||||
final info = SystemDimInfo.fromCacheJson(
|
||||
<String, Object?>{
|
||||
'systemInfo': const <String, Object?>{},
|
||||
'eventDefinitions': const <Object?>[],
|
||||
'tagDefinitions': const <Object?>[],
|
||||
'lastFetchedAt': '2',
|
||||
},
|
||||
);
|
||||
|
||||
expect(info.lastFetchedAt.millisecondsSinceEpoch, 2);
|
||||
});
|
||||
});
|
||||
|
||||
group('Model toJson', () {
|
||||
test('SystemInfo/EventDefinition/TagDefinition/SdkStrategy/EventStrategy', () {
|
||||
const system = SystemInfo(raw: <String, Object?>{'k': 'v'});
|
||||
const event = EventDefinition(eventCode: 'E', eventName: 'N');
|
||||
const tag = TagDefinition(
|
||||
tagName: 't',
|
||||
tagType: 'string',
|
||||
isRequired: true,
|
||||
);
|
||||
const strategy = SdkStrategy(
|
||||
enabled: true,
|
||||
defaultSampleRate: 0.7,
|
||||
eventSettings: <String, EventStrategy>{
|
||||
'E': EventStrategy(enabled: true, sampleRate: 0.7),
|
||||
},
|
||||
);
|
||||
|
||||
expect(SystemInfo.fromJson(system.toJson()).raw['k'], 'v');
|
||||
expect(event.toJson()['eventCode'], 'E');
|
||||
expect(tag.toJson()['isRequired'], isTrue);
|
||||
expect(strategy.toJson()['eventSettings'], contains('E'));
|
||||
});
|
||||
|
||||
test('defaultSampleRate 为 NaN 时会回落到 1', () {
|
||||
final info = SystemDimInfo.fromResponse(
|
||||
<String, Object?>{
|
||||
'sdkStrategy': <String, Object?>{
|
||||
'enabled': true,
|
||||
'defaultSampleRate': double.nan,
|
||||
'eventSettings': const <String, Object?>{},
|
||||
},
|
||||
},
|
||||
fetchedAt: DateTime.fromMillisecondsSinceEpoch(1),
|
||||
);
|
||||
|
||||
expect(info.sdkStrategy?.defaultSampleRate, 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -81,21 +81,54 @@ SystemDimInfo _dimInfo() {
|
|||
);
|
||||
}
|
||||
|
||||
SystemDimInfo _dimInfoWithTypes() {
|
||||
return SystemDimInfo(
|
||||
systemInfo: const SystemInfo(raw: <String, dynamic>{}),
|
||||
eventDefinitions: const <EventDefinition>[
|
||||
EventDefinition(eventCode: 'TYPED'),
|
||||
],
|
||||
tagDefinitions: const <TagDefinition>[
|
||||
TagDefinition(tagName: 'intNum', tagType: 'int', isRequired: true),
|
||||
TagDefinition(tagName: 'intStr', tagType: 'integer', isRequired: true),
|
||||
TagDefinition(tagName: 'boolNum', tagType: 'bool', isRequired: true),
|
||||
TagDefinition(tagName: 'boolStr', tagType: 'boolean', isRequired: true),
|
||||
TagDefinition(tagName: 'doubleStr', tagType: 'double', isRequired: true),
|
||||
TagDefinition(tagName: 'floatStr', tagType: 'float', isRequired: true),
|
||||
TagDefinition(tagName: 'numStr', tagType: 'num', isRequired: true),
|
||||
TagDefinition(tagName: 'unknown', tagType: 'mystery', isRequired: false),
|
||||
],
|
||||
sdkStrategy: null,
|
||||
lastFetchedAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
bool _isTypeMismatch(ValidationIssue issue) =>
|
||||
issue.code == Validator.typeMismatch;
|
||||
|
||||
void main() {
|
||||
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 <String, dynamic>{'tenantId': 't1'}),
|
||||
_event(
|
||||
type: 'KNOWN',
|
||||
customTags: const <String, dynamic>{'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 <String, dynamic>{
|
||||
'intNum': 1.0,
|
||||
'intStr': '2',
|
||||
'boolNum': 0,
|
||||
'boolStr': 'no',
|
||||
'doubleStr': '3.14',
|
||||
'floatStr': '2.72',
|
||||
'numStr': '42',
|
||||
'unknown': <String, dynamic>{'any': 'value'},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.hasErrors, false);
|
||||
expect(result.hasWarnings, false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue