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
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
|
|
}
|
|
|