feat(core): 设备号改用系统标识,重装不再变成新游客

- Android 读 SSAID、iOS 读 IDFV,均免权限,由原生通道提供,不引插件
- 取不到时回退本地 UUIDv4,替代原来 32 位随机 + 时间戳的拼接
This commit is contained in:
Vicky authored and trae-solo-agent committed 2026-10-27 14:15:50 +08:00
1 parent 935de13343
commit 1b19513efb
5 files changed
+165 -12

No files matched your search

+33 -12
View File
@@ -4,6 +4,7 @@ import 'dart:math';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/auth_session.dart';
import 'device_id_provider.dart';
/// 令牌与设备号的本地持久化。
///
@@ -11,13 +12,17 @@ import '../models/auth_session.dart';
/// `SharedPreferences.getInstance()` 在新版本里已标注弃用,两套混用还会让
/// widget 测试只 mock 到其中一套。
///
/// 设备号是后端注册风控与后续"下载设备绑定"的依据,所以**首次生成后就固定下来**;
/// 不引 UUID 库(保持极简依赖),用「时间戳 + 随机数」拼一个够用的 id。
/// 设备号优先取系统标识(Android SSAID / iOS IDFV),这样卸载重装后还是同一台设备;
/// 系统标识不可用时才回退到本地生成并持久化的 UUID,保证任何环境都能跑。
class AuthSessionStore {
AuthSessionStore({SharedPreferencesAsync? preferences})
: _preferences = preferences ?? SharedPreferencesAsync();
AuthSessionStore({
SharedPreferencesAsync? preferences,
DeviceIdProvider? deviceIdProvider,
}) : _preferences = preferences ?? SharedPreferencesAsync(),
_deviceIdProvider = deviceIdProvider ?? DeviceIdProvider();
final SharedPreferencesAsync _preferences;
final DeviceIdProvider _deviceIdProvider;
static const _kAccessToken = 'auth.accessToken';
static const _kRefreshToken = 'auth.refreshToken';
@@ -28,18 +33,34 @@ class AuthSessionStore {
Future<String> deviceId() async {
if (_cachedDeviceId != null) return _cachedDeviceId!;
var value = await _preferences.getString(_kDeviceId) ?? '';
if (value.isEmpty) {
final random = Random();
value =
'd-${DateTime.now().millisecondsSinceEpoch.toRadixString(36)}-'
'${random.nextInt(1 << 32).toRadixString(36)}';
await _preferences.setString(_kDeviceId, value);
}
final systemId = await _deviceIdProvider.systemDeviceId();
final value = systemId ?? await _localDeviceId();
_cachedDeviceId = value;
return value;
}
/// 系统标识取不到时的兜底:本地 UUIDv4,生成一次后固定下来。
Future<String> _localDeviceId() async {
final stored = await _preferences.getString(_kDeviceId) ?? '';
if (stored.isNotEmpty) return stored;
final generated = 'd-${_uuidV4()}';
await _preferences.setString(_kDeviceId, generated);
return generated;
}
/// UUIDv4:122 位随机,碰撞概率可忽略,不引第三方依赖。
String _uuidV4() {
final random = Random.secure();
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
String hex(int start, int end) => bytes
.sublist(start, end)
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
return '${hex(0, 4)}-${hex(4, 6)}-${hex(6, 8)}-${hex(8, 10)}-${hex(10, 16)}';
}
Future<AuthSession?> read() async {
final accessToken = await _preferences.getString(_kAccessToken) ?? '';
final refreshToken = await _preferences.getString(_kRefreshToken) ?? '';
@@ -0,0 +1,36 @@
// Author: fruit.com.cn
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
/// 读取系统级设备标识,两端都不需要任何权限。
///
/// - Android:SSAID(`Settings.Secure.ANDROID_ID`),卸载重装不变;
/// - iOS:IDFV(`identifierForVendor`),卸载本 App 不变,同厂商应用全删才会变。
///
/// 两端都由原生侧提供,不引插件:项目 iOS 走 SPM,避免混入 CocoaPods。
/// 取不到(Web、测试环境、通道异常)时返回 null,由调用方回退到本地生成的 id。
class DeviceIdProvider {
static const MethodChannel _channel = MethodChannel(
'com.stormg.lumadrama/device',
);
Future<String?> systemDeviceId() async {
if (kIsWeb) return null;
try {
if (Platform.isAndroid) {
final ssaid = await _channel.invokeMethod<String>('ssaid');
if (ssaid != null && ssaid.isNotEmpty) return 'a-$ssaid';
}
if (Platform.isIOS) {
final idfv = await _channel.invokeMethod<String>('idfv');
if (idfv != null && idfv.isNotEmpty) return 'i-$idfv';
}
} catch (_) {
// 原生通道不可用(widget 测试)时走本地兜底
}
return null;
}
}