package comm import ( "errors" "sort" "strconv" "strings" "sync" "time" "yunyan/lego/sys/mysql" "yunyan/lego/sys/postgres" "yunyan/pb" "yunyan/utils/devcode" "gorm.io/gorm" ) // 会员与算力:两层权益的唯一判定与记账入口。 // // - VIP 管**功能准入**:没有 VIP,翻译与会议纪要不可用; // - 算力管**用量计量**:翻译 / 会议 / AI 对话的消耗统一折算成算力,先扣设备赠送、再扣用户余额。 // // 两层的归属规则一致:设备赠送的归设备(解绑即失去、随设备流转),用户自购的归账号。 // 设计文档:docs/会员权益与用量统计-设计与开发文档.md // // ⚠️ 需要判 VIP / 扣算力的入口一律调本文件的函数,**别各写一份**——散落的判定过去就出过 // 「设备送的天数累加进用户表、解绑拆不开」的事故(见 ResolveVip 的说明)。 // ───────────────────────────────────────────────────────────────────────────── // 换算系数:存业务库 config 表(KV),随 user_getappconfig 的 env 下发。 // 客户端拿到系数**只用于本地预估展示**,上报的永远是原始量,换算在服务端做—— // 否则改一次系数就要发一次版,且新老版本算出的数不一致。 // ───────────────────────────────────────────────────────────────────────────── const ( ConfigKeyComputeRateTranslate = "COMPUTE_RATE_TRANSLATE" // 算力 / 分钟 ConfigKeyComputeRateMeeting = "COMPUTE_RATE_MEETING" // 算力 / 分钟 ConfigKeyComputeRateAIChat = "COMPUTE_RATE_AICHAT" // 算力 / 100 字 ConfigKeyComputeGate = "COMPUTE_GATE" // 1 = 余额不足即拒绝;0(默认)= 只记账不拦 // 运营参数(后台「会员与算力」页可配,改了不用发版) ConfigKeyNewUserGiftVipDays = "NEWUSER_GIFT_VIPDAYS" // 开户礼 VIP 天数 ConfigKeyNewUserGiftCompute = "NEWUSER_GIFT_COMPUTE" // 开户礼算力 ConfigKeyVipWarnDays = "VIP_WARN_DAYS" // VIP 到期前多少天开始提示(0=不提示) ConfigKeyComputeWarn = "COMPUTE_WARN" // 算力低于多少开始提示(0=不提示) ) // 运营参数默认值。写成常量而不是 0:配置表还没建这几行时(新部署、或运营没填过), // 取 0 会变成「开户礼什么都不送、永不提示」,那是静默的功能缺失而不是合理的默认。 const ( DefaultNewUserGiftVipDays = 30 DefaultNewUserGiftCompute = 100 DefaultVipWarnDays = 7 DefaultComputeWarn = 100 ) // OpsParams 一份生效中的运营参数。 type OpsParams struct { NewUserGiftVipDays int32 NewUserGiftCompute int64 VipWarnDays int32 ComputeWarn int64 } // DefaultOpsParams 未配置时的取值。 func DefaultOpsParams() OpsParams { return OpsParams{ NewUserGiftVipDays: DefaultNewUserGiftVipDays, NewUserGiftCompute: DefaultNewUserGiftCompute, VipWarnDays: DefaultVipWarnDays, ComputeWarn: DefaultComputeWarn, } } // ParseOpsParams 从 KV 解析;缺省回默认值。 // // ⚠️ 与换算系数不同,这四项**允许配 0**:0 是「不送 / 不提示」的合法表达。 // 所以只有「键不存在」或「解析不出数字」才回默认,解析出 0 就用 0。 func ParseOpsParams(kv map[string]string) OpsParams { p := DefaultOpsParams() pick := func(key string, set func(int64)) { if v, ok := kv[key]; ok { if n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil && n >= 0 { set(n) } } } pick(ConfigKeyNewUserGiftVipDays, func(n int64) { p.NewUserGiftVipDays = int32(n) }) pick(ConfigKeyNewUserGiftCompute, func(n int64) { p.NewUserGiftCompute = n }) pick(ConfigKeyVipWarnDays, func(n int64) { p.VipWarnDays = int32(n) }) pick(ConfigKeyComputeWarn, func(n int64) { p.ComputeWarn = n }) return p } // ToKV 反向:给后台读写用。 func (p OpsParams) ToKV() map[string]string { return map[string]string{ ConfigKeyNewUserGiftVipDays: strconv.FormatInt(int64(p.NewUserGiftVipDays), 10), ConfigKeyNewUserGiftCompute: strconv.FormatInt(p.NewUserGiftCompute, 10), ConfigKeyVipWarnDays: strconv.FormatInt(int64(p.VipWarnDays), 10), ConfigKeyComputeWarn: strconv.FormatInt(p.ComputeWarn, 10), } } var opsParamsCache struct { sync.Mutex at time.Time params OpsParams } // LoadOpsParams 读取生效中的运营参数(业务库 config 表),缓存同换算系数。 func LoadOpsParams() OpsParams { opsParamsCache.Lock() defer opsParamsCache.Unlock() if !opsParamsCache.at.IsZero() && time.Since(opsParamsCache.at) < computeRatesTTL { return opsParamsCache.params } items := make([]*pb.DBAppConfigItem, 0, 4) keys := []string{ConfigKeyNewUserGiftVipDays, ConfigKeyNewUserGiftCompute, ConfigKeyVipWarnDays, ConfigKeyComputeWarn} kv := map[string]string{} if err := mysql.Find(TableAppConfig, &items, "`key` IN ?", keys); err == nil { for _, it := range items { kv[it.Key] = it.Value } } opsParamsCache.params = ParseOpsParams(kv) opsParamsCache.at = time.Now() return opsParamsCache.params } // InvalidateOpsParams 让下一次 LoadOpsParams 重新读库。 func InvalidateOpsParams() { opsParamsCache.Lock() opsParamsCache.at = time.Time{} opsParamsCache.Unlock() } // ComputeRates 一份生效中的换算系数。 type ComputeRates struct { Translate int64 Meeting int64 AIChat int64 Gate bool } // DefaultComputeRates 三个系数默认全 1:1 分钟翻译 = 1 分钟会议 = 100 字对话 = 1 算力;闸门默认关。 func DefaultComputeRates() ComputeRates { return ComputeRates{Translate: 1, Meeting: 1, AIChat: 1, Gate: false} } // ParseComputeRates 从 env 形式的 KV 解析系数;缺省或非法一律回默认值,不让一个写坏的配置把计费打到 0。 func ParseComputeRates(kv map[string]string) ComputeRates { r := DefaultComputeRates() pick := func(key string, dst *int64) { if v, ok := kv[key]; ok { if n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil && n > 0 { *dst = n } } } pick(ConfigKeyComputeRateTranslate, &r.Translate) pick(ConfigKeyComputeRateMeeting, &r.Meeting) pick(ConfigKeyComputeRateAIChat, &r.AIChat) if v, ok := kv[ConfigKeyComputeGate]; ok { v = strings.TrimSpace(strings.ToLower(v)) r.Gate = v == "1" || v == "true" || v == "on" } return r } // ToKV 反向:给后台读写与下发用。 func (r ComputeRates) ToKV() map[string]string { gate := "0" if r.Gate { gate = "1" } return map[string]string{ ConfigKeyComputeRateTranslate: strconv.FormatInt(r.Translate, 10), ConfigKeyComputeRateMeeting: strconv.FormatInt(r.Meeting, 10), ConfigKeyComputeRateAIChat: strconv.FormatInt(r.AIChat, 10), ConfigKeyComputeGate: gate, } } var computeRatesCache struct { sync.Mutex at time.Time rates ComputeRates } // computeRatesTTL 读库缓存时长。改系数后最多这么久生效;后台保存时会调 InvalidateComputeRates 立即刷新本进程。 const computeRatesTTL = 30 * time.Second // LoadComputeRates 读取生效中的换算系数(业务库 config 表)。读失败回默认值——计费不能因为配置表抖动而中断。 func LoadComputeRates() ComputeRates { computeRatesCache.Lock() defer computeRatesCache.Unlock() if !computeRatesCache.at.IsZero() && time.Since(computeRatesCache.at) < computeRatesTTL { return computeRatesCache.rates } items := make([]*pb.DBAppConfigItem, 0, 4) keys := []string{ConfigKeyComputeRateTranslate, ConfigKeyComputeRateMeeting, ConfigKeyComputeRateAIChat, ConfigKeyComputeGate} kv := map[string]string{} // `key` 是 MySQL 保留字,必须反引号。 if err := mysql.Find(TableAppConfig, &items, "`key` IN ?", keys); err == nil { for _, it := range items { kv[it.Key] = it.Value } } computeRatesCache.rates = ParseComputeRates(kv) computeRatesCache.at = time.Now() return computeRatesCache.rates } // InvalidateComputeRates 让下一次 LoadComputeRates 重新读库。 func InvalidateComputeRates() { computeRatesCache.Lock() computeRatesCache.at = time.Time{} computeRatesCache.Unlock() } // SaveComputeRates 把系数写回 config 表(有则改、无则建),并使本进程缓存失效。 // 其它进程靠 computeRatesTTL 自然过期,或由调用方广播 Rpc_ModifyAppConifg。 func SaveComputeRates(r ComputeRates) error { if r.Translate <= 0 || r.Meeting <= 0 || r.AIChat <= 0 { return errors.New("换算系数必须大于 0") } for k, v := range r.ToKV() { item := &pb.DBAppConfigItem{} err := mysql.FindOne(TableAppConfig, item, "`key`=?", k) if err != nil && err != mysql.ErrNoDocuments { return err } if err == mysql.ErrNoDocuments { item = &pb.DBAppConfigItem{Group: "compute", Key: k, Value: v, Description: computeKeyDesc(k)} if err = mysql.Insert(TableAppConfig, item); err != nil { return err } continue } if item.Value != v { if err = mysql.Table(TableAppConfig).Where("`key`=?", k).Update("value", v).Error; err != nil { return err } } } InvalidateComputeRates() return nil } func computeKeyDesc(k string) string { switch k { case ConfigKeyComputeRateTranslate: return "算力换算:翻译,算力/分钟" case ConfigKeyComputeRateMeeting: return "算力换算:会议纪要,算力/分钟" case ConfigKeyComputeRateAIChat: return "算力换算:AI对话,算力/100字" case ConfigKeyComputeGate: return "算力闸门:1=余额不足即拒绝,0=只记账不拦(开启前须先上线预扣)" case ConfigKeyNewUserGiftVipDays: return "开户礼:赠送VIP天数(0=不送)" case ConfigKeyNewUserGiftCompute: return "开户礼:赠送算力(0=不送)" case ConfigKeyVipWarnDays: return "提示阈值:VIP到期前多少天开始提示(0=不提示)" case ConfigKeyComputeWarn: return "提示阈值:算力低于多少开始提示(0=不提示)" } return "" } func ceilDiv(a, b int64) int64 { if a <= 0 || b <= 0 { return 0 } return (a + b - 1) / b } // ForTranslate 翻译时长(秒)→ 算力。按会话累计后向上取整到分钟,单次最低 1。 func (r ComputeRates) ForTranslate(sec int64) int64 { if sec <= 0 { return 0 } if c := ceilDiv(sec, 60) * r.Translate; c > 0 { return c } return 1 } // ForMeeting 会议时长(秒)→ 算力。 func (r ComputeRates) ForMeeting(sec int64) int64 { if sec <= 0 { return 0 } if c := ceilDiv(sec, 60) * r.Meeting; c > 0 { return c } return 1 } // ForAIChat 对话字符数(输入 + 输出,Unicode 码点)→ 算力。每 100 字向上取整,单次最低 1。 func (r ComputeRates) ForAIChat(chars int64) int64 { if chars <= 0 { return 0 } if c := ceilDiv(chars, 100) * r.AIChat; c > 0 { return c } return 1 } // ───────────────────────────────────────────────────────────────────────────── // 绑定设备:VIP 与设备算力都挂在 device_mac 行上,按「当前绑定关系」取。 // 绑定关系在 userdevice(业务库 MySQL),设备行在 device_mac(console 主库 Postgres), // 两库之间只能靠 MAC 对;device_mac.uid 只是台账、解绑时尽力清理,不拿它当绑定判据。 // ───────────────────────────────────────────────────────────────────────────── // BoundFactoryDevices 取用户当前绑定的全部设备行(已被后台禁用的不算)。 // 找不到对应设备行的绑定记录静默跳过——那是台账不一致,不该让整次判定失败。 func BoundFactoryDevices(uid string) ([]*pb.DBAuthCode, error) { uds := make([]*pb.DBUserDivice, 0, 2) if err := mysql.Find(TableUserdevice, &uds, "uid=?", uid); err != nil && err != mysql.ErrNoDocuments { return nil, err } out := make([]*pb.DBAuthCode, 0, len(uds)) seen := map[string]bool{} for _, ud := range uds { mac := strings.TrimSpace(ud.Devicemac) if mac == "" { continue } if n, ok := devcode.NormalizeMac(mac); ok { mac = n } if seen[mac] { continue } seen[mac] = true d := &pb.DBAuthCode{} if err := postgres.FindOne(TableDeviceMac, d, "devicemac=?", mac); err != nil { continue } if d.Disabled != 0 { continue } out = append(out, d) } return out, nil } // DeviceVipExpire 这台设备赋予的 VIP 到期时间:激活日 + vipdays 天,按日历天连续走、解绑不暂停。 // 未激活(usedtime=0)或不送 VIP(vipdays=0)返回 0。 func DeviceVipExpire(d *pb.DBAuthCode) int64 { if d == nil || d.Usedtime <= 0 || d.Vipdays <= 0 { return 0 } return d.Usedtime + int64(d.Vipdays)*86400 } // DeviceVipLevel 设备赋予的等级;配了天数但没配等级视为 1。 func DeviceVipLevel(d *pb.DBAuthCode) int32 { if d == nil || d.Vipdays <= 0 { return 0 } if d.Viplevel <= 0 { return 1 } return d.Viplevel } // DeviceComputeLeft 设备赠送算力剩余。 func DeviceComputeLeft(d *pb.DBAuthCode) int64 { if d == nil { return 0 } if left := d.Computegrant - d.Computeused; left > 0 { return left } return 0 } // ───────────────────────────────────────────────────────────────────────────── // VIP 判定 // ───────────────────────────────────────────────────────────────────────────── // VipStatus 合并后的会员状态。 type VipStatus struct { Level int32 // 生效等级 = max(自购, 各绑定设备);0 = 当前无 VIP ExpTime int64 // 生效到期时间(各来源取最晚);无 VIP 时为各来源中最晚的历史到期,便于显示「已于 x 到期」 SelfExpTime int64 // 自购 VIP 到期时间(user.vipexptime,只记自购) FromDevice bool // 当前生效的 VIP 是否来自设备(用于展示来源) } // Active 此刻是否有 VIP。 func (v VipStatus) Active(now int64) bool { return v.Level > 0 && v.ExpTime > now } // ResolveVip 合并自购与设备赋予的 VIP。 // // 判定 = 自购未过期 **或** (当前绑定着某设备 且 该设备 VIP 期未结束)。任一成立即有 VIP。 // // ⚠️ 设备侧**不写用户表**:user.vipexptime 只记自购。以前是把赠送天数直接累加进去, // 一旦累加就与自购混成同一个数字,解绑时无法拆分该扣回多少天。现在设备 VIP 由 // device_mac 行的 usedtime + vipdays 算出,解绑时什么都不用做——不绑了自然就不享有了。 func ResolveVip(user *pb.DBUser, devices []*pb.DBAuthCode, now int64) VipStatus { v := VipStatus{} if user != nil { v.SelfExpTime = user.Vipexptime } // 候选:每个来源一条 (level, exp, fromDevice)。先挑生效中的,按 level 高、exp 晚取; // 全部过期时只记最晚的历史到期,Level 保持 0。 type cand struct { level int32 exp int64 fromDevice bool } cands := make([]cand, 0, 1+len(devices)) if user != nil && user.Vipexptime > 0 { lv := user.Viplv if lv <= 0 { lv = 1 } cands = append(cands, cand{level: lv, exp: user.Vipexptime}) } for _, d := range devices { if exp := DeviceVipExpire(d); exp > 0 { cands = append(cands, cand{level: DeviceVipLevel(d), exp: exp, fromDevice: true}) } } var latestExpired int64 for _, c := range cands { if c.exp <= now { if c.exp > latestExpired { latestExpired = c.exp } continue } better := v.Level == 0 || c.level > v.Level || (c.level == v.Level && c.exp > v.ExpTime) if better { v.Level, v.ExpTime, v.FromDevice = c.level, c.exp, c.fromDevice } } if v.Level == 0 { v.ExpTime = latestExpired } return v } // ResolveUserVip 按 uid 一步到位:读用户与其绑定设备后合并。user 可传 nil 由本函数读取。 func ResolveUserVip(uid string, user *pb.DBUser) (VipStatus, []*pb.DBAuthCode, error) { if user == nil { user = &pb.DBUser{} if err := mysql.FindOne(TableUser, user, "uid=?", uid); err != nil { return VipStatus{}, nil, err } } devices, err := BoundFactoryDevices(uid) if err != nil { return VipStatus{}, nil, err } return ResolveVip(user, devices, time.Now().Unix()), devices, nil } // ───────────────────────────────────────────────────────────────────────────── // 算力扣减 // ───────────────────────────────────────────────────────────────────────────── // ComputeUsageKind 消耗类型,决定用哪个换算系数。 type ComputeUsageKind int const ( ComputeUsageTranslate ComputeUsageKind = iota + 1 ComputeUsageMeeting ComputeUsageAIChat ) // ErrComputeNotEnough 闸门开启且余额不足。 var ErrComputeNotEnough = errors.New("compute not enough") // ErrComputeBalanceNegative 调整后余额将为负。 var ErrComputeBalanceNegative = errors.New("compute balance would be negative") // ComputeResult 一次扣减的结果。 type ComputeResult struct { Compute int64 // 本次折算出的算力 Device int64 // 从设备赠送额度扣的部分 User int64 // 从用户余额扣的部分 Over int64 // 超额(闸门关时余额不足仍放行的量;闸门开时恒为 0) DeviceMac string // 扣了哪台设备(多台以逗号分隔) DeviceLeft int64 // 扣后各绑定设备剩余合计 UserLeft int64 // 扣后用户余额 Rejected bool // 闸门开启且不足,已拒绝、未写库 } type deviceCut struct { d *pb.DBAuthCode n int64 } // planComputeDeduction 决定从哪里扣多少:先扣设备(剩余最少的先扣,用完一台再动下一台),再扣用户余额,剩下算超额。 func planComputeDeduction(need int64, devices []*pb.DBAuthCode, userBalance int64) (cuts []deviceCut, fromUser, over int64) { rem := need sorted := make([]*pb.DBAuthCode, 0, len(devices)) for _, d := range devices { if DeviceComputeLeft(d) > 0 { sorted = append(sorted, d) } } sort.SliceStable(sorted, func(i, j int) bool { return DeviceComputeLeft(sorted[i]) < DeviceComputeLeft(sorted[j]) }) for _, d := range sorted { if rem <= 0 { break } n := DeviceComputeLeft(d) if n > rem { n = rem } cuts = append(cuts, deviceCut{d: d, n: n}) rem -= n } if rem > 0 && userBalance > 0 { fromUser = userBalance if fromUser > rem { fromUser = rem } rem -= fromUser } over = rem return } // ApplyComputeUsage 一次用量的算力记账:换算 → 先设备后用户扣减 → 闸门判定 → 原子写库 → 回填流水与统计。 // // - raw:原始量(翻译/会议为秒,AI 为输入+输出字符数); // - userlog:调用方已填好原始字段的流水行,本函数只补算力五列,**由调用方负责 Insert**; // - stats:调用方持有的统计行,本函数累加 Computetotal,**由调用方负责保存**。 // // 闸门关(默认):余额不够也放行,差额记进 Over。闸门开:不够则 Rejected=true 且不写任何东西。 // // 设备扣减用 `computeused = computeused + n` 原子更新、用户余额用 `computebalance - n`, // 不走读-改-写,避免并发上报把余额算飞。 func ApplyComputeUsage(uid string, kind ComputeUsageKind, raw int64, userlog *pb.DBUserUseLog, stats *pb.DBUserStatistics) (res ComputeResult, err error) { rates := LoadComputeRates() switch kind { case ComputeUsageTranslate: res.Compute = rates.ForTranslate(raw) case ComputeUsageMeeting: res.Compute = rates.ForMeeting(raw) case ComputeUsageAIChat: res.Compute = rates.ForAIChat(raw) } user := &pb.DBUser{} if err = mysql.FindOne(TableUser, user, "uid=?", uid); err != nil { return } devices, derr := BoundFactoryDevices(uid) if derr != nil { err = derr return } if res.Compute == 0 { res.UserLeft = user.Computebalance for _, d := range devices { res.DeviceLeft += DeviceComputeLeft(d) } return } cuts, fromUser, over := planComputeDeduction(res.Compute, devices, user.Computebalance) if rates.Gate && over > 0 { res.Rejected = true res.Over = over res.UserLeft = user.Computebalance for _, d := range devices { res.DeviceLeft += DeviceComputeLeft(d) } return } macs := make([]string, 0, len(cuts)) for _, c := range cuts { if err = postgres.Table(TableDeviceMac).Where("code=?", c.d.Code). Update("computeused", gorm.Expr("computeused + ?", c.n)).Error; err != nil { return } c.d.Computeused += c.n res.Device += c.n macs = append(macs, c.d.Devicemac) } if fromUser > 0 { if err = mysql.Table(TableUser).Where("uid=?", uid). Update("computebalance", gorm.Expr("computebalance - ?", fromUser)).Error; err != nil { return } user.Computebalance -= fromUser res.User = fromUser } res.Over = over res.DeviceMac = strings.Join(macs, ",") res.UserLeft = user.Computebalance for _, d := range devices { res.DeviceLeft += DeviceComputeLeft(d) } if userlog != nil { userlog.Compute = -res.Compute userlog.Computedevice = res.Device userlog.Computeuser = res.User userlog.Computeover = res.Over userlog.Devicemac = res.DeviceMac } if stats != nil { stats.Computetotal += res.Compute } return } // AdjustUserCompute 后台调整用户算力余额(delta 可负),余额不得低于 0;同时写一条 AdminAdjust 流水。 func AdjustUserCompute(uid string, delta int64, remark string) (newBalance int64, err error) { user := &pb.DBUser{} if err = mysql.FindOne(TableUser, user, "uid=?", uid); err != nil { return } if user.Computebalance+delta < 0 { err = ErrComputeBalanceNegative return } if err = mysql.Table(TableUser).Where("uid=?", uid). Update("computebalance", gorm.Expr("computebalance + ?", delta)).Error; err != nil { return } newBalance = user.Computebalance + delta _ = mysql.Insert(TableUserUseLog, &pb.DBUserUseLog{ Uid: uid, Ts: time.Now().Unix(), Logtype: pb.UserLogType_AdminAdjust, Compute: delta, Extra: remark, }) return } // GrantUserCompute 发放用户算力(支付发货 / 开户礼等),delta 必须 > 0。流水由调用方写(它们各有自己的 logtype 与 extra)。 func GrantUserCompute(uid string, delta int64) error { if delta <= 0 { return nil } return mysql.Table(TableUser).Where("uid=?", uid). Update("computebalance", gorm.Expr("computebalance + ?", delta)).Error } // LegacyBucketsToCompute 把旧三桶的量折算成算力:翻译秒 / 会议秒按分钟向上取整 × 系数,AI 次数 1:1。 // 用于:存量余额迁移、老商品(只配了 ainum/meetnum/tradenum 没配 compute)发货、公码与后台赠送的旧字段。 func LegacyBucketsToCompute(r ComputeRates, tradeSec, meetSec, aiCount int64) int64 { var c int64 if tradeSec > 0 { c += ceilDiv(tradeSec, 60) * r.Translate } if meetSec > 0 { c += ceilDiv(meetSec, 60) * r.Meeting } if aiCount > 0 { c += aiCount } return c }