feat(admin): 渠道模型 - 拉取模型支持勾选导入并允许离线启停渠道

This commit is contained in:
Rou
2026-09-03 21:59:56 +08:00
parent 7a1f2db8c8
commit d7b808fae4
8 changed files with 410 additions and 14 deletions

View File

@@ -214,8 +214,30 @@ func RegisterFinanceRoutes(r *gin.RouterGroup, svc *service.Service) {
if !enforceRateLimit(c, "admin-channel-models-fetch:"+user.ID+":"+c.Param("id"), 10, time.Minute) {
return
}
// 上游密钥只在 service 内使用,handler 仅返回去重后的模型标识和新增数量。
result, err := svc.FetchAdminChannelModels(c.Request.Context(), user, c.Param("id"))
// 上游密钥只在 service 内使用;点击拉取时仅返回目录,确认后才写入渠道模型。
models, err := svc.PreviewAdminChannelModels(c.Request.Context(), user, c.Param("id"))
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"models": models})
})
r.POST("/admin/channels/:id/models/import", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
if !enforceRateLimit(c, "admin-channel-models-import:"+user.ID+":"+c.Param("id"), 10, time.Minute) {
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10)
var req service.AdminChannelModelImportRequest
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, err)
return
}
result, err := svc.ImportAdminChannelModels(c.Request.Context(), user, c.Param("id"), req.Models)
if err != nil {
failService(c, err)
return

View File

@@ -761,8 +761,16 @@ func (s *Service) channelFromRequest(req ChannelRequest, channel model.ModelChan
if requestedAllowLocal && !s.DesktopLocalChannelsEnabled() {
return channel, BadAuthRequest("当前后端未启用本机渠道")
}
if _, err := s.validateChannelOutboundURL(baseURL, requestedAllowLocal, false); err != nil {
return channel, err
// 启用/停用或只修改价格、模型等本地配置时,不应要求上游域名当前可解析。
// 只有 Base URL 或本机渠道开关实际变化时才做出站地址校验。
connectionChanged := strings.TrimRight(baseURL, "/") != strings.TrimRight(channel.BaseURL, "/")
if req.AllowLocalChannel != nil {
connectionChanged = connectionChanged || *req.AllowLocalChannel != channel.AllowLocalChannel
}
if connectionChanged {
if _, err := s.validateChannelOutboundURL(baseURL, requestedAllowLocal, false); err != nil {
return channel, err
}
}
models := uniqueNonEmpty(req.Models)
modelsJSON, _ := json.Marshal(models)

View File

@@ -52,6 +52,31 @@ func TestMergeChannelRequestSupportsEnabledOnlyPatch(t *testing.T) {
}
}
func TestUpdateSystemChannelEnabledOnlySkipsOutboundResolution(t *testing.T) {
svc, db := newChannelModelTestService(t)
svc.dataDir = t.TempDir()
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin}
channel := model.ModelChannel{ID: "channel-1", UserID: admin.ID, Scope: model.ChannelScopeSystem, Enabled: true, Name: "Dead", BaseURL: "https://dead.invalid/v1", APIKey: "key", APIFormat: "openai", ModelsJSON: `[]`}
if err := db.Create(&channel).Error; err != nil {
t.Fatal(err)
}
disabled := false
updated, err := svc.UpdateSystemChannel(admin, channel.ID, ChannelRequest{Enabled: &disabled})
if err != nil {
t.Fatalf("UpdateSystemChannel() should not resolve the stored URL for an enabled-only patch: %v", err)
}
if updated.Enabled {
t.Fatal("UpdateSystemChannel() did not persist disabled state")
}
var stored model.ModelChannel
if err := db.First(&stored, "id = ?", channel.ID).Error; err != nil {
t.Fatal(err)
}
if stored.Enabled {
t.Fatal("stored channel is still enabled after update")
}
}
func TestChannelFromRequestStoresAndClearsHeaders(t *testing.T) {
request := ChannelRequest{Name: "Headers", BaseURL: "https://example.com/v1", Headers: []OutboundHeader{{Name: "User-Agent", Value: "Custom Agent"}}}
channel, err := channelFromRequest(request, model.ModelChannel{})
@@ -153,6 +178,81 @@ func TestFetchAdminChannelModelsReaddsDeletedModel(t *testing.T) {
}
}
func TestImportAdminChannelModelsOnlyImportsSelectedModels(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"id":"model-a"},{"id":"model-b"}]}`))
}))
defer upstream.Close()
svc, db := newChannelModelTestService(t)
svc.runtimeCapabilities = RuntimeCapabilities{desktopLocalChannels: true}
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin}
channel := model.ModelChannel{ID: "channel-1", UserID: admin.ID, Scope: model.ChannelScopeSystem, Enabled: true, Name: "Test", BaseURL: upstream.URL + "/v1", APIKey: "key", APIFormat: "openai", ModelsJSON: `[]`, AllowLocalChannel: true}
if err := db.Create(&channel).Error; err != nil {
t.Fatal(err)
}
preview, err := svc.PreviewAdminChannelModels(context.Background(), admin, channel.ID)
if err != nil {
t.Fatal(err)
}
if len(preview) != 2 {
t.Fatalf("preview models = %#v, want two models", preview)
}
var before int64
if err := db.Model(&model.ChannelModel{}).Where("channel_id = ?", channel.ID).Count(&before).Error; err != nil {
t.Fatal(err)
}
if before != 0 {
t.Fatalf("preview created %d channel models", before)
}
result, err := svc.ImportAdminChannelModels(context.Background(), admin, channel.ID, []string{"model-b"})
if err != nil {
t.Fatal(err)
}
if result.Added != 1 || len(result.Models) != 1 || result.Models[0] != "model-b" {
t.Fatalf("import result = %#v, want only model-b", result)
}
var imported []model.ChannelModel
if err := db.Where("channel_id = ?", channel.ID).Find(&imported).Error; err != nil {
t.Fatal(err)
}
if len(imported) != 1 || imported[0].ModelKey != "model-b" {
t.Fatalf("imported models = %#v, want only model-b", imported)
}
}
func TestImportAdminChannelModelsRejectsUnknownSelection(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"id":"model-a"}]}`))
}))
defer upstream.Close()
svc, db := newChannelModelTestService(t)
svc.runtimeCapabilities = RuntimeCapabilities{desktopLocalChannels: true}
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin}
channel := model.ModelChannel{ID: "channel-1", UserID: admin.ID, Scope: model.ChannelScopeSystem, Enabled: true, Name: "Test", BaseURL: upstream.URL + "/v1", APIKey: "key", APIFormat: "openai", ModelsJSON: `[]`, AllowLocalChannel: true}
if err := db.Create(&channel).Error; err != nil {
t.Fatal(err)
}
_, err := svc.ImportAdminChannelModels(context.Background(), admin, channel.ID, []string{"not-in-catalog"})
var authErr *AuthError
if !errors.As(err, &authErr) || authErr.Message != "所选模型不在上游模型目录中:not-in-catalog" {
t.Fatalf("ImportAdminChannelModels() error = %#v", err)
}
var count int64
if err := db.Model(&model.ChannelModel{}).Where("channel_id = ?", channel.ID).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("unknown selection created %d channel models", count)
}
}
func TestSaveAdminChannelModelRejectsActiveDuplicateKey(t *testing.T) {
svc, db := newChannelModelTestService(t)
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin}

