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.
 
 
 
 
 
 

170 lines
6.8 KiB

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';
/// 「账号 ↔ Smartcar」的确权 + 绑定登记,走服务端 `user_binddevice`。
///
/// 与恒玄那套([BesDeviceAuth])同一个套路,判定口径逐条对齐,
/// 但有**一个关键差别**——
///
/// ## MAC 从广播里来,所以 iOS 也能真正确权
///
/// 恒玄链路上 iOS 拿不到真 MAC(CoreBluetooth 只给 peripheral UUID,
/// 每台手机看同一只耳机都不一样),所以那边 iOS 一律放行、闸门等于没有。
///
/// Smartcar 的**经典蓝牙 MAC 就写在 BLE 广播里**(厂商数据 CID 之后的 6 字节,原样、不异或),
/// 两端解出来完全一致。所以这条链路的确权在 iOS 上是真生效的。
/// ⚠️ 别照抄恒玄那句「非 Android 直接返回 null」。
///
/// ## 服务端不需要改
///
/// 设备表 2026-09-04 已合成全局一张 `device_mac`,按 MAC 全局反查、
/// 产品从命中行的 `productid` 列读出。所以**只报 MAC,不传 pid**——
/// 2026-09-04 那次「设备连不上」的事故根因就是客户端猜 pid 猜错了。
///
/// ## 第一次连才走服务端,之后读本地白名单
///
/// 白名单**不按账号分**:确权说的是「这台硬件是不是正品」,是设备属性不是账号属性,
/// 换账号不该让同一台设备重新变可疑。[reset](登出)只清进程内的上报去重集合。
///
/// ## 判不出来一律放行
///
/// [DeviceAuthResult.unknown] 放行是刻意选的失败方向:判严的代价是
/// **合法用户一断网就用不了自己的设备**;判宽只是未登记设备离线时能用一会儿,
/// 联网第一次确权就会被拦下。前者严重得多。
class SmartcarDeviceAuth {
SmartcarDeviceAuth._();
static const String _tag = 'SmartcarDeviceAuth';
/// 确权通过的 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();
}
/// 校验一台刚连上的 Smartcar。
///
/// [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)) 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;
}
}
}