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.
 
 
 
 
 
 

417 lines
18 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: product.Devicetype,
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()
}
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
userSaved := false
// 设备激活 + 绑定埋点。
// 激活是「设备一生一次」的事件:只在 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})
// 设备权益只发给**第一个绑定这台设备的人**,判据是 codestate==0(这台设备
// 此前从未被绑过)。
//
// ⚠️ 原条件是 `!user.Isgiveaway || codestate == 0`,多出来的
// `!user.Isgiveaway` 那一支是个口子:只要这个**用户**没领过绑定礼,哪怕设备
// 是二手的(codestate!=0,已经被别人绑过、权益也已经发出去过一次),照样再发
// 一整份。等于一台设备转手几次就能送出去几份 VIP/翻译/会议时长。
//
// codestate 是可靠判据:解绑处 `model.Status = 0` 是**注释掉的**
// (api_unbinddevice.go,只清 Uid),status 一旦 0→1 就回不去,
// 所以「绑了解、解了绑」刷不出第二份。
//
// `user.Isgiveaway = true` 仍然要写:厂家公码(api_bindauthcode.go)拿它当
// 防重领标记,见 model_user.go 的说明。把它一起删掉会让绑过设备的用户
// 还能再领一次公码。
if codestate == 0 {
Isgiveaway = true
user.Isgiveaway = true
// 绑定赠送:优先按【设备所属生产批次】发(后台「MAC生成管理 → 生成MAC」里一批一配),
// 批次没配(三项全 0 / 取不到批次 / 存量码没有批次号)才回退到产品档案上的旧字段。
vipDays, transMin, meetMin := this.resolveBindRewards(model, product)
var addVipDay, addTrade, addMeet int64
if vipDays > 0 {
// ⚠️ 这里以前写死 90 天,配多少都发 90——product.Bindrewardvpitime 实际只当了开关用。
// 现在按配置的天数发,批次里填几天就是几天。
addVipDay = int64(vipDays)
addSec := addVipDay * 24 * 60 * 60
if user.Vipexptime == 0 || user.Vipexptime < time.Now().Unix() { //重置
user.Vipexptime = time.Now().Unix() + addSec
} else { //累加
user.Vipexptime = user.Vipexptime + addSec
}
}
// translate/meeting 配置为分钟,积分按秒存储
if transMin > 0 {
addTrade = int64(transMin) * 60
user.Tradeintegral += addTrade
user.Tradetotalintegral += addTrade
}
if meetMin > 0 {
addMeet = int64(meetMin) * 60
user.Meetintegral += addMeet
user.Meettotalintegral += addMeet
}
err = this.module.model.save(user)
if err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_DBError,
Message: err.Error(),
}
return
}
userSaved = true
// 记录绑定设备奖励流水
if addVipDay > 0 || addTrade > 0 || addMeet > 0 {
_ = this.module.model.addIntegralLog(&pb.DBUserUseLog{
Uid: session.GetUserId(),
Logtype: pb.UserLogType_ActivityReward,
Ts: time.Now().Unix(),
Addvipday: addVipDay,
Addtradesecond: addTrade,
Addmeetsecond: addMeet,
Extra: req.Code, // 绑定时使用的设备/授权码
})
// 绑定赠送资源埋点
if this.module.analyze != nil {
this.module.analyze.Report(&comm.StatEvent{
Type: comm.StatEventResourceGrant,
ProductId: product.Id,
ChannelId: model.Channelid,
Uid: session.GetUserId(),
GrantVipDay: addVipDay,
GrantTradeSecond: addTrade,
GrantMeetSecond: addMeet,
})
}
}
}
// 首绑送资源分支未触发时,仍需持久化 lastbindproductid / lastbindchannelid
if !userSaved {
if err = this.module.model.save(user); err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_DBError,
Message: err.Error(),
}
return
}
}
resp = &pb.UserBindDeviceResp{
Device: divice,
Isgiveaway: Isgiveaway,
Viplv: user.Viplv,
Vipexptime: user.Vipexptime,
Tradeintegral: user.Tradeintegral,
Tradetotalintegral: user.Tradetotalintegral,
Meetintegral: user.Meetintegral,
Meettotalintegral: user.Meettotalintegral,
}
return
}
// resolveBindRewards 决定这台设备绑定时送什么:VIP天数 / 翻译分钟 / 会议分钟。
//
// 取值顺序:
// 1. 设备所属生产批次的 rewardvip/rewardtranslate/rewardmeeting——同一款产品不同批次
// (不同渠道、不同活动)送的东西经常不一样,配置点在后台「生成MAC」表单上;
// 2. 批次三项全为 0,或批次取不到(存量授权码没有 batchno、批次被撤销)→ 回退产品档案上的旧字段。
//
// ⚠️ 判「批次是否配过」用的是三项全 0,不是「批次存在」:新批次默认三项都是 0,
// 若按「批次存在就用批次的值」,所有新批次都会变成不送任何东西,而产品上原本配好的赠送
// 会在不改任何配置的情况下静默失效。
//
// ⚠️ 取批次失败只回退、不报错:赠送是绑定的附带动作,为了它把绑定整个失败掉不划算。
func (this *apiComp) resolveBindRewards(code *pb.DBAuthCode, product *pb.DBProduct) (vipDays, translateMin, meetingMin uint32) {
if code != nil {
if batch, err := this.module.model.getProductionBatch(code.Batchno); err == nil && batch != nil {
if batch.Rewardvip > 0 || batch.Rewardtranslate > 0 || batch.Rewardmeeting > 0 {
return batch.Rewardvip, batch.Rewardtranslate, batch.Rewardmeeting
}
}
}
if product == nil {
return 0, 0, 0
}
// 产品档案上的旧字段:Bindrewardvpitime 历史上只当开关用(非 0 就发 90 天),
// 这里保持那个语义,免得存量产品的赠送天数在升级后变样。
vipDays = product.Bindrewardvpitime
if vipDays > 0 {
vipDays = 90
}
return vipDays, product.Bindrewardtranslate, product.Bindrewardmeeting
}
// 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
}