From 8b74741efe35a42c67bbb35b821af9a12aca2c24 Mon Sep 17 00:00:00 2001 From: feeeei Date: Mon, 12 Oct 2026 18:09:03 +0800 Subject: [PATCH] =?UTF-8?q?fix(groups):=20=E5=B9=BF=E5=9C=BA=20Vary=20?= =?UTF-8?q?=E4=BF=9D=E7=95=99=20Origin=EF=BC=8C=E5=88=86=E7=BB=84=E8=AF=91?= =?UTF-8?q?=E6=96=87=E8=AF=AD=E8=A8=80=E4=BB=A3=E7=A0=81=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E5=A4=A7=E5=B0=8F=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 模型广场以追加方式写入 Vary: Accept-Language,保留 CORS 中间件为具体来源 写入的 Vary: Origin - 管理端写入分组译文时语言代码统一为 BCP 47 惯用大小写(en、zh-TW), 统一后重复的语言代码(如 en 与 EN)返回 400 INVALID_GROUP_I18N - 用户侧按语言取译文时规范写法的键优先,仅大小写不同的多个键按字节序取最小者, 同一请求语言的结果固定 --- backend/internal/domain/group_i18n.go | 38 ++++++++++++++-- backend/internal/domain/group_i18n_test.go | 40 +++++++++++++++++ .../internal/handler/model_plaza_handler.go | 3 +- .../handler/model_plaza_handler_test.go | 43 +++++++++++++++++++ .../service/admin_service_group_i18n_test.go | 18 ++++++++ backend/internal/service/group_i18n.go | 20 ++++++--- backend/internal/service/group_i18n_test.go | 22 ++++++++++ 7 files changed, 175 insertions(+), 9 deletions(-) diff --git a/backend/internal/domain/group_i18n.go b/backend/internal/domain/group_i18n.go index 5e5c6ab565..c02b1c5a47 100644 --- a/backend/internal/domain/group_i18n.go +++ b/backend/internal/domain/group_i18n.go @@ -39,18 +39,50 @@ func (m GroupI18n) LocalizeName(acceptLanguage, name string) string { return name } +// CanonicalLocale 按 BCP 47 的惯用大小写书写语言代码:主语言小写,4 位文字代码首字母大写, +// 2 位地区代码大写,其余小写(zh-hant-tw -> zh-Hant-TW);单字母扩展标记之后的部分全部小写。 +// 只调整大小写,不校验代码是否合法。 +func CanonicalLocale(locale string) string { + subtags := strings.Split(locale, "-") + extension := false + for i, subtag := range subtags { + subtag = strings.ToLower(subtag) + switch { + case i == 0 || extension: + case len(subtag) == 1: + extension = true + case len(subtag) == 2: + subtag = strings.ToUpper(subtag) + case len(subtag) == 4: + subtag = strings.ToUpper(subtag[:1]) + subtag[1:] + } + subtags[i] = subtag + } + return strings.Join(subtags, "-") +} + // lookup 先按完整语言代码匹配(zh-TW),再按主语言匹配(zh-CN -> zh),不区分大小写。 +// 写入时语言代码已统一为 CanonicalLocale,同一语言只有一个键;若存有仅大小写不同的多个键, +// 规范写法的键优先,其余按字节序取最小者,保证同一请求语言的结果固定。 func (m GroupI18n) lookup(tag string) (GroupLocaleText, bool) { if tag == "" || len(m) == 0 { return GroupLocaleText{}, false } primary, _, _ := strings.Cut(tag, "-") for _, candidate := range []string{tag, primary} { - for locale, text := range m { - if strings.EqualFold(locale, candidate) { - return text, true + candidate = CanonicalLocale(candidate) + if text, ok := m[candidate]; ok { + return text, true + } + matched := "" + for locale := range m { + if strings.EqualFold(locale, candidate) && (matched == "" || locale < matched) { + matched = locale } } + if matched != "" { + return m[matched], true + } } return GroupLocaleText{}, false } diff --git a/backend/internal/domain/group_i18n_test.go b/backend/internal/domain/group_i18n_test.go index 34068c2c80..2860a0a6e3 100644 --- a/backend/internal/domain/group_i18n_test.go +++ b/backend/internal/domain/group_i18n_test.go @@ -65,3 +65,43 @@ func TestGroupI18nLocalizeName(t *testing.T) { t.Fatalf("LocalizeName(zh) = %q", got) } } + +func TestCanonicalLocale(t *testing.T) { + tests := map[string]string{ + "en": "en", + "EN": "en", + "zh-tw": "zh-TW", + "ZH-Hant-tw": "zh-Hant-TW", + "es-419": "es-419", + "de-CH-1901": "de-CH-1901", + "en-US-x-Twain": "en-US-x-twain", + } + for in, want := range tests { + if got := CanonicalLocale(in); got != want { + t.Errorf("CanonicalLocale(%q) = %q, want %q", in, got, want) + } + } +} + +// 写入路径已拒绝仅大小写不同的语言代码;已存在的此类数据读取时也必须得到固定结果。 +func TestGroupI18nLocalize_CaseCollidingKeysAreDeterministic(t *testing.T) { + tests := []struct { + title string + i18n GroupI18n + acceptLanguage string + want string + }{ + {"canonical key wins", GroupI18n{"EN": {Name: "B"}, "en": {Name: "A"}, "En": {Name: "C"}}, "en", "A"}, + {"otherwise the smallest key wins", GroupI18n{"eN": {Name: "B"}, "EN": {Name: "A"}}, "en", "A"}, + {"primary language fallback is deterministic too", GroupI18n{"ZH": {Name: "B"}, "zh": {Name: "A"}}, "zh-CN", "A"}, + } + for _, tt := range tests { + t.Run(tt.title, func(t *testing.T) { + for range 200 { + if got := tt.i18n.LocalizeName(tt.acceptLanguage, "原名"); got != tt.want { + t.Fatalf("LocalizeName(%q) = %q, want %q", tt.acceptLanguage, got, tt.want) + } + } + }) + } +} diff --git a/backend/internal/handler/model_plaza_handler.go b/backend/internal/handler/model_plaza_handler.go index db00ffaa1c..6f3a905d81 100644 --- a/backend/internal/handler/model_plaza_handler.go +++ b/backend/internal/handler/model_plaza_handler.go @@ -152,7 +152,8 @@ func (h *ModelPlazaHandler) Get(c *gin.Context) { visible := filterPlazaVisibleGroups(groups, allowedGroups, restrictPublicGroups) // 广场可匿名访问,分组文案随请求语言变化,提示中间缓存按语言区分。 - c.Header("Vary", "Accept-Language") + // 追加而非覆盖:CORS 中间件已为具体来源写入 Vary: Origin。 + c.Writer.Header().Add("Vary", "Accept-Language") out := toLocalizedModelPlazaGroups(visible, userRates, c.GetHeader("Accept-Language")) response.Success(c, modelPlazaResponse{ Description: rt.Description, diff --git a/backend/internal/handler/model_plaza_handler_test.go b/backend/internal/handler/model_plaza_handler_test.go index f356d68b5e..b29c85ede2 100644 --- a/backend/internal/handler/model_plaza_handler_test.go +++ b/backend/internal/handler/model_plaza_handler_test.go @@ -3,11 +3,14 @@ package handler import ( + "context" "encoding/json" "net/http" "net/http/httptest" "testing" + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -262,3 +265,43 @@ func TestFilterPlazaVisibleGroups_SubscribedExclusiveGroup(t *testing.T) { require.Equal(t, int64(42), visible[0].ID) } } + +type plazaVarySettingRepo struct{ service.SettingRepository } + +func (plazaVarySettingRepo) GetMultiple(context.Context, []string) (map[string]string, error) { + return map[string]string{service.SettingKeyModelPlazaEnabled: "true"}, nil +} + +type plazaVaryChannelRepo struct{ service.ChannelRepository } + +func (plazaVaryChannelRepo) ListAll(context.Context) ([]service.Channel, error) { return nil, nil } + +type plazaVaryGroupRepo struct{ service.GroupRepository } + +func (plazaVaryGroupRepo) ListActive(context.Context) ([]service.Group, error) { return nil, nil } + +func TestModelPlazaHandler_VaryKeepsCORSOrigin(t *testing.T) { + gin.SetMode(gin.TestMode) + h := NewModelPlazaHandler( + service.NewModelPlazaService(plazaVaryChannelRepo{}, plazaVaryGroupRepo{}, nil, nil, nil), + nil, + service.NewSettingService(plazaVarySettingRepo{}, &config.Config{}), + ) + r := gin.New() + r.Use(middleware.CORS(config.CORSConfig{AllowedOrigins: []string{"https://a.example", "https://b.example"}})) + r.GET("/api/v1/model-plaza", h.Get) + + for _, origin := range []string{"https://a.example", "https://b.example"} { + req := httptest.NewRequest(http.MethodGet, "/api/v1/model-plaza", nil) + req.Header.Set("Origin", origin) + req.Header.Set("Accept-Language", "en") + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + require.Equal(t, origin, w.Header().Get("Access-Control-Allow-Origin")) + // 共享缓存需同时按来源与语言区分,否则 A 来源的响应可能被复用给 B。 + require.Equal(t, []string{"Origin", "Accept-Language"}, w.Header().Values("Vary")) + } +} diff --git a/backend/internal/service/admin_service_group_i18n_test.go b/backend/internal/service/admin_service_group_i18n_test.go index baa12808c5..c7361bbc63 100644 --- a/backend/internal/service/admin_service_group_i18n_test.go +++ b/backend/internal/service/admin_service_group_i18n_test.go @@ -51,6 +51,24 @@ func TestAdminService_CreateGroup_RejectsInvalidI18nLocale(t *testing.T) { require.Nil(t, repo.created) } +func TestAdminService_CreateGroup_RejectsCaseCollidingI18nLocales(t *testing.T) { + repo := &groupRepoStubForAdmin{} + svc := &adminServiceImpl{groupRepo: repo} + + _, err := svc.CreateGroup(context.Background(), &CreateGroupInput{ + Name: "默认", + Platform: PlatformAnthropic, + RateMultiplier: 1.0, + I18n: GroupI18n{"en": {Name: "English A"}, "EN": {Name: "English B"}}, + }) + + require.Error(t, err) + appErr := infraerrors.FromError(err) + require.Equal(t, int32(http.StatusBadRequest), appErr.Code) + require.Equal(t, "INVALID_GROUP_I18N", appErr.Reason) + require.Nil(t, repo.created) +} + func TestAdminService_UpdateGroup_I18n(t *testing.T) { newExisting := func() *Group { return &Group{ diff --git a/backend/internal/service/group_i18n.go b/backend/internal/service/group_i18n.go index d45b9adaf8..31bd88d29f 100644 --- a/backend/internal/service/group_i18n.go +++ b/backend/internal/service/group_i18n.go @@ -2,7 +2,9 @@ package service import ( "fmt" + "maps" "regexp" + "slices" "strings" "unicode/utf8" @@ -19,14 +21,22 @@ const groupI18nNameMaxRunes = 100 // 语言代码只校验 BCP 47 形态(en、zh、zh-TW);具体提供哪些语言由前端语言包决定。 var groupI18nLocalePattern = regexp.MustCompile(`^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$`) -// normalizeGroupI18n 去除译文首尾空白,并丢弃名称与描述都为空的语言。 -// 返回值始终非 nil,落库为 JSON 对象。 +// normalizeGroupI18n 把语言代码统一为 domain.CanonicalLocale 的写法,去除译文首尾空白, +// 并丢弃名称与描述都为空的语言。统一写法后重复的语言代码(如 en 与 EN)直接拒绝, +// 不在其中任选一份。返回值始终非 nil,落库为 JSON 对象。 func normalizeGroupI18n(in GroupI18n) (GroupI18n, error) { out := make(GroupI18n, len(in)) - for locale, text := range in { - if !groupI18nLocalePattern.MatchString(locale) { - return nil, infraerrors.BadRequest("INVALID_GROUP_I18N", fmt.Sprintf("invalid locale %q in i18n", locale)) + seen := make(map[string]struct{}, len(in)) + for _, raw := range slices.Sorted(maps.Keys(in)) { + if !groupI18nLocalePattern.MatchString(raw) { + return nil, infraerrors.BadRequest("INVALID_GROUP_I18N", fmt.Sprintf("invalid locale %q in i18n", raw)) } + locale := domain.CanonicalLocale(raw) + if _, dup := seen[locale]; dup { + return nil, infraerrors.BadRequest("INVALID_GROUP_I18N", fmt.Sprintf("duplicate locale %q in i18n", locale)) + } + seen[locale] = struct{}{} + text := in[raw] name := strings.TrimSpace(text.Name) description := strings.TrimSpace(text.Description) if utf8.RuneCountInString(name) > groupI18nNameMaxRunes { diff --git a/backend/internal/service/group_i18n_test.go b/backend/internal/service/group_i18n_test.go index 009c9a5d89..c3fd84eaf7 100644 --- a/backend/internal/service/group_i18n_test.go +++ b/backend/internal/service/group_i18n_test.go @@ -28,6 +28,28 @@ func TestNormalizeGroupI18n(t *testing.T) { }, got) }) + t.Run("locale codes are stored in canonical case", func(t *testing.T) { + got, err := normalizeGroupI18n(GroupI18n{ + "EN": {Name: "Standard"}, + "zh-tw": {Name: "標準"}, + }) + require.NoError(t, err) + require.Equal(t, GroupI18n{ + "en": {Name: "Standard"}, + "zh-TW": {Name: "標準"}, + }, got) + }) + + t.Run("rejects locales that collide after canonicalization", func(t *testing.T) { + for _, in := range []GroupI18n{ + {"en": {Name: "English A"}, "EN": {Name: "English B"}}, + {"zh-TW": {Name: "標準"}, "zh-tw": {}}, + } { + _, err := normalizeGroupI18n(in) + require.Errorf(t, err, "input %v should be rejected", in) + } + }) + t.Run("rejects malformed locale codes", func(t *testing.T) { for _, locale := range []string{"", "e", "en_US", "en-", "中文", "en us"} { _, err := normalizeGroupI18n(GroupI18n{locale: {Name: "x"}})