petsHero-AI/lib/core/auth/auth_service.dart

354 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:android_id/android_id.dart';
import 'package:collection/collection.dart';
import 'package:crypto/crypto.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:screen_secure/screen_secure.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../adjust/adjust_events.dart';
import '../api/api_client.dart';
import 'auth_token_store.dart';
import '../api/api_config.dart';
import '../api/proxy_client.dart';
import '../api/services/user_api.dart';
import '../log/app_logger.dart';
import '../referrer/referrer_service.dart';
import '../user/user_state.dart';
/// 用于判断 common_info 是否改变了首页结构(分类栏 / 列表数据源)
class _HomeExtSnapshot {
_HomeExtSnapshot(this.needWait, this.items);
final bool? needWait;
final List<dynamic>? items;
factory _HomeExtSnapshot.capture() {
final raw = UserState.extConfigItems.value;
return _HomeExtSnapshot(
UserState.needShowVideoMenu.value,
raw == null ? null : List<dynamic>.from(raw),
);
}
static bool needsFullHomeReload(_HomeExtSnapshot before, _HomeExtSnapshot after) {
if (before.needWait != after.needWait) return true;
return !const DeepCollectionEquality().equals(before.items, after.items);
}
}
/// 认证服务APP 启动时执行快速登录
class AuthService {
AuthService._();
static final _log = AppLogger('AuthService');
static Future<void>? _loginFuture;
/// 登录是否已完成,用于 UI 控制(如登录中隐藏页面加载指示器)
static final ValueNotifier<bool> isLoginComplete = ValueNotifier(false);
/// 登录完成后的 Future需鉴权接口应 await 此 Future 再请求
static Future<void> get loginComplete => _loginFuture ?? Future<void>.value();
static void _logMsg(String msg) {
_log.d(msg);
}
static const _prefsKeyFallbackDeviceId = 'persisted_device_id';
/// 设备唯一标识(用于 fast_login origin 等)。
///
/// - **Android**`Settings.Secure.ANDROID_ID`[`android_id`](https://pub.dev/packages/android_id))。\
/// 注意:`device_info_plus` 的 `AndroidDeviceInfo.id` 实为 **`Build.ID`ROM 构建号)**,同版本机型大量相同,**不能**当设备 ID。
/// - **iOS**`identifierForVendor`(同厂商应用间稳定;卸载同厂商全部应用后会变)。
/// - **其它 / 读失败**:写入 SharedPreferences 的随机 id保证进程内稳定且极低碰撞概率。
static Future<String> _getDeviceId() async {
switch (defaultTargetPlatform) {
case TargetPlatform.android:
final androidId = await const AndroidId().getId();
if (androidId != null && androidId.isNotEmpty) {
return androidId;
}
_logMsg('_getDeviceId: ANDROID_ID 为空,使用本地持久化 fallback');
return _persistedFallbackDeviceId();
case TargetPlatform.iOS:
final ios = await DeviceInfoPlugin().iosInfo;
final idfv = ios.identifierForVendor;
if (idfv != null && idfv.isNotEmpty) return idfv;
return _persistedFallbackDeviceId();
default:
return _persistedFallbackDeviceId();
}
}
static Future<String> _persistedFallbackDeviceId() async {
final prefs = await SharedPreferences.getInstance();
var id = prefs.getString(_prefsKeyFallbackDeviceId);
if (id != null && id.isNotEmpty) return id;
final random = Random.secure();
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
id = base64UrlEncode(bytes).replaceAll('=', '');
await prefs.setString(_prefsKeyFallbackDeviceId, id);
_logMsg('_getDeviceId: 已生成并持久化 fallback id');
return id;
}
/// 计算 signMD5(deviceId) 大写 32 位
static String _computeSign(String deviceId) {
final bytes = utf8.encode(deviceId);
final digest = md5.convert(bytes);
return digest.toString().toUpperCase();
}
/// 根据 extConfig.safe_area 启用/禁用系统截屏防护
static Future<void> _applyScreenSecure(bool? safeArea) async {
if (defaultTargetPlatform != TargetPlatform.android &&
defaultTargetPlatform != TargetPlatform.iOS) {
return;
}
try {
await ScreenSecure.init(screenshotBlock: false, screenRecordBlock: false);
if (safeArea == true) {
await ScreenSecure.enableScreenshotBlock();
await ScreenSecure.enableScreenRecordBlock();
_logMsg('safe_area=true: 已启用截屏/录屏防护');
} else {
await ScreenSecure.disableScreenshotBlock();
await ScreenSecure.disableScreenRecordBlock();
_logMsg('safe_area=$safeArea: 已关闭截屏/录屏防护');
}
} on ScreenSecureException catch (e) {
_logMsg('ScreenSecure 设置失败: ${e.message}');
}
}
/// 将 common_info 响应保存到全局,并解析 surge 中的 lucky是否开启三方支付
static void _saveCommonInfoToState(Map<String, dynamic> data) {
final reveal = data['reveal'] as int?;
if (reveal != null) UserState.setCredits(reveal);
final realm = data['realm'] as String?;
if (realm != null && realm.isNotEmpty) UserState.setAvatar(realm);
final terminal = data['terminal'] as String?;
if (terminal != null && terminal.isNotEmpty) {
UserState.setUserName(terminal);
}
final navigate = data['navigate'] as String?;
if (navigate != null) UserState.setNavigate(navigate);
final surgeStr = data['surge'] as String?;
if (surgeStr != null && surgeStr.isNotEmpty) {
try {
final surge = json.decode(surgeStr) as Map<String, dynamic>?;
if (surge != null) {
final enable = surge['lucky'] as bool?;
UserState.setEnableThirdPartyPayment(enable);
// extConfigneed_wait = 是否展示 Video 菜单safe_area = 是否防止截屏items = 图片列表(见 docs/extConfig.md
final needWait = surge['need_wait'] as bool?;
final safeArea = surge['safe_area'] as bool?;
final items = surge['items'] as List<dynamic>?;
UserState.setExtConfig(
needShowVideoMenuValue: needWait,
safeAreaValue: safeArea,
items: items,
);
_applyScreenSecure(safeArea);
}
} catch (e) {
_logMsg('surge JSON 解析失败: $e');
}
}
}
/// APP 启动时调用快速登录
/// 启动时网络可能未就绪,会延迟后重试
static Future<void> init() async {
if (_loginFuture != null) return _loginFuture!;
final completer = Completer<void>();
_loginFuture = completer.future;
_logMsg('init: 开始快速登录');
const maxRetries = 3;
const retryDelay = Duration(seconds: 2);
try {
// 等待网络就绪(浏览器能访问但 App 报错时,多为启动时网络未初始化)
await Future<void>.delayed(const Duration(seconds: 2));
final deviceId = await _getDeviceId();
_logMsg('init: deviceId=$deviceId');
final sign = _computeSign(deviceId);
_logMsg('init: sign=$sign');
final crest = await ReferrerService.getReferrer();
if (crest != null && crest.isNotEmpty) {
_logMsg('init: crest(referrer)=$crest');
}
await AuthTokenStore.restoreToApiClient();
if (ApiClient.instance.proxy.userToken != null &&
ApiClient.instance.proxy.userToken!.isNotEmpty) {
_logMsg('init: 已用本地 token 注入请求头,本次 fast_login 将携带 knight');
}
ApiResponse? res;
for (var i = 0; i < maxRetries; i++) {
if (i > 0) {
_logMsg('init: 第 ${i + 1} 次重试,等待 ${retryDelay.inSeconds}s...');
await Future<void>.delayed(retryDelay);
}
try {
res = await UserApi.fastLogin(
origin: deviceId,
resolution: sign,
digest: crest ?? '',
crest: crest,
);
break;
} catch (e) {
_logMsg('init: 第 ${i + 1} 次请求失败: $e');
if (i == maxRetries - 1) rethrow;
}
}
if (res == null) return;
_logMsg('init: 登录结果 code=${res.code} msg=${res.msg}');
_logMsg('init: 登录响应 data=${res.data}');
if (res.isSuccess && res.data != null) {
final data = res.data as Map<String, dynamic>?;
final token = data?['reevaluate'] as String?;
if (token != null && token.isNotEmpty) {
ApiClient.instance.setUserToken(token);
await AuthTokenStore.write(token);
_logMsg('init: 已设置 userToken 并写入本地');
} else {
_logMsg('init: 响应中无 reevaluate (userToken),保留原本地 token若有');
}
// equip = 服务端 firstRegister见 docs/user_login.md为 true/1 时上报注册事件
final equipRaw = data?['equip'];
final equipFirstRegister = equipRaw == true ||
equipRaw == 1 ||
equipRaw == '1' ||
(equipRaw is String && equipRaw.toLowerCase() == 'true');
if (equipFirstRegister) {
AdjustEvents.trackRegister();
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
'adjust_register_date',
DateTime.now().toIso8601String().substring(0, 10),
);
_logMsg('init: equip=true 首次注册,已上报 register');
}
final credits = data?['reveal'] as int?;
if (credits != null) {
UserState.setCredits(credits);
_logMsg('init: 已同步积分 $credits');
}
final uid = data?['asset'] as String?;
if (uid != null && uid.isNotEmpty) {
UserState.setUserId(uid);
_logMsg('init: 已设置 userId');
}
final avatarUrl = data?['realm'] as String?;
if (avatarUrl != null && avatarUrl.isNotEmpty) {
UserState.setAvatar(avatarUrl);
}
final name = data?['terminal'] as String?;
if (name != null && name.isNotEmpty) {
UserState.setUserName(name);
}
final countryCode = data?['navigate'] as String?;
if (countryCode != null) {
UserState.setNavigate(countryCode);
}
// 3. 归因android_adjust、gg 各上报一次(均等待响应);再拉一次 common_infoextConfig 影响首页时重载分类/任务
try {
await _reportBothReferrersAndRefreshCommonInfo(uid!, deviceId);
} catch (e) {
_logMsg('referrer/common_info 流程异常: $e');
}
} else {
_logMsg('init: 登录失败');
}
} catch (e, st) {
_logMsg('init: 异常 $e');
_logMsg('init: 堆栈 $st');
} finally {
if (!completer.isCompleted) {
completer.complete();
isLoginComplete.value = true;
}
}
}
static bool _applyCommonInfoAndDidHomeStructureChange(ApiResponse commonRes) {
if (!commonRes.isSuccess || commonRes.data == null) return false;
final commonData = commonRes.data as Map<String, dynamic>?;
if (commonData == null) return false;
final before = _HomeExtSnapshot.capture();
_saveCommonInfoToState(commonData);
final after = _HomeExtSnapshot.capture();
final changed = _HomeExtSnapshot.needsFullHomeReload(before, after);
if (changed) {
_logMsg('common_info 已更新need_wait/items 相对上次有结构性变化');
}
_logMsg('common_info 响应已应用');
return changed;
}
/// 依次上报 android_adjust、gg均等待服务端返回成败都继续结束后统一拉 common_info 更新 extConfig
static Future<void> _reportBothReferrersAndRefreshCommonInfo(
String uid,
String deviceId,
) async {
final adjustDigest = await ReferrerService.getAdjustReferrerDigest();
final ggDigest = await ReferrerService.getGgReferrerDigest();
final rAdjust = await UserApi.referrer(
sentinel: ApiConfig.appId,
asset: uid,
digest: adjustDigest,
origin: deviceId,
accolade: 'android_adjust',
);
if (rAdjust.isSuccess) {
_logMsg('referrer(android_adjust) 成功');
} else {
_logMsg(
'referrer(android_adjust) 失败: code=${rAdjust.code} msg=${rAdjust.msg}');
}
final rGg = await UserApi.referrer(
sentinel: ApiConfig.appId,
asset: uid,
digest: ggDigest,
origin: deviceId,
accolade: 'gg',
);
if (rGg.isSuccess) {
_logMsg('referrer(gg) 成功');
} else {
_logMsg('referrer(gg) 失败: code=${rGg.code} msg=${rGg.msg}');
}
final commonRes = await UserApi.getCommonInfo(
sentinel: ApiConfig.appId,
asset: uid,
);
if (_applyCommonInfoAndDidHomeStructureChange(commonRes)) {
UserState.requestHomeFullReload();
} else if (!commonRes.isSuccess) {
_logMsg(
'common_info 失败: code=${commonRes.code} msg=${commonRes.msg}');
}
}
}