feat(admin): 渠道模型 - 拉取模型支持勾选导入并允许离线启停渠道
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user