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