diff --git a/backend/internal/app/model_catalog.go b/backend/internal/app/model_catalog.go index a03557374..0fa48db2a 100644 --- a/backend/internal/app/model_catalog.go +++ b/backend/internal/app/model_catalog.go @@ -21,10 +21,11 @@ const ( // ModelCatalogResponse 是创作端模型选择的统一读模型。 // Source=frontend 时读取 Models;Source=system 时读取 Channels。 +// 两个集合都始终序列化为数组:空目录必须发 [],缺字段会被前端判成畸形响应。 type ModelCatalogResponse struct { Source ModelCatalogSource `json:"source"` - Models []PublicLogicalModel `json:"models,omitempty"` - Channels []PublicChannelCatalog `json:"channels,omitempty"` + Models []PublicLogicalModel `json:"models"` + Channels []PublicChannelCatalog `json:"channels"` } // PublicChannelCatalog 公开的渠道目录信息(脱敏) @@ -74,25 +75,25 @@ func (s *Service) ModelCatalog(intent *ModelRequestIntent) (*ModelCatalogRespons return nil, err } + // 两个集合都初始化成非 nil 空切片:空目录要发 [],不能因为“没有模型”而丢掉字段。 + response := &ModelCatalogResponse{Models: []PublicLogicalModel{}, Channels: []PublicChannelCatalog{}} if frontendEnabled { models, err := s.PublicLogicalModels(intent) if err != nil { return nil, err } - return &ModelCatalogResponse{ - Source: ModelCatalogSourceFrontend, - Models: models, - }, nil + response.Source = ModelCatalogSourceFrontend + response.Models = append(response.Models, models...) + return response, nil } channels, err := s.publicSystemChannelCatalog(intent) if err != nil { return nil, err } - return &ModelCatalogResponse{ - Source: ModelCatalogSourceSystem, - Channels: channels, - }, nil + response.Source = ModelCatalogSourceSystem + response.Channels = append(response.Channels, channels...) + return response, nil } // publicSystemChannelCatalog 组装普通用户可见的系统渠道读模型,不暴露密钥、Base URL 等执行凭证。 diff --git a/backend/internal/app/model_catalog_test.go b/backend/internal/app/model_catalog_test.go new file mode 100644 index 000000000..a7fa29f9a --- /dev/null +++ b/backend/internal/app/model_catalog_test.go @@ -0,0 +1,73 @@ +package app + +import ( + "encoding/json" + "testing" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +// 前台模型目录为空时也必须发 models: [],不能因为 omitempty 丢掉字段: +// 前端在 source=frontend 时强制校验 models 是数组,缺字段会被判成畸形响应; +// 而 auth-session-hydrator 会把该异常当成未登录并清掉会话,表现为刷新后跳回登录页。 +func TestModelCatalogAlwaysSerializesCollectionsAsArrays(t *testing.T) { + for _, test := range []struct { + name string + frontendEnabled bool + wantSource string + }{ + {name: "空的前台模型目录", frontendEnabled: true, wantSource: "frontend"}, + {name: "空的系统渠道目录", frontendEnabled: false, wantSource: "system"}, + } { + t.Run(test.name, func(t *testing.T) { + svc, db := newModelCatalogTestService(t) + if test.frontendEnabled { + if err := db.Create(&model.SystemSetting{Key: featureAvailabilitySettingKey, ValueJSON: `{"frontendModelsEnabled":true}`}).Error; err != nil { + t.Fatal(err) + } + } + + catalog, err := svc.ModelCatalog(nil) + if err != nil { + t.Fatal(err) + } + if string(catalog.Source) != test.wantSource { + t.Fatalf("source = %q, want %q", catalog.Source, test.wantSource) + } + + encoded, err := json.Marshal(catalog) + if err != nil { + t.Fatal(err) + } + var payload map[string]json.RawMessage + if err := json.Unmarshal(encoded, &payload); err != nil { + t.Fatal(err) + } + for _, key := range []string{"models", "channels"} { + raw, present := payload[key] + if !present || string(raw) == "null" { + t.Fatalf("%s 集合没有序列化成数组:%s", key, encoded) + } + } + }) + } +} + +func newModelCatalogTestService(t *testing.T) (*Service, *gorm.DB) { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if sqlDB, err := db.DB(); err == nil { + sqlDB.SetMaxOpenConns(1) + } + if err := db.AutoMigrate(&model.SystemSetting{}, &model.AdminAuditEvent{}, &model.ModelChannel{}, &model.ChannelModel{}, &model.LogicalModel{}); err != nil { + t.Fatal(err) + } + return New(repository.New(db), t.TempDir()), db +}