feat(models): 模型目录与计费 - 按展示名分组并隔离管理员积分成本
This commit is contained in:
22
backend/internal/handler/admin_channel_model_response.go
Normal file
22
backend/internal/handler/admin_channel_model_response.go
Normal 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}
|
||||
}
|
||||
102
backend/internal/handler/credit_cost_routes_test.go
Normal file
102
backend/internal/handler/credit_cost_routes_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)})
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user