feat(models): 模型目录与计费 - 按展示名分组并隔离管理员积分成本

This commit is contained in:
ddCat
2026-09-19 09:54:27 +08:00
parent 5ad05efb58
commit 237f2e2a6e
49 changed files with 802 additions and 276 deletions

View File

@@ -0,0 +1,22 @@
package handler
import "infinite-canvas/backend/internal/model"
// 成本字段只在已通过 service 管理员校验的模型编辑接口中显式投影。
type adminChannelModelPriceTier struct {
model.ChannelModelPriceTier
CostPricing model.CreditCostPricing `json:"costPricing"`
}
type adminChannelModelResponse struct {
model.ChannelModel
PriceTiers []adminChannelModelPriceTier `json:"priceTiers"`
}
func adminChannelModel(item model.ChannelModel) adminChannelModelResponse {
tiers := make([]adminChannelModelPriceTier, 0, len(item.PriceTiers))
for _, tier := range item.PriceTiers {
tiers = append(tiers, adminChannelModelPriceTier{ChannelModelPriceTier: tier, CostPricing: tier.CostPricing})
}
return adminChannelModelResponse{ChannelModel: item, PriceTiers: tiers}
}

View File

@@ -0,0 +1,102 @@
package handler
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"infinite-canvas/backend/internal/auth"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
"infinite-canvas/backend/internal/service"
"github.com/gin-gonic/gin"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestChannelCostHTTPAuthorizationAndPublicProjection(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sqlDB, _ := db.DB()
sqlDB.SetMaxOpenConns(1)
t.Cleanup(func() { _ = sqlDB.Close() })
if err := db.AutoMigrate(&model.User{}, &model.AuthSession{}, &model.ModelChannel{}, &model.ChannelModel{}, &model.ChannelModelPriceTier{}, &model.IDSequence{}, &model.SystemSetting{}); err != nil {
t.Fatal(err)
}
for _, role := range []model.UserRole{model.UserRoleAdmin, model.UserRoleUser} {
id := string(role)
if err := db.Create(&model.User{ID: id, Username: id, Email: id + "@example.invalid", Role: role, Status: model.UserStatusActive}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.AuthSession{ID: id, UserID: id, TokenHash: auth.HashToken("test-token"), ExpiresAt: time.Now().Add(time.Hour)}).Error; err != nil {
t.Fatal(err)
}
}
channel := model.ModelChannel{ID: "channel", Name: "渠道", Scope: model.ChannelScopeSystem, Enabled: true, ModelsJSON: `[]`}
if err := db.Create(&channel).Error; err != nil {
t.Fatal(err)
}
svc := service.New(repository.New(db), t.TempDir())
router := gin.New()
RegisterFinanceRoutes(router.Group("/api"), svc)
RegisterModelCatalogRoutes(router.Group("/api"), svc)
request := service.ChannelModelRequest{ModelKey: "text-model", DisplayName: "文本模型", ChannelLabel: "优惠渠道", Capability: "text", Protocol: string(model.ChannelInterfaceChatCompletion), CapabilityConfig: service.DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceChatCompletion), "text-model"), PriceTiers: []service.ChannelModelPriceTierRequest{{BillingMode: "fixed_request", UnitPriceMicrocredits: 900_000, PriceConfigured: true, CostPricing: model.CreditCostPricing{Configured: true, UnitPriceMicrocredits: 123_456}}}}
body, err := json.Marshal(request)
if err != nil {
t.Fatal(err)
}
call := func(role, method, path string, payload []byte) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, path, bytes.NewReader(payload))
r.Header.Set("Content-Type", "application/json")
if role != "" {
r.AddCookie(&http.Cookie{Name: service.SessionCookieName, Value: role + ".test-token"})
}
w := httptest.NewRecorder()
router.ServeHTTP(w, r)
return w
}
for _, method := range []string{http.MethodGet, http.MethodPost} {
for _, role := range []string{"", "user"} {
w := call(role, method, "/api/admin/channels/channel/models", body)
want := http.StatusForbidden
if role == "" {
want = http.StatusUnauthorized
}
if w.Code != want {
t.Fatalf("%s %s: %d %s", role, method, w.Code, w.Body.String())
}
}
}
for _, method := range []string{http.MethodPost, http.MethodGet} {
w := call("admin", method, "/api/admin/channels/channel/models", body)
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"costPricing":`) || !strings.Contains(w.Body.String(), "123456") {
t.Fatalf("admin cost missing: %d %s", w.Code, w.Body.String())
}
}
for _, path := range []string{"/api/model-catalog", "/api/model-catalog/available", "/api/model-catalog/quote"} {
method := http.MethodPost
payload := []byte(`{"capability":"text"}`)
if path == "/api/model-catalog" {
method = http.MethodGet
payload = nil
}
if strings.HasSuffix(path, "/quote") {
payload = []byte(`{"channelId":"channel","modelKey":"text-model","intent":{"capability":"text","operation":"text_generation","inputs":{"text":1}}}`)
}
w := call("user", method, path, payload)
if w.Code != http.StatusOK {
t.Fatalf("public %s: %d %s", path, w.Code, w.Body.String())
}
if strings.Contains(w.Body.String(), "costPricing") || strings.Contains(w.Body.String(), "123456") {
t.Fatalf("public cost leaked: %s", w.Body.String())
}
}
}

View File

@@ -205,7 +205,11 @@ func RegisterFinanceRoutes(r *gin.RouterGroup, svc *service.Service) {
failService(c, err)
return
}
ok(c, gin.H{"models": items})
models := make([]adminChannelModelResponse, 0, len(items))
for _, item := range items {
models = append(models, adminChannelModel(item))
}
ok(c, gin.H{"models": models})
})
r.POST("/admin/channels/:id/models/fetch", func(c *gin.Context) {
user, err := currentUser(c, svc)
@@ -501,5 +505,5 @@ func saveChannelModel(c *gin.Context, svc *service.Service, id string) {
failService(c, err)
return
}
ok(c, gin.H{"model": item})
ok(c, gin.H{"model": adminChannelModel(*item)})
}

View File

@@ -31,11 +31,12 @@ paths:
tags: [models]
summary: 获取创作端模型目录
description: |
source=system 时读取 channels,source=frontend 时读取 models;两个集合始终为数组。
系统渠道模型按渠道聚合展示,渠道内模型保持独立选择及价格;模型请求仍使用 channelId + modelKey 定位。
channelLabel 是后台渠道模型创建和更新时配置的公开渠道展示名,去除首尾空白后最多 80 字,空字符串表示使用渠道公开名称。
固定 source=system,channels 直接来自系统渠道下的模型列表,models 为空数组;不反查逻辑模型。
一级跨渠道按 displayName 分组,二级显示 channelLabel;模型请求仍使用 channelId + modelKey 定位。
channelLabel 是后台渠道模型创建和更新时配置的渠道展示名,去除首尾空白后最多 80 字,空字符串表示使用所属渠道名称。
description 是后台渠道模型创建和更新时填写的公开模型描述,去除首尾空白后最多 500 字;空字符串表示未填写。
displayName 和 icon 用于渠道内模型菜单,channelLabel 不参与路由、权限或计费。
displayName 和 icon 用于一级模型菜单,channelLabel 不参与路由、权限或计费。
渠道级 publicAlias 已废弃;成本配置和成本快照仅供管理端,不包含在本接口及用户报价中。
responses:
"200":
description: 当前启用的模型目录
@@ -55,7 +56,7 @@ paths:
properties:
source:
type: string
enum: [system, frontend]
enum: [system]
models:
type: array
items: