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.
916 lines
37 KiB
916 lines
37 KiB
package console
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"yunyan/comm"
|
|
"yunyan/lego/sys/log"
|
|
"yunyan/pb"
|
|
"yunyan/utils/devcode"
|
|
"yunyan/utils/mac"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// 批次绑定赠送的上限(防手滑,不是业务硬约束)。
|
|
const (
|
|
batchRewardVipDayMax uint32 = 3650 // 10 年
|
|
batchRewardMinuteMax uint32 = 525600 // 【已停用】翻译/会议分钟数不再作为赠送项
|
|
batchRewardVipLevelMax int32 = 2 // 1=VIP 2=VIP-Pro
|
|
batchRewardComputeMax int64 = 10_000_000
|
|
)
|
|
|
|
// defaultBrandContactMail 品牌商联系邮箱缺省必含项(与 modules/api 一致)。
|
|
const defaultBrandContactMail = "358030594@qq.com"
|
|
|
|
// 设备管理域写操作 handlers。console 仅超管,数据访问作用于传入的"选中应用"连接 sys。
|
|
// 生产模型:授权码核心 = productid + number(产品级全局序号,按 product.serialcursor 原子累加分配),
|
|
// 生产批次独立成 production_batch 台账;授权码不再编入批次数据。
|
|
|
|
// ---- 产品 / 版本 ----
|
|
|
|
func (this *serverComp) updateProduct(c *gin.Context, sys *appConn) {
|
|
// 不直接绑 pb.ApiUpdateProductReq:改 PID 要同时知道新旧两个号,而 pb 里只有一个 id。
|
|
var req struct {
|
|
Product *pb.DBProduct `json:"product"`
|
|
Oldid uint32 `json:"oldid"` // 原 PID;0 或与 product.id 相同 = 不改号
|
|
}
|
|
_ = c.ShouldBindJSON(&req)
|
|
if req.Product == nil {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, "product 必填")
|
|
return
|
|
}
|
|
// 定位用旧号。不传 oldid 时退化成「按 id 定位、不改号」,兼容切版本这类局部更新。
|
|
locateId := req.Oldid
|
|
if locateId == 0 {
|
|
locateId = req.Product.Id
|
|
}
|
|
old, err := dvProduct(sys, locateId)
|
|
if err != nil {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, "产品不存在")
|
|
return
|
|
}
|
|
// 改号:先校验(新号未占用、未投产),再裸 UPDATE 改主键并搬迁版本行。
|
|
if req.Product.Id != locateId {
|
|
if msg := checkProductIdChange(sys, locateId, req.Product.Id); msg != "" {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
|
|
return
|
|
}
|
|
if err = renameProductId(locateId, req.Product.Id); err != nil {
|
|
writeErr(c, pb.ErrorCode_DBError, "改 PID 失败: "+err.Error())
|
|
return
|
|
}
|
|
old.Id = req.Product.Id
|
|
}
|
|
// 方案商:dvUpdateProduct 是全字段 Save,没传就会被清空。所以传 0 视为「不改」,沿用原绑定;
|
|
// 传了值才校验存在性(产品表单必填,切版本这类局部更新不带该字段)。
|
|
if req.Product.Providerid == 0 {
|
|
req.Product.Providerid = old.Providerid
|
|
} else if msg := checkProductProvider(sys, req.Product.Providerid); msg != "" {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
|
|
return
|
|
}
|
|
if msg := checkProductOemFactory(sys, req.Product.Oemfactoryid); msg != "" {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
|
|
return
|
|
}
|
|
// 品牌商 / 渠道方 / 绑定奖励 / 功能开关已从产品表单移除,前端不再回传这些字段。
|
|
// dvUpdateProduct 是全字段 Save,不显式保留就会被抹成 0/false——那会让存量产品的
|
|
// 绑定奖励失效、客户端功能开关集体关掉(这些值仍在下发给 App,见 user_getappconfig)。
|
|
// ⚠️ Scanuuid 不在此列:它已于 2026-09-08 整体废弃,pb 里改成 gorm:"-" 的兼容字段,
|
|
// 不建列也不参与读写,Save 碰不到它。
|
|
req.Product.Brandid = 0 // 品牌商已下线,这一列只作老客户端兼容字段的来源,恒 0
|
|
req.Product.Channel = old.Channel
|
|
req.Product.Channelextra = old.Channelextra
|
|
req.Product.Bindrewardvpitime = old.Bindrewardvpitime
|
|
req.Product.Bindrewardtranslate = old.Bindrewardtranslate
|
|
req.Product.Bindrewardmeeting = old.Bindrewardmeeting
|
|
req.Product.Bindviplevel = old.Bindviplevel // 2026-09 会员与算力:批次未配置时的回退值,产品表单同样不回传
|
|
req.Product.Bindcompute = old.Bindcompute
|
|
req.Product.Iscanbindagain = old.Iscanbindagain
|
|
req.Product.Iscontinuouschat = old.Iscontinuouschat
|
|
req.Product.Eqenable = old.Eqenable
|
|
req.Product.Keyenable = old.Keyenable
|
|
req.Product.Workmodeenable = old.Workmodeenable
|
|
req.Product.Callmodeenable = old.Callmodeenable
|
|
req.Product.Ledenable = old.Ledenable
|
|
req.Product.Finddeviceenable = old.Finddeviceenable
|
|
req.Product.Noisereductionenable = old.Noisereductionenable
|
|
req.Product.Shutdownenable = old.Shutdownenable
|
|
req.Product.Showcasebattery = old.Showcasebattery
|
|
req.Product.Wakeupenable = old.Wakeupenable
|
|
req.Product.Broadcastpeertranslate = old.Broadcastpeertranslate
|
|
req.Product.Hideaiassistant = old.Hideaiassistant
|
|
req.Product.Bassfilter = old.Bassfilter
|
|
req.Product.Serialcursor = old.Serialcursor // 授权码序号游标,绝不能被表单回传清零
|
|
// 创建时间只在新增时写一次,更新是全字段 Save,前端没回传就会被抹成 0。
|
|
if req.Product.Createtime == 0 {
|
|
req.Product.Createtime = old.Createtime
|
|
}
|
|
// 绑定应用(CSV,可多选):归一去重后校验都在应用中心里,避免拼错的应用名让下拉永远空着。
|
|
req.Product.Appnames = normalizeAppNames(req.Product.Appnames)
|
|
if msg := this.checkAppNames(req.Product.Appnames); msg != "" {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
|
|
return
|
|
}
|
|
// 指定 versionid 时,从版本表同步版本/描述/升级包地址,前端无需重复传。
|
|
if req.Product.Versionid != 0 {
|
|
ver, verr := dvProductVersion(sys, req.Product.Versionid)
|
|
if verr != nil || ver == nil || ver.Productid != req.Product.Id {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, "versionid 无效")
|
|
return
|
|
}
|
|
req.Product.Version = ver.Version
|
|
req.Product.Updatedescription = ver.Description
|
|
req.Product.Updatepackageaddress = ver.Updatepackageaddress
|
|
}
|
|
if err := dvUpdateProduct(sys, req.Product); err != nil {
|
|
writeErr(c, pb.ErrorCode_DBError, err.Error())
|
|
return
|
|
}
|
|
this.module.deviceCache.refreshProducts() // 写后刷新产品缓存
|
|
writeOK(c, &pb.ApiUpdateProductResp{})
|
|
}
|
|
|
|
// delProduct 删产品,并把它那张 license_<pid> 分表一起删掉。
|
|
//
|
|
// ⚠️ 分表必须跟着删:建表在 ensureDeviceTables 里按产品列表做,删产品时却只删产品行,
|
|
// 于是每删一个产品就留下一张永远没人再读的空表。这个环境上已经攒了 44 张
|
|
// (license_b001…license_b04e),全空、全无对应产品。
|
|
//
|
|
// 有授权码就拒绝删产品:那些 MAC 已经烧进设备发出去了,删掉产品会让线上设备绑定时
|
|
// 查不到产品直接报错,而且分表一删就再也追不回是哪一批货。要下架请停用产品,不要删。
|
|
func (this *serverComp) delProduct(c *gin.Context, sys *appConn) {
|
|
var req pb.ApiDelProductReq
|
|
_ = c.ShouldBindJSON(&req)
|
|
if req.Id == 0 {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, "id 必填")
|
|
return
|
|
}
|
|
if n, err := countLicenseRows(req.Id); err != nil {
|
|
writeErr(c, pb.ErrorCode_DBError, "校验授权码失败: "+err.Error())
|
|
return
|
|
} else if n > 0 {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError,
|
|
fmt.Sprintf("该产品已生产 %d 个 MAC,不能删除(这些设备已经发出去了)", n))
|
|
return
|
|
}
|
|
var batches int64
|
|
if err := sys.AdminDB().Table(comm.TableProductionBatch).Where("productid=?", req.Id).Count(&batches).Error; err != nil {
|
|
writeErr(c, pb.ErrorCode_DBError, "校验生产批次失败: "+err.Error())
|
|
return
|
|
}
|
|
if batches > 0 {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError,
|
|
fmt.Sprintf("该产品已有 %d 个生产批次,不能删除;请先撤销这些批次", batches))
|
|
return
|
|
}
|
|
if err := dvDelProduct(sys, req.Id); err != nil {
|
|
writeErr(c, pb.ErrorCode_DBError, err.Error())
|
|
return
|
|
}
|
|
// 设备行不用删:上面 countLicenseRows 已经保证这个产品在 device_mac 里是 0 行,
|
|
// 有设备就压根不让删产品。这里只顺手清掉合表前遗留的旧分表(迁移刻意不删它们,
|
|
// 见 registry.go 的 migrateLicenseToDeviceMac)。
|
|
// 删失败也不该把「产品已删」这个结果回滚成失败——只记日志。
|
|
if res := sys.AdminDB().Exec(fmt.Sprintf("DROP TABLE IF EXISTS %s_%x", comm.TableLicense, req.Id)); res.Error != nil {
|
|
log.Warn("console.device: 删产品后清理遗留的授权码分表失败",
|
|
log.Field{Key: "productid", Value: req.Id}, log.Field{Key: "err", Value: res.Error.Error()})
|
|
}
|
|
this.module.deviceCache.refreshProducts() // 写后刷新产品缓存
|
|
writeOK(c, &pb.ApiDelProductResp{})
|
|
}
|
|
|
|
func (this *serverComp) addProductVersion(c *gin.Context, sys *appConn) {
|
|
var req pb.ApiAddProductVersionReq
|
|
_ = c.ShouldBindJSON(&req)
|
|
if req.Version == nil {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, "version 必填")
|
|
return
|
|
}
|
|
if err := dvAddProductVersion(sys, req.Version); err != nil { // Insert 后回填 Version.Id
|
|
writeErr(c, pb.ErrorCode_DBError, err.Error())
|
|
return
|
|
}
|
|
// Isuse: 把该版本设为产品当前版本并持久化。
|
|
if req.Isuse {
|
|
product, err := dvProduct(sys, req.Version.Productid)
|
|
if err != nil {
|
|
writeErr(c, pb.ErrorCode_DBError, err.Error())
|
|
return
|
|
}
|
|
product.Version = req.Version.Version
|
|
product.Updatedescription = req.Version.Description
|
|
product.Updatepackageaddress = req.Version.Updatepackageaddress
|
|
product.Versionid = req.Version.Id
|
|
if err = dvUpdateProduct(sys, product); err != nil {
|
|
writeErr(c, pb.ErrorCode_DBError, err.Error())
|
|
return
|
|
}
|
|
}
|
|
this.module.deviceCache.refreshProductVersions() // 写后刷新版本缓存
|
|
if req.Isuse {
|
|
this.module.deviceCache.refreshProducts() // Isuse 改了产品当前版本,连带刷新产品缓存
|
|
}
|
|
writeOK(c, &pb.ApiAddProductVersionResp{Version: req.Version})
|
|
}
|
|
|
|
func (this *serverComp) delProductVersion(c *gin.Context, sys *appConn) {
|
|
var req pb.ApiDelProductVersionReq
|
|
_ = c.ShouldBindJSON(&req)
|
|
if err := dvDelProductVersion(sys, req.Id); err != nil {
|
|
writeErr(c, pb.ErrorCode_DBError, err.Error())
|
|
return
|
|
}
|
|
this.module.deviceCache.refreshProductVersions() // 写后刷新版本缓存
|
|
writeOK(c, &pb.ApiDelProductVersionResp{})
|
|
}
|
|
|
|
func (this *serverComp) addProduct(c *gin.Context, sys *appConn) {
|
|
var req pb.ApiAddProductReq
|
|
_ = c.ShouldBindJSON(&req)
|
|
if req.Product == nil {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, "product 必填")
|
|
return
|
|
}
|
|
// PID 手工指定(与 CID/BID 同套路:有含义的十六进制编码)。传 0 则回落到自增分配(从 0xB001 起),
|
|
// 兼容不带 PID 的老调用;传了就必须没被占用,否则报的是裸主键冲突、看不出是什么问题。
|
|
if req.Product.Id != 0 {
|
|
if exist, _ := dvProduct(sys, req.Product.Id); exist != nil && exist.Id != 0 {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError,
|
|
fmt.Sprintf("PID 0x%X 已被产品「%s」占用", req.Product.Id, exist.Devicename))
|
|
return
|
|
}
|
|
}
|
|
// 代工厂可选(0=不指定):产品级是「默认由哪家厂生产」,具体批次上还能再选一次。
|
|
if msg := checkProductOemFactory(sys, req.Product.Oemfactoryid); msg != "" {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
|
|
return
|
|
}
|
|
// 品牌商已于 2026-09-12 整体下线(brand 表已删):DBProduct.brandid 这一列只为老客户端
|
|
// 的兼容字段 factoryid 留着(见 modules/user/compat.go),服务端不再校验、不再写入。
|
|
req.Product.Brandid = 0
|
|
// 方案商挂在产品上(不是品牌商上):同一品牌的不同产品可能用不同芯片方案。
|
|
// 三个要烧进固件的标识口径固定为 CID=芯片公司 / BID=方案公司 / PID=产品,
|
|
// 其中 BID 就是这里的 providerid、CID 再由该方案商的 vendor 推出,所以必须绑定且真实存在。
|
|
if msg := checkProductProvider(sys, req.Product.Providerid); msg != "" {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
|
|
return
|
|
}
|
|
req.Product.Appnames = normalizeAppNames(req.Product.Appnames)
|
|
if msg := this.checkAppNames(req.Product.Appnames); msg != "" {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
|
|
return
|
|
}
|
|
req.Product.Createtime = time.Now().Unix()
|
|
if err := dvAddProduct(sys, req.Product); err != nil { // Insert 后回填 Product.Id + 建 license 分表
|
|
writeErr(c, pb.ErrorCode_DBError, err.Error())
|
|
return
|
|
}
|
|
this.module.deviceCache.refreshProducts()
|
|
writeOK(c, &pb.ApiAddProductResp{Product: req.Product})
|
|
}
|
|
|
|
// ---- 方案商(写)----
|
|
|
|
// 芯片方案厂商分组(DBSolutionProvider.vendor)。一个芯片厂商下往往有多套方案,
|
|
// 每套方案单独一条方案商记录:vendor 归组、name 记方案商公司名、description 记这套方案做什么。
|
|
//
|
|
// vendor 存的是 chip_vendor 表的 id(见 model_chipvendor.go)。原先这里有一份写死的
|
|
// chipVendorNames 映射,加一家厂商要同时改 db.proto 枚举、这张表、前端 utils/chipvendors.ts
|
|
// 三处再发版;已改为查表,后台「芯片管理」页可自助增删改。
|
|
// pb 的 SolutionVendor 枚举保留不动——proto3 是开放枚举,按数值收发即可,
|
|
// 表 id 与历史枚举值 1~4 一一对应,存量方案商的 vendor 不用迁移。
|
|
|
|
// checkChipVendor 校验芯片分组:必填且必须是 chip_vendor 表里已启用的一条,返回空串表示通过。
|
|
// 停用的厂商不允许新绑(但存量方案商照常显示其名字,见前端 chipVendorName)。
|
|
func checkChipVendor(sys *appConn, v pb.SolutionVendor) string {
|
|
if int32(v) == 0 {
|
|
return "芯片分组必填,请先在「芯片管理」里维护厂商"
|
|
}
|
|
vendor, err := dvChipVendor(sys, uint32(v))
|
|
if err != nil || vendor == nil || vendor.Id == 0 {
|
|
return fmt.Sprintf("未知的芯片分组:%d(可能已被删除,请到「芯片管理」确认)", int32(v))
|
|
}
|
|
if !vendor.Enabled {
|
|
return fmt.Sprintf("芯片厂商「%s」已停用,不能再绑新方案商", vendor.Name)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// checkProductionFactory 校验批次要记的代工厂:0=不指定直接放行;非 0 时必须存在且未停用。
|
|
// 可选而非必填:本功能上线前的批次都没有工厂概念,强制填会让老流程直接卡死。
|
|
func checkProductionFactory(sys *appConn, id uint32) string {
|
|
if id == 0 {
|
|
return ""
|
|
}
|
|
f, err := dvOemFactory(sys, id)
|
|
if err != nil || f == nil || f.Id == 0 {
|
|
return "代工厂不存在,请到「工厂管理」确认"
|
|
}
|
|
if !f.Enabled {
|
|
return fmt.Sprintf("工厂「%s」已停用,不能用于生产", f.Name)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// checkProductOemFactory 校验产品绑定的代工厂:0=不指定放行;非 0 时必须存在且未停用。
|
|
// 可选而非必填:工厂档案可能还没建,强制填会让「先建产品、后补工厂」这个常见顺序走不通。
|
|
func checkProductOemFactory(sys *appConn, id uint32) string {
|
|
if id == 0 {
|
|
return ""
|
|
}
|
|
f, err := dvOemFactory(sys, id)
|
|
if err != nil || f == nil || f.Id == 0 {
|
|
return "代工厂不存在,请到「工厂管理」确认"
|
|
}
|
|
if !f.Enabled {
|
|
return fmt.Sprintf("工厂「%s」已停用,不能绑到产品上", f.Name)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// checkProductProvider 校验产品绑定的方案商:必填且必须存在,返回空串表示通过。
|
|
func checkProductProvider(sys *appConn, id uint32) string {
|
|
if id == 0 {
|
|
return "归属方案必填(方案ID(BID)取自它,芯片ID(CID)再由它的芯片推出)"
|
|
}
|
|
p, err := dvSolutionProvider(sys, id)
|
|
if err != nil || p == nil || p.Id == 0 {
|
|
return fmt.Sprintf("方案商 %X 不存在", id)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (this *serverComp) addSolutionProvider(c *gin.Context, sys *appConn) {
|
|
var req pb.ApiAddSolutionProviderReq
|
|
_ = c.ShouldBindJSON(&req)
|
|
if req.Provider == nil || strings.TrimSpace(req.Provider.Name) == "" {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, "provider name 必填")
|
|
return
|
|
}
|
|
// 方案商 id 是手工指定的有含义编码(十六进制看,如 0xC101=杰里01方案),不自增:
|
|
// 不填就会插成 id=0,第二条再插直接主键冲突,所以这里必须挡住。
|
|
if req.Provider.Id == 0 {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, "provider id 必填(手工指定,如 0xC101;新增请用 C 开头段)")
|
|
return
|
|
}
|
|
if old, _ := dvSolutionProvider(sys, req.Provider.Id); old != nil && old.Id != 0 {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, fmt.Sprintf("方案商 id %X 已存在(%s)", old.Id, old.Name))
|
|
return
|
|
}
|
|
if msg := checkChipVendor(sys, req.Provider.Vendor); msg != "" {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
|
|
return
|
|
}
|
|
req.Provider.Createtime = time.Now().Unix()
|
|
if err := dvAddSolutionProvider(sys, req.Provider); err != nil {
|
|
writeErr(c, pb.ErrorCode_DBError, err.Error())
|
|
return
|
|
}
|
|
writeOK(c, &pb.ApiAddSolutionProviderResp{Provider: req.Provider})
|
|
}
|
|
|
|
func (this *serverComp) updateSolutionProvider(c *gin.Context, sys *appConn) {
|
|
// 不直接绑 pb.ApiUpdateSolutionProviderReq:改 BID 要同时知道新旧两个号,
|
|
// 而 pb 里只有一个 id。多带一个 oldid 字段比改 proto 轻。
|
|
var req struct {
|
|
Provider *pb.DBSolutionProvider `json:"provider"`
|
|
Oldid uint32 `json:"oldid"` // 原 BID;0 或与 provider.id 相同 = 不改号
|
|
}
|
|
_ = c.ShouldBindJSON(&req)
|
|
if req.Provider == nil || req.Provider.Id == 0 {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, "provider id is required")
|
|
return
|
|
}
|
|
// 定位用旧号,落库用新号。前端不传 oldid 时退化成「按 id 定位、不改号」,兼容旧调用。
|
|
locateId := req.Oldid
|
|
if locateId == 0 {
|
|
locateId = req.Provider.Id
|
|
}
|
|
if strings.TrimSpace(req.Provider.Name) == "" {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, "provider name 必填")
|
|
return
|
|
}
|
|
if msg := checkChipVendor(sys, req.Provider.Vendor); msg != "" {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
|
|
return
|
|
}
|
|
old, err := dvSolutionProvider(sys, locateId)
|
|
if err != nil {
|
|
writeErr(c, pb.ErrorCode_DBError, err.Error())
|
|
return
|
|
}
|
|
// 改号:先校验(新号未被占用、名下没有产品),再裸 UPDATE 改主键,随后按新号继续保存其余字段。
|
|
if req.Provider.Id != locateId {
|
|
if msg := checkProviderIdChange(sys, locateId, req.Provider.Id); msg != "" {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
|
|
return
|
|
}
|
|
if err = renameProviderId(locateId, req.Provider.Id); err != nil {
|
|
writeErr(c, pb.ErrorCode_DBError, "改 BID 失败: "+err.Error())
|
|
return
|
|
}
|
|
old.Id = req.Provider.Id
|
|
}
|
|
old.Name = req.Provider.Name
|
|
old.Description = req.Provider.Description
|
|
old.Contactmails = req.Provider.Contactmails
|
|
old.Vendor = req.Provider.Vendor // 归属芯片厂商(chip_vendor.id)
|
|
if err = dvSaveSolutionProvider(sys, old); err != nil {
|
|
writeErr(c, pb.ErrorCode_DBError, err.Error())
|
|
return
|
|
}
|
|
writeOK(c, &pb.ApiUpdateSolutionProviderResp{Provider: old})
|
|
}
|
|
|
|
func (this *serverComp) delSolutionProvider(c *gin.Context, sys *appConn) {
|
|
var req pb.ApiDelSolutionProviderReq
|
|
_ = c.ShouldBindJSON(&req)
|
|
if err := dvDelSolutionProvider(sys, req.Id); err != nil {
|
|
writeErr(c, pb.ErrorCode_DBError, err.Error())
|
|
return
|
|
}
|
|
writeOK(c, &pb.ApiDelSolutionProviderResp{Id: req.Id})
|
|
}
|
|
|
|
// ---- 授权码生产(产品级序号 + 独立批次台账)----
|
|
|
|
// genBatchNo 生成 6 位可读批次号(防碰撞重试;仅作展示标签,兜底空串)。
|
|
func genBatchNo(sys *appConn) string {
|
|
for i := 0; i < 5; i++ {
|
|
code, err := comm.GeneratePublicCode()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if existing, e := dvProductionBatches(sys, "batchno=?", code); e == nil && len(existing) == 0 {
|
|
return code
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// previewAuthCodes 生产预检:按产品类型算出最终授权码数量、预分配批次号、预估序号/MAC 区间,
|
|
// 供前端「二次确认」界面按邮件规格展示。不落库、不占用序号游标——序号/MAC 区间按当前游标估算,
|
|
// 正式生产(createAuthCodes 回传本接口给的 batchno)时以实际原子分配为准。
|
|
func (this *serverComp) previewAuthCodes(c *gin.Context, sys *appConn) {
|
|
var req pb.ApiCreateAuthCodesReq
|
|
_ = c.ShouldBindJSON(&req)
|
|
|
|
// 生产归属只剩渠道商一维(品牌商 2026-09-12 下线)。
|
|
channel, ok := resolveProductionChannelModel(c, sys, req.Channelid)
|
|
if !ok {
|
|
return
|
|
}
|
|
// 产品必选:设备的应用归属完全靠 device_mac.productid → product.appnames 这条链推出来
|
|
// (表上没有应用列),productid=0 的设备等于「不属于任何应用」,谁都绑不了也查不到。
|
|
// 不显式拦的话这里会落到下面的「产品不存在: record not found」,提示指向数据缺失、
|
|
// 实际是表单没选产品。库侧另有 CHECK 约束兜底,见 console registry.go。
|
|
if req.Productid == 0 {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, "请先选择产品:设备必须挂在产品下,否则无法归属到应用")
|
|
return
|
|
}
|
|
product, err := dvProduct(sys, req.Productid)
|
|
if err != nil {
|
|
writeErr(c, pb.ErrorCode_DBError, "产品不存在: "+err.Error())
|
|
return
|
|
}
|
|
|
|
codeCount := product.Devicecodecount
|
|
if codeCount == 0 {
|
|
codeCount = 1
|
|
}
|
|
var total int64
|
|
var importMacs []string
|
|
switch req.Createtype {
|
|
case 1:
|
|
total = int64(req.Number) * int64(codeCount)
|
|
case 2:
|
|
// 预览也走同一套规范化/查重:格式错、重复、已生产过这三类问题在这一步就报出来,
|
|
// 别等运营在核对页点了「确认生产」才失败——那时序号游标已经被推进过了。
|
|
var msg string
|
|
if importMacs, msg = normalizeImportMacs(sys, req.Productid, req.Macaddresses); msg != "" {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
|
|
return
|
|
}
|
|
total = int64(len(importMacs))
|
|
default:
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, "createtype 无效(1 生产 / 2 导入)")
|
|
return
|
|
}
|
|
if total <= 0 {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, "生成数量必须大于 0")
|
|
return
|
|
}
|
|
|
|
// 预估序号区间:当前游标 +1 ~ +total(正式生产时原子分配,并发生产时以实际为准)。
|
|
start := product.Serialcursor + 1
|
|
end := product.Serialcursor + total
|
|
// MAC 区间只对【生产模式】成立:那时 MAC 由 (PID, 序号) 算出来,首尾两个就是真实落地的边界。
|
|
//
|
|
// ⚠️ 导入模式没有区间。以前这里对 createtype==2 也按序号算了一对 MAC,那两个值
|
|
// 与实际入库的外部 MAC 毫无关系——核对页显示的是一段根本不会出现的地址,批次台账里
|
|
// 也存了同样的假区间。导入模式改成不给区间,改给条数与前几条样例。
|
|
startmac, endmac := "", ""
|
|
if req.Createtype == 1 {
|
|
startmac = mac.Generate(uint16(req.Productid), uint32(start))
|
|
endmac = mac.Generate(uint16(req.Productid), uint32(end))
|
|
}
|
|
// 导入模式给前 3 条样例,让运营在核对页能确认「导进去的确实是我那份 CSV」。
|
|
importSample := make([]string, 0, 3)
|
|
if req.Createtype == 2 {
|
|
for i, m := range importMacs {
|
|
if i >= 3 {
|
|
break
|
|
}
|
|
importSample = append(importSample, m)
|
|
}
|
|
}
|
|
|
|
writeOK(c, gin.H{
|
|
"batchno": genBatchNo(sys),
|
|
"channelid": channel.Id,
|
|
"channelname": channel.Name,
|
|
"productid": req.Productid,
|
|
"productname": product.Devicename,
|
|
"codetype": map[bool]string{true: "MAC(外部导入)", false: "MAC(按序号生成)"}[req.Createtype == 2],
|
|
"codecount": codeCount,
|
|
"total": total,
|
|
"serialstart": start,
|
|
"serialend": end,
|
|
"startmac": startmac,
|
|
"endmac": endmac,
|
|
"importsample": importSample,
|
|
"sharerate": req.Sharerate,
|
|
"factoryid": product.Oemfactoryid,
|
|
"rewardvip": req.Rewardvip,
|
|
"rewardtranslate": req.Rewardtranslate,
|
|
"rewardmeeting": req.Rewardmeeting,
|
|
})
|
|
}
|
|
|
|
func (this *serverComp) createAuthCodes(c *gin.Context, sys *appConn) {
|
|
var req pb.ApiCreateAuthCodesReq
|
|
_ = c.ShouldBindJSON(&req)
|
|
|
|
// 渠道商必选:生产时定下的归属会随批次写进每条授权码,用户绑定该设备后统计与结算都按它归属。
|
|
channel, ok := resolveProductionChannelModel(c, sys, req.Channelid)
|
|
if !ok {
|
|
return
|
|
}
|
|
// 渠道分成比例按批次定(同一渠道商不同批次可以谈不同比例),生产时冻结进批次台账。
|
|
if req.Sharerate < 0 || req.Sharerate > channelShareRateMax {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, "分成比例需在 0~10000(万分比) 之间")
|
|
return
|
|
}
|
|
if msg := checkBatchRewards(req.Rewardvip, req.Viplevel, req.Computegrant); msg != "" {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
|
|
return
|
|
}
|
|
product, err := dvProduct(sys, req.Productid)
|
|
if err != nil {
|
|
writeErr(c, pb.ErrorCode_DBError, "产品不存在: "+err.Error())
|
|
return
|
|
}
|
|
|
|
// 代工厂由产品档案推出,不收前端传值:一款产品固定由哪家厂代工是产品属性,
|
|
// 生产表单上已改成只读展示。仍要校验一次——产品建档后工厂可能被停用/删除。
|
|
factoryId := product.Oemfactoryid
|
|
if msg := checkProductionFactory(sys, factoryId); msg != "" {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
|
|
return
|
|
}
|
|
// 每副产品需要生产的授权码份数(0或1视为单份)。
|
|
codeCount := product.Devicecodecount
|
|
if codeCount == 0 {
|
|
codeCount = 1
|
|
}
|
|
var total int64
|
|
var importMacs []string
|
|
switch req.Createtype {
|
|
case 1: // 生产模式
|
|
total = int64(req.Number) * int64(codeCount)
|
|
case 2: // 导入模式(外部已烧录好的 MAC 列表)
|
|
var msg string
|
|
if importMacs, msg = normalizeImportMacs(sys, req.Productid, req.Macaddresses); msg != "" {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
|
|
return
|
|
}
|
|
total = int64(len(importMacs))
|
|
default:
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, "createtype 无效(1 生产 / 2 导入)")
|
|
return
|
|
}
|
|
if total <= 0 {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, "生成数量必须大于 0")
|
|
return
|
|
}
|
|
|
|
// 原子分配产品级序号区间 [start, end]。
|
|
end, err := dvAllocSerial(sys, req.Productid, total)
|
|
if err != nil {
|
|
writeErr(c, pb.ErrorCode_DBError, "分配序号失败: "+err.Error())
|
|
return
|
|
}
|
|
start := end - total + 1
|
|
|
|
// MAC 区间只在【生产模式】下有意义:MAC 由 (PID, 序号) 算出,序号连续,首尾即真实边界。
|
|
// 导入模式的 MAC 来自外部 CSV、彼此不连续,留空——批次台账宁可显示「-」,
|
|
// 也不要存一对按序号算出来、库里根本不存在的假地址。明细在 license_<pid> 里,
|
|
// 生产记录点「查看MAC」就能看到全部。
|
|
startmac, endmac := "", ""
|
|
if req.Createtype == 1 {
|
|
startmac = mac.Generate(uint16(req.Productid), uint32(start))
|
|
endmac = mac.Generate(uint16(req.Productid), uint32(end))
|
|
}
|
|
|
|
// 先建生产批次,再把批次号回填到每条授权码。批次号优先用预览确认流程回传的
|
|
// req.Batchno(保证二次确认界面展示的号 = 实际生产的号),未传则现生成;
|
|
// production_batch.batchno 唯一索引兜底防撞。
|
|
batchno := strings.TrimSpace(req.Batchno)
|
|
if batchno == "" {
|
|
batchno = genBatchNo(sys)
|
|
}
|
|
batch := &pb.DBProductionBatch{
|
|
Ts: time.Now().Unix(),
|
|
Productid: req.Productid,
|
|
Serialstart: start,
|
|
Serialend: end,
|
|
Startmac: startmac,
|
|
Endmac: endmac,
|
|
Devicenum: uint32(total),
|
|
Batchno: batchno,
|
|
Remark: req.Remark,
|
|
Channelid: channel.Id,
|
|
Factoryid: factoryId,
|
|
Sharerate: req.Sharerate,
|
|
// 绑定赠送冻结进批次:事后改这里不影响已发出的货,设备激活时按所属批次把三项快照到设备行。
|
|
// 2026-09 起只有「VIP 天数 + VIP 等级 + 算力」,翻译/会议分钟数两列不再写入。
|
|
Rewardvip: req.Rewardvip,
|
|
Viplevel: normalizeVipLevel(req.Viplevel),
|
|
Computegrant: req.Computegrant,
|
|
}
|
|
if err = dvAddProductionBatch(sys, batch); err != nil { // Insert 回填 batch.Id
|
|
writeErr(c, pb.ErrorCode_DBError, err.Error())
|
|
return
|
|
}
|
|
|
|
// ============ 逐条建档 ============
|
|
//
|
|
// ⚠️ **一条设备 = 一个 MAC,code 就是 MAC**(2026-09-02 起,授权码字符串已彻底移除)。
|
|
//
|
|
// 原先每条要同时生成一个 20 字符的 license 串和一个 MAC,两个都指向同一台设备,
|
|
// 而绑定、禁用、重置、结算反查批次全都只用 MAC,license 从头到尾没人校验。
|
|
// 两套标识并存的代价是实打实的:工厂要多问一列烧不烧,出问题时说不清「设备的唯一标识
|
|
// 到底是哪个」,reset 一类接口还得先猜是哪种形态才能反解出 PID。
|
|
//
|
|
// MAC 由 (PID, 序号) 算出来,前 2 字节就是 PID —— 产品、批次、渠道全都能从它查回来,
|
|
// 这正是把 license 整条拿掉的底气。生产模式对**所有产品**都发 MAC,不再区分设备类型:
|
|
// MAC 只是 (PID,序号) 的一种编码,跟设备是不是经典蓝牙无关。
|
|
models := make([]*pb.DBAuthCode, 0, total)
|
|
for i := int64(0); i < total; i++ {
|
|
row := &pb.DBAuthCode{
|
|
Codetype: pb.Codetype_DEVICECODE,
|
|
Productid: req.Productid,
|
|
Number: start + i,
|
|
Batchno: batch.Batchno,
|
|
Channelid: channel.Id,
|
|
Createtime: time.Now().Unix(),
|
|
}
|
|
if req.Createtype == 2 {
|
|
row.Devicemac = importMacs[i] // 导入模式:外部已烧录好的 MAC,上面已规范化并查过重
|
|
} else {
|
|
row.Devicemac = mac.Generate(uint16(req.Productid), uint32(start+i))
|
|
}
|
|
row.Code = row.Devicemac
|
|
models = append(models, row)
|
|
}
|
|
|
|
if err = dvAddAuthCodes(sys, req.Productid, models); err != nil {
|
|
writeErr(c, pb.ErrorCode_DBError, err.Error())
|
|
return
|
|
}
|
|
writeOK(c, &pb.ApiCreateAuthCodesResp{
|
|
Channelid: channel.Id,
|
|
Productid: req.Productid,
|
|
Batchno: batch.Batchno,
|
|
Startmac: startmac,
|
|
Endmac: endmac,
|
|
Serialstart: start,
|
|
Serialend: end,
|
|
Number: uint32(total),
|
|
Devices: models,
|
|
})
|
|
}
|
|
|
|
func (this *serverComp) disableAuthCodes(c *gin.Context, sys *appConn) {
|
|
var req pb.ApiDisableAuthCodesReq
|
|
_ = c.ShouldBindJSON(&req)
|
|
if req.Productid == 0 || len(req.Codes) == 0 {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, "productid/codes 不能为空")
|
|
return
|
|
}
|
|
// 只接受 0(正常) 或 -1(禁用)。
|
|
disabled := int32(0)
|
|
if req.Disabled != 0 {
|
|
disabled = -1
|
|
}
|
|
affected, err := dvSetAuthCodesDisabled(sys, req.Productid, req.Codes, disabled)
|
|
if err != nil {
|
|
writeErr(c, pb.ErrorCode_DBError, err.Error())
|
|
return
|
|
}
|
|
writeOK(c, &pb.ApiDisableAuthCodesResp{Affected: affected})
|
|
}
|
|
|
|
// revokeProductionBatch 撤销一个生产批次:校验批次存在 → 批次内无已用授权码 →
|
|
// 删除该批次全部授权码 + 批次台账。已消耗的产品级序号不回退(序号只增不复用)。
|
|
func (this *serverComp) revokeProductionBatch(c *gin.Context, sys *appConn) {
|
|
var req pb.ApiRevokeProductionBatchReq
|
|
_ = c.ShouldBindJSON(&req)
|
|
if req.Productid == 0 || req.Batchno == "" {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, "productid/batchno 不能为空")
|
|
return
|
|
}
|
|
batch, err := dvProductionBatch(sys, req.Batchno)
|
|
if err != nil || batch == nil || batch.Productid != req.Productid {
|
|
writeErr(c, pb.ErrorCode_DBError, "未找到指定生产批次")
|
|
return
|
|
}
|
|
used, err := dvCountUsedAuthCodesByBatch(sys, req.Productid, req.Batchno)
|
|
if err != nil {
|
|
writeErr(c, pb.ErrorCode_DBError, err.Error())
|
|
return
|
|
}
|
|
if used > 0 {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, "该批次已有授权码被激活/绑定,无法撤销")
|
|
return
|
|
}
|
|
affected, err := dvDelAuthCodesByBatch(sys, req.Productid, req.Batchno)
|
|
if err != nil {
|
|
writeErr(c, pb.ErrorCode_DBError, err.Error())
|
|
return
|
|
}
|
|
if err = dvDelProductionBatch(sys, req.Batchno); err != nil {
|
|
writeErr(c, pb.ErrorCode_DBError, err.Error())
|
|
return
|
|
}
|
|
writeOK(c, &pb.ApiRevokeProductionBatchResp{Deleted: uint32(affected)})
|
|
}
|
|
|
|
// ---- 授权码重置(单条 / 批量 / 按用户)----
|
|
|
|
// resetOneLicense 把一条授权码/MAC 重置到初始未用状态(单条/批量共享):
|
|
// 定位 pid → 若已绑定用户则删其 userdevice → 清行(status/uid/devicemac/usedtime)
|
|
// → 若原为激活态则扣减 product_stat 激活数。
|
|
//
|
|
// productid 由调用方传入(后台列表本来就按产品筛,PID 是现成的)。传 0 才退回从设备码反解。
|
|
//
|
|
// ⚠️ 以前这里是「反解 license,失败就回退写死的 45058」——那会去 license_b002 这张不相干的
|
|
// 分表里找,结果必然是 record not found,接口报错但看起来像「这条码不存在」。
|
|
// 现在 code 可能就是 MAC,而导入进来的 MAC 前两字节是外部厂商的 OUI,反解出来是别人的 PID,
|
|
// 后果从「报错」变成「悄悄改了别的产品的数据」。所以宁可拿不到 PID 就报错,不要猜。
|
|
func resetOneLicense(sys *appConn, productid uint32, code string) error {
|
|
pid := productid
|
|
if pid == 0 {
|
|
p, err := devcode.ProductID(code)
|
|
if err != nil {
|
|
return fmt.Errorf("无法确定该设备码所属产品,请带上 productid: %s", code)
|
|
}
|
|
pid = uint32(p)
|
|
}
|
|
model, err := dvAuthCode(sys, pid, code)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
wasActivated := model.Uid != ""
|
|
if wasActivated {
|
|
if err = dvDelUserDevice(sys, model.Uid); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
model.Status = 0
|
|
model.Uid = ""
|
|
// ⚠️ 只有认证码(AUTHCODE)的 devicemac 是绑定时填进去的,重置要清掉。
|
|
// 设备码(DEVICECODE)的 devicemac 是这台设备出厂就定死的身份——2026-09-02 起 code 就是它——
|
|
// 清掉等于把设备从库里抹了:按 MAC 反查绑定的路径再也找不到它,而 code 那一列还留着,
|
|
// 两列从此对不上。解绑路径(api_unbinddevice.go)一直是按这个规则做的,这里以前漏了。
|
|
// ⚠️ 不清 devicemac:设备码 = MAC = 分表主键 code(授权码字符串已于 2026-09-02 移除),
|
|
// 清掉等于把这台设备从库里抹掉——按 MAC 反查绑定的路径再也找不到它,
|
|
// 而 code 那一列还留着,两列从此对不上。
|
|
model.Usedtime = 0
|
|
if err = dvUpdateAuthCode(sys, pid, model); err != nil {
|
|
return err
|
|
}
|
|
if wasActivated && model.Productid > 0 {
|
|
_ = dvDecrProductStatActivated(sys, model.Productid)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// batchResetLicenseStatus 批量重置授权码,逐条统计成功/失败数(单条失败不阻断其余)。
|
|
func (this *serverComp) batchResetLicenseStatus(c *gin.Context, sys *appConn) {
|
|
var req pb.ApiBatchResetLicenseStatusReq
|
|
_ = c.ShouldBindJSON(&req)
|
|
resp := &pb.ApiBatchResetLicenseStatusResp{}
|
|
for _, code := range req.Licenses {
|
|
if err := resetOneLicense(sys, req.Productid, code); err != nil {
|
|
resp.FailCount++
|
|
} else {
|
|
resp.SuccessCount++
|
|
}
|
|
}
|
|
writeOK(c, resp)
|
|
}
|
|
|
|
// restFactoryDevics 按用户重置:删该用户的 userdevice,并把他在该产品下持有的授权码全部清回未用态。
|
|
func (this *serverComp) restFactoryDevics(c *gin.Context, sys *appConn) {
|
|
var req pb.ApiRestUserDevicsReq
|
|
_ = c.ShouldBindJSON(&req)
|
|
if err := dvDelUserDevice(sys, req.Uid); err != nil {
|
|
writeErr(c, pb.ErrorCode_DBError, err.Error())
|
|
return
|
|
}
|
|
models, err := dvAuthCodesForUid(sys, req.Productid, req.Uid)
|
|
if err != nil {
|
|
writeErr(c, pb.ErrorCode_DBError, err.Error())
|
|
return
|
|
}
|
|
for _, m := range models {
|
|
m.Status = 0
|
|
m.Uid = ""
|
|
m.Devicemac = ""
|
|
}
|
|
if err = dvUpdateAuthCodes(sys, req.Productid, models); err != nil {
|
|
writeErr(c, pb.ErrorCode_DBError, err.Error())
|
|
return
|
|
}
|
|
writeOK(c, &pb.ApiRestUserDevicsResp{})
|
|
}
|
|
|
|
// checkBatchRewards 校验批次的绑定赠送三项(VIP 天数 / VIP 等级 / 算力)。返回空串表示通过。
|
|
func checkBatchRewards(vipDays uint32, vipLevel int32, compute int64) string {
|
|
if vipDays > batchRewardVipDayMax {
|
|
return fmt.Sprintf("赠送VIP天数不能超过 %d 天", batchRewardVipDayMax)
|
|
}
|
|
if vipLevel < 0 || vipLevel > batchRewardVipLevelMax {
|
|
return fmt.Sprintf("VIP等级只能是 1(VIP) 或 2(VIP-Pro),得到 %d", vipLevel)
|
|
}
|
|
if compute < 0 || compute > batchRewardComputeMax {
|
|
return fmt.Sprintf("赠送算力需在 0~%d 之间", batchRewardComputeMax)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// normalizeVipLevel 没填等级时按 1(VIP) 处理,避免批次表里出现「有天数没等级」的行。
|
|
func normalizeVipLevel(lv int32) int32 {
|
|
if lv <= 0 {
|
|
return 1
|
|
}
|
|
return lv
|
|
}
|
|
|
|
// normalizeImportMacs 规范化并校验导入模式的 MAC 列表。返回 (规范化后的列表, 错误提示)。
|
|
//
|
|
// MAC 现在就是授权码分表的主键,所以三件事必须在写库之前挡住,否则代价很高:
|
|
// 1. **格式**:大小写/有无冒号不一致会让同一台设备存成两行,而绑定是按 MAC 精确匹配的,
|
|
// 烧进耳机的写法和库里的写法差一点就永远绑不上;
|
|
// 2. **本次重复**:批量 INSERT 撞主键会整批失败,但序号游标已经被原子推进过了,
|
|
// 那段序号就白白废掉、再也发不出来;
|
|
// 3. **已存在**:同一批 MAC 被导入两次,第二次同样撞主键整批失败。
|
|
//
|
|
// 逐条报第一个出错的位置,运营才知道去改 CSV 的哪一行。
|
|
func normalizeImportMacs(sys *appConn, pid uint32, raw string) ([]string, string) {
|
|
parts := strings.Split(raw, ",")
|
|
out := make([]string, 0, len(parts))
|
|
seen := make(map[string]bool, len(parts))
|
|
for _, p := range parts {
|
|
p = strings.TrimSpace(p)
|
|
if p == "" {
|
|
continue // 允许结尾多一个逗号、CSV 里夹空行
|
|
}
|
|
norm, ok := devcode.NormalizeMac(p)
|
|
if !ok {
|
|
return nil, fmt.Sprintf("第 %d 个 MAC 格式不对: %s(需要 6 字节十六进制,如 B0:23:00:00:00:01)", len(out)+1, p)
|
|
}
|
|
if seen[norm] {
|
|
return nil, fmt.Sprintf("导入列表里 MAC 重复: %s", norm)
|
|
}
|
|
seen[norm] = true
|
|
out = append(out, norm)
|
|
}
|
|
if len(out) == 0 {
|
|
return nil, "MAC 地址列表为空"
|
|
}
|
|
// 与已有记录查重。一次 IN 查询,不逐条查——导入动辄上千条。
|
|
existing, err := dvAuthCodesByCodes(sys, pid, out)
|
|
if err != nil {
|
|
return nil, "校验 MAC 是否已存在失败: " + err.Error()
|
|
}
|
|
if len(existing) > 0 {
|
|
return nil, fmt.Sprintf("有 %d 个 MAC 已经生产过了,例如 %s", len(existing), existing[0])
|
|
}
|
|
return out, ""
|
|
}
|
|
|