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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user