petsHero-AI/lib/core/auth/auth_service.dart
2026-03-31 09:42:49 +08:00

425 lines
15 KiB
Dart
Raw Permalink 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}');
}
}
/// 系统相册/相机 Activity 返回后,若开启了截屏防护([UserState.safeArea]),部分机型会黑屏卡死甚至重启。
/// 在整段选图流程外包一层:临时关闭防护,结束后按当前配置恢复。
static Future<T> runWithNativeMediaPicker<T>(Future<T> Function() action) async {
if (defaultTargetPlatform != TargetPlatform.android &&
defaultTargetPlatform != TargetPlatform.iOS) {
return await action();
}
try {
await ScreenSecure.disableScreenshotBlock();
await ScreenSecure.disableScreenRecordBlock();
_logMsg('native media picker: ScreenSecure released');
} on ScreenSecureException catch (e) {
_logMsg('native media picker: disable failed: ${e.message}');
}
try {
return await action();
} finally {
await _applyScreenSecure(UserState.safeArea.value);
}
}
/// 将 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);
} else {
_logMsg('common_info: surge 解析为 nullJSON 顶层非对象?');
}
} catch (e) {
_logMsg('surge JSON 解析失败: $e');
}
} else {
_logMsg(
'common_info: 无 surge 或 surge 为空串extConfig/第三方支付开关可能未更新(其它字段仍已写入)',
);
}
}
/// 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 {
// 极短让步:避免个别机型刚起进程时网络栈未就绪;过长会拖慢冷启动
await Future<void>.delayed(const Duration(milliseconds: 400));
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. 归因与 common_info后台执行不阻塞 loginComplete避免服务端慢/挂起导致一直卡在启动遮罩)
unawaited(_runPostLoginReferrerWork(uid!, deviceId));
} 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) {
_logMsg(
'common_info 失败: code=${commonRes.code} msg=${commonRes.msg} '
'dataPreview=${_shortDataPreview(commonRes.data)}',
);
return false;
}
if (commonRes.data == null) {
_logMsg('common_info 失败: code=0 但 data 为 null');
return false;
}
final commonData = commonRes.data as Map<String, dynamic>?;
if (commonData == null) {
_logMsg(
'common_info 失败: code=0 但 data 非 Map类型=${commonRes.data.runtimeType} '
'preview=${_shortDataPreview(commonRes.data)}',
);
return false;
}
_logMsg(
'common_info 收到 data字段 keys=${commonData.keys.toList()}',
);
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}');
}
_logMsg(
'common_info 请求 GET /v1/user/common_info '
'sentinel=${ApiConfig.appId} asset=$uid',
);
final commonRes = await UserApi.getCommonInfo(
sentinel: ApiConfig.appId,
asset: uid,
);
if (_applyCommonInfoAndDidHomeStructureChange(commonRes)) {
UserState.requestHomeFullReload();
}
}
/// 日志里预览 [data],避免整段 surge 撑爆日志
static String _shortDataPreview(dynamic data, {int maxLen = 240}) {
if (data == null) return 'null';
if (data is Map) {
return 'Map(keys=${data.keys.toList()}, size=${data.length})';
}
if (data is List) {
return 'List(len=${data.length})';
}
String s;
try {
s = data is String ? data : jsonEncode(data);
} catch (_) {
s = data.toString();
}
if (s.length <= maxLen) return s;
return '${s.substring(0, maxLen)}…(len=${s.length})';
}
static Future<void> _runPostLoginReferrerWork(String uid, String deviceId) async {
try {
await _reportBothReferrersAndRefreshCommonInfo(uid, deviceId);
} catch (e, st) {
_logMsg('referrer/common_info 后台任务异常: $e');
_logMsg('referrer/common_info 堆栈: $st');
}
}
}