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.
269 lines
12 KiB
269 lines
12 KiB
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_<pid>` 分表合并成**全局一张** `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<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();
|
|
|
|
/// 这台 MAC 之前确权通过过吗
|
|
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);
|
|
}
|
|
|
|
/// 把一台设备移出白名单,下次连上时重新走服务端确权。
|
|
///
|
|
/// 用户在设备管理页主动解绑时必须调这个:白名单命中会**跳过接口**,
|
|
/// 不清的话解绑之后再连上不会重新登记,设备管理页就永远空着了。
|
|
/// (代价是解绑完再连一次又会自动绑回来——这是「不需要手动点绑定」的
|
|
/// 必然结果,不是 bug。)
|
|
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();
|
|
}
|
|
|
|
/// 校验当前已连接的这台恒玄耳机。
|
|
///
|
|
/// 被 [BesBluetoothService] 的连接状态流调用——那条流是**所有**连接路径的必经
|
|
/// 之地(主动连接、冷启动回连、原生 ACL 广播触发的被动接管都会经过),
|
|
/// 放在扫描页拦不住被动回连。
|
|
///
|
|
/// 判定为 [BesAuthResult.denied] 时会**就地断开连接并提示用户**,调用方只需
|
|
/// 据返回值决定要不要继续后续握手。
|
|
Future<BesAuthResult> 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<BesAuthResult> _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<String, dynamic>) {
|
|
// 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<void> _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),
|
|
);
|
|
}
|
|
}
|
|
|