fix(groups): 广场 Vary 保留 Origin,分组译文语言代码统一大小写

- 模型广场以追加方式写入 Vary: Accept-Language,保留 CORS 中间件为具体来源
  写入的 Vary: Origin
- 管理端写入分组译文时语言代码统一为 BCP 47 惯用大小写(en、zh-TW),
  统一后重复的语言代码(如 en 与 EN)返回 400 INVALID_GROUP_I18N
- 用户侧按语言取译文时规范写法的键优先,仅大小写不同的多个键按字节序取最小者,
  同一请求语言的结果固定
This commit is contained in:
feeeei
2026-10-12 18:09:03 +08:00
parent 9a51333101
commit 8b74741efe
7 changed files with 175 additions and 9 deletions
+35 -3
View File
@@ -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
}
@@ -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)
}
}
})
}
}
@@ -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,
@@ -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"))
}
}
@@ -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{
+15 -5
View File
@@ -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 {
@@ -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"}})