mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-19 16:17:56 +00:00
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:
@@ -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{
|
||||
|
||||
@@ -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"}})
|
||||
|
||||
Reference in New Issue
Block a user