package user import ( "encoding/json" "fmt" "strconv" "strings" "time" "yunyan/comm" "yunyan/lego/sys/log" "yunyan/pb" ) // @Summary 绑定耳机设备 // @Description 绑定耳机设备 // @Tags User // @Accept json // @Produce json // @Security BearerAuth // @Param user body pb.UserBindDeviceReq true "发送绑定请求" // @Success 200 {object} comm.HttpResult{data=pb.UserBindDeviceResp} "成功返回" // @Router /api/home/user_binddevice [post] func (this *apiComp) BindDevice(session comm.IUserSession, req *pb.UserBindDeviceReq) (resp *pb.UserBindDeviceResp, errdata *pb.ErrorData) { var ( user *pb.DBUser product *pb.DBProduct divices []*pb.DBUserDivice model *pb.DBAuthCode codestate int32 divice *pb.DBUserDivice Isgiveaway bool err error ) if user, err = this.module.model.findforid(session.GetUserId()); err != nil { //未找到授权码 errdata = &pb.ErrorData{ Code: pb.ErrorCode_UserSessionNobeing, Message: err.Error(), } return } // ⚠️ 产品**不再**用 req.Pid 取——pid 现在是从设备行的 productid 列读出来的结果, // 见下面「定位设备行」那段。req.Pid 仅为协议兼容保留,服务端不再采信。 if divices, err = this.module.model.getDevices(session.GetUserId()); err != nil { //未找到授权码 errdata = &pb.ErrorData{ Code: pb.ErrorCode_AuthorizeNoCanUse, Message: err.Error(), } return } for _, v := range divices { if alreadyBound(v, req) { // 名字可能变了(Android 给的是系统缓存的配对名,固件也改过名: // DEEPVOICE → Echo-one),顺手刷新,但**不新建一行**。 if req.Devicename != "" && v.Devicename != req.Devicename { v.Devicename = req.Devicename if err = this.module.model.saveDevice(v); err != nil { this.module.Warn("BindDevice: 刷新设备名失败", log.Field{Key: "deviceId", Value: v.Id}, log.Field{Key: "err", Value: err.Error()}) err = nil } } divice = v resp = &pb.UserBindDeviceResp{ Device: divice, } return } } // ---- 定位设备行 ---- // // device_mac 是**全局一张**表(2026-09-04 由 license_ 分表合并而来): // 主键 code 与 devicemac 本来就全局唯一,定位一台设备**不需要 pid**。 // // 以前这里按 product.Devicetype 分两支,而 product 又是拿客户端传的 req.Pid 取的: // - 经典蓝牙耳机 → 按 (客户端猜的 pid, MAC) 去对应分表查 // - 其它 → 从 code 反解 pid,解不出回退写死的 45058 // 客户端猜 pid 靠「产品名 ↔ 设备名互相包含」,猜不中就退回第一个耳机产品。 // 2026-09-04 就栽在这:一台已正确导入的耳机,因所属产品的 devicetype 被配成 2 // (BLE双端)而非 1(经典蓝牙),被客户端过滤掉、退回了别的产品的 pid, // 服务端拿着错 pid 去错的分表查,报「这台 MAC 没登记在该产品下」—— // 提示指向数据缺失,实际数据好好的,只是 pid 错了。 // // 现在:code 与 MAC 都试一遍,命中即可,pid 从命中行里读。 // 先 code 后 MAC——设备码类设备(杰理等)只有 code,经典蓝牙耳机只有 MAC,各命中一条。 located := false if c := strings.TrimSpace(req.Code); c != "" { if model, err = this.module.model.getFactoryDevice(c); err == nil { located = true } } if !located { if mc := strings.TrimSpace(req.Devicemac); mc != "" { if model, err = this.module.model.getFactoryDeviceformac(mc); err == nil { located = true } } } if !located { this.module.Warn("BindDevice: 设备未登记", log.Field{Key: "uid", Value: session.GetUserId()}, log.Field{Key: "code", Value: req.Code}, log.Field{Key: "devicemac", Value: req.Devicemac}, log.Field{Key: "devicename", Value: req.Devicename}) errdata = &pb.ErrorData{ Code: pb.ErrorCode_AuthorizeNoCanUse, Message: "设备未登记", } return } // 产品归属来自设备行本身。取不到说明产品被删了、或 productid 是脏数据—— // 这时绑定没法继续:下面的渠道归属、绑定礼、埋点全要靠 product。 if product, err = this.module.cache.GetProduct(int32(model.Productid)); err != nil { this.module.Warn("BindDevice: 设备行的产品取不到", log.Field{Key: "code", Value: model.Code}, log.Field{Key: "productid", Value: model.Productid}, log.Field{Key: "err", Value: err.Error()}) errdata = &pb.ErrorData{ Code: pb.ErrorCode_AuthorizeNoCanUse, Message: err.Error(), } return } if model.Disabled != 0 { //设备码已被后台禁用 直接拒绝绑定 errdata = &pb.ErrorData{ Code: pb.ErrorCode_AuthorizeNoCanUse, Message: "设备码已被禁用", } return } codestate = model.Status if model.Status != 0 && model.Uid != session.GetUserId() { //授权码已被使用 自动解绑 if divices, err = this.module.model.getDevices(model.Uid); err == nil { for _, v := range divices { // ⚠️ 不能只比 License:经典蓝牙耳机绑定时客户端 code 传空, // 存量 userdevice 行的 license 列就是空串,永远等不于 model.Code, // 于是原用户那条绑定删不掉——同一台耳机换个账号登录, // 旧账号的设备列表里会一直留着一台它其实已经不拥有的耳机。 // 设备码类设备的身份是 MAC,按 MAC 兜底认。 if sameUserDevice(v, model) { this.module.model.delDevice(v.Id) // 删除设备 // 接管即原用户被动解绑:按原设备自身的产品/渠道冲正 -1, // 否则同一台设备每换一次账号就多计一次绑定。 // 不带 Uid:原用户并未操作,不应被计入当日活跃用户。 if this.module.analyze != nil { this.module.analyze.Report(&comm.StatEvent{ Type: comm.StatEventDeviceBind, ProductId: uint32(v.Productid), ChannelId: v.Channelid, Count: -1, }) } } } } // errdata = &pb.ErrorData{ // Code: pb.ErrorCode_AuthorizeNoCanUse, // Message: "授权码已被使用", // } // return } // 移动渠道产品生成 CMEI: TUI(8位) + batch(2位) + serial(5位) = 15位 var cmei string if product.Channel == "mobile" { var tui string if product.Channelextra != "" { extra := struct { Tui string `json:"tui"` }{} if jerr := json.Unmarshal([]byte(product.Channelextra), &extra); jerr == nil { tui = extra.Tui } } macStr := strings.ReplaceAll(req.Devicemac, ":", "") if len(tui) == 8 && len(macStr) == 12 { batch, _ := strconv.ParseUint(macStr[4:6], 16, 8) serial, _ := strconv.ParseUint(macStr[8:12], 16, 16) cmei = fmt.Sprintf("%s%02d%05d", tui, batch%100, serial%100000) } } divice = &pb.DBUserDivice{ Uid: session.GetUserId(), Productid: product.Id, // 渠道商归属来自授权码(生产时定下),绑定即固化到用户设备上, // 后续该用户的充值/消耗埋点都按它归到对应渠道商名下(统计与月结算的依据)。 Channelid: model.Channelid, // devicetype 不再从产品上抄:产品的「设备类型」已于 2026-09-08 废弃 // (pb 里降级成 gorm:"-" 的老客户端兼容字段,恒为 0,见 db.proto)。 // userdevice 上这一列暂时保留,存量行的值原样不动,新行为 0(客户端「我的设备」 // 页会显示成「未知设备」)。等客户端那边一并改掉分类展示后再删列。 Devicename: req.Devicename, Devicemac: req.Devicemac, // 存授权码行的主键(model.Code),不是客户端传上来的 req.Code: // 经典蓝牙耳机走 MAC 反查,客户端 code 传的是空串,照抄会让这一列永远为空, // 解绑与接管都要靠它定位授权码行(见 sameUserDevice / api_unbinddevice.go)。 License: model.Code, Cmei: cmei, Version: req.Version, } model.Status = 1 //已绑定 model.Uid = session.GetUserId() // ⚠️ **不拿客户端上报的 MAC 覆盖 model.Devicemac**。设备的 MAC 是出厂就定死的身份, // 2026-09-02 起分表主键 code 就是它(授权码字符串已整体移除)。 // 客户端送的写法可能不规范(上面的反查做了规范化兜底,仍能查到这一行), // 一旦照它覆盖,code 与 devicemac 两列就对不上,这台设备再也查不回来。 // 首次激活(此前 status==0)记录激活时间:供按统计日聚合设备激活/绑定数。 // 历史设备漏记 usedtime 是失误,从此补上;仅在 0→1 首激时写,换绑/接管不覆盖原始激活日。 if codestate == 0 { model.Usedtime = time.Now().Unix() // 设备权益快照:VIP 天数 / 等级 / 赠送算力,激活这一刻从批次(回退产品)固化到设备行。 // 此后改批次配置只影响尚未激活的库存设备。**不写用户表**——设备 VIP 由 usedtime+vipdays 算出, // 设备算力记在 computegrant/computeused 上,谁绑定谁享有剩余部分,解绑无需回收。 model.Vipdays, model.Viplevel, model.Computegrant = this.resolveBindRewards(model, product) model.Computeused = 0 } if err = this.module.model.saveFactoryDevice(model); err != nil { errdata = &pb.ErrorData{ Code: pb.ErrorCode_DBError, Message: err.Error(), } return } if err = this.module.model.addDevice(divice); err != nil { errdata = &pb.ErrorData{ Code: pb.ErrorCode_DBError, Message: err.Error(), } return } // 记录最后绑定的产品ID与渠道商:后续充值/消耗埋点按它们归属统计与结算 user.Lastbindproductid = product.Id user.Lastbindchannelid = model.Channelid // 设备激活 + 绑定埋点。 // 激活是「设备一生一次」的事件:只在 codestate==0 首激时记,换绑/接管不再计—— // 与上面 model.Usedtime 只在 0→1 时写保持同一口径,否则激活数被换绑次数放大。 // 绑定是净增流量:这里 +1,解绑/被接管处 -1(见 api_unbinddevice.go 与上面自动解绑分支)。 if this.module.analyze != nil { if codestate == 0 { this.module.analyze.Report(&comm.StatEvent{Type: comm.StatEventDeviceActive, Uid: session.GetUserId(), ProductId: uint32(product.Id), ChannelId: model.Channelid}) } this.module.analyze.Report(&comm.StatEvent{Type: comm.StatEventDeviceBind, Uid: session.GetUserId(), ProductId: uint32(product.Id), ChannelId: model.Channelid}) } this.module.Debug("BindDevice", log.Field{Key: "Isgiveaway", Value: user.Isgiveaway}, log.Field{Key: "Iscanbindagain", Value: product.Iscanbindagain}, log.Field{Key: "codestate", Value: codestate}, log.Field{Key: "vipdays", Value: model.Vipdays}, log.Field{Key: "computegrant", Value: model.Computegrant}) // 2026-09 起绑定**不再往用户表累加**任何权益:设备赠送的 VIP 与算力都挂在设备行上(见上面快照), // 判定时按「当前绑定着且未到期」算。原来把天数加进 user.vipexptime 的做法一旦累加就与自购 // 混成同一个数字,解绑时拆不开;三个额度桶也已停用。 // // `user.Isgiveaway = true` 仍然要写:厂家公码(2026-09-12 已下线)原先拿它当防重领标记, // 现在已无读取方,但这是 DBUser 上的存量列、老客户端仍会解析,故只停用语义、不删字段。 if codestate == 0 { Isgiveaway = true user.Isgiveaway = true } if err = this.module.model.save(user); err != nil { errdata = &pb.ErrorData{ Code: pb.ErrorCode_DBError, Message: err.Error(), } return } if codestate == 0 && (model.Vipdays > 0 || model.Computegrant > 0) { // 首激流水:记录这台设备带了什么(不是发给用户的,是设备自己的权益) _ = this.module.model.addIntegralLog(&pb.DBUserUseLog{ Uid: session.GetUserId(), Logtype: pb.UserLogType_ActivityReward, Ts: time.Now().Unix(), Addvipday: int64(model.Vipdays), Compute: model.Computegrant, Devicemac: model.Devicemac, Extra: "device activate grant", }) if this.module.analyze != nil { this.module.analyze.Report(&comm.StatEvent{ Type: comm.StatEventResourceGrant, ProductId: product.Id, ChannelId: model.Channelid, Uid: session.GetUserId(), GrantVipDay: int64(model.Vipdays), }) } } // 老客户端仍按 viplv/vipexptime 判会员,这里回**合并后的有效值**(自购 或 设备赋予), // 让它们不改代码也能看到设备带来的 VIP;三个积分字段回 0(已停用)。 vip, _, verr := comm.ResolveUserVip(session.GetUserId(), user) if verr != nil { this.module.Warn("BindDevice: VIP 合并判定失败", log.Field{Key: "uid", Value: session.GetUserId()}, log.Field{Key: "err", Value: verr.Error()}) vip = comm.ResolveVip(user, []*pb.DBAuthCode{model}, time.Now().Unix()) } resp = &pb.UserBindDeviceResp{ Device: divice, Isgiveaway: Isgiveaway, Viplv: vip.Level, Vipexptime: vip.ExpTime, Devicevipexptime: comm.DeviceVipExpire(model), Deviceviplevel: comm.DeviceVipLevel(model), Devicecomputeleft: comm.DeviceComputeLeft(model), } return } // resolveBindRewards 决定这台设备激活时带什么:VIP 天数 / VIP 等级 / 赠送算力。 // // 取值顺序: // 1. 设备所属生产批次(后台「MAC生成管理 → 生成MAC」里一批一配); // 2. 批次 VIP 天数与算力**都为 0**,或批次取不到(存量码没有批次号、批次被撤销)→ 回退产品档案上的字段。 // // ⚠️ 判「批次是否配过」用的是两项全 0,不是「批次存在」:新批次默认全 0,若按存在与否判定, // 所有新批次都会静默变成不送任何东西。 // // ⚠️ 取批次失败只回退、不报错:赠送是绑定的附带动作,为了它把绑定整个失败掉不划算。 // // 产品档案上的 bindrewardvpitime 历史上是**开关**(后台勾选写 1,绑定时发 90 天)。为了不让存量 // 产品在升级后从 90 天变成 1 天,这里保留「1 → 90」的映射;后台改成按天数填之后,填 90 就是 90。 func (this *apiComp) resolveBindRewards(code *pb.DBAuthCode, product *pb.DBProduct) (vipDays, vipLevel int32, compute int64) { if code != nil { if batch, err := this.module.model.getProductionBatch(code.Batchno); err == nil && batch != nil { if batch.Rewardvip > 0 || batch.Computegrant > 0 { lv := batch.Viplevel if lv <= 0 { lv = 1 } return int32(batch.Rewardvip), lv, batch.Computegrant } } } if product == nil { return 0, 0, 0 } vipDays = int32(product.Bindrewardvpitime) if vipDays == 1 { vipDays = 90 // 存量「开关」语义 } vipLevel = product.Bindviplevel if vipDays > 0 && vipLevel <= 0 { vipLevel = 1 } return vipDays, vipLevel, product.Bindcompute } // alreadyBound 判断用户已有的这条绑定是不是本次要绑的同一台设备。 // // 口径与 [sameUserDevice] 一致:**设备的身份是 MAC**。只有没有 MAC 的设备 // (早年按设备码导入、未登记 MAC 的)才退回比设备码。 // // ⚠️ 原判断是 `v.Devicename == req.Devicename && v.Devicemac == req.Devicemac // && v.License == req.Code`,两个条件都会误判: // - **License**:经典蓝牙耳机客户端 code 传空(req.Code == ""),而服务端存进 // userdevice.license 的是 model.Code(就是 MAC),两者永远不等 —— 这个去重 // **恒为 false**。而 addDevice 是裸 Insert,于是每重新绑定一次就多一条重复行。 // - **Devicename**:Android 的 getBondedDevices 给的是系统缓存的配对名, // 固件还改过名(DEEPVOICE → Echo-one),同一台设备换个名字就又插一行。 func alreadyBound(v *pb.DBUserDivice, req *pb.UserBindDeviceReq) bool { if v == nil || req == nil { return false } if mac := strings.TrimSpace(req.Devicemac); mac != "" && strings.TrimSpace(v.Devicemac) != "" { return strings.EqualFold(strings.TrimSpace(v.Devicemac), mac) } return req.Code != "" && v.License == req.Code } // sameUserDevice 判断用户设备列表里的某一行是否就是这条授权码所代表的设备。 // // 口径:**设备的身份是 MAC**,MAC 对得上就是同一台,与 license 写成什么无关 // (大小写不敏感——两边的写法取决于当初是谁写进去的)。license 只用于认那些 // 没有 MAC 的设备:早年按设备码导入、未登记 MAC 的杰理类设备走这条。 // // ⚠️ 原实现是「License 非空就只比 License」,两个坑: // - 经典蓝牙耳机 2026-09-02 之前绑定的行 license 列恒为空(客户端 code 传空、 // 服务端照抄),之后写入的又等于 MAC 本身,优先它没有任何好处; // - license 与 MAC 不一致时(后台重导入过 license)会判成「不是同一台」, // 接管时清不掉原用户那条绑定,旧账号的设备列表里就一直留着一台已经不属于 // 它的耳机。 func sameUserDevice(v *pb.DBUserDivice, code *pb.DBAuthCode) bool { if v == nil || code == nil { return false } if v.Devicemac != "" && code.Devicemac != "" && strings.EqualFold(strings.TrimSpace(v.Devicemac), strings.TrimSpace(code.Devicemac)) { return true } return v.License != "" && v.License == code.Code }