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.
 
 
 
 
 
 

389 lines
17 KiB

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_<pid> 分表合并而来):
// 主键 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
}