diff --git a/sundynix-gateway/internal/handler/admin.go b/sundynix-gateway/internal/handler/admin.go index b91d5e2..989ae94 100644 --- a/sundynix-gateway/internal/handler/admin.go +++ b/sundynix-gateway/internal/handler/admin.go @@ -217,32 +217,56 @@ func (h *Handler) ListPricing(c *gin.Context) { out := make([]gin.H, 0, len(rows)) for _, p := range rows { out = append(out, gin.H{ - "model_id": p.ModelID, "input_per_1k": p.InputPer1K, "output_per_1k": p.OutputPer1K, "currency": p.Currency, + "model_id": p.ModelID, "input_per_1k": p.InputPer1K, "output_per_1k": p.OutputPer1K, + "credit_weight": p.CreditWeight, "currency": p.Currency, }) } c.JSON(http.StatusOK, gin.H{"pricing": out}) } -// SavePricing: PUT /api/v1/admin/pricing —— 设置某模型的输入/输出单价(每 1K token)。 +// SavePricing: PUT /api/v1/admin/pricing —— 设置某模型的输入/输出单价(每 1K token)+ 积分权重。 func (h *Handler) SavePricing(c *gin.Context) { var b struct { - ModelID string `json:"model_id"` - InputPer1K float64 `json:"input_per_1k"` - OutputPer1K float64 `json:"output_per_1k"` - Currency string `json:"currency"` + ModelID string `json:"model_id"` + InputPer1K float64 `json:"input_per_1k"` + OutputPer1K float64 `json:"output_per_1k"` + CreditWeight float64 `json:"credit_weight"` + Currency string `json:"currency"` } if err := c.ShouldBindJSON(&b); err != nil || b.ModelID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "model_id required"}) return } - if b.InputPer1K < 0 || b.OutputPer1K < 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "单价不能为负"}) + if b.InputPer1K < 0 || b.OutputPer1K < 0 || b.CreditWeight < 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "单价/权重不能为负"}) return } if b.Currency == "" { b.Currency = "CNY" } - if err := h.db.UpsertPricing(c.Request.Context(), b.ModelID, b.InputPer1K, b.OutputPer1K, b.Currency); err != nil { + if err := h.db.UpsertPricing(c.Request.Context(), b.ModelID, b.InputPer1K, b.OutputPer1K, b.CreditWeight, b.Currency); err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "ok"}) +} + +// BillingConfig: GET /api/v1/admin/billing-config —— 全局计费规则(当前仅 token→积分汇率)。 +func (h *Handler) BillingConfig(c *gin.Context) { + tpc := h.db.GetSetting(c.Request.Context(), store.SettingTokensPerCredit) + c.JSON(http.StatusOK, gin.H{"tokens_per_credit": tpc}) // 空串=未设,前端回退默认 +} + +// SaveBillingConfig: PUT /api/v1/admin/billing-config —— 设 token→积分汇率(>0)。 +func (h *Handler) SaveBillingConfig(c *gin.Context) { + var b struct { + TokensPerCredit float64 `json:"tokens_per_credit"` + } + if err := c.ShouldBindJSON(&b); err != nil || b.TokensPerCredit <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "tokens_per_credit 必须 > 0"}) + return + } + if err := h.db.SetSetting(c.Request.Context(), store.SettingTokensPerCredit, strconv.FormatFloat(b.TokensPerCredit, 'f', -1, 64)); err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return } diff --git a/sundynix-gateway/internal/router/router.go b/sundynix-gateway/internal/router/router.go index 4b16458..7b377e7 100644 --- a/sundynix-gateway/internal/router/router.go +++ b/sundynix-gateway/internal/router/router.go @@ -96,8 +96,10 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob. admin.POST("/models/:id/active", h.SetActiveModel) admin.DELETE("/models/:id", h.DeleteModel) admin.POST("/models/test", h.TestModel) - admin.GET("/pricing", h.ListPricing) // 各模型计价(token↔真钱) - admin.PUT("/pricing", h.SavePricing) // 设置某模型输入/输出单价 + admin.GET("/pricing", h.ListPricing) // 各模型计价(token↔真钱 + 积分权重) + admin.PUT("/pricing", h.SavePricing) // 设置某模型输入/输出单价 + 积分权重 + admin.GET("/billing-config", h.BillingConfig) // 全局计费规则(token→积分汇率) + admin.PUT("/billing-config", h.SaveBillingConfig) admin.GET("/status", h.AdminStatus) // 服务状态:基建/服务探活 + MCP 工具注册 admin.GET("/overview", h.AdminOverview) // 系统级聚合:全平台用户/任务/评测/模型态/提示词态/健康 admin.GET("/usage", h.AdminUsage) // 用量/积分/成本:全平台按天趋势 + 租户排行 / 单租户余额 diff --git a/sundynix-gateway/internal/store/model.go b/sundynix-gateway/internal/store/model.go index 5698d42..806dd28 100644 --- a/sundynix-gateway/internal/store/model.go +++ b/sundynix-gateway/internal/store/model.go @@ -268,15 +268,16 @@ func (p *Postgres) ListPricing(ctx context.Context) ([]Pricing, error) { return rows, err } -// UpsertPricing 写入/更新某模型的计价(model_id 唯一,重复即覆盖单价/币种)。 -func (p *Postgres) UpsertPricing(ctx context.Context, modelID string, inPer1K, outPer1K float64, currency string) error { +// UpsertPricing 写入/更新某模型的计价(model_id 唯一,重复即覆盖单价/币种/积分权重)。 +// creditWeight 为每模型积分权重(0=按 1.0 计,即不加权)。 +func (p *Postgres) UpsertPricing(ctx context.Context, modelID string, inPer1K, outPer1K, creditWeight float64, currency string) error { if p.db == nil { return errStoreDisabled } return p.db.WithContext(ctx).Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "model_id"}}, - DoUpdates: clause.Assignments(map[string]any{"input_per_1k": inPer1K, "output_per_1k": outPer1K, "currency": currency, "updated_at": time.Now()}), - }).Create(&Pricing{ModelID: modelID, InputPer1K: inPer1K, OutputPer1K: outPer1K, Currency: currency}).Error + DoUpdates: clause.Assignments(map[string]any{"input_per_1k": inPer1K, "output_per_1k": outPer1K, "credit_weight": creditWeight, "currency": currency, "updated_at": time.Now()}), + }).Create(&Pricing{ModelID: modelID, InputPer1K: inPer1K, OutputPer1K: outPer1K, CreditWeight: creditWeight, Currency: currency}).Error } // LLMModel 是一个模型后端配置(控制面:管理员在此登记可用模型)。 diff --git a/sundynix-gateway/internal/store/pgsql.go b/sundynix-gateway/internal/store/pgsql.go index 025ca14..36802f8 100644 --- a/sundynix-gateway/internal/store/pgsql.go +++ b/sundynix-gateway/internal/store/pgsql.go @@ -66,7 +66,7 @@ func OpenPostgres(dsn string) *Postgres { migrateLegacyIntIDs(db) migrateDocLinkToID(db) - if err := db.AutoMigrate(&User{}, &Task{}, &Eval{}, &LLMModel{}, &KB{}, &Doc{}, &Agent{}, &DocLink{}, &Pricing{}, &Prompt{}, &AuditLog{}, &GuardrailEvent{}, &Tenant{}, &TenantMember{}, &UsageEvent{}, &CreditLedger{}, &UsageRollup{}); err != nil { + if err := db.AutoMigrate(&User{}, &Task{}, &Eval{}, &LLMModel{}, &KB{}, &Doc{}, &Agent{}, &DocLink{}, &Pricing{}, &Prompt{}, &AuditLog{}, &GuardrailEvent{}, &Tenant{}, &TenantMember{}, &UsageEvent{}, &CreditLedger{}, &UsageRollup{}, &Setting{}); err != nil { log.Printf("[store] postgres AutoMigrate 失败,降级运行: %v", err) return &Postgres{} } diff --git a/sundynix-gateway/internal/store/setting.go b/sundynix-gateway/internal/store/setting.go new file mode 100644 index 0000000..566947c --- /dev/null +++ b/sundynix-gateway/internal/store/setting.go @@ -0,0 +1,43 @@ +package store + +import ( + "context" + + "gorm.io/gorm/clause" +) + +// Setting 是平台级键值配置(全局,非租户作用域)——如 token→积分汇率 等可后台热调的计费规则。 +// 表名 sundynix_setting。Key 唯一。 +type Setting struct { + BaseModel + Key string `gorm:"size:64;uniqueIndex"` + Value string `gorm:"size:255"` +} + +func (Setting) TableName() string { return "sundynix_setting" } + +// 平台设置键。 +const SettingTokensPerCredit = "tokens_per_credit" + +// GetSetting 读一个平台设置;不存在返回空串(调用方回退默认)。 +func (p *Postgres) GetSetting(ctx context.Context, key string) string { + if p.db == nil { + return "" + } + var s Setting + if err := p.db.WithContext(ctx).Select("value").Where("key = ?", key).First(&s).Error; err != nil { + return "" + } + return s.Value +} + +// SetSetting 幂等写一个平台设置(key 唯一,重复即覆盖 value)。 +func (p *Postgres) SetSetting(ctx context.Context, key, value string) error { + if p.db == nil { + return errStoreDisabled + } + return p.db.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "key"}}, + DoUpdates: clause.AssignmentColumns([]string{"value", "updated_at"}), + }).Create(&Setting{Key: key, Value: value}).Error +} diff --git a/sundynix-gateway/internal/store/usage.go b/sundynix-gateway/internal/store/usage.go index 7e04e73..eadf7ff 100644 --- a/sundynix-gateway/internal/store/usage.go +++ b/sundynix-gateway/internal/store/usage.go @@ -34,8 +34,21 @@ type UsageEvent struct { func (UsageEvent) TableName() string { return "sundynix_usage_event" } func (UsageEvent) isTenantScoped() {} -// TokensPerCredit 读 token→积分 汇率(env TOKENS_PER_CREDIT,缺省/非法=1000)。设 1 即 token 直计。 -func TokensPerCredit() float64 { +// tokensPerCredit 读 token→积分 汇率:优先 DB 平台设置(后台可热调),回退 env,再回退 1000。 +// 设 1 即 token 直计。 +func (p *Postgres) tokensPerCredit(ctx context.Context) float64 { + if p.db != nil { + if v := p.GetSetting(ctx, SettingTokensPerCredit); v != "" { + if n, err := strconv.ParseFloat(v, 64); err == nil && n > 0 { + return n + } + } + } + return tokensPerCreditEnv() +} + +// tokensPerCreditEnv 是 DB 未设时的缺省来源(env TOKENS_PER_CREDIT,非法=1000)。 +func tokensPerCreditEnv() float64 { if v := os.Getenv("TOKENS_PER_CREDIT"); v != "" { if n, err := strconv.ParseFloat(v, 64); err == nil && n > 0 { return n @@ -71,7 +84,7 @@ func (p *Postgres) SaveUsageEvent(ctx context.Context, ev *contract.UsageEvent) costMicros = int64(cost * 1e6) } // credits_micro = total_tok / tokensPerCredit * weight,×10⁶ 存微积分。 - creditsMicro := int64(float64(ev.TotalTok) / TokensPerCredit() * weight * 1e6) + creditsMicro := int64(float64(ev.TotalTok) / p.tokensPerCredit(ctx) * weight * 1e6) row := &UsageEvent{ TenantID: ev.TenantID, Owner: ev.UserID, TaskID: ev.TaskID, Model: model,