feat(admin): 计价配置(按模型·分输入/输出单价)—— 计费比率配置落地

计费需 token↔真钱比率,配置归管理端。本次落地"按模型·分输入/输出"粒度:

后端(gateway):
- store.Pricing 模型(BaseModel + model_id 唯一 + input_per_1k/output_per_1k + currency),
  AutoMigrate 建 sundynix_pricing;ListPricing/UpsertPricing(OnConflict model_id 覆盖)。
- admin handler:GET /admin/pricing 列表、PUT /admin/pricing 设置(校验非负,币种默认 CNY),
  挂在 RequireAdmin 组下。

前端(admin):
- api:listPricing/savePricing(带 Bearer)。
- PricingPage:列出所有已登记模型(chat+embedding),每行可编辑 输入/输出每1K单价 + 币种,逐行保存。
- routes 新增「计价」页(配置组)。

实测:PUT→ok;GET 返回正确行;重复 PUT 同 model_id 仍 1 行且值更新(upsert 生效);表自动迁移。
前端 tsc 干净。下一步可做用量计量 × 单价折算(真正计费)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-19 11:25:24 +08:00
parent 030dcda9b4
commit 597665f3c8
8 changed files with 254 additions and 2 deletions
@@ -42,6 +42,48 @@ func (h *Handler) ListModels(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"models": out})
}
// ListPricing: GET /api/v1/admin/pricing —— 列出各模型的计价配置(token↔真钱)。
func (h *Handler) ListPricing(c *gin.Context) {
rows, err := h.db.ListPricing(c.Request.Context())
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
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,
})
}
c.JSON(http.StatusOK, gin.H{"pricing": out})
}
// 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"`
}
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": "单价不能为负"})
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 {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// SaveModel: POST /api/v1/admin/models —— 新增/更新一条模型配置。
func (h *Handler) SaveModel(c *gin.Context) {
var b modelBody
@@ -75,6 +75,8 @@ 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) // 设置某模型输入/输出单价
}
}
return r
+33
View File
@@ -216,6 +216,39 @@ func (p *Postgres) ListLinks(ctx context.Context, owner, kb string) ([]DocLink,
return rows, err
}
// Pricing 是某模型的计价配置(token↔真钱):按模型分输入/输出单价(每 1K token)。
// 表名 sundynix_pricing。ModelID 关联 sundynix_model.id,唯一。
type Pricing struct {
BaseModel
ModelID string `gorm:"size:24;uniqueIndex"` // 关联 sundynix_model.id
InputPer1K float64 `gorm:"column:input_per_1k"` // 每 1K 输入 token 单价
OutputPer1K float64 `gorm:"column:output_per_1k"` // 每 1K 输出 token 单价
Currency string `gorm:"size:8"` // 币种(CNY / USD…)
}
func (Pricing) TableName() string { return "sundynix_pricing" }
// ListPricing 列出全部计价配置。
func (p *Postgres) ListPricing(ctx context.Context) ([]Pricing, error) {
if p.db == nil {
return nil, nil
}
var rows []Pricing
err := p.db.WithContext(ctx).Find(&rows).Error
return rows, err
}
// UpsertPricing 写入/更新某模型的计价(model_id 唯一,重复即覆盖单价/币种)。
func (p *Postgres) UpsertPricing(ctx context.Context, modelID string, inPer1K, outPer1K 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
}
// LLMModel 是一个模型后端配置(控制面:管理员在此登记可用模型)。
// 表名 sundynix_model(遵守前缀约定)。每个 kind 同一时刻仅一条 Active=true。
type LLMModel struct {
+1 -1
View File
@@ -39,7 +39,7 @@ func OpenPostgres(dsn string) *Postgres {
migrateLegacyIntIDs(db)
migrateDocLinkToID(db)
if err := db.AutoMigrate(&User{}, &Task{}, &LLMModel{}, &KB{}, &Doc{}, &Agent{}, &DocLink{}); err != nil {
if err := db.AutoMigrate(&User{}, &Task{}, &LLMModel{}, &KB{}, &Doc{}, &Agent{}, &DocLink{}, &Pricing{}); err != nil {
log.Printf("[store] postgres AutoMigrate 失败,降级运行: %v", err)
return &Postgres{}
}