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.
 
 
 
 
 
 

664 lines
23 KiB

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
}