You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
177 lines
7.1 KiB
177 lines
7.1 KiB
import 'dart:async';
|
|
|
|
import 'package:get_storage/get_storage.dart';
|
|
|
|
import '../../core/utils/logger.dart';
|
|
import '../../data/models/user_Info.dart';
|
|
import '../../data/services/network/api.dart';
|
|
import '../device_auth.dart';
|
|
import '../device_bind_registry.dart';
|
|
|
|
/// 「账号 ↔ EaiCar」的确权 + 绑定登记,走服务端 `user_binddevice`。
|
|
///
|
|
/// 与恒玄那套([BesDeviceAuth])同一个套路,判定口径逐条对齐,
|
|
/// 但有**一个关键差别**——
|
|
///
|
|
/// ## MAC 从广播里来,所以 iOS 也能真正确权
|
|
///
|
|
/// 恒玄链路上 iOS 拿不到真 MAC(CoreBluetooth 只给 peripheral UUID,
|
|
/// 每台手机看同一只耳机都不一样),所以那边 iOS 一律放行、闸门等于没有。
|
|
///
|
|
/// EaiCar 的**经典蓝牙 MAC 就写在 BLE 广播里**(厂商数据 CID 之后的 6 字节,原样、不异或),
|
|
/// 两端解出来完全一致。所以这条链路的确权在 iOS 上是真生效的。
|
|
/// ⚠️ 别照抄恒玄那句「非 Android 直接返回 null」。
|
|
///
|
|
/// ## 服务端不需要改
|
|
///
|
|
/// 设备表 2026-09-04 已合成全局一张 `device_mac`,按 MAC 全局反查、
|
|
/// 产品从命中行的 `productid` 列读出。所以**只报 MAC,不传 pid**——
|
|
/// 2026-09-04 那次「设备连不上」的事故根因就是客户端猜 pid 猜错了。
|
|
///
|
|
/// ## 第一次连才走服务端,之后读本地白名单
|
|
///
|
|
/// 白名单**不按账号分**:确权说的是「这台硬件是不是正品」,是设备属性不是账号属性,
|
|
/// 换账号不该让同一台设备重新变可疑。[reset](登出)只清进程内的上报去重集合。
|
|
///
|
|
/// ## 判不出来一律放行
|
|
///
|
|
/// [DeviceAuthResult.unknown] 放行是刻意选的失败方向:判严的代价是
|
|
/// **合法用户一断网就用不了自己的设备**;判宽只是未登记设备离线时能用一会儿,
|
|
/// 联网第一次确权就会被拦下。前者严重得多。
|
|
class EaiCarDeviceAuth {
|
|
EaiCarDeviceAuth._();
|
|
|
|
static const String _tag = 'EaiCarDeviceAuth';
|
|
|
|
/// 确权通过的 MAC 白名单(持久化,存大写)
|
|
static const String _authorizedKey = 'smartcar_authorized_macs';
|
|
|
|
static final GetStorage _storage = GetStorage();
|
|
|
|
/// 本进程已成功登记的 MAC,避免每次回连都打接口。只记成功的。
|
|
static final Set<String> _reported = <String>{};
|
|
|
|
/// 同一台设备的确权正在进行,避免状态抖动时并发打接口
|
|
static final Set<String> _inflight = <String>{};
|
|
|
|
/// 登出时调:换账号后同一台设备要重新登记到新账号名下。
|
|
/// **白名单不清**(理由见类注释)。
|
|
static void reset() => _reported.clear();
|
|
|
|
static List<String> get _whitelist =>
|
|
(_storage.read<List>(_authorizedKey) ?? const [])
|
|
.map((e) => e.toString())
|
|
.toList();
|
|
|
|
static bool isAuthorizedLocally(String mac) =>
|
|
mac.isNotEmpty && _whitelist.contains(mac.toUpperCase());
|
|
|
|
static Future<void> _remember(String mac) async {
|
|
final list = _whitelist;
|
|
final m = mac.toUpperCase();
|
|
if (list.contains(m)) return;
|
|
list.add(m);
|
|
await _storage.write(_authorizedKey, list);
|
|
}
|
|
|
|
/// 把一台设备移出白名单,下次连上重新走服务端确权。
|
|
///
|
|
/// ⚠️ 用户在设备管理页主动解绑时**必须**调这个:白名单命中会跳过接口,
|
|
/// 不清的话解绑之后再连上不会重新登记,设备管理页就永远空着了。
|
|
static Future<void> forget(String mac) async {
|
|
if (mac.isEmpty) return;
|
|
final m = mac.toUpperCase();
|
|
_reported.remove(m);
|
|
final list = _whitelist..remove(m);
|
|
await _storage.write(_authorizedKey, list);
|
|
}
|
|
|
|
/// 【调试/售后】清空白名单
|
|
static Future<void> clearWhitelist() async {
|
|
await _storage.remove(_authorizedKey);
|
|
_reported.clear();
|
|
}
|
|
|
|
/// 校验一台刚连上的 EaiCar。
|
|
///
|
|
/// [mac] 来自广播(CID 之后 6 字节原样)。拿不到就 [DeviceAuthResult.unknown]。
|
|
static Future<DeviceAuthResult> verify({
|
|
required String? mac,
|
|
required String name,
|
|
}) async {
|
|
if (mac == null || mac.isEmpty) {
|
|
Logger.i(_tag, '广播里没解出 MAC,跳过校验(放行)');
|
|
return DeviceAuthResult.unknown;
|
|
}
|
|
final m = mac.toUpperCase();
|
|
|
|
// ① 本地白名单——第一次之后走这条,不打接口、离线也能连
|
|
if (isAuthorizedLocally(m)) {
|
|
// 放行的只是确权;登记是账号属性,换账号后必须重报一次,理由见 DeviceBindRegistry
|
|
unawaited(DeviceBindRegistry.ensureBound(mac: m, name: name));
|
|
return DeviceAuthResult.authorized;
|
|
}
|
|
|
|
if (_inflight.contains(m)) return DeviceAuthResult.unknown;
|
|
_inflight.add(m);
|
|
try {
|
|
return await _verifyRemote(mac: m, name: name);
|
|
} finally {
|
|
_inflight.remove(m);
|
|
}
|
|
}
|
|
|
|
static Future<DeviceAuthResult> _verifyRemote({
|
|
required String mac,
|
|
required String name,
|
|
}) async {
|
|
if (!User.isLoggedIn()) {
|
|
// 先连设备后登录是常见顺序,不算失败:下次连接或拉设备列表时会再校一次
|
|
Logger.i(_tag, '未登录,暂不校验,放行');
|
|
return DeviceAuthResult.unknown;
|
|
}
|
|
|
|
// 服务端已经绑过这台了,等于确权通过,省一次往返
|
|
if (User.instance.devices
|
|
.any((d) => d.devicemac.toUpperCase() == mac)) {
|
|
await _remember(mac);
|
|
_reported.add(mac);
|
|
return DeviceAuthResult.authorized;
|
|
}
|
|
|
|
try {
|
|
// ⚠️ **不传 pid**:服务端按 MAC 全局反查(device_mac 合表后),
|
|
// 产品是从命中行读出来的。客户端猜 pid 出过事故,别加回来。
|
|
// code 传空:C2 的 License 在广播里,但服务端这条链路认的是 MAC。
|
|
// 10s 上限:确权排在握手后、业务可用前,dio 那边 connect/receive 各 30s,
|
|
// 弱网不限制会让首连干等一分钟。超时 → catch → unknown → 放行。
|
|
final resp = await Api.binddevice({
|
|
'code': '',
|
|
'devicename': name,
|
|
'devicemac': mac,
|
|
}).timeout(const Duration(seconds: 10));
|
|
|
|
// ⚠️ null 与抛异常含义**完全相反**,绝不能合并处理:
|
|
// AuthInterceptor 在业务码 != 0 时把 data 置 null 后 **resolve(不抛)**,
|
|
// 网络层出问题才抛 DioException。
|
|
// 所以 null = 服务端明确拒绝 → denied;异常 = 没连上服务端 → unknown。
|
|
// 写成 try{ if(resp==null) ... }catch{ 同样处理 } 等于断网即锁死设备。
|
|
if (resp == null) {
|
|
Logger.w(
|
|
_tag,
|
|
'服务端拒绝: name=$name mac=$mac —— 这个 MAC 不在 device_mac 表里,'
|
|
'去后台「设备管理 → 生成MAC」确认是否已生产/导入');
|
|
return DeviceAuthResult.denied;
|
|
}
|
|
|
|
await _remember(mac);
|
|
_reported.add(mac);
|
|
Logger.i(_tag, '确权通过并已登记: $mac');
|
|
return DeviceAuthResult.authorized;
|
|
} catch (e) {
|
|
// 网络层问题:放行。判严会让合法用户断网时用不了自己的设备。
|
|
Logger.w(_tag, '确权请求异常(放行): $e');
|
|
return DeviceAuthResult.unknown;
|
|
}
|
|
}
|
|
}
|
|
|