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