diff --git a/backend/internal/repository/finance.go b/backend/internal/repository/finance.go index 77d7728c9..a19b2a598 100644 --- a/backend/internal/repository/finance.go +++ b/backend/internal/repository/finance.go @@ -73,12 +73,6 @@ func (r *Repository) ChannelModels(channelID string, includeDisabled bool) ([]mo return items, query.Find(&items).Error } -func (r *Repository) ChannelModelsIncludingDeleted(channelID string) ([]model.ChannelModel, error) { - var items []model.ChannelModel - err := r.db.Unscoped().Where("channel_id = ?", channelID).Order("created_at asc").Find(&items).Error - return items, err -} - func (r *Repository) ChannelModelByID(channelID string, id string) (*model.ChannelModel, error) { var item model.ChannelModel if err := r.db.First(&item, "id = ? AND channel_id = ?", id, channelID).Error; err != nil { @@ -95,6 +89,14 @@ func (r *Repository) ChannelModelByKey(channelID string, modelKey string) (*mode return &item, nil } +func (r *Repository) ChannelModelByKeyIncludingDisabled(channelID string, modelKey string) (*model.ChannelModel, error) { + var item model.ChannelModel + if err := r.db.First(&item, "channel_id = ? AND model_key = ?", channelID, modelKey).Error; err != nil { + return nil, err + } + return &item, nil +} + func (r *Repository) SaveChannelModel(item *model.ChannelModel) error { return r.db.Save(item).Error } diff --git a/backend/internal/service/admin_channel_test.go b/backend/internal/service/admin_channel_test.go index d8fa0869b..dab5eae4e 100644 --- a/backend/internal/service/admin_channel_test.go +++ b/backend/internal/service/admin_channel_test.go @@ -1,11 +1,17 @@ package service import ( + "context" + "errors" "net/http" "net/http/httptest" "testing" "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" ) func TestChannelFromRequestStoresConnectionWithoutDefaultProtocol(t *testing.T) { @@ -105,4 +111,83 @@ func TestRuntimeConcurrencyUsesEnvironmentFallback(t *testing.T) { } } +func TestFetchAdminChannelModelsReaddsDeletedModel(t *testing.T) { + t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") + 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) + 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: `[]`} + deleted := model.ChannelModel{ID: "deleted-model", ChannelID: channel.ID, ModelKey: "model-a", DisplayName: "model-a", BillingMode: "fixed_request", PriceVersion: 1} + if err := db.Create(&channel).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&deleted).Error; err != nil { + t.Fatal(err) + } + if err := db.Delete(&deleted).Error; err != nil { + t.Fatal(err) + } + + result, err := svc.FetchAdminChannelModels(context.Background(), admin, channel.ID) + if err != nil { + t.Fatal(err) + } + if result.Added != 1 { + t.Fatalf("Added = %d, want 1", result.Added) + } + var active model.ChannelModel + if err := db.First(&active, "channel_id = ? AND model_key = ?", channel.ID, "model-a").Error; err != nil { + t.Fatal(err) + } + if active.ID == deleted.ID || active.Enabled || active.PriceConfigured { + t.Fatalf("re-added model = %#v", active) + } + var total int64 + if err := db.Unscoped().Model(&model.ChannelModel{}).Where("channel_id = ? AND model_key = ?", channel.ID, "model-a").Count(&total).Error; err != nil { + t.Fatal(err) + } + if total != 2 { + t.Fatalf("model history count = %d, want 2", total) + } +} + +func TestSaveAdminChannelModelRejectsActiveDuplicateKey(t *testing.T) { + svc, db := newChannelModelTestService(t) + 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: "https://example.com/v1", APIKey: "key", APIFormat: "openai", ModelsJSON: `[]`} + items := []model.ChannelModel{ + {ID: "model-a", ChannelID: channel.ID, ModelKey: "model-a", DisplayName: "Model A", Capability: "text", Protocol: model.ChannelInterfaceChatCompletion, BillingMode: "fixed_request", Enabled: true, PriceVersion: 1}, + {ID: "model-b", ChannelID: channel.ID, ModelKey: "model-b", DisplayName: "Model B", Capability: "text", Protocol: model.ChannelInterfaceChatCompletion, BillingMode: "fixed_request", Enabled: true, PriceVersion: 1}, + } + if err := db.Create(&channel).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&items).Error; err != nil { + t.Fatal(err) + } + enabled := true + _, err := svc.SaveAdminChannelModel(admin, channel.ID, items[0].ID, ChannelModelRequest{ModelKey: "model-b", DisplayName: "Duplicate", Capability: "text", Protocol: string(model.ChannelInterfaceChatCompletion), BillingMode: "fixed_request", Enabled: &enabled}) + var authErr *AuthError + if !errors.As(err, &authErr) || authErr.Status != http.StatusBadRequest || authErr.Message != "该渠道已存在模型 model-b,请直接编辑已有模型" { + t.Fatalf("SaveAdminChannelModel() error = %#v", err) + } +} + +func newChannelModelTestService(t *testing.T) (*Service, *gorm.DB) { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.ModelChannel{}, &model.ChannelModel{}); err != nil { + t.Fatal(err) + } + return &Service{repo: repository.New(db)}, db +} + func intPtr(value int) *int { return &value } diff --git a/backend/internal/service/channel_models.go b/backend/internal/service/channel_models.go index 1c1448c1c..0724e543f 100644 --- a/backend/internal/service/channel_models.go +++ b/backend/internal/service/channel_models.go @@ -84,8 +84,8 @@ func (s *Service) FetchAdminChannelModels(ctx context.Context, actor *model.User if err != nil { return nil, err } - // 删除项作为 tombstone 参与去重,避免再次拉取上游目录时自动恢复到页面。 - existing, err := s.repo.ChannelModelsIncludingDeleted(channelID) + // 只按当前未删除记录去重;重新拉取已删除模型时应生成新的待配置记录。 + existing, err := s.repo.ChannelModels(channelID, true) if err != nil { return nil, err } @@ -141,6 +141,13 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id } item.PriceVersion++ } + conflict, conflictErr := s.repo.ChannelModelByKeyIncludingDisabled(channelID, modelKey) + if conflictErr != nil && !errors.Is(conflictErr, gorm.ErrRecordNotFound) { + return nil, conflictErr + } + if conflict != nil && conflict.ID != item.ID { + return nil, BadAuthRequest("该渠道已存在模型 " + modelKey + ",请直接编辑已有模型") + } item.ModelKey = modelKey item.DisplayName = strings.TrimSpace(req.DisplayName) if item.DisplayName == "" {