From de6a642f6cbe46df36a3420d45fbcaab2545affa Mon Sep 17 00:00:00 2001 From: zhuchiwangzhiqiang <38930125+zhuchiwangzhiqiang@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:39:18 +0800 Subject: [PATCH] =?UTF-8?q?fix(backend):=20=E6=A8=A1=E5=9E=8B=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=20-=20=E7=A9=BA=E7=9B=AE=E5=BD=95=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E4=B8=A2=E5=A4=B1=E9=9B=86=E5=90=88=E5=AD=97=E6=AE=B5=E5=AF=BC?= =?UTF-8?q?=E8=87=B4=E5=88=B7=E6=96=B0=E6=8E=89=E7=99=BB=E5=BD=95=20(#502)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/app/model_catalog.go | 21 ++++--- backend/internal/app/model_catalog_test.go | 73 ++++++++++++++++++++++ 2 files changed, 84 insertions(+), 10 deletions(-) create mode 100644 backend/internal/app/model_catalog_test.go 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 +}