fix(admin): 渠道模型 - 删除后支持重新拉取并友好提示重名

This commit is contained in:
ddCat
2026-07-31 09:47:46 +08:00
parent ffd222c6a6
commit a8b3727c38
3 changed files with 102 additions and 8 deletions

View File

@@ -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
}

View File

@@ -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 }

View File

@@ -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 == "" {