Initial commit: 91dj android client dev_1.0.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
谢宇宁
2026-10-08 11:37:25 +07:00
co-authored by Claude Opus 5.5
commit 97dd6a739a
1262 changed files with 119813 additions and 0 deletions
@@ -0,0 +1,592 @@
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(',');
}
}