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.
 
 
 
 
 
 

445 lines
17 KiB

package console
import (
"errors"
"fmt"
"strings"
"time"
"yunyan/comm"
"yunyan/lego/sys/log"
"yunyan/lego/sys/mysql"
"yunyan/pb"
"yunyan/utils/devcode"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// 会员与算力 · 后台接口(设计文档:docs/会员权益与用量统计-设计与开发文档.md §6/§7)。
//
// 所有接口都按选中应用(X-App-Id)直连其业务库:换算系数存业务库 config 表(随 user_getappconfig 的 env
// 下发给客户端,客户端仅用于本地预估展示);用户算力余额与流水在业务库 user / useruselog。
// 设备赠送的 VIP / 算力挂在 console 主库的 device_mac 行上,用户详情里跨库拼出来。
//
// comm.ApplyComputeUsage / AdjustUserCompute 用的是业务进程自己的全局 mysql 连接,console 这边
// 拿到的是按应用取出的连接对象,所以余额调整逻辑在这里按 conn 重写了一份(语义完全一致)。
var computeConfigKeys = []string{
comm.ConfigKeyComputeRateTranslate,
comm.ConfigKeyComputeRateMeeting,
comm.ConfigKeyComputeRateAIChat,
comm.ConfigKeyComputeGate,
comm.ConfigKeyNewUserGiftVipDays,
comm.ConfigKeyNewUserGiftCompute,
comm.ConfigKeyVipWarnDays,
comm.ConfigKeyComputeWarn,
}
// computeRatesView 与前端约定的形状:换算系数 + 闸门 + 运营参数,一次读写。
type computeRatesView struct {
Translate int64 `json:"translate"` // 算力 / 分钟
Meeting int64 `json:"meeting"` // 算力 / 分钟
Aichat int64 `json:"aichat"` // 算力 / 100 字
Gate bool `json:"gate"` // 算力闸门
// 运营参数:允许配 0(= 不送 / 不提示),所以校验用 >= 0 而不是 > 0。
NewUserGiftVipDays int32 `json:"newuser_gift_vipdays"` // 开户礼 VIP 天数
NewUserGiftCompute int64 `json:"newuser_gift_compute"` // 开户礼算力
VipWarnDays int32 `json:"vip_warn_days"` // VIP 到期前多少天提示
ComputeWarn int64 `json:"compute_warn"` // 算力低于多少提示
}
func (this *serverComp) appConnFromCtx(c *gin.Context) (mysql.ISys, bool) {
appId := parseAppId(c.GetHeader("X-App-Id"))
if !this.requireAppScope(c, appId) {
return nil, false
}
conn, err := this.module.registry.getServiceDB(appId)
if err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return nil, false
}
return conn, true
}
// computeRatesOf 读选中应用业务库里的换算系数(读不到回默认值,计费不能因配置表抖动而中断)。
//
// ⚠️ **console 里不能用 comm.LoadComputeRates()**:它走的是包级 mysql 连接(业务进程自己的那条),
// 而 console 是单例服务,InitSys 只初始化 postgres(主库)/redis/nats/email,**从不 mysql.OnInit** ——
// 包级 defsys 恒为 nil,一调就 nil interface method call panic,被 gin.Recovery 兜成 HTTP 500,
// 前端只看得到状态码、拿不到任何业务错误信息。console 拿到的是按 X-App-Id 取出的连接对象,只能走 conn。
// 另外系数本来就是**按应用**存在各自业务库的 config 表里,包级连接就算初始化了也会读到别人那一份。
func computeRatesOf(conn mysql.ISys) comm.ComputeRates {
items := make([]*pb.DBAppConfigItem, 0, 4)
kv := map[string]string{}
if err := conn.Find(comm.TableAppConfig, &items, "`key` IN ?", computeConfigKeys); err == nil {
for _, it := range items {
kv[it.Key] = it.Value
}
}
return comm.ParseComputeRates(kv)
}
// getComputeConfig 读选中应用的算力换算系数与闸门。
func (this *serverComp) getComputeConfig(c *gin.Context) {
conn, ok := this.appConnFromCtx(c)
if !ok {
return
}
items := make([]*pb.DBAppConfigItem, 0, 4)
if err := conn.Find(comm.TableAppConfig, &items, "`key` IN ?", computeConfigKeys); err != nil && !errors.Is(err, mysql.ErrNoDocuments) {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
kv := map[string]string{}
for _, it := range items {
kv[it.Key] = it.Value
}
r := comm.ParseComputeRates(kv)
o := comm.ParseOpsParams(kv)
writeOK(c, &computeRatesView{
Translate: r.Translate, Meeting: r.Meeting, Aichat: r.AIChat, Gate: r.Gate,
NewUserGiftVipDays: o.NewUserGiftVipDays, NewUserGiftCompute: o.NewUserGiftCompute,
VipWarnDays: o.VipWarnDays, ComputeWarn: o.ComputeWarn,
})
}
// saveComputeConfig 保存系数与闸门(仅超管/管理员)。
//
// ⚠️ 闸门开启前必须先上线预扣:现在 user_usages 是用完才上报,余额只剩 1 的用户仍可先用满再报——
// 开关关着时无所谓(反正放行),开了而没预扣等于形同虚设。前端在开关旁有提示,这里只做值校验。
func (this *serverComp) saveComputeConfig(c *gin.Context) {
if idt := currentIdentity(c); idt != pb.Identity_Admin && idt != pb.Identity_Manager {
writeErr(c, pb.ErrorCode_InsufficientPermissions, "仅超管/管理员可修改算力配置")
return
}
conn, ok := this.appConnFromCtx(c)
if !ok {
return
}
var req computeRatesView
if err := c.ShouldBindJSON(&req); err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
return
}
const rateMax = 1_000_000
for name, v := range map[string]int64{"translate": req.Translate, "meeting": req.Meeting, "aichat": req.Aichat} {
if v <= 0 || v > rateMax {
writeErr(c, pb.ErrorCode_ReqParameterError, fmt.Sprintf("换算系数 %s 需在 1~%d 之间", name, rateMax))
return
}
}
// 运营参数允许为 0(不送 / 不提示),只挡负数与离谱的大值
if req.NewUserGiftVipDays < 0 || req.NewUserGiftVipDays > 3650 {
writeErr(c, pb.ErrorCode_ReqParameterError, "开户礼 VIP 天数需在 0~3650 之间")
return
}
if req.NewUserGiftCompute < 0 || req.NewUserGiftCompute > 10_000_000 {
writeErr(c, pb.ErrorCode_ReqParameterError, "开户礼算力需在 0~10000000 之间")
return
}
if req.VipWarnDays < 0 || req.VipWarnDays > 365 {
writeErr(c, pb.ErrorCode_ReqParameterError, "VIP 到期提示天数需在 0~365 之间")
return
}
if req.ComputeWarn < 0 || req.ComputeWarn > 10_000_000 {
writeErr(c, pb.ErrorCode_ReqParameterError, "算力提示阈值需在 0~10000000 之间")
return
}
r := comm.ComputeRates{Translate: req.Translate, Meeting: req.Meeting, AIChat: req.Aichat, Gate: req.Gate}
o := comm.OpsParams{
NewUserGiftVipDays: req.NewUserGiftVipDays, NewUserGiftCompute: req.NewUserGiftCompute,
VipWarnDays: req.VipWarnDays, ComputeWarn: req.ComputeWarn,
}
kvAll := r.ToKV()
for k, v := range o.ToKV() {
kvAll[k] = v
}
for k, v := range kvAll {
item := &pb.DBAppConfigItem{}
err := conn.FindOne(comm.TableAppConfig, item, "`key`=?", k)
switch {
case errors.Is(err, mysql.ErrNoDocuments):
item = &pb.DBAppConfigItem{Group: "compute", Key: k, Value: v, Description: computeConfigDesc(k)}
if err = conn.Insert(comm.TableAppConfig, item); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
case err != nil:
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
case item.Value != v:
if err = conn.Table(comm.TableAppConfig).Where("`key`=?", k).Update("value", v).Error; err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
}
}
// 让业务进程立即重载应用配置(env 里的系数是从它的缓存下发的);服务端换算本身走 30s TTL 缓存。
broadcastConfigChanged(comm.ConfigKindGlobalConfig, "update", 0)
writeOK(c, &req)
}
func computeConfigDesc(k string) string {
switch k {
case comm.ConfigKeyComputeRateTranslate:
return "算力换算:翻译,算力/分钟"
case comm.ConfigKeyComputeRateMeeting:
return "算力换算:会议纪要,算力/分钟"
case comm.ConfigKeyComputeRateAIChat:
return "算力换算:AI对话,算力/100字"
case comm.ConfigKeyComputeGate:
return "算力闸门:1=余额不足即拒绝,0=只记账不拦(开启前须先上线预扣)"
case comm.ConfigKeyNewUserGiftVipDays:
return "开户礼:赠送VIP天数(0=不送)"
case comm.ConfigKeyNewUserGiftCompute:
return "开户礼:赠送算力(0=不送)"
case comm.ConfigKeyVipWarnDays:
return "提示阈值:VIP到期前多少天开始提示(0=不提示)"
case comm.ConfigKeyComputeWarn:
return "提示阈值:算力低于多少开始提示(0=不提示)"
}
return ""
}
// adjustUserCompute 后台调整用户算力余额(加减),余额不得低于 0,必填备注,写 AdminAdjust 流水。
func (this *serverComp) adjustUserCompute(c *gin.Context) {
if idt := currentIdentity(c); idt != pb.Identity_Admin && idt != pb.Identity_Manager {
writeErr(c, pb.ErrorCode_InsufficientPermissions, "仅超管/管理员可调整算力余额")
return
}
conn, ok := this.appConnFromCtx(c)
if !ok {
return
}
var req struct {
Uid string `json:"uid"`
Delta int64 `json:"delta"`
Remark string `json:"remark"`
}
_ = c.ShouldBindJSON(&req)
uid := strings.TrimSpace(req.Uid)
remark := strings.TrimSpace(req.Remark)
if uid == "" || req.Delta == 0 || remark == "" {
writeErr(c, pb.ErrorCode_ReqParameterError, "uid、非零的 delta、备注 都必填")
return
}
user := &pb.DBUser{}
if err := conn.FindOne(comm.TableUser, user, "uid=?", uid); err != nil {
if errors.Is(err, mysql.ErrNoDocuments) {
writeErr(c, pb.ErrorCode_UserSessionNobeing, "未找到该用户")
return
}
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
if user.Computebalance+req.Delta < 0 {
writeErr(c, pb.ErrorCode_ComputeBalanceNegative, fmt.Sprintf("当前余额 %d,调整后将为负", user.Computebalance))
return
}
if err := conn.Table(comm.TableUser).Where("uid=?", uid).
Update("computebalance", gorm.Expr("computebalance + ?", req.Delta)).Error; err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
_ = conn.Insert(comm.TableUserUseLog, &pb.DBUserUseLog{
Uid: uid,
Ts: time.Now().Unix(),
Logtype: pb.UserLogType_AdminAdjust,
Compute: req.Delta,
Extra: fmt.Sprintf("console adjust by account=%d: %s", currentAccountId(c), remark),
})
writeOK(c, gin.H{"uid": uid, "computebalance": user.Computebalance + req.Delta})
}
// getUserComputeLogs 用户流水(按时间倒序分页),含发放/消耗/后台调整全部类型,前端按 logtype 与算力列展示。
func (this *serverComp) getUserComputeLogs(c *gin.Context) {
conn, ok := this.appConnFromCtx(c)
if !ok {
return
}
var req struct {
Uid string `json:"uid"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
_ = c.ShouldBindJSON(&req)
uid := strings.TrimSpace(req.Uid)
if uid == "" {
writeErr(c, pb.ErrorCode_ReqParameterError, "uid 必填")
return
}
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 || req.PageSize > 200 {
req.PageSize = 50
}
var total int64
if err := conn.Table(comm.TableUserUseLog).Where("uid=?", uid).Count(&total).Error; err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
rows := make([]*pb.DBUserUseLog, 0, req.PageSize)
if err := conn.Table(comm.TableUserUseLog).Where("uid=?", uid).
Order("ts DESC, id DESC").Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).
Find(&rows).Error; err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
writeOK(c, gin.H{"logs": rows, "total": total, "page": req.Page, "page_size": req.PageSize})
}
// computeStatsDay 按日消耗。
type computeStatsDay struct {
Day string `json:"day"`
Compute int64 `json:"compute"`
Users int64 `json:"users"`
}
// computeStatsKind 按功能消耗(raw 为原始量:翻译/会议为秒,AI 为次)。
type computeStatsKind struct {
Compute int64 `json:"compute"`
Raw int64 `json:"raw"`
Times int64 `json:"times"`
}
type computeStatsTop struct {
Uid string `json:"uid"`
Compute int64 `json:"compute"`
}
// getComputeStats 算力看板:按日 / 按功能 / 用户 Top N(选中应用业务库流水),以及设备算力发放与消耗总量(console 主库)。
// 用户 Top N 是**异常用量的发现入口**——额度闸门取消后,单用户成本上不封顶,这里是唯一能看出来的地方。
func (this *serverComp) getComputeStats(c *gin.Context) {
conn, ok := this.appConnFromCtx(c)
if !ok {
return
}
var req struct {
From int64 `json:"from"` // unix 秒,含
To int64 `json:"to"` // unix 秒,含
Top int `json:"top"`
}
_ = c.ShouldBindJSON(&req)
now := time.Now()
if req.To <= 0 {
req.To = now.Unix()
}
if req.From <= 0 || req.From > req.To {
req.From = now.AddDate(0, 0, -30).Unix()
}
if req.Top <= 0 || req.Top > 100 {
req.Top = 20
}
base := func() *gorm.DB {
return conn.Table(comm.TableUserUseLog).
Where("logtype=? AND compute<0 AND ts BETWEEN ? AND ?", int32(pb.UserLogType_UserConsume), req.From, req.To)
}
days := make([]*computeStatsDay, 0, 31)
if err := base().
Select("FROM_UNIXTIME(ts,'%Y-%m-%d') AS day, COALESCE(SUM(-compute),0) AS compute, COUNT(DISTINCT uid) AS users").
Group("day").Order("day").Scan(&days).Error; err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
var kinds struct {
TranslateCompute, TranslateRaw, TranslateTimes int64
MeetingCompute, MeetingRaw, MeetingTimes int64
AichatCompute, AichatRaw, AichatTimes int64
}
if err := base().Select(
"COALESCE(SUM(CASE WHEN addtradesecond<0 THEN -compute ELSE 0 END),0) AS translate_compute," +
"COALESCE(SUM(CASE WHEN addtradesecond<0 THEN -addtradesecond ELSE 0 END),0) AS translate_raw," +
"COALESCE(SUM(CASE WHEN addtradesecond<0 THEN 1 ELSE 0 END),0) AS translate_times," +
"COALESCE(SUM(CASE WHEN addmeetsecond<0 THEN -compute ELSE 0 END),0) AS meeting_compute," +
"COALESCE(SUM(CASE WHEN addmeetsecond<0 THEN -addmeetsecond ELSE 0 END),0) AS meeting_raw," +
"COALESCE(SUM(CASE WHEN addmeetsecond<0 THEN 1 ELSE 0 END),0) AS meeting_times," +
"COALESCE(SUM(CASE WHEN addagentintegral<0 THEN -compute ELSE 0 END),0) AS aichat_compute," +
"COALESCE(SUM(CASE WHEN addagentintegral<0 THEN -addagentintegral ELSE 0 END),0) AS aichat_raw," +
"COALESCE(SUM(CASE WHEN addagentintegral<0 THEN 1 ELSE 0 END),0) AS aichat_times").
Scan(&kinds).Error; err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
top := make([]*computeStatsTop, 0, req.Top)
if err := base().Select("uid, COALESCE(SUM(-compute),0) AS compute").
Group("uid").Order("compute DESC").Limit(req.Top).Scan(&top).Error; err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
// 设备算力总量(console 主库,跨应用;product 才按应用归属,这里不细分)
var dev struct{ Grant, Used, Devices int64 }
if adb := consoleDeviceConn().AdminDB(); adb != nil {
if err := adb.Table(comm.TableDeviceMac).
Select("COALESCE(SUM(computegrant),0) AS grant, COALESCE(SUM(computeused),0) AS used, COUNT(*) AS devices").
Where("usedtime>0 AND computegrant>0").Scan(&dev).Error; err != nil {
log.Warn("console: 设备算力汇总失败", log.Field{Key: "err", Value: err.Error()})
}
}
writeOK(c, gin.H{
"from": req.From, "to": req.To,
"days": days,
"kinds": gin.H{
"translate": &computeStatsKind{Compute: kinds.TranslateCompute, Raw: kinds.TranslateRaw, Times: kinds.TranslateTimes},
"meeting": &computeStatsKind{Compute: kinds.MeetingCompute, Raw: kinds.MeetingRaw, Times: kinds.MeetingTimes},
"aichat": &computeStatsKind{Compute: kinds.AichatCompute, Raw: kinds.AichatRaw, Times: kinds.AichatTimes},
},
"top": top,
"device": gin.H{"grant": dev.Grant, "used": dev.Used, "left": dev.Grant - dev.Used, "devices": dev.Devices},
})
}
// userComputeSummary 用户详情里的会员与算力附加信息:设备赋予的 VIP / 算力按当前绑定关系跨库取。
func userComputeSummary(conn mysql.ISys, user *pb.DBUser) (out gin.H) {
uds := make([]*pb.DBUserDivice, 0, 2)
_ = conn.Find(comm.TableUserdevice, &uds, "uid=?", user.Uid)
devs := make([]*pb.DBAuthCode, 0, len(uds))
views := make([]gin.H, 0, len(uds))
var deviceLeft int64
if adb := consoleDeviceConn().AdminDB(); adb != nil {
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 := adb.FindOne(comm.TableDeviceMac, d, "devicemac=?", mac); err != nil || d.Disabled != 0 {
continue
}
devs = append(devs, d)
deviceLeft += comm.DeviceComputeLeft(d)
views = append(views, gin.H{
"devicemac": d.Devicemac,
"devicename": ud.Devicename,
"usedtime": d.Usedtime,
"vipdays": d.Vipdays,
"viplevel": comm.DeviceVipLevel(d),
"vipexptime": comm.DeviceVipExpire(d),
"computegrant": d.Computegrant,
"computeused": d.Computeused,
"computeleft": comm.DeviceComputeLeft(d),
})
}
}
vip := comm.ResolveVip(user, devs, time.Now().Unix())
return gin.H{
"vip_effective": gin.H{
"level": vip.Level,
"exptime": vip.ExpTime,
"selfexptime": vip.SelfExpTime,
"fromdevice": vip.FromDevice,
},
"compute_devices": views,
"compute_deviceleft": deviceLeft,
}
}