Initial commit: 91dj android client dev_1.0.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:analytics_sdk/analytics_sdk.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:device_identity/device_identity.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:jydj/extension/extensions.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../config/config.dart';
|
||||
import '../hj_utils/light_model.dart';
|
||||
import '../hj_utils/store_keys.dart';
|
||||
import '../hj_utils/text_util.dart';
|
||||
|
||||
//设备信息管理类
|
||||
class DeviceInfoService {
|
||||
/// iOS Keychain 存 DevID。同一个 bundle id 卸载重装后 Keychain 项仍然存在(iOS
|
||||
/// 沙盒清理不动 Keychain) → 同账号身份。**没声明 Keychain Sharing access group,
|
||||
/// 不同 bundle id 马甲互相读不到对方的 DevID(各自独立),这是产品取舍。**
|
||||
static const _kKeychainDevIdKey = 'jyporn_device_id_v1';
|
||||
static const _secureStorage = FlutterSecureStorage(
|
||||
iOptions: IOSOptions(
|
||||
accessibility: KeychainAccessibility.first_unlock,
|
||||
),
|
||||
);
|
||||
|
||||
static final DeviceInfoPlugin _infoPlugin = DeviceInfoPlugin();
|
||||
static String? _brand;
|
||||
static String? _model;
|
||||
static String? _deviceId;
|
||||
static String? _deviceOS;
|
||||
static String? _systemVersion;
|
||||
static String? _devType;
|
||||
static String? _ua;
|
||||
static String? _buildID;
|
||||
|
||||
//剪切板缓存(iOS 每次读取会弹系统提示,全局只读一次;缓存 Future 防并发重复触发)
|
||||
static Future<String?>? _clipboardFuture;
|
||||
|
||||
/// 获取剪切板文本,全局只触发一次系统读取
|
||||
static Future<String?> getClipboardTextCached() {
|
||||
return _clipboardFuture ??= _readClipboardOnce();
|
||||
}
|
||||
|
||||
static Future<String?> _readClipboardOnce() async {
|
||||
try {
|
||||
final data = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
return data?.text;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取品牌
|
||||
static String get brand => _brand ?? "";
|
||||
|
||||
/// 获取型号
|
||||
static String get model => _model ?? "";
|
||||
|
||||
/// 设备唯一 ID(APP 内保持不变)
|
||||
static String get deviceId => _deviceId ?? "";
|
||||
|
||||
/// 设备平台
|
||||
static String get deviceOS => _deviceOS ?? "";
|
||||
|
||||
//系统版本
|
||||
static String get systemVersion => _systemVersion ?? "";
|
||||
|
||||
//拼接参数
|
||||
static String get devType => _devType ?? "";
|
||||
static String get ua => _ua ?? "";
|
||||
|
||||
/// 修正后的 pkgName:iOS 用 TF 内部包名常量,Android 用真实包名
|
||||
static String get buildID => _buildID ?? "";
|
||||
|
||||
/// ios tf 的内部包名
|
||||
/// note_this:不要修改这个值
|
||||
static const iosBundleId = "yinse_opera";
|
||||
static const naticeChannel = 'com.yinse/device'; //原生通道(设备/视频/编解码信息)
|
||||
static const requestChannel = 'com.yinse/request'; //原生系统桥(现只剩换启动图标 changeIcon)
|
||||
|
||||
/// 外部调用入口
|
||||
static Future<void> init() async {
|
||||
// buildID 初始化一次,后续都用缓存
|
||||
if (Platform.isIOS) {
|
||||
_buildID = iosBundleId;
|
||||
} else {
|
||||
final pkg = await PackageInfo.fromPlatform();
|
||||
_buildID = pkg.packageName;
|
||||
}
|
||||
// 设备ID:多重持久化(SP + 私有目录文件),任一存在即复用
|
||||
_deviceId = await _resolveDeviceId();
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
final android = await _infoPlugin.androidInfo;
|
||||
_brand = android.brand;
|
||||
_model = android.model;
|
||||
_deviceOS = "Android";
|
||||
_systemVersion = android.version.release;
|
||||
_devType = "${android.device}:${android.version.sdkInt}:id=${android.id}";
|
||||
} else if (Platform.isIOS) {
|
||||
final ios = await _infoPlugin.iosInfo;
|
||||
_brand = "Apple";
|
||||
_model = ios.utsname.machine; // 如 iPhone14,2
|
||||
_deviceOS = "iOS";
|
||||
_systemVersion = ios.utsname.release;
|
||||
_devType = "${ios.systemName}:$_systemVersion";
|
||||
} else {
|
||||
_brand = "Unknown";
|
||||
_model = "Unknown";
|
||||
_deviceOS = Platform.operatingSystem;
|
||||
_systemVersion = 'Unknown';
|
||||
}
|
||||
_ua = genUserAgent();
|
||||
}
|
||||
|
||||
/// 拼装 UA。[deviceId] 可覆盖默认 deviceId
|
||||
static String genUserAgent({String? deviceId}) {
|
||||
final Map<String, String?> params = {
|
||||
"DevID": (deviceId == null || deviceId.isEmpty) ? DeviceInfoService.deviceId : deviceId,
|
||||
"DevType": DeviceInfoService.devType,
|
||||
"SysType": Platform.operatingSystem,
|
||||
"Ver": Config.innerVersion,
|
||||
"BuildID": DeviceInfoService.buildID,
|
||||
'DeviceBrand': DeviceInfoService.brand,
|
||||
'SystemName': DeviceInfoService.deviceOS,
|
||||
'SystemVersion': DeviceInfoService.systemVersion,
|
||||
'device_model': AnalyticsSdk.getDeviceCommonFields()['device_model'],
|
||||
};
|
||||
params.removeWhere((key, value) => value == null || value.isEmpty);
|
||||
final ua = Uri.encodeComponent(params.entries.map((e) => "${e.key}=${e.value}").join(";"));
|
||||
return ua;
|
||||
}
|
||||
|
||||
/// 获取带缓存的 UA。
|
||||
/// - [deviceId] 为空:命中 KV 缓存直接返回;
|
||||
/// - [deviceId] 非空:跳过缓存,按当前最新设备信息重新生成并写回 KV(用于扫码登录、设备切换等场景)。
|
||||
/// 实际写入 UA 的 DevID 字段:deviceId 非空用它,否则 fallback 到 [DeviceInfoService.deviceId]。
|
||||
static Future<String> getCachedUserAgent({String? deviceId}) async {
|
||||
final oldUa = (await lightKV.getString(StoreKeys.UA_CACHE)) ?? '';
|
||||
if (TextUtil.isEmpty(deviceId) && TextUtil.isNotEmpty(oldUa)) {
|
||||
return oldUa;
|
||||
}
|
||||
final newUa = genUserAgent(deviceId: deviceId);
|
||||
if (TextUtil.isNotEmpty(newUa)) lightKV.setString(StoreKeys.UA_CACHE, newUa);
|
||||
return newUa;
|
||||
}
|
||||
|
||||
/// 清除 UA 缓存
|
||||
static Future clearCachedUserAgent() => lightKV.setString(StoreKeys.UA_CACHE, null);
|
||||
|
||||
/// 从剪切板 json 里取推广 tid,取不到返回空串
|
||||
static Future<String> fetchTraceId() async {
|
||||
final clipboardText = await getClipboardTextCached();
|
||||
if (TextUtil.isEmpty(clipboardText)) return '';
|
||||
try {
|
||||
return jsonDecode(clipboardText!)['tid'] ?? '';
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/// 已知 ANDROID_ID 脏值(厂商定制 ROM / 早期山寨机长期出现)
|
||||
static const _dirtyAndroidIds = <String>{
|
||||
'9774d56d682e549c', // Android 4.2 之前山寨机著名脏值
|
||||
'0000000000000000',
|
||||
'ffffffffffffffff',
|
||||
'0123456789abcdef',
|
||||
};
|
||||
|
||||
/// 解析设备 ID:按平台分两条独立路径,任一命中都回写到该平台的所有存储层。
|
||||
///
|
||||
/// iOS Keychain 是唯一**跨卸载 + 跨 bundle id** 持久的存储(配合 Keychain Sharing)。
|
||||
/// SP/文件在 app 沙盒里,卸载就抹;放最高优先级用 Keychain 才能保住账号身份。
|
||||
/// Android 没有等价机制,applicationId 一变就是新 app,账号无法共享(产品取舍)。
|
||||
static Future<String> _resolveDeviceId() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return Platform.isIOS ? _resolveIOS(prefs) : _resolveAndroid(prefs);
|
||||
}
|
||||
|
||||
/// iOS:Keychain → SP → 文件 → 新生成,命中即回写三层(Keychain+SP+文件)
|
||||
static Future<String> _resolveIOS(SharedPreferences prefs) async {
|
||||
// 1. Keychain 优先(卸载重装、马甲切换都能拿到原 DevID)
|
||||
try {
|
||||
final kid = await _secureStorage.read(key: _kKeychainDevIdKey);
|
||||
if (TextUtil.isNotEmpty(kid)) return _persistIOS(prefs, kid!);
|
||||
} catch (_) {
|
||||
// Keychain 偶发失败不阻塞启动,继续走 SP/文件兜底
|
||||
}
|
||||
// 2. SP 命中
|
||||
final spId = prefs.getString(StoreKeys.DEVICE_ID);
|
||||
if (TextUtil.isNotEmpty(spId)) return _persistIOS(prefs, spId!);
|
||||
// 3. 文件命中
|
||||
final fileId = await _readIdFile();
|
||||
if (TextUtil.isNotEmpty(fileId)) return _persistIOS(prefs, fileId!);
|
||||
// 4. 全新生成
|
||||
return _persistIOS(prefs, await _generateDeviceId());
|
||||
}
|
||||
|
||||
/// Android:SP → 文件 → 新生成,命中即回写两层(SP+文件),无 Keychain 等价机制
|
||||
static Future<String> _resolveAndroid(SharedPreferences prefs) async {
|
||||
// 1. SP 命中
|
||||
final spId = prefs.getString(StoreKeys.DEVICE_ID);
|
||||
if (TextUtil.isNotEmpty(spId)) return _persistAndroid(prefs, spId!);
|
||||
// 2. 文件命中
|
||||
final fileId = await _readIdFile();
|
||||
if (TextUtil.isNotEmpty(fileId)) return _persistAndroid(prefs, fileId!);
|
||||
// 3. 全新生成
|
||||
return _persistAndroid(prefs, await _generateDeviceId());
|
||||
}
|
||||
|
||||
/// iOS 回写三层,保证 Keychain/SP/文件一致
|
||||
static Future<String> _persistIOS(SharedPreferences prefs, String id) async {
|
||||
await prefs.setString(StoreKeys.DEVICE_ID, id);
|
||||
await _writeIdFile(id);
|
||||
await _trySaveKeychain(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
/// Android 回写 SP + 文件
|
||||
static Future<String> _persistAndroid(SharedPreferences prefs, String id) async {
|
||||
await prefs.setString(StoreKeys.DEVICE_ID, id);
|
||||
await _writeIdFile(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
/// 写 Keychain 失败不抛错(权限/entitlements 配错时降级走 SP+文件,不卡启动)
|
||||
static Future<void> _trySaveKeychain(String id) async {
|
||||
try {
|
||||
await _secureStorage.write(key: _kKeychainDevIdKey, value: id);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// 生成新设备 ID
|
||||
/// Android:OAID → ANDROID_ID → UUID 兜底
|
||||
/// iOS:identifierForVendor(IDFV)→ UUID 兜底
|
||||
static Future<String> _generateDeviceId() async {
|
||||
String raw = '';
|
||||
if (Platform.isAndroid) {
|
||||
try {
|
||||
await DeviceIdentity.register();
|
||||
raw = await DeviceIdentity.oaid;
|
||||
if (_isInvalidId(raw)) {
|
||||
raw = await DeviceIdentity.androidId;
|
||||
}
|
||||
} catch (_) {}
|
||||
} else if (Platform.isIOS) {
|
||||
try {
|
||||
final ios = await _infoPlugin.iosInfo;
|
||||
raw = ios.identifierForVendor ?? '';
|
||||
} catch (_) {}
|
||||
}
|
||||
if (_isInvalidId(raw)) raw = const Uuid().v4();
|
||||
// 业务规则:91jd_ 前缀,去空格,限制 50 字符(日志系统约束)
|
||||
return '91dj_${raw.replaceAll(' ', '')}'.limit(50);
|
||||
}
|
||||
|
||||
/// 脏值检测:空 / 太短 / 全 0 / 已知脏值
|
||||
static bool _isInvalidId(String? id) {
|
||||
if (TextUtil.isEmpty(id)) return true;
|
||||
final v = id!.toLowerCase();
|
||||
if (v.length < 8) return true;
|
||||
if (v.hasManyZeros()) return true;
|
||||
return _dirtyAndroidIds.contains(v);
|
||||
}
|
||||
|
||||
/// 写入应用支持目录文件(零权限,卸载会丢,但与 SP 互为冗余)
|
||||
static Future<void> _writeIdFile(String id) async {
|
||||
try {
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
final file = File('${dir.path}/.device_id');
|
||||
await file.writeAsString(id, flush: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// 读取应用支持目录文件
|
||||
static Future<String?> _readIdFile() async {
|
||||
try {
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
final file = File('${dir.path}/.device_id');
|
||||
if (!await file.exists()) return null;
|
||||
final v = (await file.readAsString()).trim();
|
||||
return v.isEmpty ? null : v;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取加密后的设备id验签
|
||||
static String getDevToken(String devId) {
|
||||
String a = "g24p5VJ4fJ";
|
||||
String b = "5P#at%Yu";
|
||||
String c = "ZPRwQuKl8YlVIr";
|
||||
String s = a + b + c + devId + a + b + c;
|
||||
var data = utf8.encode(s);
|
||||
var chiper = sha256.convert(data);
|
||||
return base64Encode(chiper.bytes);
|
||||
}
|
||||
|
||||
///获取渠道(非 Android 或取不到都返回空串)
|
||||
static Future<String> getChannel() async {
|
||||
if (!Platform.isAndroid) return "";
|
||||
try {
|
||||
return await const MethodChannel(naticeChannel).invokeMethod("getChannel") ?? "";
|
||||
} on PlatformException {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
import 'package:analytics_sdk/analytics_sdk.dart';
|
||||
import 'package:analytics_sdk/manager/page_name_manager.dart';
|
||||
import 'package:analytics_sdk/entity/ad_click_event.dart';
|
||||
import 'package:analytics_sdk/entity/ad_impression_event.dart';
|
||||
import 'package:analytics_sdk/entity/comic_event.dart';
|
||||
import 'package:analytics_sdk/entity/keyword_click_event.dart';
|
||||
import 'package:analytics_sdk/entity/keyword_search_event.dart';
|
||||
import 'package:analytics_sdk/entity/novel_event.dart';
|
||||
import 'package:analytics_sdk/entity/video_event.dart';
|
||||
import 'package:analytics_sdk/enum/click_item_type_enum.dart';
|
||||
import 'package:analytics_sdk/enum/read_behavior_enum.dart';
|
||||
import 'package:analytics_sdk/enum/video_content_type_enum.dart';
|
||||
import 'package:analytics_sdk/enum/video_event_enum.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:jydj/tools_base/debug_log.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../hj_model/cartoon_media_info.dart';
|
||||
import '../hj_model/media_content.dart';
|
||||
import '../hj_model/splash/ads_model.dart';
|
||||
import '../hj_model/video_model.dart';
|
||||
|
||||
//页面枚举
|
||||
enum TrackEventPageType {
|
||||
start('start', '启动页'), // 启动页广告
|
||||
home('home', '首页'), // 首页公告弹窗
|
||||
acgHome('acg_home', 'acg首页'), // 首页acg
|
||||
live('live', '直播列表'), // 直播顶部广告
|
||||
liveDetail('live_detail', '直播详情页'), // 直播详情页广告
|
||||
commPost('comm_post', '社区首页'), // 顶部公共广告
|
||||
commImg('comm_img', '社区图集'), // 顶部公共广告
|
||||
commNovel('comm_novel', '社区小说'), // 顶部公共广告
|
||||
commGroup('comm_group', '社区群聊'), // 顶部公共广告
|
||||
darkweb('darkweb', '暗网'), // 直播顶部广告
|
||||
mine('mine', '我的'), // 我的页面广告
|
||||
search('search', '搜索页'), // 搜索页广告
|
||||
videoPlay('video_play', '视频播放页'), // 视频播放页广告
|
||||
topicDetail('topic_detail', '专题详情页'), // 专题详情顶部广告
|
||||
appList('app_list', '应用推荐'), // 应用等
|
||||
postDetail('post_detail', '社区详情页'), // 应用等
|
||||
imChatCenter('im_chat_center', '私信中心'), // IM 私信会话列表
|
||||
imChatDetail('im_chat_detail', '私信详情'); // IM 私信聊天页
|
||||
|
||||
final String pagekey;
|
||||
final String pageName;
|
||||
|
||||
const TrackEventPageType(this.pagekey, this.pageName);
|
||||
}
|
||||
|
||||
//搜索枚举
|
||||
enum TrackEventSearchType {
|
||||
video('video', 'video'), // 视频
|
||||
comic('comic', '漫画'), // 漫画
|
||||
acg('acg', 'acg'), // acg
|
||||
user('user', '用户'), // 用户
|
||||
image('image', 'image'), // image
|
||||
post('post', '帖子'); // 帖子
|
||||
|
||||
final String clickTypeKey;
|
||||
final String clickTypeName;
|
||||
|
||||
const TrackEventSearchType(this.clickTypeKey, this.clickTypeName);
|
||||
}
|
||||
|
||||
/// 广告埋点类型(对应 payload 的 ad_type 字段)
|
||||
enum AdType {
|
||||
smallIcon('small_icon'),
|
||||
verticalIcon('vertical_icon'),
|
||||
moreIcon('more_icon'),
|
||||
bigBanner('big_banner'),
|
||||
smallBanner('small_banner'),
|
||||
banner('banner'),
|
||||
feed('feed'),
|
||||
splash('splash'),
|
||||
dialog('dialog');
|
||||
|
||||
/// 上报字符串值
|
||||
final String value;
|
||||
|
||||
const AdType(this.value);
|
||||
|
||||
/// 字符串反查枚举,找不到返回 null
|
||||
static AdType? fromValue(String? value) => values.firstWhereOrNull((e) => e.value == value);
|
||||
}
|
||||
|
||||
class AdExposureModel {
|
||||
final TrackEventPageType? pageType; //页面位置信息
|
||||
AdType? adType; //广告类型,banner 类组件会就地改写(如 ..adType = AdType.bigBanner)
|
||||
final int? tabIndex; //tabIndex
|
||||
|
||||
AdExposureModel({
|
||||
this.pageType,
|
||||
this.adType,
|
||||
this.tabIndex,
|
||||
});
|
||||
}
|
||||
|
||||
/// 广告曝光统一管理(业务级)
|
||||
class AdExposureManager {
|
||||
static final Set<String> _exposedKeys = {};
|
||||
|
||||
/// 返回 true = 允许曝光
|
||||
static bool tryExpose(String key) => _exposedKeys.add(key);
|
||||
}
|
||||
|
||||
//广告曝光,只记录一次曝光
|
||||
mixin AdExposureMixin {
|
||||
void adExposure({
|
||||
required AdsInfoModel adInfo,
|
||||
AdExposureModel? exposureModel,
|
||||
}) {
|
||||
final pageKey = exposureModel?.pageType?.pagekey ?? '';
|
||||
final slotKey = adInfo.position?.toString() ?? '';
|
||||
final adId = adInfo.id?.toString() ?? '';
|
||||
|
||||
// 业务唯一曝光 Key,最终裁判在这里
|
||||
final exposureKey = '$pageKey|$slotKey|$adId|tab${exposureModel?.tabIndex ?? 0}';
|
||||
if (!AdExposureManager.tryExpose(exposureKey)) return;
|
||||
|
||||
AnalyticsSdk.instance.track(AdImpressionEvent(
|
||||
pageKey: pageKey,
|
||||
pageName: exposureModel?.pageType?.pageName ?? '',
|
||||
adSlotKey: slotKey,
|
||||
adId: adId,
|
||||
creativeId: adId,
|
||||
adType: exposureModel?.adType?.value ?? '', // 传广告类型字符串,不传默认空字符串
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
final adClickManager = AdClickManager();
|
||||
|
||||
//广告点击
|
||||
class AdClickManager {
|
||||
// 单例
|
||||
static final AdClickManager _ins = AdClickManager._();
|
||||
|
||||
factory AdClickManager() => _ins;
|
||||
|
||||
AdClickManager._();
|
||||
|
||||
//广告点击事件
|
||||
void adClicks({
|
||||
required AdsInfoModel? adInfo,
|
||||
required Map<String, dynamic> payload,
|
||||
TrackEventPageType? page,
|
||||
}) {
|
||||
if (adInfo == null) return;
|
||||
try {
|
||||
final adId = adInfo.id?.toString() ?? '';
|
||||
AnalyticsSdk.instance.track(AdClickEvent(
|
||||
pageKey: page?.pagekey ?? '',
|
||||
pageName: page?.pageName ?? '',
|
||||
adSlotKey: adInfo.position?.toString() ?? '',
|
||||
adId: adId,
|
||||
creativeId: adId,
|
||||
adType: payload['ad_type'] ?? '',
|
||||
));
|
||||
} catch (e) {
|
||||
debugLog('adClicks', e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final searchClickManager = SearchClickManager();
|
||||
|
||||
class SearchClickManager {
|
||||
// 单例
|
||||
static final SearchClickManager _ins = SearchClickManager._();
|
||||
|
||||
factory SearchClickManager() => _ins;
|
||||
|
||||
SearchClickManager._();
|
||||
|
||||
//搜索埋点
|
||||
void searchKeyword({
|
||||
required String? keyword, //搜索关键词
|
||||
required int? count, //搜索结果数量
|
||||
required String? traceId, //搜索引擎的trace_id。没有对接搜索推荐引擎的上报空字符串。
|
||||
required String? searchId, //搜索引擎列表的video_id(视频),novel_id(小说),comic_id(漫画), 可视窗口有多个,使用英文逗号分隔。
|
||||
}) {
|
||||
AnalyticsSdk.instance.track(KeywordSearchEvent(
|
||||
keyword: keyword ?? '',
|
||||
searchResultCount: count ?? 0,
|
||||
searchTraceId: traceId ?? '', // 未接搜索引擎时传空字符串
|
||||
searchId: searchId ?? '', // 未接搜索引擎时传空字符串
|
||||
));
|
||||
}
|
||||
|
||||
//搜索点击事件
|
||||
void searchClicks({
|
||||
required String? keyword,
|
||||
required TrackEventSearchType clickType,
|
||||
required String? id,
|
||||
required int? index,
|
||||
required String? traceId, //搜索引擎的trace_id。没有对接搜索推荐引擎的上报空字符串。
|
||||
}) {
|
||||
AnalyticsSdk.instance.track(KeywordClickEvent(
|
||||
keyword: keyword ?? "",
|
||||
clickItemId: id ?? '',
|
||||
clickItemType: ClickItemTypeEnum(clickType.clickTypeKey, clickType.clickTypeName),
|
||||
clickPosition: (index ?? 0) + 1,
|
||||
searchTraceId: traceId ?? '', // 未接搜索引擎时传空字符串
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
class TrackVideoManager {
|
||||
//上次上报播放完成的秒级时间戳,用于防重
|
||||
static int _lastComplete = 0;
|
||||
|
||||
//埋点字段不收空值,统一兜默认值
|
||||
static String _or(String? value, String fallback) => (value?.isEmpty ?? true) ? fallback : value!;
|
||||
|
||||
//视频事件
|
||||
static void send(
|
||||
VideoEventEnum event,
|
||||
VideoModel? model,
|
||||
VideoPlayerController? ctr, {
|
||||
bool isPreVideo = false,
|
||||
}) {
|
||||
if (isPreVideo) return; //预览视频的不上报
|
||||
if (ctr == null) return; //播放器为空不上报
|
||||
final duration = ctr.value.duration.inSeconds;
|
||||
if (duration == 0) return; //时长为0不上报,避免除0异常
|
||||
final position = ctr.value.position.inSeconds;
|
||||
final progress = position * 100 ~/ duration;
|
||||
|
||||
if (event == VideoEventEnum.VIDEO_COMPLETE) {
|
||||
if (progress < 90) {
|
||||
//播放进度不足90%, 转为播放事件上报
|
||||
event = VideoEventEnum.VIDEO_PLAY;
|
||||
} else {
|
||||
// 自动播放完,业务监听会多次处理, 视频播放完成事件防重
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
if (now - _lastComplete < 2) {
|
||||
debugLog("video complete 重复处理");
|
||||
return;
|
||||
}
|
||||
_lastComplete = now;
|
||||
}
|
||||
}
|
||||
|
||||
//新增SDK埋点
|
||||
AnalyticsSdk.instance.track(VideoEvent(
|
||||
videoId: model?.id ?? '',
|
||||
videoTitle: model?.title ?? '',
|
||||
videoTypeId: _or(model?.videoTypeId, 'default'),
|
||||
videoTypeName: _or(model?.videoTypeName, '默认分类'),
|
||||
videoTagKey: _or(model?.tags?.map((e) => e.id).join(','), 'default'),
|
||||
videoTagName: _or(model?.tags?.map((e) => e.name).join(','), '默认标签'),
|
||||
videoDuration: duration,
|
||||
playDuration: position,
|
||||
playProgress: progress,
|
||||
videoBehavior: event,
|
||||
videoContentType: model?.contentType == 'video' ? VideoContentTypeEnum.video : VideoContentTypeEnum.shortVideo,
|
||||
recommendTraceId: '',
|
||||
// 未接推荐引擎传 ''
|
||||
mediaId: model?.lsjId ?? '',
|
||||
));
|
||||
}
|
||||
|
||||
//漫画行为 展示, 下一页, 上一页,读完
|
||||
static void comicEvent(
|
||||
ReadBehaviorEnum behavior, //行为
|
||||
{
|
||||
CartoonMediaInfo? model,
|
||||
int? readProgress = 0, //章节百分比
|
||||
int? pageNo = 1,
|
||||
MediaContent? mediaContent,
|
||||
}) {
|
||||
AnalyticsSdk.instance.track(ComicEvent(
|
||||
comicId: model?.id ?? '',
|
||||
comicTitle: model?.title ?? '',
|
||||
comicTypeId: _or(model?.mId, 'default'),
|
||||
comicTypeName: _or(model?.moduleName, '默认分类'),
|
||||
comicTagKey: _or(model?.tagIds, 'default'),
|
||||
comicTagName: _or(model?.tagNames, '默认标签'),
|
||||
readProgress: readProgress ?? 0,
|
||||
pageNo: pageNo ?? 0,
|
||||
comicBehavior: behavior,
|
||||
mediaId: model?.lsjId ?? '',
|
||||
chapterId: _or(mediaContent?.id, '1'),
|
||||
chapterName: _or(mediaContent?.name, '第一章'),
|
||||
));
|
||||
}
|
||||
|
||||
//小说行为 展示, 下一页, 上一页,读完
|
||||
static void novelEvent(
|
||||
ReadBehaviorEnum behavior, {
|
||||
CartoonMediaInfo? model, //小说模型
|
||||
int? readProgress = 0, //章节百分比
|
||||
int? pageNo = 1,
|
||||
String? chapterId,
|
||||
String? chapterName,
|
||||
}) {
|
||||
AnalyticsSdk.instance.track(NovelEvent(
|
||||
novelId: model?.id ?? '',
|
||||
novelTitle: model?.title ?? '',
|
||||
novelTypeId: _or(model?.mId, 'default'),
|
||||
novelTypeName: _or(model?.moduleName, '默认分类'),
|
||||
novelTagKey: _or(model?.tagIds, 'default'),
|
||||
novelTagName: _or(model?.tagNames, '默认标签'),
|
||||
readProgress: readProgress ?? 0,
|
||||
pageNo: pageNo ?? 0,
|
||||
novelBehavior: behavior,
|
||||
mediaId: model?.lsjId ?? '',
|
||||
chapterId: _or(chapterId, '1'),
|
||||
chapterName: _or(chapterName, '第一章'),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// 页面 pageKey -> 中文名映射,app 启动时注册一次(main.dart)
|
||||
/// key 就是 Get.to 自动生成的路由名(去掉斜杠后的页面类名),改类名要同步改这里
|
||||
void addMappings() {
|
||||
PageNameMapper.addMappings({
|
||||
'AiHomePage': 'AI首页',
|
||||
'AiSquarePage': 'AI广场',
|
||||
'CartoonDetailPage': '漫画详情页',
|
||||
'CartoonReadPage': '漫画阅读页',
|
||||
'CartoonRecommendPage': '漫画推荐页',
|
||||
'CartoonSectionListPage': '漫画专题更多页',
|
||||
'CartoonSectionSubPage': '漫画专题列表页',
|
||||
'CartoonSubGridPage': '漫画海角页',
|
||||
'CartoonSubModulePage': '漫画亚模块页',
|
||||
'NovelDetailPage': '小说详情页',
|
||||
'TextNovelReadPage': '文字小说阅读页',
|
||||
'VoiceNovelReadPage': '有声小说阅读页',
|
||||
'CommunityModulePage': '社区模块页',
|
||||
'CommunityTabPage': '社区Tab页',
|
||||
'SourceSubModulePage': '社区资源亚模块页',
|
||||
'CommunityMainPage': '社区主页',
|
||||
'TagDetailSubView': '标签详情页',
|
||||
'CommunityTagDetailPage': '社区标签详情页',
|
||||
'CommunityImagePage': '社区图片详情页',
|
||||
'PostDetailPage': '社区帖子详情页',
|
||||
'ChoseTopicPage': '发布选择话题页',
|
||||
'PublishPage': '帖子发布页',
|
||||
'PublishRulePage': '发布规则页',
|
||||
'PicsDetailPage': '色图详情页',
|
||||
'HomeTabSectionPage': '首页Tab专题页',
|
||||
'HomeTabSortPage': '首页Tab分类页',
|
||||
'SearchACGResultPage': '搜索ACG结果页',
|
||||
'SearchResultMainPage': '搜索结果主页面',
|
||||
'SearchVideoResultPage': '搜索视频结果页',
|
||||
'SectionAllPage': '原创达人',
|
||||
'SpecialTopicsDetailPage': '视频专题详情页',
|
||||
'CartoonTagPage': '漫画标签页',
|
||||
'VideoTagPage': '视频标签页',
|
||||
'DramaTagPage': '短剧标签页', //DramaTagDetailPage.routeName,改名要同步
|
||||
'DarkHomePage': '暗网首页',
|
||||
'HomeMainPage': '视频主页',
|
||||
'LiveDetailPage': '直播详情页',
|
||||
'LiveMainPage': '直播主页',
|
||||
'LiveSubPage': '直播子页面',
|
||||
'MainPage': '主页面',
|
||||
'AiPaintPage': 'AI绘画页',
|
||||
'AiItvPage': 'AI图生视频页',
|
||||
'AINovelSubPage': 'AI小说首页',
|
||||
'AiNovelDetailPage': 'AI小说详情页',
|
||||
'AIChangeFacePage': 'AI换脸页',
|
||||
'AIFaceChangeSubPage': 'AI换脸子页',
|
||||
'AIStripSubPage': 'AI脱衣首页',
|
||||
'AIGrilFriendPage': 'AI女友页',
|
||||
'AiH5Page': 'AI H5页',
|
||||
'AIMainRecordPage': 'AI生成记录主页',
|
||||
'AiMainItvPage': 'AI图生视频主页',
|
||||
'AIModDetailPage': 'AI换脸模版页',
|
||||
'AiRecordPage': 'AI生成记录子页',
|
||||
'AdvancePage': '高级设置页',
|
||||
'ExchangeVipPage': '兑换会员页',
|
||||
'IntegralExchangePage': '积分兑换页',
|
||||
'OnlinePayPage': '在线支付页',
|
||||
'MineCollectPage': '我的收藏页',
|
||||
'MineBuyPage': '我的购买页',
|
||||
'MineHistoryPage': '历史记录页',
|
||||
'MineLikePage': '我的喜欢页',
|
||||
'MineAccountIdentityPage': '账号凭证页',
|
||||
'RecordsPage': '明细综合页',
|
||||
'WithdrawalPage': '提现页',
|
||||
'MessageCenterPage': '消息中心页',
|
||||
'ChatDetailPage': '私信详情页',
|
||||
'MineDrivePage': '加群开车页',
|
||||
'MineFollowSubPage': '关注子页面',
|
||||
'MineFollowingPage': '我的关注页',
|
||||
'MinePublishPostPage': '创作中心',
|
||||
'BankCardHomePage': '银行卡管理页',
|
||||
'BindPhonePage': '绑定手机号页',
|
||||
'InviteBindPage': '绑定邀请码',
|
||||
'MineExchangeCodePage': '兑换码页',
|
||||
'MineFindAccountPage': '找回账号',
|
||||
'MinePasswordPage': '手势密码',
|
||||
'MineScanLoginPage': '扫码登录',
|
||||
'MineSettingPage': '设置中心',
|
||||
'MineSettingProfilePage': '设置资料页',
|
||||
'SettingProfilePage': '编辑资料',
|
||||
'SettingAvatarPage': '设置头像',
|
||||
'SettingNickNamePage': '设置昵称',
|
||||
'MineSharePage': '分享邀请',
|
||||
'MineShareRecordPage': '邀请记录',
|
||||
'VideoCachePage': '下载缓存',
|
||||
'MineChargeCoinPage': '金币充值',
|
||||
'MineChargeVipPage': '会员充值',
|
||||
'MineFeedbackPage': '意见反馈',
|
||||
'MineHelpPage': '帮助反馈',
|
||||
'MineQAPage': '常见问题',
|
||||
'LouFengAdPage': '应用推荐',
|
||||
'MineIncomePage': '分享邀请',
|
||||
'RecommendAppPage': '推荐应用页',
|
||||
'MoreFreeVideoPage': '更多免费视频',
|
||||
'ShareDataListPage': '推广数据列表',
|
||||
'ShareDetailsPage': '推广数据',
|
||||
'WelfareHomePage': '应用推荐首页',
|
||||
'SignDailyPage': '每日签到',
|
||||
'HomeMinePage': '我的',
|
||||
'PreSalePage': '预售详情页',
|
||||
'ShortVideoMainPage': '抖音首页',
|
||||
'ShortVideoPage': '抖音',
|
||||
'SplashPage': '启动页',
|
||||
'RankMainPage': '排行榜主页',
|
||||
'RankSubPage': '排行榜子页',
|
||||
'UserCenterPage': '用户首页',
|
||||
'GroupChatPage': '群聊页',
|
||||
'GroupChatDetailPage': '群聊详情页',
|
||||
'SimpleVideoPlayerPage': '简单视频播放页',
|
||||
'VideoFullPage': '全屏视频播放',
|
||||
'VideoPage': '视频播放页',
|
||||
'H5Page': 'H5页面',
|
||||
'SearchMainPage': '搜索页',
|
||||
'VideoAllTypePage': '视频片库',
|
||||
'AcgGameRecommendPage': 'ACG游戏推荐页',
|
||||
'AcgGameDetailPage': 'ACG游戏详情页',
|
||||
'CartoonSubDetailPage': '漫画子详情页',
|
||||
'MessageCenterSubPage': '消息中心子页',
|
||||
'MinePublishSubPage': '创作中心子页',
|
||||
'Home': '主页Tab',
|
||||
'Community': '社区Tab',
|
||||
'ShortVideo': '抖音Tab',
|
||||
'AI': 'AI Tab',
|
||||
'Mine': '我的Tab',
|
||||
'Pic': ' 色图Tab',
|
||||
'Novel': '小说Tab',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'package:jydj/hj_utils/light_model.dart';
|
||||
import 'package:jydj/hj_utils/store_keys.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// 会话管理器 - 生成并维护统一的会话 ID (sid)
|
||||
///
|
||||
/// 会话 = 用户一次连续使用产生的一组行为,同一会话内所有请求/埋点共享同一个 sid。
|
||||
///
|
||||
/// 会话重建规则(满足任一即换新 sid):
|
||||
/// 1. App 冷启动
|
||||
/// 2. 从后台回前台,且后台停留 > 30 分钟
|
||||
/// 3. 从后台回前台,且距会话创建 > 30 分钟
|
||||
///
|
||||
/// 持久化在 MMKV (lightKV):当前 sid / 会话创建时间 / 进入后台时间
|
||||
class TrackSessionManager {
|
||||
static final TrackSessionManager _instance = TrackSessionManager._();
|
||||
|
||||
factory TrackSessionManager() => _instance;
|
||||
|
||||
TrackSessionManager._();
|
||||
|
||||
/// 会话超时阈值:30 分钟
|
||||
static const Duration _timeout = Duration(minutes: 30);
|
||||
|
||||
String _sid = '';
|
||||
DateTime? _sessionAt; //会话创建时间
|
||||
DateTime? _backgroundedAt; //App 进入后台的时间
|
||||
bool _inited = false;
|
||||
|
||||
/// 当前会话 ID,为空则立即新建(防御性)
|
||||
String get currentSid {
|
||||
if (_sid.isEmpty) _newSession();
|
||||
return _sid;
|
||||
}
|
||||
|
||||
/// App 冷启动时调用一次(main.dart):恢复旧状态后强制开新会话
|
||||
Future<void> onAppColdStart() async {
|
||||
if (_inited) return;
|
||||
await _restore();
|
||||
_newSession();
|
||||
}
|
||||
|
||||
/// App 进入后台:记录时刻,用于回前台时判断停留时长
|
||||
/// 调用时机:AppLifecycleState.paused / inactive / detached
|
||||
Future<void> onAppPause() async {
|
||||
_backgroundedAt = DateTime.now();
|
||||
await lightKV.setInt(StoreKeys.TRACK_SESSION_BACKGROUND_TS, _backgroundedAt!.millisecondsSinceEpoch);
|
||||
}
|
||||
|
||||
/// App 回到前台:后台停留超时或会话本身超时都换新 sid(规则2、3)
|
||||
/// 调用时机:AppLifecycleState.resumed
|
||||
Future<void> onAppResume() async {
|
||||
//没进过后台不算规则2,所以要先判非空
|
||||
final bgExpired = _backgroundedAt != null && _isExpired(_backgroundedAt);
|
||||
if (bgExpired || _isExpired(_sessionAt)) _newSession();
|
||||
|
||||
_backgroundedAt = null;
|
||||
await lightKV.setInt(StoreKeys.TRACK_SESSION_BACKGROUND_TS, 0);
|
||||
}
|
||||
|
||||
/// 从存储恢复上次的 sid 和时间戳,已过期则先开新会话
|
||||
Future<void> _restore() async {
|
||||
_sid = (await lightKV.getString(StoreKeys.TRACK_SESSION_SID)) ?? '';
|
||||
|
||||
//键名沿用历史的 LAST_EVENT_TS,存的是会话创建时间
|
||||
final sessionTs = await lightKV.getInt(StoreKeys.TRACK_SESSION_LAST_EVENT_TS);
|
||||
if (sessionTs != null && sessionTs > 0) _sessionAt = DateTime.fromMillisecondsSinceEpoch(sessionTs);
|
||||
|
||||
final backgroundTs = await lightKV.getInt(StoreKeys.TRACK_SESSION_BACKGROUND_TS);
|
||||
if (backgroundTs != null && backgroundTs > 0) _backgroundedAt = DateTime.fromMillisecondsSinceEpoch(backgroundTs);
|
||||
|
||||
if (_sid.isEmpty || _isExpired(_sessionAt)) _newSession();
|
||||
_inited = true;
|
||||
}
|
||||
|
||||
/// 距 [time] 是否已超过 30 分钟(time 为空按已过期处理)
|
||||
bool _isExpired(DateTime? time) {
|
||||
if (time == null) return true;
|
||||
return DateTime.now().difference(time) > _timeout;
|
||||
}
|
||||
|
||||
/// 新建会话并立即落盘,防止进程被杀丢数据
|
||||
void _newSession() {
|
||||
_sid = Uuid().v4().replaceAll('-', '');
|
||||
_sessionAt = DateTime.now();
|
||||
lightKV.setString(StoreKeys.TRACK_SESSION_SID, _sid);
|
||||
lightKV.setInt(StoreKeys.TRACK_SESSION_LAST_EVENT_TS, _sessionAt!.millisecondsSinceEpoch);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user