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'; /// 「账号 ↔ 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 _reported = {}; /// 同一台设备的确权正在进行,避免状态抖动时并发打接口 static final Set _inflight = {}; /// 登出时调:换账号后同一台设备要重新登记到新账号名下。 /// **白名单不清**(理由见类注释)。 static void reset() => _reported.clear(); static List get _whitelist => (_storage.read(_authorizedKey) ?? const []) .map((e) => e.toString()) .toList(); static bool isAuthorizedLocally(String mac) => mac.isNotEmpty && _whitelist.contains(mac.toUpperCase()); static Future _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 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 clearWhitelist() async { await _storage.remove(_authorizedKey); _reported.clear(); } /// 校验一台刚连上的 Smartcar。 /// /// [mac] 来自广播(CID 之后 6 字节原样)。拿不到就 [DeviceAuthResult.unknown]。 static Future 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 _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; } } }