593 lines
23 KiB
Dart
593 lines
23 KiB
Dart
import 'package:analytics_sdk/manager/session_manager.dart';
|
||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||
import 'package:analytics_sdk/utils/logger.dart';
|
||
|
||
/// 事件字段校验器
|
||
///
|
||
/// 校验规则分两类:
|
||
///
|
||
/// 1. **关键字段**:校验失败直接丢弃整个事件,[validate] 返回 `null`。
|
||
/// 2. **可纠正字段**:自动截断/转换/填充,记录日志后继续上报。
|
||
class EventValidator {
|
||
// ──────── 公共字段长度限制 ────────
|
||
static const int _maxEventLen = 64;
|
||
static const int _maxAppIdLen = 64;
|
||
static const int _maxShortLen = 128;
|
||
static const int _maxFpLen = 128;
|
||
static const int _maxUaLen = 512;
|
||
|
||
// ──────── timestamp 合法范围(2000-01-01 ~ 2100-01-01) ────────
|
||
static const int _minTs = 946684800;
|
||
static const int _maxTs = 4102444800;
|
||
|
||
// ──────── 正则 ────────
|
||
static final RegExp _eventPattern = RegExp(r'^[a-z0-9_]+$');
|
||
static final RegExp _eventIdPattern = RegExp(r'^[A-Za-z0-9]{1,32}$');
|
||
static final RegExp _eventSigPattern = RegExp(r'^[a-f0-9]{64}$');
|
||
static final RegExp _keyPattern = RegExp(r'^[A-Za-z0-9_]+$');
|
||
|
||
// ──────── payload 字段分类 ────────
|
||
|
||
/// 关键 ID 字段:校验失败丢弃整个事件
|
||
static const Set<String> _criticalIdFields = {
|
||
'video_id',
|
||
'novel_id',
|
||
'comic_id',
|
||
'click_item_id',
|
||
};
|
||
|
||
/// 普通 ID 字段:校验失败仅移除该字段
|
||
/// 注意:ad_id / recommend_id 已在 _commaSeparatedFields 中处理,不重复列入
|
||
static const Set<String> _normalIdFields = {
|
||
'order_id',
|
||
'item_id',
|
||
'page_id',
|
||
};
|
||
|
||
/// 逗号分隔字段(分割 → trim → 过滤空 → 重新拼接)
|
||
static const Set<String> _commaSeparatedFields = {
|
||
'ad_id',
|
||
};
|
||
|
||
/// 名称字段,最大 128 字符
|
||
static const Set<String> _nameFields = {
|
||
'page_name',
|
||
'referrer_page_name',
|
||
'current_page_name',
|
||
'source_page_name',
|
||
'item_name',
|
||
'tab_name',
|
||
'product_name',
|
||
'video_type_name',
|
||
'novel_type_name',
|
||
'comic_type_name',
|
||
'navigation_name',
|
||
'advertising_name',
|
||
'video_behavior_name',
|
||
'novel_behavior_name',
|
||
'comic_behavior_name',
|
||
'consume_reason_name',
|
||
'vip_duration_name',
|
||
'click_item_type_name',
|
||
};
|
||
|
||
/// 标题字段,最大 256 字符
|
||
static const Set<String> _titleFields = {
|
||
'video_title',
|
||
'novel_title',
|
||
'comic_title',
|
||
};
|
||
|
||
/// 关键词/评论字段,最大 500 字符
|
||
static const Set<String> _keywordFields = {
|
||
'keyword',
|
||
'comment_content',
|
||
};
|
||
|
||
/// 百分比字段,范围 0~100
|
||
static const Set<String> _percentageFields = {
|
||
'play_progress',
|
||
'read_progress',
|
||
'click_x_percent',
|
||
'click_y_percent',
|
||
};
|
||
|
||
/// 位置字段,值 ≥ 1
|
||
static const Set<String> _positionFields = {
|
||
'page_no',
|
||
'click_position',
|
||
};
|
||
|
||
/// 非负数字字段,值 ≥ 0
|
||
static const Set<String> _numericFields = {
|
||
'play_duration',
|
||
'video_duration',
|
||
'coin_quantity',
|
||
'coin_consume_amount',
|
||
'coin_balance_before',
|
||
'coin_balance_after',
|
||
'amount',
|
||
'search_result_count',
|
||
'screen_width',
|
||
'screen_height',
|
||
'screen_dpi',
|
||
'page_load_time',
|
||
'flag',
|
||
'available_storage',
|
||
'storage',
|
||
'fill_count',
|
||
'load_duration',
|
||
'position',
|
||
};
|
||
|
||
/// 时间戳字段(范围同 client_ts)
|
||
static const Set<String> _timestampFields = {
|
||
'create_time',
|
||
'vip_expiration_time',
|
||
};
|
||
|
||
// ─────────────────────────────────────────────────────────
|
||
// 公开 API
|
||
// ─────────────────────────────────────────────────────────
|
||
|
||
/// 校验并自动纠正事件 Map。
|
||
///
|
||
/// 返回纠正后的 Map;若关键字段无法修复则返回 `null`(调用方应丢弃该事件)。
|
||
/// 本方法永远不会抛出异常。
|
||
static Map<String, dynamic>? validate(Map<String, dynamic> event) {
|
||
try {
|
||
final result = Map<String, dynamic>.from(event);
|
||
|
||
// ── 1. event ─────────────────────────────────────────
|
||
// 必填,trim,正则 ^[a-z0-9_]+$,最大 64 字符;不符合 → 丢弃
|
||
final rawEvent = _toStr(result['event']);
|
||
if (rawEvent == null) {
|
||
Logger.analyticsSdk('校验失败:event 字段缺失或类型错误,丢弃事件', level: LogLevel.warn);
|
||
return null;
|
||
}
|
||
if (!_eventPattern.hasMatch(rawEvent) || rawEvent.length > _maxEventLen) {
|
||
Logger.analyticsSdk('校验失败:event "$rawEvent" 格式不合规或超长,丢弃事件',
|
||
level: LogLevel.warn);
|
||
return null;
|
||
}
|
||
result['event'] = rawEvent;
|
||
|
||
// ── 2. event_id ──────────────────────────────────────
|
||
// 必填,trim,正则 ^[A-Za-z0-9]{1,32}$;不符合 → 丢弃
|
||
final rawEventId = _toStr(result['event_id']);
|
||
if (rawEventId == null || rawEventId.isEmpty) {
|
||
Logger.analyticsSdk('校验失败:event_id 字段缺失或为空,丢弃事件', level: LogLevel.warn);
|
||
return null;
|
||
}
|
||
if (!_eventIdPattern.hasMatch(rawEventId)) {
|
||
Logger.analyticsSdk('校验失败:event_id "$rawEventId" 格式不合规,丢弃事件',
|
||
level: LogLevel.warn);
|
||
return null;
|
||
}
|
||
result['event_id'] = rawEventId;
|
||
|
||
// ── 3. app_id ────────────────────────────────────────
|
||
// 必填,不能为空,最大 64 字符;空或缺失 → 丢弃;超长 → 截断
|
||
final rawAppId = _toStr(result['app_id']);
|
||
if (rawAppId == null || rawAppId.isEmpty) {
|
||
Logger.analyticsSdk('校验失败:app_id 字段缺失或为空,丢弃事件', level: LogLevel.warn);
|
||
return null;
|
||
}
|
||
result['app_id'] = _trimTrunc(rawAppId, _maxAppIdLen);
|
||
|
||
// ── 4. channel ───────────────────────────────────────
|
||
// 可选,trim,最大 128;超长截断
|
||
final rawChannel = _toStr(result['channel']);
|
||
if (rawChannel != null) {
|
||
result['channel'] = _trimTrunc(rawChannel, _maxShortLen);
|
||
}
|
||
|
||
// ── 5. uid ───────────────────────────────────────────
|
||
// 可选,trim,最大 128;超长截断
|
||
final rawUid = _toStr(result['uid']);
|
||
if (rawUid != null) {
|
||
result['uid'] = _trimTrunc(rawUid, _maxShortLen);
|
||
}
|
||
|
||
// ── 6. sid ───────────────────────────────────────────
|
||
// 必填;空/缺失 → 自动生成;生成后仍为空 → 丢弃
|
||
String sid = _toStr(result['sid']) ?? '';
|
||
if (sid.isEmpty) {
|
||
try {
|
||
sid = SessionManager.instance.getSessionId();
|
||
Logger.analyticsSdk('自动纠正:sid 为空,已自动生成: $sid');
|
||
} catch (e) {
|
||
Logger.analyticsSdk('自动纠正:sid 生成失败: $e', level: LogLevel.warn);
|
||
sid = '';
|
||
}
|
||
}
|
||
if (sid.isEmpty) {
|
||
Logger.analyticsSdk('校验失败:sid 无法获取,丢弃事件', level: LogLevel.warn);
|
||
return null;
|
||
}
|
||
result['sid'] = sid;
|
||
|
||
// ── 7. client_ts ─────────────────────────────────────
|
||
// 必填,整数,范围 946684800~4102444800;字符串可尝试转换;超出范围 → 丢弃
|
||
final tsVal = _parseTs(result['client_ts']);
|
||
if (tsVal == null) {
|
||
Logger.analyticsSdk('校验失败:client_ts 无效或超出范围,丢弃事件',
|
||
level: LogLevel.warn);
|
||
return null;
|
||
}
|
||
result['client_ts'] = tsVal;
|
||
|
||
// ── 8. device ────────────────────────────────────────
|
||
// 仅 Android/iOS/PC;大小写自动纠正;其他值 → 丢弃;空字符串视为未设置,跳过
|
||
final rawDevice = _toStr(result['device']);
|
||
if (rawDevice != null && rawDevice.isNotEmpty) {
|
||
final normalized = _normalizeDevice(rawDevice);
|
||
if (normalized == null) {
|
||
Logger.analyticsSdk(
|
||
'校验失败:device "$rawDevice" 不合规(须为 Android/iOS/PC),丢弃事件',
|
||
level: LogLevel.warn);
|
||
return null;
|
||
}
|
||
result['device'] = normalized;
|
||
}
|
||
|
||
// ── 9. device_id ─────────────────────────────────────
|
||
// 必填,trim,1~128;空或缺失 → 丢弃;超长截断
|
||
final rawDeviceId = _toStr(result['device_id']);
|
||
if (rawDeviceId == null || rawDeviceId.isEmpty) {
|
||
Logger.analyticsSdk('校验失败:device_id 字段缺失或为空,丢弃事件',
|
||
level: LogLevel.warn);
|
||
return null;
|
||
}
|
||
result['device_id'] = _trimTrunc(rawDeviceId, _maxShortLen);
|
||
|
||
// ── 10. user_agent ───────────────────────────────────
|
||
// 可选,trim,超长截断(512,规范要求)
|
||
final rawUa = _toStr(result['user_agent']);
|
||
if (rawUa != null) {
|
||
result['user_agent'] = _trimTrunc(rawUa, _maxUaLen);
|
||
}
|
||
|
||
// ── 11. device_brand / device_model / system_name / system_version ──
|
||
// 可选,trim,超长截断(128)
|
||
for (final field in [
|
||
'device_brand',
|
||
'device_model',
|
||
'system_name',
|
||
'system_version'
|
||
]) {
|
||
final v = _toStr(result[field]);
|
||
if (v != null) {
|
||
result[field] = _trimTrunc(v, _maxShortLen);
|
||
}
|
||
}
|
||
|
||
// ── 12. sdk_version / app_version ────────────────────
|
||
// 可选,trim,最大 128 字符;超长截断
|
||
for (final field in ['sdk_version', 'app_version']) {
|
||
final v = _toStr(result[field]);
|
||
if (v != null) {
|
||
result[field] = _trimTrunc(v, _maxShortLen);
|
||
}
|
||
}
|
||
|
||
// ── 12.1 system / app / network environment ─────────
|
||
for (final field in [
|
||
'language',
|
||
'timezone',
|
||
'package_name',
|
||
'network_type',
|
||
'browser_name',
|
||
'browser_version',
|
||
]) {
|
||
final v = _toStr(result[field]);
|
||
if (v != null) {
|
||
result[field] = _trimTrunc(v, _maxShortLen);
|
||
}
|
||
}
|
||
|
||
// ── 12.2 hardware / browser numeric environment ─────
|
||
for (final field in [
|
||
'battery_level',
|
||
'is_charging',
|
||
'cpu_cores',
|
||
'device_memory_gb',
|
||
'cookie_enabled',
|
||
'local_storage_enabled',
|
||
'touch_support',
|
||
'color_depth',
|
||
'is_pwa',
|
||
]) {
|
||
final v = _parseInt(result[field]);
|
||
if (v != null) result[field] = v;
|
||
}
|
||
|
||
// ── 13. device_fingerprint ───────────────────────────
|
||
// SDK 管控字段,不接受外部覆盖。
|
||
final rawFp = _toStr(result['device_fingerprint']);
|
||
final hasDeviceFingerprintField =
|
||
result.containsKey('device_fingerprint');
|
||
if (SdkContext.deviceFingerprint.isNotEmpty ||
|
||
hasDeviceFingerprintField) {
|
||
final sdkFp = _trimTrunc(SdkContext.deviceFingerprint, _maxFpLen);
|
||
if (rawFp != sdkFp) {
|
||
Logger.analyticsSdk('自动纠正:device_fingerprint 不允许外部覆盖,已改为 SDK 内部值');
|
||
}
|
||
result['device_fingerprint'] = sdkFp;
|
||
}
|
||
|
||
// ── 14. fp_version ───────────────────────────────────
|
||
// fp_version 是 SDK 指纹规则版本,不接受外部覆盖。
|
||
final fp = (result['device_fingerprint'] as String?) ?? '';
|
||
final hasFpVersionField = result.containsKey('fp_version');
|
||
if (fp.isNotEmpty || hasFpVersionField || hasDeviceFingerprintField) {
|
||
final fpVer = _toStr(result['fp_version']) ?? '';
|
||
if (fpVer != SdkContext.fingerprintVersion) {
|
||
result['fp_version'] = SdkContext.fingerprintVersion;
|
||
if (fpVer.isEmpty) {
|
||
Logger.analyticsSdk(
|
||
'自动纠正:fp_version 为空,已填充默认版本 ${SdkContext.fingerprintVersion}');
|
||
} else {
|
||
Logger.analyticsSdk(
|
||
'自动纠正:fp_version=$fpVer 不允许外部覆盖,已改为 ${SdkContext.fingerprintVersion}');
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── 14.1 event_sig / event_sig_version ───────────────
|
||
// 客户端签名字段由 SdkContext 生成;校验器只规范化已存在字段。
|
||
if (result.containsKey('event_sig')) {
|
||
final rawEventSig = _toStr(result['event_sig']) ?? '';
|
||
result['event_sig'] =
|
||
_eventSigPattern.hasMatch(rawEventSig) ? rawEventSig : '';
|
||
}
|
||
|
||
if (result.containsKey('event_sig_version')) {
|
||
final rawEventSigVersion = _toStr(result['event_sig_version']) ?? '';
|
||
result['event_sig_version'] =
|
||
_trimTrunc(rawEventSigVersion, _maxShortLen);
|
||
}
|
||
|
||
// ── 15. payload ──────────────────────────────────────
|
||
// 可为 null(补空 Map),类型错误 → 丢弃
|
||
final rawPayload = result['payload'];
|
||
if (rawPayload == null) {
|
||
result['payload'] = <String, dynamic>{};
|
||
} else if (rawPayload is! Map) {
|
||
Logger.analyticsSdk('校验失败:payload 类型错误(${rawPayload.runtimeType}),丢弃事件',
|
||
level: LogLevel.warn);
|
||
return null;
|
||
} else {
|
||
final validated = _validatePayload(
|
||
Map<String, dynamic>.from(rawPayload),
|
||
eventType: result['event'] as String,
|
||
);
|
||
if (validated == null) return null; // 关键 ID 字段校验失败
|
||
result['payload'] = validated;
|
||
}
|
||
|
||
return result;
|
||
} catch (e) {
|
||
Logger.analyticsSdk('事件校验内部异常: $e,丢弃事件', level: LogLevel.warn);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────
|
||
// payload 业务字段校验
|
||
// ─────────────────────────────────────────────────────────
|
||
|
||
/// 校验 payload 业务字段。
|
||
/// 返回纠正后的 Map;若关键 ID 字段不合规则返回 `null`(丢弃事件)。
|
||
static Map<String, dynamic>? _validatePayload(
|
||
Map<String, dynamic> payload, {
|
||
required String eventType,
|
||
}) {
|
||
final result = <String, dynamic>{};
|
||
|
||
for (final entry in payload.entries) {
|
||
final key = entry.key;
|
||
final value = entry.value;
|
||
|
||
// key 本身须符合 ^[A-Za-z0-9_]+$
|
||
if (!_keyPattern.hasMatch(key)) {
|
||
Logger.analyticsSdk('自动纠正:payload 字段名 "$key" 不合规,已移除',
|
||
level: LogLevel.warn);
|
||
continue;
|
||
}
|
||
|
||
// ── 逗号分隔字段 ─────────────────────────────────────
|
||
if (_commaSeparatedFields.contains(key)) {
|
||
final s = _toStr(value);
|
||
if (s == null) {
|
||
Logger.analyticsSdk('自动纠正:payload.$key 类型错误,已移除');
|
||
continue;
|
||
}
|
||
final cleaned = _cleanComma(s);
|
||
if (cleaned.isEmpty) {
|
||
// 逗号分隔字段清洗后为空,视为无效字段移除
|
||
Logger.analyticsSdk('自动纠正:payload.$key 清洗后为空,已移除');
|
||
continue;
|
||
}
|
||
result[key] = cleaned;
|
||
continue;
|
||
}
|
||
|
||
// ── 关键 ID 字段 ─────────────────────────────────────
|
||
if (_criticalIdFields.contains(key)) {
|
||
final s = _toStr(value);
|
||
if (s == null || s.isEmpty) {
|
||
Logger.analyticsSdk('校验失败:payload.$key(关键ID)为空或类型错误,丢弃事件',
|
||
level: LogLevel.warn);
|
||
return null;
|
||
}
|
||
result[key] = s;
|
||
continue;
|
||
}
|
||
|
||
// ── 普通 ID 字段 ─────────────────────────────────────
|
||
if (_normalIdFields.contains(key)) {
|
||
final s = _toStr(value);
|
||
if (s == null || s.isEmpty) {
|
||
Logger.analyticsSdk('自动纠正:payload.$key(ID字段)为空或类型错误,已移除');
|
||
continue;
|
||
}
|
||
result[key] = s;
|
||
continue;
|
||
}
|
||
|
||
// ── 名称字段(最大 128) ──────────────────────────────
|
||
if (_nameFields.contains(key)) {
|
||
final s = _toStr(value);
|
||
if (s == null) {
|
||
Logger.analyticsSdk('自动纠正:payload.$key 类型错误,已移除');
|
||
continue;
|
||
}
|
||
result[key] = _trimTrunc(s, 128);
|
||
continue;
|
||
}
|
||
|
||
// ── 标题字段(最大 256) ──────────────────────────────
|
||
if (_titleFields.contains(key)) {
|
||
final s = _toStr(value);
|
||
if (s == null) {
|
||
Logger.analyticsSdk('自动纠正:payload.$key 类型错误,已移除');
|
||
continue;
|
||
}
|
||
result[key] = _trimTrunc(s, 256);
|
||
continue;
|
||
}
|
||
|
||
// ── 关键词字段(最大 500) ────────────────────────────
|
||
if (_keywordFields.contains(key)) {
|
||
final s = _toStr(value);
|
||
if (s == null) {
|
||
Logger.analyticsSdk('自动纠正:payload.$key 类型错误,已移除');
|
||
continue;
|
||
}
|
||
result[key] = _trimTrunc(s, 500);
|
||
continue;
|
||
}
|
||
|
||
// ── 百分比字段(0~100) ───────────────────────────────
|
||
if (_percentageFields.contains(key)) {
|
||
final n = _parseInt(value);
|
||
if (n == null || n < 0 || n > 100) {
|
||
Logger.analyticsSdk('自动纠正:payload.$key 百分比值无效($value),已移除');
|
||
continue;
|
||
}
|
||
result[key] = n;
|
||
continue;
|
||
}
|
||
|
||
// ── 位置字段(≥ 1) ───────────────────────────────────
|
||
if (_positionFields.contains(key)) {
|
||
final n = _parseInt(value);
|
||
if (n == null || n < 1) {
|
||
Logger.analyticsSdk('自动纠正:payload.$key 位置值无效($value),已移除');
|
||
continue;
|
||
}
|
||
result[key] = n;
|
||
continue;
|
||
}
|
||
|
||
// ── 数字字段(≥ 0) ───────────────────────────────────
|
||
if (_numericFields.contains(key)) {
|
||
final n = _parseInt(value);
|
||
final min = eventType == 'sdk_init' ? -1 : 0;
|
||
if (n == null || n < min) {
|
||
Logger.analyticsSdk('自动纠正:payload.$key 数字值无效($value),已移除');
|
||
continue;
|
||
}
|
||
result[key] = n;
|
||
continue;
|
||
}
|
||
|
||
// ── 时间戳字段(范围校验) ─────────────────────────────
|
||
if (_timestampFields.contains(key)) {
|
||
final ts = _parseTs(value);
|
||
if (ts == null) {
|
||
Logger.analyticsSdk('自动纠正:payload.$key 时间戳无效($value),已移除');
|
||
continue;
|
||
}
|
||
result[key] = ts;
|
||
continue;
|
||
}
|
||
|
||
// ── 其他字段(字符串超长截断,非字符串保留原值) ───────
|
||
if (value is String && value.length > 500) {
|
||
result[key] = value.substring(0, 500);
|
||
Logger.analyticsSdk('自动纠正:payload.$key 超长,已截断至500字符');
|
||
} else {
|
||
result[key] = value;
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────
|
||
// 工具方法
|
||
// ─────────────────────────────────────────────────────────
|
||
|
||
/// 将 dynamic 转为 trim 后的 String
|
||
///
|
||
/// - String → trim
|
||
/// - num / bool → toString().trim()(业务方可能传入 int 类型的 uid、channel 等)
|
||
/// - 其他类型 → null
|
||
static String? _toStr(dynamic v) {
|
||
if (v == null) return null;
|
||
if (v is String) return v.trim();
|
||
if (v is num || v is bool) return v.toString().trim();
|
||
return null;
|
||
}
|
||
|
||
/// 截断字符串到最大长度(已是 trim 后的值)
|
||
static String _trimTrunc(String s, int max) {
|
||
return s.length > max ? s.substring(0, max) : s;
|
||
}
|
||
|
||
/// 将 device 字段大小写标准化,返回 null 表示无法识别
|
||
static String? _normalizeDevice(String raw) {
|
||
final lower = raw.trim().toLowerCase();
|
||
if (lower == 'android') return 'Android';
|
||
if (lower == 'ios') return 'iOS';
|
||
if (lower == 'pc') return 'PC';
|
||
return null;
|
||
}
|
||
|
||
/// 解析整数(支持 int 和数字字符串)
|
||
static int? _parseInt(dynamic v) {
|
||
if (v is int) return v;
|
||
if (v is double) return v.toInt();
|
||
if (v is String) return int.tryParse(v.trim());
|
||
return null;
|
||
}
|
||
|
||
/// 解析时间戳,范围须在 [_minTs, _maxTs];支持 int / double / 数字字符串
|
||
///
|
||
/// 注意:Dart Web 上 int 与 double 统一为 JS number,显式处理 double
|
||
/// 以保证 Native 与 Web 行为一致。
|
||
static int? _parseTs(dynamic v) {
|
||
int? ts;
|
||
if (v is int) {
|
||
ts = v;
|
||
} else if (v is double) {
|
||
if (v.isFinite) ts = v.toInt();
|
||
} else if (v is String) {
|
||
ts = int.tryParse(v.trim());
|
||
}
|
||
if (ts == null || ts < _minTs || ts > _maxTs) return null;
|
||
return ts;
|
||
}
|
||
|
||
/// 清理逗号分隔字符串:各段 trim、过滤空段、重新拼接
|
||
static String _cleanComma(String raw) {
|
||
return raw
|
||
.split(',')
|
||
.map((s) => s.trim())
|
||
.where((s) => s.isNotEmpty)
|
||
.join(',');
|
||
}
|
||
}
|