Browse Source
设备的身份是它自己的 MAC,本来就全局唯一,定位一台设备不需要 pid。 原先按产品切分表,pid 既是查询键、又得由客户端猜出来,猜错就报 "这台 MAC 没登记在该产品下"——提示指向数据缺失,实际数据好好的。 触发改造的事故:一台已正确导入的 DEEPVOICE 耳机(MAC 在 license_b022, 产品 45090,status=0 未绑过)连不上、弹"设备连接受限"。根因是产品 45090 的 devicetype 被配成 2(BLE双端)而不是 1(经典蓝牙):客户端 _resolveProductId 第一步就 where(devicetype==1) 把整个产品滤掉,候选只剩 Echo-one,名字互不包含 → 兜底退回 headsets.first.id = 45091,服务端拿错 pid 去 license_b023 查,自然查不到。而且就算 pid 猜对也过不去——服务端 只有 devicetype==1 才走 MAC 反查,2 会走 else 分支从空 code 反解 pid、 回退写死的 45058。 改动: - 新增全局表 device_mac(comm.TableDeviceMac),主键 code = MAC; productid 降为归属列,不再是查询键。TableLicense 标记废弃,只留给迁移读。 - registry.go 的 migrateLicenseToDeviceMac 每次启动跑、幂等: ① code := devicemac 修存量 → ② 按两表列名交集做 INSERT ... ON CONFLICT DO NOTHING(不能 SELECT *:各分表建于不同时期,有的多 factoryid/probatch) → ③ productid 为空的用表名里的 pid 补上。孤儿分表里也是真实发出去的设备, 一并搬。搬完刻意不删旧表——不可逆数据,旧表是出问题时唯一的回退依据。 - binddevice / unbinddevice 改为先按 code 再按 MAC 全局定位,命中行的 productid 才是产品。req.Pid 不再采信。杰理链路也顺带不需要 devcode 反解 pid 了。 - 新增 comm.ScopeToProduct:合表后分表那层天然隔离没有了,每个"按产品" 的读写都必须显式带 productid,漏一处就跨产品操作——查询多列几行只是 难看,批量禁用 / 按批次删除漏掉就是删别人产品的数据。它会把调用方的 条件整体括起来,防止 "a=? or b=?" 让 or 把 productid 短路掉。 - 导入查重改成全局:同一个 MAC 出现在两个产品下会让按 MAC 反查出现歧义。 - 客户端删掉 _resolveProductId,binddevice 只报 MAC 不传 pid。 按需求明确不做应用隔离:MAC 存在即放行。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>main
13 changed files with 2361 additions and 389 deletions
@ -0,0 +1,257 @@ |
|||||
|
|
||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:get_storage/get_storage.dart'; |
||||
|
|
||||
|
import '../../core/utils/logger.dart'; |
||||
|
import '../models/user_Info.dart'; |
||||
|
import 'bes_bluetooth_service.dart'; |
||||
|
import 'network/api.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._(); |
||||
|
|
||||
|
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] 时会**就地断开连接并提示用户**,调用方只需 |
||||
|
/// 据返回值决定要不要继续后续握手。 |
||||
|
static Future<BesAuthResult> verifyConnected() async { |
||||
|
if (!Get.isRegistered<BesBluetoothService>()) return BesAuthResult.unknown; |
||||
|
final bes = BesBluetoothService.to; |
||||
|
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 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); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
static 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 |
||||
|
/// 广播会立刻把它连回来,变成「连上→弹窗→断开→连上」的死循环。 |
||||
|
static Future<void> _reject(String address) async { |
||||
|
try { |
||||
|
// removePairedDevice 内部在地址匹配时会顺带 disconnectDevice |
||||
|
await BesBluetoothService.to.removePairedDevice(address); |
||||
|
if (BesBluetoothService.to.isConnected) { |
||||
|
await BesBluetoothService.to.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), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,26 @@ |
|||||
|
package comm |
||||
|
|
||||
|
import "strings" |
||||
|
|
||||
|
// ScopeToProduct 把「只看这个产品」这层过滤显式拼进查询条件。
|
||||
|
//
|
||||
|
// device_mac 合表之后(2026-09-04 由 license_<pid十六进制> 分表合并而来),
|
||||
|
// productid 不再由表名承担。原先每个函数都靠 license_<pid> 这张表**隐含**了
|
||||
|
// 产品作用域,合表后**每一处按产品语义的读写都必须显式带上 productid**——
|
||||
|
// 漏一处就跨产品操作:查询多列出别的产品的设备还只是难看,
|
||||
|
// 批量禁用 / 按批次删除漏掉就是直接误伤别人的数据。
|
||||
|
//
|
||||
|
// pid == 0 表示不限产品(后台「全部产品」视图),此时原样返回。
|
||||
|
func ScopeToProduct(pid uint32, query string, args []interface{}) (string, []interface{}) { |
||||
|
if pid == 0 { |
||||
|
return query, args |
||||
|
} |
||||
|
scoped := make([]interface{}, 0, len(args)+1) |
||||
|
scoped = append(scoped, pid) |
||||
|
scoped = append(scoped, args...) |
||||
|
if strings.TrimSpace(query) == "" { |
||||
|
return "productid=?", scoped |
||||
|
} |
||||
|
// 原条件整体括起来:调用方传的可能是 "a=? or b=?",不括会让 or 把 productid 短路掉。
|
||||
|
return "productid=? and (" + query + ")", scoped |
||||
|
} |
||||
@ -0,0 +1,142 @@ |
|||||
|
package console |
||||
|
|
||||
|
import ( |
||||
|
"fmt" |
||||
|
|
||||
|
"yunyan/comm" |
||||
|
"yunyan/lego/sys/postgres" |
||||
|
) |
||||
|
|
||||
|
// ============================ 主键改号(BID / PID) ============================
|
||||
|
//
|
||||
|
// 方案商的 BID 与产品的 PID 都是「有含义的十六进制编码」,且都是所在表的主键。
|
||||
|
// 建档时填错过去只能删了重建(删产品会连带丢掉版本记录),所以放开了改号。
|
||||
|
//
|
||||
|
// ⚠️ 改主键是不可逆操作,且这两个值会烧进固件 / 决定 license 分表名,所以一律
|
||||
|
// 「有任何引用就不许改」,而不是级联更新:
|
||||
|
// - 方案商 BID:被 product.providerid 引用;
|
||||
|
// - 产品 PID:被 production_batch.productid、license_<pid十六进制> 分表、
|
||||
|
// 以及各应用业务库的 userdevice.productid / product_stat.productid 引用。
|
||||
|
// 后两者在别的库里,console 够不着也保证不了一致性——所以只要本库已经有批次或授权码,
|
||||
|
// 就说明货已经生产出去了,此时改号必然造成线上设备对不上,直接拒绝。
|
||||
|
//
|
||||
|
// 允许改的窗口就是「刚建档、还没投产」。这也是实际会填错的时候。
|
||||
|
|
||||
|
// checkChipVendorIdChange 校验芯片厂商能否从 oldId 改成 newId(即改 CID)。返回空串表示可以改。
|
||||
|
//
|
||||
|
// 与 BID/PID 不同:芯片厂商的下游只有方案商的 vendor 列,且完全在本库内,所以这里【级联更新】
|
||||
|
// 而不是拒绝——CID 是最可能填错、也最需要事后订正的一个(新芯片方案定型前常常只有暂定值)。
|
||||
|
func checkChipVendorIdChange(sys *appConn, oldId, newId uint32) string { |
||||
|
if newId == 0 { |
||||
|
return "CID 必填" |
||||
|
} |
||||
|
if newId == oldId { |
||||
|
return "" |
||||
|
} |
||||
|
if exist, _ := dvChipVendor(sys, newId); exist != nil && exist.Id != 0 { |
||||
|
return fmt.Sprintf("CID 0x%X 已被芯片厂商「%s」占用", newId, exist.Name) |
||||
|
} |
||||
|
return "" |
||||
|
} |
||||
|
|
||||
|
// renameChipVendorId 改芯片厂商的 CID,并级联把方案商的 vendor 引用一起搬过去。
|
||||
|
// 两步必须都做:只改厂商表会让名下方案商指向不存在的厂商,列表里显示成「厂商#N」。
|
||||
|
func renameChipVendorId(oldId, newId uint32) error { |
||||
|
if oldId == newId { |
||||
|
return nil |
||||
|
} |
||||
|
if res := postgres.Exec("UPDATE "+comm.TableChipVendor+" SET id=? WHERE id=?", newId, oldId); res.Error != nil { |
||||
|
return res.Error |
||||
|
} |
||||
|
if res := postgres.Exec("UPDATE "+comm.TableSolutionProvider+" SET vendor=? WHERE vendor=?", newId, oldId); res.Error != nil { |
||||
|
return res.Error |
||||
|
} |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
// checkProviderIdChange 校验方案商能否从 oldId 改成 newId。返回空串表示可以改。
|
||||
|
func checkProviderIdChange(sys *appConn, oldId, newId uint32) string { |
||||
|
if newId == 0 { |
||||
|
return "BID 必填" |
||||
|
} |
||||
|
if newId == oldId { |
||||
|
return "" |
||||
|
} |
||||
|
// 新号不能已被占用
|
||||
|
if exist, _ := dvSolutionProvider(sys, newId); exist != nil && exist.Id != 0 { |
||||
|
return fmt.Sprintf("BID 0x%X 已被方案商「%s」占用", newId, exist.Name) |
||||
|
} |
||||
|
// 已有产品挂在旧号下就不许改:product.providerid 是裸整数、没有外键,
|
||||
|
// 改了那些产品会指向一个不存在的方案商,而 CID 正是从这里推导的。
|
||||
|
n, err := dvCountProductsByProvider(sys, oldId) |
||||
|
if err != nil { |
||||
|
return "校验产品引用失败: " + err.Error() |
||||
|
} |
||||
|
if n > 0 { |
||||
|
return fmt.Sprintf("该方案商下已有 %d 个产品,不能改 BID。请先把这些产品改挂到别的方案商下", n) |
||||
|
} |
||||
|
return "" |
||||
|
} |
||||
|
|
||||
|
// checkProductIdChange 校验产品能否从 oldId 改成 newId。返回空串表示可以改。
|
||||
|
func checkProductIdChange(sys *appConn, oldId, newId uint32) string { |
||||
|
if newId == 0 { |
||||
|
return "PID 必填" |
||||
|
} |
||||
|
if newId == oldId { |
||||
|
return "" |
||||
|
} |
||||
|
if exist, _ := dvProduct(sys, newId); exist != nil && exist.Id != 0 { |
||||
|
return fmt.Sprintf("PID 0x%X 已被产品「%s」占用", newId, exist.Devicename) |
||||
|
} |
||||
|
// 已投产就不许改:批次台账与授权码分表都按 PID 组织,且设备码已经发出去了。
|
||||
|
var batches int64 |
||||
|
if err := sys.AdminDB().Table(comm.TableProductionBatch).Where("productid=?", oldId).Count(&batches).Error; err != nil { |
||||
|
return "校验生产批次失败: " + err.Error() |
||||
|
} |
||||
|
if batches > 0 { |
||||
|
return fmt.Sprintf("该产品已有 %d 个生产批次,不能改 PID(授权码分表 license_%x 与已发出的设备都按它组织)", batches, oldId) |
||||
|
} |
||||
|
if n, err := countLicenseRows(oldId); err != nil { |
||||
|
return "校验授权码失败: " + err.Error() |
||||
|
} else if n > 0 { |
||||
|
return fmt.Sprintf("该产品已生产 %d 个授权码,不能改 PID", n) |
||||
|
} |
||||
|
return "" |
||||
|
} |
||||
|
|
||||
|
// countLicenseRows 统计某产品名下已生产的设备行数(改 PID 前的前置检查)。
|
||||
|
// 2026-09-04 合表后设备都在 device_mac,按 productid 过滤即可。
|
||||
|
func countLicenseRows(pid uint32) (int64, error) { |
||||
|
var n int64 |
||||
|
if err := postgres.Table(comm.TableDeviceMac).Where("productid=?", pid).Count(&n).Error; err != nil { |
||||
|
return 0, err |
||||
|
} |
||||
|
return n, nil |
||||
|
} |
||||
|
|
||||
|
// renameProviderId 执行方案商改号(先校验后调用)。用裸 UPDATE 改主键:
|
||||
|
// gorm 的 Save 是「按主键定位再更新」,改不动主键本身。
|
||||
|
func renameProviderId(oldId, newId uint32) error { |
||||
|
if oldId == newId { |
||||
|
return nil |
||||
|
} |
||||
|
res := postgres.Exec("UPDATE "+comm.TableSolutionProvider+" SET id=? WHERE id=?", newId, oldId) |
||||
|
return res.Error |
||||
|
} |
||||
|
|
||||
|
// renameProductId 执行产品改号(先校验后调用)。产品版本表按 productid 归属,
|
||||
|
// 跟着一起搬——版本是产品的附属数据,不搬会变成谁也看不见的孤儿行。
|
||||
|
// 授权码/批次在校验里已确认为空,无需处理。
|
||||
|
func renameProductId(oldId, newId uint32) error { |
||||
|
if oldId == newId { |
||||
|
return nil |
||||
|
} |
||||
|
if res := postgres.Exec("UPDATE "+comm.TableProduct+" SET id=? WHERE id=?", newId, oldId); res.Error != nil { |
||||
|
return res.Error |
||||
|
} |
||||
|
if res := postgres.Exec("UPDATE "+comm.TableProductVersion+" SET productid=? WHERE productid=?", newId, oldId); res.Error != nil { |
||||
|
return res.Error |
||||
|
} |
||||
|
return nil |
||||
|
} |
||||
Loading…
Reference in new issue