import 'dart:async'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; import '../../core/utils/logger.dart'; import '../../data/models/user_Info.dart'; import '../device_bind_registry.dart'; import '../../data/services/network/api.dart'; import 'bes_bluetooth_service.dart'; /// 恒玄耳机的确权结论。 enum BesAuthResult { /// 本地白名单命中,或服务端确认这台 MAC 挂在本应用的这个产品名下。 authorized, /// 服务端**明确拒绝**:这台 MAC 没登记在该产品下(AuthorizeNoCanUse)。 denied, /// 得不出结论——未登录 / 断网 / 产品表还没下发 / iOS 拿不到真 MAC。 /// **按放行处理**,理由见 [verifyConnected]。 unknown, } /// 「账号 ↔ 恒玄耳机」的确权 + 绑定登记,走服务端 `user_binddevice`。 /// /// ## 这个接口对恒玄耳机就是「校验 MAC」,仅此而已 /// /// 服务端的设备表 2026-09-04 已从 `license_` 分表合并成**全局一张** `device_mac`: /// 主键就是设备 MAC。`api_binddevice.go` 拿 MAC 全局反查,查到即放行, /// 产品是从查到那一行的 `productid` 列**读出来的结果**。 /// /// 所以客户端这边**只需要把 MAC 报上去**——不传 pid、不查本地产品表、不做名字匹配。 /// 杰理那套广播授权码(`code`)这条链路不用,传空即可。 /// /// ⚠️ 别再加回「先在本地产品表里挑出 pid」那套。它出过事故:一台已正确导入的耳机, /// 因所属产品的 devicetype 被后台配成 2(BLE双端)而不是 1(经典蓝牙), /// 被「只在 devicetype==1 的产品里挑」过滤掉、退回了别的产品的 pid, /// 服务端拿着错 pid 查错的分表,报「这台 MAC 没登记在该产品下」—— /// 提示指向数据缺失,实际数据好好的。 /// /// ## 第一次连才走服务端,之后读本地白名单 /// /// 确权通过的 MAC 落到 [_authorizedKey],下次连上直接命中放行、不打接口。 /// 白名单**不按账号分**:确权说的是「这台硬件是不是正品」,是设备属性不是账号 /// 属性,换账号不该让同一台耳机重新变成可疑设备。所以 [reset](登出)只清进程内 /// 的上报去重集合,不动白名单。 /// /// ## 判不出来的时候一律放行 /// /// [BesAuthResult.unknown] 放行是刻意选的失败方向。判严的代价是**合法用户一断网 /// 就用不了自己的耳机**;判宽的代价只是未登记的设备在离线时能用一会儿,等联上网 /// 第一次确权就会被拦下。前者严重得多。 /// /// ## 两端的 MAC 从哪来(2026-09-02 起 iOS 也能校验了) /// /// `BesBluetoothService.connectedDeviceId` 在 Android 上是蓝牙 MAC,在 iOS 上却是 /// CoreBluetooth 的 peripheral UUID(bluetooth_manager 的 `BluetoothManager.swift` /// 里 `p.identifier.uuidString`),**每台 iPhone 看同一只耳机都不一样**,不可能命中 /// 后台登记的 MAC——所以 iOS 上这道闸门以前直接放行、等于没有。 /// /// 现在统一走 [BesBluetoothService.resolveDeviceMac]:Android 仍用 connectedDeviceId, /// iOS 用耳机在 `BB 0A` 状态帧属性 7 里**自报**的蓝牙地址(不经过系统 API,两端一致; /// 实测与 Android 拿到的逐字节吻合)。取不到时照旧返回 [BesAuthResult.unknown] 放行。 class BesDeviceAuth { BesDeviceAuth(this._bes); final BesBluetoothService _bes; static const String _tag = 'BesDeviceAuth'; /// 确权通过的 MAC 白名单(持久化)。存大写 MAC。 static const String _authorizedKey = 'bes_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(); /// 这台 MAC 之前确权通过过吗 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); } /// 把一台设备移出白名单,下次连上时重新走服务端确权。 /// /// 用户在设备管理页主动解绑时必须调这个:白名单命中会**跳过接口**, /// 不清的话解绑之后再连上不会重新登记,设备管理页就永远空着了。 /// (代价是解绑完再连一次又会自动绑回来——这是「不需要手动点绑定」的 /// 必然结果,不是 bug。) 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(); } /// 校验当前已连接的这台恒玄耳机。 /// /// 被 [BesBluetoothService] 的连接状态流调用——那条流是**所有**连接路径的必经 /// 之地(主动连接、冷启动回连、原生 ACL 广播触发的被动接管都会经过), /// 放在扫描页拦不住被动回连。 /// /// 判定为 [BesAuthResult.denied] 时会**就地断开连接并提示用户**,调用方只需 /// 据返回值决定要不要继续后续握手。 Future verifyConnected() async { final bes = _bes; if (!bes.isConnected) return BesAuthResult.unknown; // iOS 上 connectedDeviceId 是 peripheral UUID,不能拿去校验; // 改从耳机自报的 `BB 0A` 属性 7 取真 MAC(见 BesBluetoothService.resolveDeviceMac)。 final mac = await bes.resolveDeviceMac(); if (mac == null || mac.isEmpty) { Logger.i(_tag, '取不到设备 MAC,跳过校验(放行)'); return BesAuthResult.unknown; } final name = bes.connectedDeviceName.value; // ① 本地白名单——第一次之后就走这条,不打接口、离线也能连 if (isAuthorizedLocally(mac)) { // ⚠️ 放行的只是**确权**,绑定登记不能跟着一起省:白名单不按账号分 // (确权是设备属性,一台正品永远是正品),换个账号登录同一台设备仍会命中, // 直接 return 就等于 binddevice 一次都不打 —— 服务端还认为它属于上一个账号, // 新账号的「设备管理」页永远空着,而且连接/录音/OTA 全都正常,看不出异常。 // 登记按当前账号判断,异步补,不阻塞握手。见 DeviceBindRegistry。 unawaited(DeviceBindRegistry.ensureBound(mac: mac, name: name)); return BesAuthResult.authorized; } if (_inflight.contains(mac)) return BesAuthResult.unknown; _inflight.add(mac); try { final result = await _verifyRemote(mac: mac, name: name); if (result == BesAuthResult.denied) { // 传原始 deviceId 而不是大写化的 mac:pairedDevices 里存的是 // connectToDevice 当时拿到的那个串,大小写必须逐字对上才删得掉。 await _reject(bes.connectedDeviceId.value); } return result; } finally { _inflight.remove(mac); } } Future _verifyRemote({ required String mac, required String name, }) async { if (!User.isLoggedIn()) { // 先连设备后登录是常见顺序。这里不算失败——等 MyDevicesController 拉列表 // 或下次连接时会再校一次。 Logger.i(_tag, '未登录,暂不校验,放行'); return BesAuthResult.unknown; } // 服务端已经绑过这台了,等于确权通过,省一次往返 if (User.instance.devices.any( (d) => (d.devicemac).toUpperCase() == mac)) { await _remember(mac); _reported.add(mac); return BesAuthResult.authorized; } try { // ⚠️ **不再传 pid**(2026-09-04 起)。服务端的设备表已合成全局一张 device_mac, // 按 MAC 就能定位到设备,产品是从查到的那一行的 productid 列读出来的。 // // 以前这里要先在本地产品表里猜出 pid 才能发请求,猜法是「只在 devicetype==1 // 的产品里按名字互相包含匹配,匹配不上退回第一个」。这个猜法出过事故: // 一台已正确导入的耳机,因所属产品的 devicetype 被后台配成 2(BLE双端) // 而不是 1(经典蓝牙),整个产品被过滤掉、退回了别的产品的 pid, // 服务端拿着错 pid 去错的分表查,报「这台 MAC 没登记在该产品下」—— // 于是一台合法设备被判成 denied、断开、弹「设备连接受限」。 // // code 传空:恒玄没有杰理那套广播里的授权码。 // 10s 上限:确权排在握手前面,dio 那边 connect/receive 各 30s, // 弱网时不加这道限制会让首连的固件版本/电量查询干等一分钟。 // 超时抛 TimeoutException → 走 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 = 服务端明确拒绝, // 异常 = 没连上服务端。前者拦,后者放。 if (resp == null) { Logger.w(_tag, '服务端拒绝: name=$name mac=$mac —— 这个 MAC 不在设备表(device_mac)里,' '去后台「设备管理 → 生成MAC」确认是否已生产/导入'); return BesAuthResult.denied; } final deviceJson = resp['device']; if (deviceJson is! Map) { // code==0 却没有 device:协议异常,不当成「未授权」处理 Logger.w(_tag, '绑定接口未返回 device 字段,按无结论放行。resp=$resp'); return BesAuthResult.unknown; } final device = UserDevice.fromJson(deviceJson); // addUserDevice 是无脑 add,先去重,免得反复连接堆出重复行 User.instance.removeUserDevice(device.id); User.instance.addUserDevice(device); await _remember(mac); _reported.add(mac); Logger.i(_tag, '确权通过: name=$name mac=$mac id=${device.id}'); return BesAuthResult.authorized; } catch (e) { Logger.e(_tag, '校验请求失败(按无结论放行): $e'); return BesAuthResult.unknown; } } /// 确权不通过:断开这台设备,并把它从自动回连列表里摘掉。 /// /// ⚠️ **必须移出 pairedDevices**,否则冷启动的 `tryAutoConnect` 和原生 ACL /// 广播会立刻把它连回来,变成「连上→弹窗→断开→连上」的死循环。 Future _reject(String address) async { try { // removePairedDevice 内部在地址匹配时会顺带 disconnectDevice await _bes.removePairedDevice(address); if (_bes.isConnected) { await _bes.disconnectDevice(); } } catch (e) { Logger.e(_tag, '断开未确权设备失败: $e'); } Get.snackbar( 'tip'.tr, 'deviceConnectionRestricted'.tr, snackPosition: SnackPosition.TOP, backgroundColor: Colors.red.withValues(alpha: 0.1), colorText: Colors.red, duration: const Duration(seconds: 4), ); } }