package user import ( "yunyan/lego/sys/log" "context" "fmt" "strings" "time" "yunyan/comm" "yunyan/lego/core" "yunyan/lego/core/cbase" "yunyan/lego/sys/mysql" "yunyan/lego/sys/postgres" redissys "yunyan/lego/sys/redis" "yunyan/pb" "yunyan/utils/devcode" "gorm.io/gorm/clause" ) // 代理模型 type modelUserComp struct { cbase.ModuleCompBase module *User } // 组件初始化接口 func (this *modelUserComp) Init(service core.IService, module core.IModule, comp core.IModuleComp, opt core.IModuleOptions) (err error) { this.ModuleCompBase.Init(service, module, comp, opt) this.module = module.(*User) if err = mysql.CreateTable(comm.TableUser, &pb.DBUser{}); err != nil { this.module.Errorln(err) } if err = mysql.CreateTable(comm.TableUserdevice, &pb.DBUserDivice{}); err != nil { this.module.Errorln(err) } if err = mysql.CreateTable(comm.TableUserStatistics, &pb.DBUserStatistics{}); err != nil { this.module.Errorln(err) } if err = mysql.CreateTable(comm.TableUserUseLog, &pb.DBUserUseLog{}); err != nil { this.module.Errorln(err) } this.migrateLegacyBucketsToCompute() return } // legacyComputeMigratedKey config 表里的一次性迁移标记;存在即不再跑。 const legacyComputeMigratedKey = "COMPUTE_LEGACY_MIGRATED" // migrateLegacyBucketsToCompute 把存量用户的三个旧额度桶(翻译秒 / 会议秒 / AI 次数)按换算系数 // 折成算力并入 computebalance。只跑一次(config 表打标记),旧列**保留不清**—— // 它们已不参与任何判定,清掉是不可逆操作,没必要冒险;老客户端还会把它们当「剩余分钟」显示一阵子。 // // 一条 SQL 完成,避免逐行读改写在大表上拖慢启动。系数取此刻生效值(首次多半是默认 1)。 func (this *modelUserComp) migrateLegacyBucketsToCompute() { marker := &pb.DBAppConfigItem{} if err := mysql.FindOne(comm.TableAppConfig, marker, "`key`=?", legacyComputeMigratedKey); err == nil { return } else if err != mysql.ErrNoDocuments { this.module.Warn("算力迁移:读标记失败,本次跳过", log.Field{Key: "err", Value: err.Error()}) return } r := comm.LoadComputeRates() res := mysql.Exec( "UPDATE "+comm.TableUser+" SET computebalance = computebalance"+ " + CEIL(GREATEST(tradeintegral,0)/60)*?"+ " + CEIL(GREATEST(meetintegral,0)/60)*?"+ " + GREATEST(aichatintegral,0)"+ " WHERE tradeintegral > 0 OR meetintegral > 0 OR aichatintegral > 0", r.Translate, r.Meeting) if res.Error != nil { this.module.Error("算力迁移:折算存量额度失败", log.Field{Key: "err", Value: res.Error.Error()}) return } if err := mysql.Insert(comm.TableAppConfig, &pb.DBAppConfigItem{ Group: "compute", Key: legacyComputeMigratedKey, Value: "1", Description: "存量三桶已折算进算力(自动迁移标记,勿删)", }); err != nil { this.module.Warn("算力迁移:写标记失败,下次启动会重复折算!", log.Field{Key: "err", Value: err.Error()}) return } this.module.Infof("算力迁移:已把 %d 个用户的存量额度折算进 computebalance", res.RowsAffected) } func (this *modelUserComp) findforid(uid string) (user *pb.DBUser, err error) { user = &pb.DBUser{} err = mysql.FindOne(comm.TableUser, user, "uid=?", uid) return } func (this *modelUserComp) findformac(mac string) (user *pb.DBUser, err error) { user = &pb.DBUser{} err = mysql.FindOne(comm.TableUser, user, "phonemac=?", mac) return } // findtouristformac 按设备硬件 id 找回**游客账号**。 // // 不能直接用 findformac:phonemac 是所有登录方式建号时都会写的字段, // 同一台设备上先用手机号登录过,再点游客登录就会串进那个正式账号里去。 // 所以这里额外要求所有身份标识都为空——只有游客账号满足这个条件。 func (this *modelUserComp) findtouristformac(mac string) (user *pb.DBUser, err error) { user = &pb.DBUser{} err = mysql.FindOne(comm.TableUser, user, "phonemac=? AND phone='' AND mail='' AND account='' AND wxopenid='' "+ "AND googleopenid='' AND facebookopenid='' AND appleopenid=''", mac) return } func (this *modelUserComp) findforphone(phone string) (user *pb.DBUser, err error) { user = &pb.DBUser{} err = mysql.FindOne(comm.TableUser, user, "phone=?", phone) return } func (this *modelUserComp) findformail(mail string) (user *pb.DBUser, err error) { user = &pb.DBUser{} err = mysql.FindOne(comm.TableUser, user, "mail=?", mail) return } func (this *modelUserComp) findforwx(openid string) (user *pb.DBUser, err error) { user = &pb.DBUser{} err = mysql.FindOne(comm.TableUser, user, "wxopenid=?", openid) return } func (this *modelUserComp) findforgoogle(openid string) (user *pb.DBUser, err error) { user = &pb.DBUser{} err = mysql.FindOne(comm.TableUser, user, "googleopenid=?", openid) return } func (this *modelUserComp) findforapple(openid string) (user *pb.DBUser, err error) { user = &pb.DBUser{} err = mysql.FindOne(comm.TableUser, user, "appleopenid=?", openid) return } func (this *modelUserComp) findforfacebook(openid string) (user *pb.DBUser, err error) { user = &pb.DBUser{} err = mysql.FindOne(comm.TableUser, user, "facebookopenid=?", openid) return } func (this *modelUserComp) add(user *pb.DBUser) (err error) { err = mysql.Insert(comm.TableUser, user) return } func (this *modelUserComp) save(user *pb.DBUser) (err error) { err = mysql.Save(comm.TableUser, user) return } // 软注销:清理用户业务数据 + 个人痕迹,保留身份锚点与 Isgiveaway // 保留 user 行作为防刷锚点,下次同一身份再登录时可复活 func (this *modelUserComp) cancel(user *pb.DBUser) (err error) { uid := user.Uid // 1) 删除用户模块自己管的三张表(按 uid) if err = mysql.Delete(comm.TableUserdevice, "uid=?", uid); err != nil { return } if err = mysql.Delete(comm.TableUserStatistics, "uid=?", uid); err != nil { return } if err = mysql.Delete(comm.TableUserUseLog, "uid=?", uid); err != nil { return } // 2) 跨模块:allhelp_task 表、chat_summary 表、echomeet_record 表(同一 ServiceDB) if err = mysql.Delete(comm.TableAllhelpTask, "uid=?", uid); err != nil { return } if err = mysql.Delete(comm.TableChatSummary, "uid=?", uid); err != nil { return } if err = mysql.Delete(comm.TableEchomeetRecord, "uid=?", uid); err != nil { return } // 记忆中心(拾忆):花销金额、灵感原文、全部行程都在这两张表里,注销必须清干净。 // ⚠️ 这个清单是**硬编码**的,没有任何机制会提醒你新表漏登记——漏了不报错, // 只是用户注销后个人数据静静留在库里,发现时已经是合规问题。加新表记得回来加一行。 if err = mysql.Delete(comm.TableMemoryItem, "uid=?", uid); err != nil { return } if err = mysql.Delete(comm.TableMemoryReport, "uid=?", uid); err != nil { return } // 实名认证记录(身份证掩码 + 加盐指纹)。既有遗漏,本次一并补上。 if err = mysql.Delete(comm.TableUserIdVerify, "uid=?", uid); err != nil { return } // 3) 清理 DBUser 上的业务字段与 PII,保留身份锚点、Createtime、Isgiveaway user.Name = "" user.Avatar = "" user.Phonemodel = "" user.Phonemac = "" user.Viplv = 0 user.Vipexptime = 0 user.Aichatintegral = 0 user.Aichattotalintegral = 0 user.Tradeintegral = 0 user.Tradetotalintegral = 0 user.Meetintegral = 0 user.Meettotalintegral = 0 user.State = pb.UserState_Cancelled user.Canceltime = time.Now().Unix() // 保留:Uid / Phone / Mail / Wxopenid / Googleopenid / Facebookopenid / Appleopenid // Createtime / Isgiveaway / Isactivatecode / Language / Channel / Version / Lastsgintime err = mysql.Save(comm.TableUser, user) return } // 添加设备 func (this *modelUserComp) addDevice(divice *pb.DBUserDivice) (err error) { err = mysql.Insert(comm.TableUserdevice, divice) return } // 获取用户设备列表 func (this *modelUserComp) getDevices(uid string) (divices []*pb.DBUserDivice, err error) { divices = make([]*pb.DBUserDivice, 0) err = mysql.Find(comm.TableUserdevice, &divices, "uid=?", uid) return } // 获取用户设备列表 func (this *modelUserComp) getDevice(id uint64) (divice *pb.DBUserDivice, err error) { divice = &pb.DBUserDivice{} err = mysql.FindOne(comm.TableUserdevice, divice, "id=?", id) return } // 保存用户设备 func (this *modelUserComp) saveDevice(divice *pb.DBUserDivice) (err error) { err = mysql.Save(comm.TableUserdevice, divice) return } // 获取用户设备列表 func (this *modelUserComp) delDevice(id uint64) (err error) { err = mysql.Delete(comm.TableUserdevice, "id=?", id) return } // ------------------- // 合作商绑定 // // 设备/授权码统一住在**全局一张** device_mac 表里(2026-09-04 由 license_ 分表合并而来)。 // 主键 code 就是设备 MAC,本来就全局唯一,所以定位一台设备**不需要也不应该带 pid**—— // pid 是从查到的那一行的 productid 列**读出来的结果**,不是查询条件。 // // 分表时代的教训:客户端得先猜出 pid 才能校验设备,猜错就报「这台 MAC 没登记在该产品下」, // 提示词指向数据缺失、实际是 pid 错了,极难排查。详见 registry.go 的 migrateLicenseToDeviceMac。 // // 这两个函数是绑定/解绑路径上的"读后写同一行"校验读,用 FindOnePrimary 强制走主库, // 避免读到只读副本复制延迟内的旧状态造成重复绑定竞态。 // deviceInThisApp 这台设备是否属于本应用。 // // device_mac 是**全平台一张表**(2026-09-04 从 license_ 分表合过来),MAC 全局唯一、 // 表上没有应用列。应用归属沿这条链走:device_mac.productid → product.appnames(CSV)→ 本应用。 // 不校验的话,A 应用的设备能绑到 B 应用的账号上,绑定礼/设备 VIP/算力也跟着串过去。 // // ⚠️ productid=0 一律**拒绝**,不是放行:设备必须挂在产品下(后台生成/导入都已在入口校验, // device_mac 上还有 CHECK 约束兜底),0 意味着这条数据本身是坏的——既推不出应用归属, // 也说不清是谁的设备。两台机实测都没有这种行,真出现了应该在后台修数据而不是放它过去。 func (this *modelUserComp) deviceInThisApp(model *pb.DBAuthCode) bool { if model == nil { return false } if model.Productid == 0 { return false } // getProduct 自带 appnames 过滤:产品不存在、或不属于本应用,都返回 ErrNoDocuments。 _, err := this.getProduct(int32(model.Productid)) return err == nil } // getFactoryDevice 按授权码(= 主键 code)取设备行。不属于本应用的设备等同于「查不到」。 func (this *modelUserComp) getFactoryDevice(code string) (model *pb.DBAuthCode, err error) { model = &pb.DBAuthCode{} if err = postgres.FindOnePrimary(comm.TableDeviceMac, model, "code=?", code); err != nil { return } if !this.deviceInThisApp(model) { return &pb.DBAuthCode{}, postgres.ErrNoDocuments } return } // getFactoryDeviceformac 按 MAC 全局反查这台设备。**恒玄耳机的绑定校验就走这里**。 // // devicemac 是精确字符串匹配,两端的写法必须逐字一致。落库形态统一是「大写 + 冒号」 // (mac.Generate 的输出,导入模式也已规范化成同一形态),客户端 Android 侧送上来的 // BluetoothDevice.getAddress() 本来也是这个形态。但两端各自演进容易走偏(换 SDK、 // iOS 补上 MAC 来源、运维手工塞数据…),一旦写法差一个大小写或少个冒号, // 表现是「这台 MAC 没登记」——提示词指向数据缺失,实际是格式不匹配。 // // 所以这里多加一次兜底:精确匹配没中,就把入参规范化后再查一次。 // 顺序不能反——先精确保证存量非规范数据(如果有)仍然查得到,规范化只用于补救。 // ⚠️ 命中后还要过一道应用归属(deviceInThisApp):这张表是全平台共用的, // 别的应用的设备也在里面,不拦就等于「谁的 MAC 都能绑到本应用的账号上」。 func (this *modelUserComp) getFactoryDeviceformac(devicemac string) (model *pb.DBAuthCode, err error) { model = &pb.DBAuthCode{} if err = postgres.FindOnePrimary(comm.TableDeviceMac, model, "devicemac=?", devicemac); err == nil { if !this.deviceInThisApp(model) { return &pb.DBAuthCode{}, postgres.ErrNoDocuments } return model, nil } norm, ok := devcode.NormalizeMac(devicemac) if !ok || norm == devicemac { return model, err // 规范化不了,或本来就是规范形态:保留第一次的错误 } model = &pb.DBAuthCode{} if err = postgres.FindOnePrimary(comm.TableDeviceMac, model, "devicemac=?", norm); err != nil { return } if !this.deviceInThisApp(model) { return &pb.DBAuthCode{}, postgres.ErrNoDocuments } return } // getProductionBatch 按批次号取生产批次台账(console 主库与 license 分表同一个 postgres)。 // 绑定赠送已改成按批次配置,绑定时靠它取这一批送什么。 // batchno 为空(存量授权码没有批次号)直接返回 nil,由调用方回退到产品上的旧字段。 func (this *modelUserComp) getProductionBatch(batchno string) (model *pb.DBProductionBatch, err error) { if strings.TrimSpace(batchno) == "" { return nil, nil } model = &pb.DBProductionBatch{} if err = postgres.FindOne(comm.TableProductionBatch, model, "batchno=?", batchno); err != nil { return nil, err } return model, nil } func (this *modelUserComp) saveFactoryDevice(code *pb.DBAuthCode) (err error) { err = postgres.Save(comm.TableDeviceMac, code) return } func (this *modelUserComp) saveCode(addr string, code string) (err error) { err = redissys.Conn().Set(context.Background(), redissys.RKey(fmt.Sprintf("%s:%s", comm.Redis_Verification, addr)), code, time.Minute*2).Err() return } func (this *modelUserComp) getCode(addr string) (code string, err error) { code, err = redissys.Conn().Get(context.Background(), redissys.RKey(fmt.Sprintf("%s:%s", comm.Redis_Verification, addr))).Result() return } // 以下三个产品读取口的结果都会下发到客户端,出库即过 compatProduct 回填老客户端的 factoryid 字段(见 compat.go)。 // // ⚠️ product 是**公共库里的全平台表**(没有 app_name 列,多个应用共用一张),按应用的归属记在 // DBProduct.Appnames(CSV,console 产品管理里勾选)。这里必须按 comm.AppName() 过滤—— // 不过滤的话 EAIMAR 的客户端会拿到 deepGlass 的产品: // - 设备绑定的 pid 是客户端从这张表里挑的,多出别家的产品会挑错 → 服务端按 (pid, MAC) 查不到 → AuthorizeNoCanUse; // - OTA 页按「设备名与产品名互相包含」匹配产品,多一个同名/近名产品就会匹配到别家的固件。 // 过滤只能在内存里做,不能下推成 SQL LIKE,理由见 comm.AppNamesHas。 func (this *modelUserComp) getProduct(id int32) (model *pb.DBProduct, err error) { model = &pb.DBProduct{} if err = postgres.FindOne(comm.TableProduct, model, "id=?", id); err != nil { return } if !comm.AppNamesHas(model.Appnames, comm.AppName()) { // 存在但不属于本应用:等同于「没有这个产品」,别把别家的产品当自己的返回。 return nil, postgres.ErrNoDocuments } compatProduct(model) return } func (this *modelUserComp) getProducts() (models []*pb.DBProduct, err error) { all := make([]*pb.DBProduct, 0) if err = postgres.Find(comm.TableProduct, &all, ""); err != nil { return } app := comm.AppName() models = make([]*pb.DBProduct, 0, len(all)) for _, m := range all { if comm.AppNamesHas(m.Appnames, app) { models = append(models, m) } } compatProducts(models) return } func (this *modelUserComp) getProductForName(devicename string) (model *pb.DBProduct, err error) { model = &pb.DBProduct{} if err = postgres.FindOne(comm.TableProduct, model, "devicename=?", devicename); err != nil { return } if !comm.AppNamesHas(model.Appnames, comm.AppName()) { return nil, postgres.ErrNoDocuments } compatProduct(model) return } func (this *modelUserComp) getProductVersions(pid uint32) (models []*pb.DBProductVersion, err error) { models = make([]*pb.DBProductVersion, 0) err = postgres.Find(comm.TableProductVersion, &models, "productid=?", pid) return } // 渠道分发配置的数据源已从「应用业务库 mysql 的 channelapp 表」迁到「console 主库 postgres 的 // channel_app 表」(comm.ChannelApp),按 app_name 作用域读、应用行覆盖全局行——与 agent / 第三方服务 // 下发同语义。下发给客户端的仍是 pb.DBChannelApp,由 toPbChannelApp 做映射。 // loadChannelApps 读本应用作用域 + 全局默认的全部渠道配置行。 // 应用名走 comm.AppName()——即 .env 的 ANALYZE_APP_NAME(APP_NAME 已于 2026-08-12 删除, // 别再照旧注释去找)。它须与 app_registry 的 app_name 列一致,否则读不到自己那一层配置、 // 只剩全局默认行。 func (this *modelUserComp) loadChannelApps() ([]*comm.ChannelApp, error) { app := comm.AppName() rows := make([]*comm.ChannelApp, 0) err := postgres.Find(comm.TableChannelAppCfg, &rows, "(app_name=? OR app_name='')", app) if err != nil && err != postgres.ErrNoDocuments { return nil, err } return rows, nil } // loadAppRelease 取该应用生效的发版配置(应用行覆盖全局行);都没配返回 nil。 // // 版本/强更/送审版本/游客显隐这几项按**应用**存(不分渠道),见 comm.AppRelease。 func (this *modelUserComp) loadAppRelease() (*comm.AppRelease, error) { app := comm.AppName() rows := make([]*comm.AppRelease, 0) err := postgres.Find(comm.TableAppRelease, &rows, "(app_name=? OR app_name='')", app) if err != nil && err != postgres.ErrNoDocuments { return nil, err } return comm.PickAppRelease(rows, app), nil } // toPbChannelApp 把后台配置映射成下发结构;两张表都没配才返回 nil。 // // **下发结构是两张表拼出来的**:因渠道而异的(下载地址/上架状态/支付渠道/跳过绑定)来自 // [comm.ChannelApp],整个应用一套的(版本/强更/更新日志/送审版本/游客显隐)来自 // [comm.AppRelease]。任一为 nil 就只填另一边,对应字段留零值 —— // 客户端据此走「无更新 / 不显示入口」分支,不会因为配置缺失就把人挡在门外。 // // ⚠️ **渠道行不存在时绝不能整个返回 nil**。应用级的开关(尤其是游客登录)与渠道无关, // 一旦让它取决于「渠道分发里有没有这一行」,就等于把刚拆开的耦合又接了回去: // 运营在「游客显隐控制」里明明配好了,只因为没给这台设备的品牌渠道建过行, // 入口就永远不出现,而且不报任何错。这个坑真踩过,别改回去。 // // channel 由调用方给(而不是从 c 里取):渠道行缺失时 c 是 nil,但客户端仍然需要知道 // 这条回的是哪个渠道。 // // clientVer 是客户端上报的当前版本号,只用于把 Tourists 从「后台的原始配置」换算成 // 「这一版客户端到底该不该显示游客登录入口」——下发的 Tourists 是结论而非配置, // 客户端直接照着显隐即可,不需要也不应该再拿 Touristsversion 自己比一遍。 // Touristsversion 仍原样下发,仅供排查「为什么这版看不到入口」。 func toPbChannelApp(channel int32, c *comm.ChannelApp, rel *comm.AppRelease, clientVer string) *pb.DBChannelApp { if c == nil && rel == nil { return nil } out := &pb.DBChannelApp{Channel: channel} channelAddress := "" if c != nil { out.Description = c.Description out.Allowskipdevicebinding = c.AllowSkipDeviceBinding out.Paychannels = c.PayChannels out.Status = c.Status channelAddress = c.Address } // 渠道填了本渠道商店链接就用它,否则回退应用默认地址。 out.Address = rel.DownloadAddress(channelAddress) if rel != nil { out.Version = rel.Version out.Minversion = rel.MinVersion out.Forceupdate = rel.ForceUpdate out.Changelog = rel.Changelog out.Reviewversion = rel.ReviewVersion out.Tourists = rel.ShowTourists(clientVer) out.Touristsversion = rel.TouristsVersion } return out } // getChannelAppCfg 取该渠道生效的原始配置(应用行覆盖全局行);未配置或处于草稿态返回 nil。 // 返回 comm.ChannelApp 而非 pb 结构,是为了让接口层能用 NeedForceUpdate/IsReviewing 做升级判定。 func (this *modelUserComp) getChannelAppCfg(channel int32) (*comm.ChannelApp, error) { rows, err := this.loadChannelApps() if err != nil { return nil, err } picked := comm.PickChannelApp(rows, channel) if picked == nil || !picked.Enable || picked.Status == comm.ChannelStatusDraft { return nil, nil } return picked, nil } func (this *modelUserComp) getChannelApp(channel int32, clientVer string) (model *pb.DBChannelApp, err error) { cfg, err := this.getChannelAppCfg(channel) if err != nil { return nil, err } rel, err := this.loadAppRelease() if err != nil { return nil, err } return toPbChannelApp(channel, cfg, rel, clientVer), nil } // getChannelApps 下发全部渠道:同一渠道应用行覆盖全局行;草稿状态不下发。 // clientVer 供游客登录的版本限定判定,见 toPbChannelApp。 // // 发版配置只查一次,拼进每一条渠道记录 —— 它是按应用存的,与渠道无关。 func (this *modelUserComp) getChannelApps(clientVer string) (models []*pb.DBChannelApp, err error) { rows, err := this.loadChannelApps() if err != nil { return nil, err } rel, err := this.loadAppRelease() if err != nil { return nil, err } seen := map[int32]bool{} models = make([]*pb.DBChannelApp, 0, len(rows)) for _, r := range rows { if seen[r.Channel] { continue } seen[r.Channel] = true picked := comm.PickChannelApp(rows, r.Channel) if picked == nil || !picked.Enable || picked.Status == comm.ChannelStatusDraft { continue } models = append(models, toPbChannelApp(picked.Channel, picked, rel, clientVer)) } return models, nil } func (this *modelUserComp) getStatistics(uid string) (statistics *pb.DBUserStatistics, err error) { statistics = &pb.DBUserStatistics{Uid: uid} mysql.FindOne(comm.TableUserStatistics, statistics, "uid=?", uid) return } func (this *modelUserComp) updateStatistics(statistics *pb.DBUserStatistics) (err error) { err = mysql.Table(comm.TableUserStatistics). Clauses(clause.OnConflict{UpdateAll: true}). Create(statistics).Error return } // 添加积分日志 func (this *modelUserComp) addIntegralLog(log *pb.DBUserUseLog) (err error) { err = mysql.Insert(comm.TableUserUseLog, log) return } // sumComputeUsedSince 从 since 起的算力消耗合计(流水里消耗记为负数,这里取绝对值)。 func (this *modelUserComp) sumComputeUsedSince(uid string, since int64) (used int64, err error) { var total struct{ Total int64 } err = mysql.Table(comm.TableUserUseLog). Select("COALESCE(SUM(-compute),0) AS total"). Where("uid=? AND ts>=? AND logtype=? AND compute<0", uid, since, int32(pb.UserLogType_UserConsume)). Scan(&total).Error used = total.Total return }