View File

@@ -59,6 +59,10 @@ type AdminChannelModelFetchResult struct {
Added int64 `json:"added"`
}
type AdminChannelModelImportRequest struct {
Models []string `json:"models"`
}
type AdminChannelModelTestResult struct {
DurationMs int64 `json:"durationMs"`
}
@@ -184,6 +188,107 @@ func (s *Service) FetchAdminChannelModels(ctx context.Context, actor *model.User
return &AdminChannelModelFetchResult{Models: models, Added: added}, nil
}
// PreviewAdminChannelModels 只读取上游模型目录,不修改渠道模型配置。
func (s *Service) PreviewAdminChannelModels(ctx context.Context, actor *model.User, channelID string) ([]string, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
return s.fetchAdminChannelModelCatalog(ctx, actor, channelID)
}
// ImportAdminChannelModels 只导入管理员明确选择、且仍存在于上游目录中的模型。
func (s *Service) ImportAdminChannelModels(ctx context.Context, actor *model.User, channelID string, selected []string) (*AdminChannelModelFetchResult, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
channel, err := s.adminSystemChannel(channelID)
if err != nil {
return nil, err
}
models, err := s.fetchAdminChannelModelCatalog(ctx, actor, channelID)
if err != nil {
return nil, err
}
if len(selected) == 0 {
return nil, BadAuthRequest("请至少选择一个要导入的模型")
}
if len(selected) > 500 {
return nil, BadAuthRequest("单次最多导入 500 个模型")
}
available := make(map[string]string, len(models))
for _, name := range models {
available[channelModelCatalogKey(name)] = name
}
chosen := make([]string, 0, len(selected))
seen := make(map[string]struct{}, len(selected))
for _, rawName := range selected {
name := strings.TrimPrefix(strings.TrimSpace(rawName), "models/")
key := channelModelCatalogKey(name)
if key == "" {
continue
}
canonical, ok := available[key]
if !ok {
return nil, BadAuthRequest("所选模型不在上游模型目录中:" + name)
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
chosen = append(chosen, canonical)
}
if len(chosen) == 0 {
return nil, BadAuthRequest("请至少选择一个有效的模型")
}
existing, err := s.repo.ChannelModels(channelID, true)
if err != nil {
return nil, err
}
known := make(map[string]struct{}, len(existing))
for _, item := range existing {
known[channelModelCatalogKey(item.ModelKey)] = struct{}{}
}
retired := retiredChannelModelKeys(channel.RetiredModelsJSON)
missing := make([]model.ChannelModel, 0, len(chosen))
for _, name := range chosen {
key := channelModelCatalogKey(name)
if _, ok := known[key]; ok || retired[key] {
continue
}
modelID, idErr := s.repo.NextPrefixedID("MODEL")
if idErr != nil {
return nil, idErr
}
missing = append(missing, model.ChannelModel{ID: modelID, ChannelID: channelID, ModelKey: name, DisplayName: name, BillingMode: "fixed_request", Enabled: false, PriceConfigured: false, PriceVersion: 1})
known[key] = struct{}{}
}
added, err := s.repo.CreateMissingChannelModels(missing)
if err != nil {
return nil, err
}
if added > 0 {
s.invalidateRouteCatalog()
}
return &AdminChannelModelFetchResult{Models: chosen, Added: added}, nil
}
func (s *Service) fetchAdminChannelModelCatalog(ctx context.Context, actor *model.User, channelID string) ([]string, error) {
channel, err := s.adminSystemChannel(channelID)
if err != nil {
return nil, err
}
headers, err := ParseOutboundHeadersJSON(channel.HeadersJSON)
if err != nil {
return nil, err
}
models, err := s.FetchChannelModels(ctx, actor, ChannelModelsRequest{BaseURL: channel.BaseURL, AllowLocalChannel: channel.AllowLocalChannel, APIKey: channel.APIKey, APIFormat: channel.APIFormat, Headers: headers})
if err != nil {
return nil, err
}
return uniqueNonEmpty(models), nil
}
func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id string, req ChannelModelRequest) (*model.ChannelModel, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err