From 9a51333101a849bb948abf94c5ed53682e373edd Mon Sep 17 00:00:00 2001 From: feeeei Date: Tue, 6 Oct 2026 16:23:40 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(groups):=20=E5=88=86=E7=BB=84=E5=90=8D?= =?UTF-8?q?=E7=A7=B0=E4=B8=8E=E6=8F=8F=E8=BF=B0=E6=94=AF=E6=8C=81=E5=A4=9A?= =?UTF-8?q?=E8=AF=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - groups 新增 i18n JSONB 列(迁移 242):语言代码 -> {name, description},只存译文; name/description 原列语义不变,仍用于唯一性校验、搜索与各类日志 - 用户侧接口由后端按请求语言输出:取 Accept-Language 中权重最高的一项,先按完整 语言代码、再按主语言匹配;该语言有译文的字段换成译文,否则保持原字段,且不再带出 译文表。覆盖密钥、可用分组、订阅(含进度与汇总)、兑换、使用记录、可用渠道、 模型广场、套餐列表 - 管理端接口一律返回原字段;分组管理接口额外带 i18n 供编辑 - 创建/编辑分组:名称、描述的标题行右侧新增语言下拉,同一个输入框在默认内容与各语言 版本之间切换;语种跟随前端 availableLocales - 管理端写入:创建/更新接受 i18n(更新时缺省不修改,传入则整体替换),校验语言代码 形态与名称长度并丢弃空译文;简易模式同样放行;复制分组只带走描述译文 - 切换界面语言后整页刷新,让已加载的数据换成新语言;模型广场响应加 Vary: Accept-Language - 网关路径,以及只带 group_name 字符串的统计、错误记录出口不做本地化 --- backend/ent/group.go | 15 +- backend/ent/group/group.go | 5 + backend/ent/group_create.go | 57 +++++ backend/ent/group_update.go | 18 ++ backend/ent/migrate/schema.go | 13 +- backend/ent/mutation.go | 56 ++++- backend/ent/runtime/runtime.go | 96 +++++---- backend/ent/schema/group.go | 5 + backend/internal/domain/group_i18n.go | 81 +++++++ backend/internal/domain/group_i18n_test.go | 67 ++++++ .../internal/handler/admin/group_handler.go | 11 +- backend/internal/handler/api_key_handler.go | 10 +- .../handler/available_channel_handler.go | 13 ++ .../internal/handler/dto/group_localize.go | 49 +++++ .../handler/dto/group_localize_test.go | 71 +++++++ backend/internal/handler/dto/mappers.go | 1 + backend/internal/handler/dto/types.go | 4 + .../internal/handler/group_localize_test.go | 92 ++++++++ .../internal/handler/model_plaza_handler.go | 18 +- backend/internal/handler/payment_handler.go | 4 +- backend/internal/handler/redeem_handler.go | 6 +- .../internal/handler/subscription_handler.go | 11 +- backend/internal/handler/usage_handler.go | 4 +- backend/internal/repository/api_key_repo.go | 1 + backend/internal/repository/group_repo.go | 10 + .../repository/group_repo_integration_test.go | 42 ++++ backend/internal/service/admin_group.go | 16 +- .../internal/service/admin_group_duplicate.go | 1 + backend/internal/service/admin_service.go | 2 + .../service/admin_service_group_i18n_test.go | 132 ++++++++++++ backend/internal/service/channel_available.go | 2 + backend/internal/service/group.go | 1 + backend/internal/service/group_i18n.go | 54 +++++ backend/internal/service/group_i18n_test.go | 61 ++++++ .../internal/service/model_plaza_service.go | 2 + .../internal/service/payment_config_plans.go | 4 + backend/migrations/242_group_i18n.sql | 5 + .../components/admin/group/GroupI18nField.vue | 179 ++++++++++++++++ .../group/__tests__/GroupI18nField.spec.ts | 197 ++++++++++++++++++ .../src/components/common/LocaleSwitcher.vue | 2 + .../common/__tests__/LocaleSwitcher.spec.ts | 60 ++++++ .../src/i18n/locales/en/admin/overview.ts | 6 + .../src/i18n/locales/zh/admin/overview.ts | 6 + frontend/src/types/index.ts | 14 ++ frontend/src/views/admin/GroupsView.vue | 53 +++-- 45 files changed, 1459 insertions(+), 98 deletions(-) create mode 100644 backend/internal/domain/group_i18n.go create mode 100644 backend/internal/domain/group_i18n_test.go create mode 100644 backend/internal/handler/dto/group_localize.go create mode 100644 backend/internal/handler/dto/group_localize_test.go create mode 100644 backend/internal/handler/group_localize_test.go create mode 100644 backend/internal/service/admin_service_group_i18n_test.go create mode 100644 backend/internal/service/group_i18n.go create mode 100644 backend/internal/service/group_i18n_test.go create mode 100644 backend/migrations/242_group_i18n.sql create mode 100644 frontend/src/components/admin/group/GroupI18nField.vue create mode 100644 frontend/src/components/admin/group/__tests__/GroupI18nField.spec.ts create mode 100644 frontend/src/components/common/__tests__/LocaleSwitcher.spec.ts diff --git a/backend/ent/group.go b/backend/ent/group.go index 5510c5aa1a..747a09d1aa 100644 --- a/backend/ent/group.go +++ b/backend/ent/group.go @@ -30,6 +30,8 @@ type Group struct { Name string `json:"name,omitempty"` // Description holds the value of the "description" field. Description *string `json:"description,omitempty"` + // 名称与描述的界面语言译文:语言代码 -> {name, description};缺失的语言沿用 name/description + I18n domain.GroupI18n `json:"i18n,omitempty"` // RateMultiplier holds the value of the "rate_multiplier" field. RateMultiplier float64 `json:"rate_multiplier,omitempty"` // 是否启用高峰时段倍率 @@ -258,7 +260,7 @@ func (*Group) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) for i := range columns { switch columns[i] { - case group.FieldVideoModelPrices, group.FieldModelPricing, group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelAllowlist, group.FieldCodexModelsManifestConfig, group.FieldReasoningEffortMappings: + case group.FieldI18n, group.FieldVideoModelPrices, group.FieldModelPricing, group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelAllowlist, group.FieldCodexModelsManifestConfig, group.FieldReasoningEffortMappings: values[i] = new([]byte) case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldVideoRateIndependent, group.FieldLongContextPricingEnabled, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldAllowLive, group.FieldForceOpenaiFast, group.FieldFreeOpenaiFast, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet, group.FieldProfitControlEnabled: values[i] = new(sql.NullBool) @@ -323,6 +325,14 @@ func (_m *Group) assignValues(columns []string, values []any) error { _m.Description = new(string) *_m.Description = value.String } + case group.FieldI18n: + if value, ok := values[i].(*[]byte); !ok { + return fmt.Errorf("unexpected type %T for field i18n", values[i]) + } else if value != nil && len(*value) > 0 { + if err := json.Unmarshal(*value, &_m.I18n); err != nil { + return fmt.Errorf("unmarshal field i18n: %w", err) + } + } case group.FieldRateMultiplier: if value, ok := values[i].(*sql.NullFloat64); !ok { return fmt.Errorf("unexpected type %T for field rate_multiplier", values[i]) @@ -817,6 +827,9 @@ func (_m *Group) String() string { builder.WriteString(*v) } builder.WriteString(", ") + builder.WriteString("i18n=") + builder.WriteString(fmt.Sprintf("%v", _m.I18n)) + builder.WriteString(", ") builder.WriteString("rate_multiplier=") builder.WriteString(fmt.Sprintf("%v", _m.RateMultiplier)) builder.WriteString(", ") diff --git a/backend/ent/group/group.go b/backend/ent/group/group.go index 66dbdc412d..cf3feeb6c9 100644 --- a/backend/ent/group/group.go +++ b/backend/ent/group/group.go @@ -26,6 +26,8 @@ const ( FieldName = "name" // FieldDescription holds the string denoting the description field in the database. FieldDescription = "description" + // FieldI18n holds the string denoting the i18n field in the database. + FieldI18n = "i18n" // FieldRateMultiplier holds the string denoting the rate_multiplier field in the database. FieldRateMultiplier = "rate_multiplier" // FieldPeakRateEnabled holds the string denoting the peak_rate_enabled field in the database. @@ -228,6 +230,7 @@ var Columns = []string{ FieldDeletedAt, FieldName, FieldDescription, + FieldI18n, FieldRateMultiplier, FieldPeakRateEnabled, FieldPeakStart, @@ -326,6 +329,8 @@ var ( UpdateDefaultUpdatedAt func() time.Time // NameValidator is a validator for the "name" field. It is called by the builders before save. NameValidator func(string) error + // DefaultI18n holds the default value on creation for the "i18n" field. + DefaultI18n domain.GroupI18n // DefaultRateMultiplier holds the default value on creation for the "rate_multiplier" field. DefaultRateMultiplier float64 // DefaultPeakRateEnabled holds the default value on creation for the "peak_rate_enabled" field. diff --git a/backend/ent/group_create.go b/backend/ent/group_create.go index 7f4c650708..d2a3a0d0e3 100644 --- a/backend/ent/group_create.go +++ b/backend/ent/group_create.go @@ -92,6 +92,12 @@ func (_c *GroupCreate) SetNillableDescription(v *string) *GroupCreate { return _c } +// SetI18n sets the "i18n" field. +func (_c *GroupCreate) SetI18n(v domain.GroupI18n) *GroupCreate { + _c.mutation.SetI18n(v) + return _c +} + // SetRateMultiplier sets the "rate_multiplier" field. func (_c *GroupCreate) SetRateMultiplier(v float64) *GroupCreate { _c.mutation.SetRateMultiplier(v) @@ -1047,6 +1053,10 @@ func (_c *GroupCreate) defaults() error { v := group.DefaultUpdatedAt() _c.mutation.SetUpdatedAt(v) } + if _, ok := _c.mutation.I18n(); !ok { + v := group.DefaultI18n + _c.mutation.SetI18n(v) + } if _, ok := _c.mutation.RateMultiplier(); !ok { v := group.DefaultRateMultiplier _c.mutation.SetRateMultiplier(v) @@ -1230,6 +1240,9 @@ func (_c *GroupCreate) check() error { return &ValidationError{Name: "name", err: fmt.Errorf(`ent: validator failed for field "Group.name": %w`, err)} } } + if _, ok := _c.mutation.I18n(); !ok { + return &ValidationError{Name: "i18n", err: errors.New(`ent: missing required field "Group.i18n"`)} + } if _, ok := _c.mutation.RateMultiplier(); !ok { return &ValidationError{Name: "rate_multiplier", err: errors.New(`ent: missing required field "Group.rate_multiplier"`)} } @@ -1465,6 +1478,10 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) { _spec.SetField(group.FieldDescription, field.TypeString, value) _node.Description = &value } + if value, ok := _c.mutation.I18n(); ok { + _spec.SetField(group.FieldI18n, field.TypeJSON, value) + _node.I18n = value + } if value, ok := _c.mutation.RateMultiplier(); ok { _spec.SetField(group.FieldRateMultiplier, field.TypeFloat64, value) _node.RateMultiplier = value @@ -1925,6 +1942,18 @@ func (u *GroupUpsert) ClearDescription() *GroupUpsert { return u } +// SetI18n sets the "i18n" field. +func (u *GroupUpsert) SetI18n(v domain.GroupI18n) *GroupUpsert { + u.Set(group.FieldI18n, v) + return u +} + +// UpdateI18n sets the "i18n" field to the value that was provided on create. +func (u *GroupUpsert) UpdateI18n() *GroupUpsert { + u.SetExcluded(group.FieldI18n) + return u +} + // SetRateMultiplier sets the "rate_multiplier" field. func (u *GroupUpsert) SetRateMultiplier(v float64) *GroupUpsert { u.Set(group.FieldRateMultiplier, v) @@ -3039,6 +3068,20 @@ func (u *GroupUpsertOne) ClearDescription() *GroupUpsertOne { }) } +// SetI18n sets the "i18n" field. +func (u *GroupUpsertOne) SetI18n(v domain.GroupI18n) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetI18n(v) + }) +} + +// UpdateI18n sets the "i18n" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdateI18n() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdateI18n() + }) +} + // SetRateMultiplier sets the "rate_multiplier" field. func (u *GroupUpsertOne) SetRateMultiplier(v float64) *GroupUpsertOne { return u.Update(func(s *GroupUpsert) { @@ -4485,6 +4528,20 @@ func (u *GroupUpsertBulk) ClearDescription() *GroupUpsertBulk { }) } +// SetI18n sets the "i18n" field. +func (u *GroupUpsertBulk) SetI18n(v domain.GroupI18n) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetI18n(v) + }) +} + +// UpdateI18n sets the "i18n" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdateI18n() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdateI18n() + }) +} + // SetRateMultiplier sets the "rate_multiplier" field. func (u *GroupUpsertBulk) SetRateMultiplier(v float64) *GroupUpsertBulk { return u.Update(func(s *GroupUpsert) { diff --git a/backend/ent/group_update.go b/backend/ent/group_update.go index 091466452f..d4d8b85729 100644 --- a/backend/ent/group_update.go +++ b/backend/ent/group_update.go @@ -97,6 +97,12 @@ func (_u *GroupUpdate) ClearDescription() *GroupUpdate { return _u } +// SetI18n sets the "i18n" field. +func (_u *GroupUpdate) SetI18n(v domain.GroupI18n) *GroupUpdate { + _u.mutation.SetI18n(v) + return _u +} + // SetRateMultiplier sets the "rate_multiplier" field. func (_u *GroupUpdate) SetRateMultiplier(v float64) *GroupUpdate { _u.mutation.ResetRateMultiplier() @@ -1581,6 +1587,9 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) { if _u.mutation.DescriptionCleared() { _spec.ClearField(group.FieldDescription, field.TypeString) } + if value, ok := _u.mutation.I18n(); ok { + _spec.SetField(group.FieldI18n, field.TypeJSON, value) + } if value, ok := _u.mutation.RateMultiplier(); ok { _spec.SetField(group.FieldRateMultiplier, field.TypeFloat64, value) } @@ -2291,6 +2300,12 @@ func (_u *GroupUpdateOne) ClearDescription() *GroupUpdateOne { return _u } +// SetI18n sets the "i18n" field. +func (_u *GroupUpdateOne) SetI18n(v domain.GroupI18n) *GroupUpdateOne { + _u.mutation.SetI18n(v) + return _u +} + // SetRateMultiplier sets the "rate_multiplier" field. func (_u *GroupUpdateOne) SetRateMultiplier(v float64) *GroupUpdateOne { _u.mutation.ResetRateMultiplier() @@ -3805,6 +3820,9 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error) if _u.mutation.DescriptionCleared() { _spec.ClearField(group.FieldDescription, field.TypeString) } + if value, ok := _u.mutation.I18n(); ok { + _spec.SetField(group.FieldI18n, field.TypeJSON, value) + } if value, ok := _u.mutation.RateMultiplier(); ok { _spec.SetField(group.FieldRateMultiplier, field.TypeFloat64, value) } diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index 17ee59bdca..f12c758391 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -908,6 +908,7 @@ var ( {Name: "deleted_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}}, {Name: "name", Type: field.TypeString, Size: 100}, {Name: "description", Type: field.TypeString, Nullable: true, SchemaType: map[string]string{"postgres": "text"}}, + {Name: "i18n", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}}, {Name: "rate_multiplier", Type: field.TypeFloat64, Default: 1, SchemaType: map[string]string{"postgres": "decimal(10,4)"}}, {Name: "peak_rate_enabled", Type: field.TypeBool, Default: false}, {Name: "peak_start", Type: field.TypeString, Size: 5, Default: ""}, @@ -979,22 +980,22 @@ var ( { Name: "group_status", Unique: false, - Columns: []*schema.Column{GroupsColumns[12]}, + Columns: []*schema.Column{GroupsColumns[13]}, }, { Name: "group_platform", Unique: false, - Columns: []*schema.Column{GroupsColumns[14]}, + Columns: []*schema.Column{GroupsColumns[15]}, }, { Name: "group_subscription_type", Unique: false, - Columns: []*schema.Column{GroupsColumns[15]}, + Columns: []*schema.Column{GroupsColumns[16]}, }, { Name: "group_is_exclusive", Unique: false, - Columns: []*schema.Column{GroupsColumns[11]}, + Columns: []*schema.Column{GroupsColumns[12]}, }, { Name: "group_deleted_at", @@ -1004,12 +1005,12 @@ var ( { Name: "group_sort_order", Unique: false, - Columns: []*schema.Column{GroupsColumns[49]}, + Columns: []*schema.Column{GroupsColumns[50]}, }, { Name: "idx_groups_duplicate_operation_id_active", Unique: true, - Columns: []*schema.Column{GroupsColumns[13]}, + Columns: []*schema.Column{GroupsColumns[14]}, Annotation: &entsql.IndexAnnotation{ Where: "duplicate_operation_id IS NOT NULL AND deleted_at IS NULL", }, diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index 38fd383f76..b4442aef44 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -22086,6 +22086,7 @@ type GroupMutation struct { deleted_at *time.Time name *string description *string + i18n *domain.GroupI18n rate_multiplier *float64 addrate_multiplier *float64 peak_rate_enabled *bool @@ -22505,6 +22506,42 @@ func (m *GroupMutation) ResetDescription() { delete(m.clearedFields, group.FieldDescription) } +// SetI18n sets the "i18n" field. +func (m *GroupMutation) SetI18n(di domain.GroupI18n) { + m.i18n = &di +} + +// I18n returns the value of the "i18n" field in the mutation. +func (m *GroupMutation) I18n() (r domain.GroupI18n, exists bool) { + v := m.i18n + if v == nil { + return + } + return *v, true +} + +// OldI18n returns the old "i18n" field's value of the Group entity. +// If the Group object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *GroupMutation) OldI18n(ctx context.Context) (v domain.GroupI18n, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldI18n is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldI18n requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldI18n: %w", err) + } + return oldValue.I18n, nil +} + +// ResetI18n resets all changes to the "i18n" field. +func (m *GroupMutation) ResetI18n() { + m.i18n = nil +} + // SetRateMultiplier sets the "rate_multiplier" field. func (m *GroupMutation) SetRateMultiplier(f float64) { m.rate_multiplier = &f @@ -25921,7 +25958,7 @@ func (m *GroupMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *GroupMutation) Fields() []string { - fields := make([]string, 0, 66) + fields := make([]string, 0, 67) if m.created_at != nil { fields = append(fields, group.FieldCreatedAt) } @@ -25937,6 +25974,9 @@ func (m *GroupMutation) Fields() []string { if m.description != nil { fields = append(fields, group.FieldDescription) } + if m.i18n != nil { + fields = append(fields, group.FieldI18n) + } if m.rate_multiplier != nil { fields = append(fields, group.FieldRateMultiplier) } @@ -26138,6 +26178,8 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) { return m.Name() case group.FieldDescription: return m.Description() + case group.FieldI18n: + return m.I18n() case group.FieldRateMultiplier: return m.RateMultiplier() case group.FieldPeakRateEnabled: @@ -26279,6 +26321,8 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e return m.OldName(ctx) case group.FieldDescription: return m.OldDescription(ctx) + case group.FieldI18n: + return m.OldI18n(ctx) case group.FieldRateMultiplier: return m.OldRateMultiplier(ctx) case group.FieldPeakRateEnabled: @@ -26445,6 +26489,13 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error { } m.SetDescription(v) return nil + case group.FieldI18n: + v, ok := value.(domain.GroupI18n) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetI18n(v) + return nil case group.FieldRateMultiplier: v, ok := value.(float64) if !ok { @@ -27398,6 +27449,9 @@ func (m *GroupMutation) ResetField(name string) error { case group.FieldDescription: m.ResetDescription() return nil + case group.FieldI18n: + m.ResetI18n() + return nil case group.FieldRateMultiplier: m.ResetRateMultiplier() return nil diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index 6253e31dad..d36bddb446 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -1037,204 +1037,208 @@ func init() { return nil } }() + // groupDescI18n is the schema descriptor for i18n field. + groupDescI18n := groupFields[2].Descriptor() + // group.DefaultI18n holds the default value on creation for the i18n field. + group.DefaultI18n = groupDescI18n.Default.(domain.GroupI18n) // groupDescRateMultiplier is the schema descriptor for rate_multiplier field. - groupDescRateMultiplier := groupFields[2].Descriptor() + groupDescRateMultiplier := groupFields[3].Descriptor() // group.DefaultRateMultiplier holds the default value on creation for the rate_multiplier field. group.DefaultRateMultiplier = groupDescRateMultiplier.Default.(float64) // groupDescPeakRateEnabled is the schema descriptor for peak_rate_enabled field. - groupDescPeakRateEnabled := groupFields[3].Descriptor() + groupDescPeakRateEnabled := groupFields[4].Descriptor() // group.DefaultPeakRateEnabled holds the default value on creation for the peak_rate_enabled field. group.DefaultPeakRateEnabled = groupDescPeakRateEnabled.Default.(bool) // groupDescPeakStart is the schema descriptor for peak_start field. - groupDescPeakStart := groupFields[4].Descriptor() + groupDescPeakStart := groupFields[5].Descriptor() // group.DefaultPeakStart holds the default value on creation for the peak_start field. group.DefaultPeakStart = groupDescPeakStart.Default.(string) // group.PeakStartValidator is a validator for the "peak_start" field. It is called by the builders before save. group.PeakStartValidator = groupDescPeakStart.Validators[0].(func(string) error) // groupDescPeakEnd is the schema descriptor for peak_end field. - groupDescPeakEnd := groupFields[5].Descriptor() + groupDescPeakEnd := groupFields[6].Descriptor() // group.DefaultPeakEnd holds the default value on creation for the peak_end field. group.DefaultPeakEnd = groupDescPeakEnd.Default.(string) // group.PeakEndValidator is a validator for the "peak_end" field. It is called by the builders before save. group.PeakEndValidator = groupDescPeakEnd.Validators[0].(func(string) error) // groupDescPeakRateMultiplier is the schema descriptor for peak_rate_multiplier field. - groupDescPeakRateMultiplier := groupFields[6].Descriptor() + groupDescPeakRateMultiplier := groupFields[7].Descriptor() // group.DefaultPeakRateMultiplier holds the default value on creation for the peak_rate_multiplier field. group.DefaultPeakRateMultiplier = groupDescPeakRateMultiplier.Default.(float64) // groupDescIsExclusive is the schema descriptor for is_exclusive field. - groupDescIsExclusive := groupFields[7].Descriptor() + groupDescIsExclusive := groupFields[8].Descriptor() // group.DefaultIsExclusive holds the default value on creation for the is_exclusive field. group.DefaultIsExclusive = groupDescIsExclusive.Default.(bool) // groupDescStatus is the schema descriptor for status field. - groupDescStatus := groupFields[8].Descriptor() + groupDescStatus := groupFields[9].Descriptor() // group.DefaultStatus holds the default value on creation for the status field. group.DefaultStatus = groupDescStatus.Default.(string) // group.StatusValidator is a validator for the "status" field. It is called by the builders before save. group.StatusValidator = groupDescStatus.Validators[0].(func(string) error) // groupDescDuplicateOperationID is the schema descriptor for duplicate_operation_id field. - groupDescDuplicateOperationID := groupFields[9].Descriptor() + groupDescDuplicateOperationID := groupFields[10].Descriptor() // group.DuplicateOperationIDValidator is a validator for the "duplicate_operation_id" field. It is called by the builders before save. group.DuplicateOperationIDValidator = groupDescDuplicateOperationID.Validators[0].(func(string) error) // groupDescPlatform is the schema descriptor for platform field. - groupDescPlatform := groupFields[10].Descriptor() + groupDescPlatform := groupFields[11].Descriptor() // group.DefaultPlatform holds the default value on creation for the platform field. group.DefaultPlatform = groupDescPlatform.Default.(string) // group.PlatformValidator is a validator for the "platform" field. It is called by the builders before save. group.PlatformValidator = groupDescPlatform.Validators[0].(func(string) error) // groupDescSubscriptionType is the schema descriptor for subscription_type field. - groupDescSubscriptionType := groupFields[11].Descriptor() + groupDescSubscriptionType := groupFields[12].Descriptor() // group.DefaultSubscriptionType holds the default value on creation for the subscription_type field. group.DefaultSubscriptionType = groupDescSubscriptionType.Default.(string) // group.SubscriptionTypeValidator is a validator for the "subscription_type" field. It is called by the builders before save. group.SubscriptionTypeValidator = groupDescSubscriptionType.Validators[0].(func(string) error) // groupDescDefaultValidityDays is the schema descriptor for default_validity_days field. - groupDescDefaultValidityDays := groupFields[15].Descriptor() + groupDescDefaultValidityDays := groupFields[16].Descriptor() // group.DefaultDefaultValidityDays holds the default value on creation for the default_validity_days field. group.DefaultDefaultValidityDays = groupDescDefaultValidityDays.Default.(int) // groupDescAllowImageGeneration is the schema descriptor for allow_image_generation field. - groupDescAllowImageGeneration := groupFields[16].Descriptor() + groupDescAllowImageGeneration := groupFields[17].Descriptor() // group.DefaultAllowImageGeneration holds the default value on creation for the allow_image_generation field. group.DefaultAllowImageGeneration = groupDescAllowImageGeneration.Default.(bool) // groupDescAllowBatchImageGeneration is the schema descriptor for allow_batch_image_generation field. - groupDescAllowBatchImageGeneration := groupFields[17].Descriptor() + groupDescAllowBatchImageGeneration := groupFields[18].Descriptor() // group.DefaultAllowBatchImageGeneration holds the default value on creation for the allow_batch_image_generation field. group.DefaultAllowBatchImageGeneration = groupDescAllowBatchImageGeneration.Default.(bool) // groupDescImageRateIndependent is the schema descriptor for image_rate_independent field. - groupDescImageRateIndependent := groupFields[18].Descriptor() + groupDescImageRateIndependent := groupFields[19].Descriptor() // group.DefaultImageRateIndependent holds the default value on creation for the image_rate_independent field. group.DefaultImageRateIndependent = groupDescImageRateIndependent.Default.(bool) // groupDescImageRateMultiplier is the schema descriptor for image_rate_multiplier field. - groupDescImageRateMultiplier := groupFields[19].Descriptor() + groupDescImageRateMultiplier := groupFields[20].Descriptor() // group.DefaultImageRateMultiplier holds the default value on creation for the image_rate_multiplier field. group.DefaultImageRateMultiplier = groupDescImageRateMultiplier.Default.(float64) // groupDescBatchImageDiscountMultiplier is the schema descriptor for batch_image_discount_multiplier field. - groupDescBatchImageDiscountMultiplier := groupFields[23].Descriptor() + groupDescBatchImageDiscountMultiplier := groupFields[24].Descriptor() // group.DefaultBatchImageDiscountMultiplier holds the default value on creation for the batch_image_discount_multiplier field. group.DefaultBatchImageDiscountMultiplier = groupDescBatchImageDiscountMultiplier.Default.(float64) // groupDescBatchImageHoldMultiplier is the schema descriptor for batch_image_hold_multiplier field. - groupDescBatchImageHoldMultiplier := groupFields[24].Descriptor() + groupDescBatchImageHoldMultiplier := groupFields[25].Descriptor() // group.DefaultBatchImageHoldMultiplier holds the default value on creation for the batch_image_hold_multiplier field. group.DefaultBatchImageHoldMultiplier = groupDescBatchImageHoldMultiplier.Default.(float64) // groupDescVideoRateIndependent is the schema descriptor for video_rate_independent field. - groupDescVideoRateIndependent := groupFields[25].Descriptor() + groupDescVideoRateIndependent := groupFields[26].Descriptor() // group.DefaultVideoRateIndependent holds the default value on creation for the video_rate_independent field. group.DefaultVideoRateIndependent = groupDescVideoRateIndependent.Default.(bool) // groupDescVideoRateMultiplier is the schema descriptor for video_rate_multiplier field. - groupDescVideoRateMultiplier := groupFields[26].Descriptor() + groupDescVideoRateMultiplier := groupFields[27].Descriptor() // group.DefaultVideoRateMultiplier holds the default value on creation for the video_rate_multiplier field. group.DefaultVideoRateMultiplier = groupDescVideoRateMultiplier.Default.(float64) // groupDescSearchPricePer1k is the schema descriptor for search_price_per_1k field. - groupDescSearchPricePer1k := groupFields[32].Descriptor() + groupDescSearchPricePer1k := groupFields[33].Descriptor() // group.SearchPricePer1kValidator is a validator for the "search_price_per_1k" field. It is called by the builders before save. group.SearchPricePer1kValidator = groupDescSearchPricePer1k.Validators[0].(func(float64) error) // groupDescAudioRealtimePricePerMin is the schema descriptor for audio_realtime_price_per_min field. - groupDescAudioRealtimePricePerMin := groupFields[33].Descriptor() + groupDescAudioRealtimePricePerMin := groupFields[34].Descriptor() // group.AudioRealtimePricePerMinValidator is a validator for the "audio_realtime_price_per_min" field. It is called by the builders before save. group.AudioRealtimePricePerMinValidator = groupDescAudioRealtimePricePerMin.Validators[0].(func(float64) error) // groupDescAudioTtsPricePerMillionChars is the schema descriptor for audio_tts_price_per_million_chars field. - groupDescAudioTtsPricePerMillionChars := groupFields[34].Descriptor() + groupDescAudioTtsPricePerMillionChars := groupFields[35].Descriptor() // group.AudioTtsPricePerMillionCharsValidator is a validator for the "audio_tts_price_per_million_chars" field. It is called by the builders before save. group.AudioTtsPricePerMillionCharsValidator = groupDescAudioTtsPricePerMillionChars.Validators[0].(func(float64) error) // groupDescAudioSttPricePerHour is the schema descriptor for audio_stt_price_per_hour field. - groupDescAudioSttPricePerHour := groupFields[35].Descriptor() + groupDescAudioSttPricePerHour := groupFields[36].Descriptor() // group.AudioSttPricePerHourValidator is a validator for the "audio_stt_price_per_hour" field. It is called by the builders before save. group.AudioSttPricePerHourValidator = groupDescAudioSttPricePerHour.Validators[0].(func(float64) error) // groupDescLongContextPricingEnabled is the schema descriptor for long_context_pricing_enabled field. - groupDescLongContextPricingEnabled := groupFields[36].Descriptor() + groupDescLongContextPricingEnabled := groupFields[37].Descriptor() // group.DefaultLongContextPricingEnabled holds the default value on creation for the long_context_pricing_enabled field. group.DefaultLongContextPricingEnabled = groupDescLongContextPricingEnabled.Default.(bool) // groupDescClaudeCodeOnly is the schema descriptor for claude_code_only field. - groupDescClaudeCodeOnly := groupFields[38].Descriptor() + groupDescClaudeCodeOnly := groupFields[39].Descriptor() // group.DefaultClaudeCodeOnly holds the default value on creation for the claude_code_only field. group.DefaultClaudeCodeOnly = groupDescClaudeCodeOnly.Default.(bool) // groupDescModelRoutingEnabled is the schema descriptor for model_routing_enabled field. - groupDescModelRoutingEnabled := groupFields[42].Descriptor() + groupDescModelRoutingEnabled := groupFields[43].Descriptor() // group.DefaultModelRoutingEnabled holds the default value on creation for the model_routing_enabled field. group.DefaultModelRoutingEnabled = groupDescModelRoutingEnabled.Default.(bool) // groupDescMcpXMLInject is the schema descriptor for mcp_xml_inject field. - groupDescMcpXMLInject := groupFields[43].Descriptor() + groupDescMcpXMLInject := groupFields[44].Descriptor() // group.DefaultMcpXMLInject holds the default value on creation for the mcp_xml_inject field. group.DefaultMcpXMLInject = groupDescMcpXMLInject.Default.(bool) // groupDescSupportedModelScopes is the schema descriptor for supported_model_scopes field. - groupDescSupportedModelScopes := groupFields[44].Descriptor() + groupDescSupportedModelScopes := groupFields[45].Descriptor() // group.DefaultSupportedModelScopes holds the default value on creation for the supported_model_scopes field. group.DefaultSupportedModelScopes = groupDescSupportedModelScopes.Default.([]string) // groupDescSortOrder is the schema descriptor for sort_order field. - groupDescSortOrder := groupFields[45].Descriptor() + groupDescSortOrder := groupFields[46].Descriptor() // group.DefaultSortOrder holds the default value on creation for the sort_order field. group.DefaultSortOrder = groupDescSortOrder.Default.(int) // groupDescAllowMessagesDispatch is the schema descriptor for allow_messages_dispatch field. - groupDescAllowMessagesDispatch := groupFields[46].Descriptor() + groupDescAllowMessagesDispatch := groupFields[47].Descriptor() // group.DefaultAllowMessagesDispatch holds the default value on creation for the allow_messages_dispatch field. group.DefaultAllowMessagesDispatch = groupDescAllowMessagesDispatch.Default.(bool) // groupDescAllowLive is the schema descriptor for allow_live field. - groupDescAllowLive := groupFields[47].Descriptor() + groupDescAllowLive := groupFields[48].Descriptor() // group.DefaultAllowLive holds the default value on creation for the allow_live field. group.DefaultAllowLive = groupDescAllowLive.Default.(bool) // groupDescForceOpenaiFast is the schema descriptor for force_openai_fast field. - groupDescForceOpenaiFast := groupFields[48].Descriptor() + groupDescForceOpenaiFast := groupFields[49].Descriptor() // group.DefaultForceOpenaiFast holds the default value on creation for the force_openai_fast field. group.DefaultForceOpenaiFast = groupDescForceOpenaiFast.Default.(bool) // groupDescFreeOpenaiFast is the schema descriptor for free_openai_fast field. - groupDescFreeOpenaiFast := groupFields[49].Descriptor() + groupDescFreeOpenaiFast := groupFields[50].Descriptor() // group.DefaultFreeOpenaiFast holds the default value on creation for the free_openai_fast field. group.DefaultFreeOpenaiFast = groupDescFreeOpenaiFast.Default.(bool) // groupDescRequireOauthOnly is the schema descriptor for require_oauth_only field. - groupDescRequireOauthOnly := groupFields[50].Descriptor() + groupDescRequireOauthOnly := groupFields[51].Descriptor() // group.DefaultRequireOauthOnly holds the default value on creation for the require_oauth_only field. group.DefaultRequireOauthOnly = groupDescRequireOauthOnly.Default.(bool) // groupDescRequirePrivacySet is the schema descriptor for require_privacy_set field. - groupDescRequirePrivacySet := groupFields[51].Descriptor() + groupDescRequirePrivacySet := groupFields[52].Descriptor() // group.DefaultRequirePrivacySet holds the default value on creation for the require_privacy_set field. group.DefaultRequirePrivacySet = groupDescRequirePrivacySet.Default.(bool) // groupDescDefaultMappedModel is the schema descriptor for default_mapped_model field. - groupDescDefaultMappedModel := groupFields[52].Descriptor() + groupDescDefaultMappedModel := groupFields[53].Descriptor() // group.DefaultDefaultMappedModel holds the default value on creation for the default_mapped_model field. group.DefaultDefaultMappedModel = groupDescDefaultMappedModel.Default.(string) // group.DefaultMappedModelValidator is a validator for the "default_mapped_model" field. It is called by the builders before save. group.DefaultMappedModelValidator = groupDescDefaultMappedModel.Validators[0].(func(string) error) // groupDescMessagesDispatchModelConfig is the schema descriptor for messages_dispatch_model_config field. - groupDescMessagesDispatchModelConfig := groupFields[53].Descriptor() + groupDescMessagesDispatchModelConfig := groupFields[54].Descriptor() // group.DefaultMessagesDispatchModelConfig holds the default value on creation for the messages_dispatch_model_config field. group.DefaultMessagesDispatchModelConfig = groupDescMessagesDispatchModelConfig.Default.(domain.OpenAIMessagesDispatchModelConfig) // groupDescModelAllowlist is the schema descriptor for model_allowlist field. - groupDescModelAllowlist := groupFields[54].Descriptor() + groupDescModelAllowlist := groupFields[55].Descriptor() // group.DefaultModelAllowlist holds the default value on creation for the model_allowlist field. group.DefaultModelAllowlist = groupDescModelAllowlist.Default.(domain.GroupModelAllowlist) // groupDescCodexModelsManifestConfig is the schema descriptor for codex_models_manifest_config field. - groupDescCodexModelsManifestConfig := groupFields[55].Descriptor() + groupDescCodexModelsManifestConfig := groupFields[56].Descriptor() // group.DefaultCodexModelsManifestConfig holds the default value on creation for the codex_models_manifest_config field. group.DefaultCodexModelsManifestConfig = groupDescCodexModelsManifestConfig.Default.(domain.GroupCodexModelsManifestConfig) // groupDescRpmLimit is the schema descriptor for rpm_limit field. - groupDescRpmLimit := groupFields[56].Descriptor() + groupDescRpmLimit := groupFields[57].Descriptor() // group.DefaultRpmLimit holds the default value on creation for the rpm_limit field. group.DefaultRpmLimit = groupDescRpmLimit.Default.(int) // groupDescMaxReasoningEffort is the schema descriptor for max_reasoning_effort field. - groupDescMaxReasoningEffort := groupFields[57].Descriptor() + groupDescMaxReasoningEffort := groupFields[58].Descriptor() // group.DefaultMaxReasoningEffort holds the default value on creation for the max_reasoning_effort field. group.DefaultMaxReasoningEffort = groupDescMaxReasoningEffort.Default.(string) // group.MaxReasoningEffortValidator is a validator for the "max_reasoning_effort" field. It is called by the builders before save. group.MaxReasoningEffortValidator = groupDescMaxReasoningEffort.Validators[0].(func(string) error) // groupDescMaxReasoningEffortOverLimit is the schema descriptor for max_reasoning_effort_over_limit field. - groupDescMaxReasoningEffortOverLimit := groupFields[58].Descriptor() + groupDescMaxReasoningEffortOverLimit := groupFields[59].Descriptor() // group.DefaultMaxReasoningEffortOverLimit holds the default value on creation for the max_reasoning_effort_over_limit field. group.DefaultMaxReasoningEffortOverLimit = groupDescMaxReasoningEffortOverLimit.Default.(string) // group.MaxReasoningEffortOverLimitValidator is a validator for the "max_reasoning_effort_over_limit" field. It is called by the builders before save. group.MaxReasoningEffortOverLimitValidator = groupDescMaxReasoningEffortOverLimit.Validators[0].(func(string) error) // groupDescReasoningEffortMappings is the schema descriptor for reasoning_effort_mappings field. - groupDescReasoningEffortMappings := groupFields[59].Descriptor() + groupDescReasoningEffortMappings := groupFields[60].Descriptor() // group.DefaultReasoningEffortMappings holds the default value on creation for the reasoning_effort_mappings field. group.DefaultReasoningEffortMappings = groupDescReasoningEffortMappings.Default.([]domain.ReasoningEffortMapping) // groupDescProfitControlEnabled is the schema descriptor for profit_control_enabled field. - groupDescProfitControlEnabled := groupFields[60].Descriptor() + groupDescProfitControlEnabled := groupFields[61].Descriptor() // group.DefaultProfitControlEnabled holds the default value on creation for the profit_control_enabled field. group.DefaultProfitControlEnabled = groupDescProfitControlEnabled.Default.(bool) // groupDescProfitMinMargin is the schema descriptor for profit_min_margin field. - groupDescProfitMinMargin := groupFields[61].Descriptor() + groupDescProfitMinMargin := groupFields[62].Descriptor() // group.DefaultProfitMinMargin holds the default value on creation for the profit_min_margin field. group.DefaultProfitMinMargin = groupDescProfitMinMargin.Default.(float64) // groupDescProfitSafetyBuffer is the schema descriptor for profit_safety_buffer field. - groupDescProfitSafetyBuffer := groupFields[62].Descriptor() + groupDescProfitSafetyBuffer := groupFields[63].Descriptor() // group.DefaultProfitSafetyBuffer holds the default value on creation for the profit_safety_buffer field. group.DefaultProfitSafetyBuffer = groupDescProfitSafetyBuffer.Default.(float64) idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin() diff --git a/backend/ent/schema/group.go b/backend/ent/schema/group.go index 2d39e5724a..48222c69c5 100644 --- a/backend/ent/schema/group.go +++ b/backend/ent/schema/group.go @@ -44,6 +44,11 @@ func (Group) Fields() []ent.Field { Optional(). Nillable(). SchemaType(map[string]string{dialect.Postgres: "text"}), + // 名称与描述的界面语言译文 (added by migration 242) + field.JSON("i18n", domain.GroupI18n{}). + Default(domain.GroupI18n{}). + SchemaType(map[string]string{dialect.Postgres: "jsonb"}). + Comment("名称与描述的界面语言译文:语言代码 -> {name, description};缺失的语言沿用 name/description"), field.Float("rate_multiplier"). SchemaType(map[string]string{dialect.Postgres: "decimal(10,4)"}). Default(1.0), diff --git a/backend/internal/domain/group_i18n.go b/backend/internal/domain/group_i18n.go new file mode 100644 index 0000000000..5e5c6ab565 --- /dev/null +++ b/backend/internal/domain/group_i18n.go @@ -0,0 +1,81 @@ +package domain + +import ( + "strconv" + "strings" +) + +// GroupLocaleText 是分组名称与描述在某个界面语言下的译文。 +// 字段为空表示该语言沿用分组自身的 name/description。 +type GroupLocaleText struct { + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` +} + +// GroupI18n 以界面语言代码(如 "en"、"zh")为键保存分组展示文案的译文。 +// 仅用于页面展示;分组的唯一性、搜索与各类日志仍以 name 为准。 +type GroupI18n map[string]GroupLocaleText + +// Localize 返回 name、description 在请求首选语言下的版本;该语言没有译文的字段保持原值。 +// acceptLanguage 是 Accept-Language 请求头。只采用其中优先级最高的语言:原字段的语言 +// 未知,退到次选语言的译文不一定比原字段更合适。 +func (m GroupI18n) Localize(acceptLanguage, name, description string) (string, string) { + text, ok := m.lookup(preferredLanguage(acceptLanguage)) + if !ok { + return name, description + } + if text.Name != "" { + name = text.Name + } + if text.Description != "" { + description = text.Description + } + return name, description +} + +// LocalizeName 是只需要名称时的 Localize。 +func (m GroupI18n) LocalizeName(acceptLanguage, name string) string { + name, _ = m.Localize(acceptLanguage, name, "") + return name +} + +// lookup 先按完整语言代码匹配(zh-TW),再按主语言匹配(zh-CN -> zh),不区分大小写。 +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 + } + } + } + return GroupLocaleText{}, false +} + +// preferredLanguage 取 Accept-Language 中权重最高的语言代码,权重相同取靠前者; +// 通配符 * 与 q=0 的条目不参与。 +func preferredLanguage(acceptLanguage string) string { + best, bestQ := "", 0.0 + for _, part := range strings.Split(acceptLanguage, ",") { + tag, params, _ := strings.Cut(part, ";") + tag = strings.ReplaceAll(strings.TrimSpace(tag), "_", "-") + if tag == "" || tag == "*" { + continue + } + q := 1.0 + if value, ok := strings.CutPrefix(strings.TrimSpace(params), "q="); ok { + parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64) + if err != nil { + continue + } + q = parsed + } + if q > bestQ { + best, bestQ = tag, q + } + } + return best +} diff --git a/backend/internal/domain/group_i18n_test.go b/backend/internal/domain/group_i18n_test.go new file mode 100644 index 0000000000..34068c2c80 --- /dev/null +++ b/backend/internal/domain/group_i18n_test.go @@ -0,0 +1,67 @@ +package domain + +import "testing" + +func TestGroupI18nLocalize(t *testing.T) { + i18n := GroupI18n{ + "en": {Name: "Standard", Description: "Pro + Max pool"}, + "zh-TW": {Name: "標準"}, + "ja": {Description: "説明のみ"}, + } + const name, description = "标准", "号池" + + tests := []struct { + title string + acceptLanguage string + wantName string + wantDescription string + }{ + {"exact locale", "en", "Standard", "Pro + Max pool"}, + {"region falls back to the primary language", "en-US", "Standard", "Pro + Max pool"}, + {"locale match ignores case and underscore", "ZH_tw", "標準", description}, + {"a missing field keeps the original text", "ja", name, "説明のみ"}, + {"no translation for the language keeps both", "zh", name, description}, + {"primary language does not match a regional translation", "zh-CN", name, description}, + {"empty header keeps both", "", name, description}, + {"wildcard only keeps both", "*", name, description}, + // 原字段的语言未知,不退到次选语言:浏览器的 zh 用户不应拿到英文译文。 + {"only the preferred language is considered", "zh-CN,zh;q=0.9,en;q=0.8", name, description}, + {"highest weight wins over position", "zh;q=0.5, en;q=0.9", "Standard", "Pro + Max pool"}, + {"equal weight keeps the first", "en, zh", "Standard", "Pro + Max pool"}, + {"entries with q=0 are ignored", "en;q=0, ja", name, "説明のみ"}, + {"malformed weight is skipped", "en;q=abc, ja", name, "説明のみ"}, + } + for _, tt := range tests { + t.Run(tt.title, func(t *testing.T) { + gotName, gotDescription := i18n.Localize(tt.acceptLanguage, name, description) + if gotName != tt.wantName || gotDescription != tt.wantDescription { + t.Fatalf("Localize(%q) = (%q, %q), want (%q, %q)", + tt.acceptLanguage, gotName, gotDescription, tt.wantName, tt.wantDescription) + } + }) + } +} + +func TestGroupI18nLocalize_NilMap(t *testing.T) { + var i18n GroupI18n + + name, description := i18n.Localize("en", "标准", "号池") + + if name != "标准" || description != "号池" { + t.Fatalf("nil translations must keep the original text, got (%q, %q)", name, description) + } + if got := i18n.LocalizeName("en", "标准"); got != "标准" { + t.Fatalf("LocalizeName on nil translations = %q", got) + } +} + +func TestGroupI18nLocalizeName(t *testing.T) { + i18n := GroupI18n{"en": {Name: "Standard"}} + + if got := i18n.LocalizeName("en", "标准"); got != "Standard" { + t.Fatalf("LocalizeName(en) = %q", got) + } + if got := i18n.LocalizeName("zh", "标准"); got != "标准" { + t.Fatalf("LocalizeName(zh) = %q", got) + } +} diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index e35ed66571..4fed4b2bb4 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -142,6 +142,8 @@ type simpleModeGroupResponse struct { Platform string `json:"platform"` Status string `json:"status"` + I18n service.GroupI18n `json:"i18n,omitempty"` + AccountCount int64 `json:"account_count,omitempty"` ActiveAccountCount int64 `json:"active_account_count,omitempty"` RateLimitedAccountCount int64 `json:"rate_limited_account_count,omitempty"` @@ -157,6 +159,7 @@ func groupForSimpleMode(group *service.Group) *simpleModeGroupResponse { return &simpleModeGroupResponse{ ID: group.ID, Name: group.Name, Description: group.Description, Platform: group.Platform, Status: group.Status, + I18n: group.I18n, AccountCount: group.AccountCount, ActiveAccountCount: group.ActiveAccountCount, RateLimitedAccountCount: group.RateLimitedAccountCount, SortOrder: group.SortOrder, CreatedAt: group.CreatedAt, UpdatedAt: group.UpdatedAt, @@ -167,7 +170,7 @@ func sanitizeCreateGroupRequestForSimpleMode(req *CreateGroupRequest) { if req == nil { return } - allowed := CreateGroupRequest{Name: req.Name, Description: req.Description, Platform: req.Platform} + allowed := CreateGroupRequest{Name: req.Name, Description: req.Description, I18n: req.I18n, Platform: req.Platform} allowed.RateMultiplier = 1 allowed.SubscriptionType = service.SubscriptionTypeStandard *req = allowed @@ -177,13 +180,14 @@ func sanitizeUpdateGroupRequestForSimpleMode(req *UpdateGroupRequest) { if req == nil { return } - *req = UpdateGroupRequest{Name: req.Name, Description: req.Description} + *req = UpdateGroupRequest{Name: req.Name, Description: req.Description, I18n: req.I18n} } // CreateGroupRequest represents create group request type CreateGroupRequest struct { Name string `json:"name" binding:"required"` Description string `json:"description"` + I18n service.GroupI18n `json:"i18n"` // 名称与描述的界面语言译文 Platform string `json:"platform" binding:"omitempty,group_platform"` RateMultiplier float64 `json:"rate_multiplier"` IsExclusive bool `json:"is_exclusive"` @@ -258,6 +262,7 @@ type CreateGroupRequest struct { type UpdateGroupRequest struct { Name string `json:"name"` Description *string `json:"description"` + I18n *service.GroupI18n `json:"i18n"` // 缺省不修改,传入则整体替换 Platform string `json:"platform" binding:"omitempty,group_platform"` RateMultiplier *float64 `json:"rate_multiplier"` IsExclusive *bool `json:"is_exclusive"` @@ -664,6 +669,7 @@ func (h *GroupHandler) Create(c *gin.Context) { group, err := h.adminService.CreateGroup(c.Request.Context(), &service.CreateGroupInput{ Name: req.Name, Description: req.Description, + I18n: req.I18n, Platform: req.Platform, RateMultiplier: req.RateMultiplier, IsExclusive: req.IsExclusive, @@ -809,6 +815,7 @@ func (h *GroupHandler) Update(c *gin.Context) { group, err := h.adminService.UpdateGroup(c.Request.Context(), groupID, &service.UpdateGroupInput{ Name: req.Name, Description: req.Description, + I18n: req.I18n, Platform: req.Platform, RateMultiplier: req.RateMultiplier, IsExclusive: req.IsExclusive, diff --git a/backend/internal/handler/api_key_handler.go b/backend/internal/handler/api_key_handler.go index 6361b97dd6..0bce1e9fe6 100644 --- a/backend/internal/handler/api_key_handler.go +++ b/backend/internal/handler/api_key_handler.go @@ -142,7 +142,7 @@ func (h *APIKeyHandler) List(c *gin.Context) { out := make([]dto.APIKey, 0, len(keys)) for i := range keys { - out = append(out, *dto.APIKeyFromService(&keys[i])) + out = append(out, *dto.APIKeyFromService(&keys[i]).Localized(c.GetHeader("Accept-Language"))) } response.Paginated(c, out, result.Total, page, pageSize) } @@ -174,7 +174,7 @@ func (h *APIKeyHandler) GetByID(c *gin.Context) { return } - response.Success(c, dto.APIKeyFromService(key)) + response.Success(c, dto.APIKeyFromService(key).Localized(c.GetHeader("Accept-Language"))) } // Create handles creating a new API key @@ -222,7 +222,7 @@ func (h *APIKeyHandler) Create(c *gin.Context) { if err != nil { return nil, err } - return dto.APIKeyFromService(key), nil + return dto.APIKeyFromService(key).Localized(c.GetHeader("Accept-Language")), nil }) } @@ -290,7 +290,7 @@ func (h *APIKeyHandler) Update(c *gin.Context) { return } - response.Success(c, dto.APIKeyFromService(key)) + response.Success(c, dto.APIKeyFromService(key).Localized(c.GetHeader("Accept-Language"))) } // Delete handles deleting an API key @@ -334,7 +334,7 @@ func (h *APIKeyHandler) GetAvailableGroups(c *gin.Context) { out := make([]dto.Group, 0, len(groups)) for i := range groups { - out = append(out, *dto.GroupFromService(&groups[i])) + out = append(out, *dto.GroupFromService(&groups[i]).Localized(c.GetHeader("Accept-Language"))) } response.Success(c, out) } diff --git a/backend/internal/handler/available_channel_handler.go b/backend/internal/handler/available_channel_handler.go index 46e871a0c3..3d5a22c8ba 100644 --- a/backend/internal/handler/available_channel_handler.go +++ b/backend/internal/handler/available_channel_handler.go @@ -62,6 +62,9 @@ type userAvailableGroup struct { PeakEnd string `json:"peak_end"` PeakRateMultiplier float64 `json:"peak_rate_multiplier"` IsExclusive bool `json:"is_exclusive"` + + // 不对外输出;List 按请求语言把 Name 换成译文后丢弃。 + i18n service.GroupI18n } // userSupportedModelPricing 用户可见的定价字段白名单。 @@ -154,6 +157,7 @@ func (h *AvailableChannelHandler) List(c *gin.Context) { return } + acceptLanguage := c.GetHeader("Accept-Language") out := make([]userAvailableChannel, 0, len(channels)) for _, ch := range channels { if ch.Status != service.StatusActive { @@ -163,6 +167,7 @@ func (h *AvailableChannelHandler) List(c *gin.Context) { if len(visibleGroups) == 0 { continue } + localizeUserAvailableGroups(visibleGroups, acceptLanguage) sections := buildPlatformSections(ch, visibleGroups) if len(sections) == 0 { continue @@ -177,6 +182,13 @@ func (h *AvailableChannelHandler) List(c *gin.Context) { response.Success(c, out) } +// localizeUserAvailableGroups 把分组名称就地换成请求首选语言的版本。 +func localizeUserAvailableGroups(groups []userAvailableGroup, acceptLanguage string) { + for i := range groups { + groups[i].Name = groups[i].i18n.LocalizeName(acceptLanguage, groups[i].Name) + } +} + // buildPlatformSections 把一个渠道按 visibleGroups 的平台集合拆成有序的 section 列表: // 每个 section 对应一个具体平台,只包含该平台的 groups 和 supported_models。 // @@ -254,6 +266,7 @@ func filterUserVisibleGroups( visible = append(visible, userAvailableGroup{ ID: g.ID, Name: g.Name, + i18n: g.I18n, Platform: g.Platform, SubscriptionType: g.SubscriptionType, RateMultiplier: g.RateMultiplier, diff --git a/backend/internal/handler/dto/group_localize.go b/backend/internal/handler/dto/group_localize.go new file mode 100644 index 0000000000..6a9ce7d8fd --- /dev/null +++ b/backend/internal/handler/dto/group_localize.go @@ -0,0 +1,49 @@ +package dto + +// 用户侧接口在输出前调用 Localized:把分组的名称与描述换成请求首选语言 +// (Accept-Language)的版本,并去掉译文表。管理端接口不调用,保留原字段与译文表, +// 分组编辑表单依赖原字段回写。 + +// Localized 就地本地化分组并返回自身。 +func (g *Group) Localized(acceptLanguage string) *Group { + if g == nil { + return nil + } + g.Name, g.Description = g.I18n.Localize(acceptLanguage, g.Name, g.Description) + g.I18n = nil + return g +} + +// Localized 本地化 API Key 所属分组并返回自身。 +func (k *APIKey) Localized(acceptLanguage string) *APIKey { + if k != nil { + k.Group.Localized(acceptLanguage) + } + return k +} + +// Localized 本地化兑换码关联分组并返回自身。 +func (r *RedeemCode) Localized(acceptLanguage string) *RedeemCode { + if r != nil { + r.Group.Localized(acceptLanguage) + } + return r +} + +// Localized 本地化使用记录关联的分组(含其 API Key 与订阅各自携带的分组)并返回自身。 +func (l *UsageLog) Localized(acceptLanguage string) *UsageLog { + if l != nil { + l.Group.Localized(acceptLanguage) + l.APIKey.Localized(acceptLanguage) + l.Subscription.Localized(acceptLanguage) + } + return l +} + +// Localized 本地化订阅关联分组并返回自身。 +func (s *UserSubscription) Localized(acceptLanguage string) *UserSubscription { + if s != nil { + s.Group.Localized(acceptLanguage) + } + return s +} diff --git a/backend/internal/handler/dto/group_localize_test.go b/backend/internal/handler/dto/group_localize_test.go new file mode 100644 index 0000000000..e1b44e50f3 --- /dev/null +++ b/backend/internal/handler/dto/group_localize_test.go @@ -0,0 +1,71 @@ +package dto + +import ( + "encoding/json" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/domain" + "github.com/stretchr/testify/require" +) + +func localizableGroup() *Group { + return &Group{ + ID: 1, Name: "标准", Description: "号池", + I18n: domain.GroupI18n{"en": {Name: "Standard", Description: "Pro + Max pool"}}, + } +} + +func TestGroupLocalized_ReplacesTextAndDropsTranslations(t *testing.T) { + g := localizableGroup().Localized("en") + + require.Equal(t, "Standard", g.Name) + require.Equal(t, "Pro + Max pool", g.Description) + + raw, err := json.Marshal(g) + require.NoError(t, err) + var decoded map[string]any + require.NoError(t, json.Unmarshal(raw, &decoded)) + require.NotContains(t, decoded, "i18n", "user-facing output must not carry the translation table") +} + +func TestGroupLocalized_KeepsOriginalWhenLanguageHasNoTranslation(t *testing.T) { + g := localizableGroup().Localized("zh") + + require.Equal(t, "标准", g.Name) + require.Equal(t, "号池", g.Description) + require.Nil(t, g.I18n) +} + +func TestLocalized_NilSafe(t *testing.T) { + require.Nil(t, (*Group)(nil).Localized("en")) + require.Nil(t, (*APIKey)(nil).Localized("en")) + require.Nil(t, (*RedeemCode)(nil).Localized("en")) + require.Nil(t, (*UsageLog)(nil).Localized("en")) + require.Nil(t, (*UserSubscription)(nil).Localized("en")) + + // 未携带分组的记录原样返回 + require.Nil(t, (&APIKey{}).Localized("en").Group) +} + +func TestLocalized_NestedGroups(t *testing.T) { + key := (&APIKey{Group: localizableGroup()}).Localized("en") + require.Equal(t, "Standard", key.Group.Name) + + code := (&RedeemCode{Group: localizableGroup()}).Localized("en") + require.Equal(t, "Standard", code.Group.Name) + + sub := (&UserSubscription{Group: localizableGroup()}).Localized("en") + require.Equal(t, "Standard", sub.Group.Name) + + // 使用记录除自身分组外,还带着 API Key 与订阅各自的分组 + log := (&UsageLog{ + Group: localizableGroup(), + APIKey: &APIKey{Group: localizableGroup()}, + Subscription: &UserSubscription{Group: localizableGroup()}, + }).Localized("en") + require.Equal(t, "Standard", log.Group.Name) + require.Equal(t, "Standard", log.APIKey.Group.Name) + require.Equal(t, "Standard", log.Subscription.Group.Name) + require.Nil(t, log.APIKey.Group.I18n) + require.Nil(t, log.Subscription.Group.I18n) +} diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 42a1d82594..d0c7de939a 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -182,6 +182,7 @@ func groupFromServiceBase(g *service.Group) Group { ID: g.ID, Name: g.Name, Description: g.Description, + I18n: g.I18n, Platform: g.Platform, RateMultiplier: g.RateMultiplier, IsExclusive: g.IsExclusive, diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 73c02a7af7..1c79cc49c0 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -99,6 +99,10 @@ type Group struct { IsExclusive bool `json:"is_exclusive"` Status string `json:"status"` + // I18n 名称与描述的各语言译文。只有管理端输出;用户侧接口经 Localized 把 + // name/description 换成请求语言的版本后清空本字段。 + I18n domain.GroupI18n `json:"i18n,omitempty"` + SubscriptionType string `json:"subscription_type"` DailyLimitUSD *float64 `json:"daily_limit_usd"` WeeklyLimitUSD *float64 `json:"weekly_limit_usd"` diff --git a/backend/internal/handler/group_localize_test.go b/backend/internal/handler/group_localize_test.go new file mode 100644 index 0000000000..502c5a5164 --- /dev/null +++ b/backend/internal/handler/group_localize_test.go @@ -0,0 +1,92 @@ +package handler + +import ( + "encoding/json" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestToLocalizedModelPlazaGroups(t *testing.T) { + visible := []service.PlazaGroup{ + { + ID: 1, Name: "标准", Description: "号池", Platform: "anthropic", + I18n: service.GroupI18n{"en": {Name: "Standard", Description: "Pro + Max pool"}}, + }, + {ID: 2, Name: "无译文", Description: "原描述", Platform: "openai"}, + } + + english := toLocalizedModelPlazaGroups(visible, nil, "en") + require.Equal(t, "Standard", english[0].Name) + require.Equal(t, "Pro + Max pool", english[0].Description) + require.Equal(t, "无译文", english[1].Name) + require.Equal(t, "原描述", english[1].Description) + + chinese := toLocalizedModelPlazaGroups(visible, nil, "zh") + require.Equal(t, "标准", chinese[0].Name) + require.Equal(t, "号池", chinese[0].Description) + + // service 层对象不被改写,同一批分组可继续按别的语言输出 + require.Equal(t, "标准", visible[0].Name) + + raw, err := json.Marshal(english[0]) + require.NoError(t, err) + var decoded map[string]any + require.NoError(t, json.Unmarshal(raw, &decoded)) + require.NotContains(t, decoded, "i18n") +} + +func TestLocalizeUserAvailableGroups(t *testing.T) { + refs := []service.AvailableGroupRef{ + {ID: 1, Name: "标准", Platform: "anthropic", I18n: service.GroupI18n{"en": {Name: "Standard"}}}, + {ID: 2, Name: "无译文", Platform: "anthropic"}, + } + allowed := map[int64]struct{}{1: {}, 2: {}} + + english := filterUserVisibleGroups(refs, allowed) + localizeUserAvailableGroups(english, "en") + require.Equal(t, "Standard", english[0].Name) + require.Equal(t, "无译文", english[1].Name) + + chinese := filterUserVisibleGroups(refs, allowed) + localizeUserAvailableGroups(chinese, "zh") + require.Equal(t, "标准", chinese[0].Name) + + raw, err := json.Marshal(english[0]) + require.NoError(t, err) + var decoded map[string]any + require.NoError(t, json.Unmarshal(raw, &decoded)) + require.NotContains(t, decoded, "i18n") +} + +// 用户侧 handler 输出带分组的 DTO 时必须经过 Localized,否则分组名称与描述会 +// 停留在原字段,并把译文表原样带给用户。管理端(handler/admin)不在此列。 +func TestUserFacingGroupDTOsAreLocalized(t *testing.T) { + mapperCall := regexp.MustCompile(`dto\.(APIKey|UserSubscription|RedeemCode|UsageLog|Group)FromService[A-Za-z]*\(`) + + files, err := filepath.Glob("*.go") + require.NoError(t, err) + + checked := 0 + for _, file := range files { + if strings.HasSuffix(file, "_test.go") { + continue + } + source, err := os.ReadFile(file) + require.NoError(t, err) + for i, line := range strings.Split(string(source), "\n") { + if !mapperCall.MatchString(line) { + continue + } + checked++ + require.Containsf(t, line, ".Localized(", + "%s:%d outputs a group DTO without Localized(Accept-Language)", file, i+1) + } + } + require.NotZero(t, checked, "expected to find user-facing group DTO call sites") +} diff --git a/backend/internal/handler/model_plaza_handler.go b/backend/internal/handler/model_plaza_handler.go index e613080a74..db00ffaa1c 100644 --- a/backend/internal/handler/model_plaza_handler.go +++ b/backend/internal/handler/model_plaza_handler.go @@ -151,10 +151,9 @@ func (h *ModelPlazaHandler) Get(c *gin.Context) { visible := filterPlazaVisibleGroups(groups, allowedGroups, restrictPublicGroups) - out := make([]modelPlazaGroup, 0, len(visible)) - for i := range visible { - out = append(out, toModelPlazaGroupDTO(&visible[i], userRates)) - } + // 广场可匿名访问,分组文案随请求语言变化,提示中间缓存按语言区分。 + c.Header("Vary", "Accept-Language") + out := toLocalizedModelPlazaGroups(visible, userRates, c.GetHeader("Accept-Language")) response.Success(c, modelPlazaResponse{ Description: rt.Description, Groups: out, @@ -185,6 +184,17 @@ func filterPlazaVisibleGroups( return visible } +// toLocalizedModelPlazaGroups 转换可见分组,并把名称与描述换成请求首选语言的版本。 +func toLocalizedModelPlazaGroups(visible []service.PlazaGroup, userRates map[int64]float64, acceptLanguage string) []modelPlazaGroup { + out := make([]modelPlazaGroup, 0, len(visible)) + for i := range visible { + group := toModelPlazaGroupDTO(&visible[i], userRates) + group.Name, group.Description = visible[i].I18n.Localize(acceptLanguage, group.Name, group.Description) + out = append(out, group) + } + return out +} + // toModelPlazaGroupDTO 将 service 层广场分组映射为白名单 DTO,并合并用户专属倍率。 func toModelPlazaGroupDTO(g *service.PlazaGroup, userRates map[int64]float64) modelPlazaGroup { models := make([]modelPlazaModel, 0, len(g.Models)) diff --git a/backend/internal/handler/payment_handler.go b/backend/internal/handler/payment_handler.go index 24e318cc5a..ca60e2fcf5 100644 --- a/backend/internal/handler/payment_handler.go +++ b/backend/internal/handler/payment_handler.go @@ -78,7 +78,7 @@ func (h *PaymentHandler) GetPlans(c *gin.Context) { gi := groupInfo[p.GroupID] result = append(result, planWithPlatform{ ID: int64(p.ID), GroupID: p.GroupID, - GroupPlatform: gi.Platform, GroupName: gi.Name, + GroupPlatform: gi.Platform, GroupName: gi.I18n.LocalizeName(c.GetHeader("Accept-Language"), gi.Name), RateMultiplier: gi.RateMultiplier, PeakRateEnabled: gi.PeakRateEnabled, PeakStart: gi.PeakStart, PeakEnd: gi.PeakEnd, PeakRateMultiplier: gi.PeakRateMultiplier, Name: p.Name, Description: p.Description, Price: p.Price, OriginalPrice: p.OriginalPrice, @@ -126,7 +126,7 @@ func (h *PaymentHandler) GetCheckoutInfo(c *gin.Context) { gi := groupInfo[p.GroupID] planList = append(planList, checkoutPlan{ ID: int64(p.ID), GroupID: p.GroupID, - GroupPlatform: gi.Platform, GroupName: gi.Name, + GroupPlatform: gi.Platform, GroupName: gi.I18n.LocalizeName(c.GetHeader("Accept-Language"), gi.Name), RateMultiplier: gi.RateMultiplier, PeakRateEnabled: gi.PeakRateEnabled, PeakStart: gi.PeakStart, PeakEnd: gi.PeakEnd, PeakRateMultiplier: gi.PeakRateMultiplier, diff --git a/backend/internal/handler/redeem_handler.go b/backend/internal/handler/redeem_handler.go index 5d919cdc4e..f80f833b7e 100644 --- a/backend/internal/handler/redeem_handler.go +++ b/backend/internal/handler/redeem_handler.go @@ -59,7 +59,7 @@ func (h *RedeemHandler) Redeem(c *gin.Context) { return } - response.Success(c, dto.RedeemCodeFromService(result)) + response.Success(c, dto.RedeemCodeFromService(result).Localized(c.GetHeader("Accept-Language"))) } // GetHistory returns the user's redemption history @@ -98,7 +98,7 @@ func (h *RedeemHandler) GetHistory(c *gin.Context) { } out := make([]dto.RedeemCode, 0, len(codes)) for i := range codes { - out = append(out, *dto.RedeemCodeFromService(&codes[i])) + out = append(out, *dto.RedeemCodeFromService(&codes[i]).Localized(c.GetHeader("Accept-Language"))) } response.Paginated(c, out, result.Total, page, pageSize) return @@ -115,7 +115,7 @@ func (h *RedeemHandler) GetHistory(c *gin.Context) { out := make([]dto.RedeemCode, 0, len(codes)) for i := range codes { - out = append(out, *dto.RedeemCodeFromService(&codes[i])) + out = append(out, *dto.RedeemCodeFromService(&codes[i]).Localized(c.GetHeader("Accept-Language"))) } response.Success(c, out) } diff --git a/backend/internal/handler/subscription_handler.go b/backend/internal/handler/subscription_handler.go index b40df83336..282207f835 100644 --- a/backend/internal/handler/subscription_handler.go +++ b/backend/internal/handler/subscription_handler.go @@ -59,7 +59,7 @@ func (h *SubscriptionHandler) List(c *gin.Context) { out := make([]dto.UserSubscription, 0, len(subscriptions)) for i := range subscriptions { - out = append(out, *dto.UserSubscriptionFromService(&subscriptions[i])) + out = append(out, *dto.UserSubscriptionFromService(&subscriptions[i]).Localized(c.GetHeader("Accept-Language"))) } response.Success(c, out) } @@ -81,7 +81,7 @@ func (h *SubscriptionHandler) GetActive(c *gin.Context) { out := make([]dto.UserSubscription, 0, len(subscriptions)) for i := range subscriptions { - out = append(out, *dto.UserSubscriptionFromService(&subscriptions[i])) + out = append(out, *dto.UserSubscriptionFromService(&subscriptions[i]).Localized(c.GetHeader("Accept-Language"))) } response.Success(c, out) } @@ -110,8 +110,11 @@ func (h *SubscriptionHandler) GetProgress(c *gin.Context) { // Skip subscriptions with errors continue } + if sub.Group != nil { + progress.GroupName = sub.Group.I18n.LocalizeName(c.GetHeader("Accept-Language"), progress.GroupName) + } result = append(result, SubscriptionProgressInfo{ - Subscription: dto.UserSubscriptionFromService(sub), + Subscription: dto.UserSubscriptionFromService(sub).Localized(c.GetHeader("Accept-Language")), Progress: progress, }) } @@ -150,7 +153,7 @@ func (h *SubscriptionHandler) GetSummary(c *gin.Context) { // Add group info if preloaded if sub.Group != nil { - item.GroupName = sub.Group.Name + item.GroupName = sub.Group.I18n.LocalizeName(c.GetHeader("Accept-Language"), sub.Group.Name) if sub.Group.DailyLimitUSD != nil { item.DailyLimitUSD = *sub.Group.DailyLimitUSD } diff --git a/backend/internal/handler/usage_handler.go b/backend/internal/handler/usage_handler.go index 9a4284450b..987092ca46 100644 --- a/backend/internal/handler/usage_handler.go +++ b/backend/internal/handler/usage_handler.go @@ -254,7 +254,7 @@ func (h *UsageHandler) List(c *gin.Context) { out := make([]dto.UsageLog, 0, len(records)) for i := range records { - out = append(out, *dto.UsageLogFromService(&records[i])) + out = append(out, *dto.UsageLogFromService(&records[i]).Localized(c.GetHeader("Accept-Language"))) } response.Paginated(c, out, result.Total, page, pageSize) } @@ -400,7 +400,7 @@ func (h *UsageHandler) GetByID(c *gin.Context) { return } - response.Success(c, dto.UsageLogFromService(record)) + response.Success(c, dto.UsageLogFromService(record).Localized(c.GetHeader("Accept-Language"))) } // Stats handles getting usage statistics diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index dd831283b7..99796615fe 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -979,6 +979,7 @@ func groupEntityToService(g *dbent.Group) *service.Group { ID: g.ID, Name: g.Name, Description: derefString(g.Description), + I18n: g.I18n, Platform: g.Platform, RateMultiplier: g.RateMultiplier, IsExclusive: g.IsExclusive, diff --git a/backend/internal/repository/group_repo.go b/backend/internal/repository/group_repo.go index 8b2bbb827d..4a51292195 100644 --- a/backend/internal/repository/group_repo.go +++ b/backend/internal/repository/group_repo.go @@ -167,6 +167,10 @@ func createGroupRecord(ctx context.Context, client *dbent.Client, groupIn *servi // 设置支持的模型系列(始终设置,空数组表示不限制) builder = builder.SetSupportedModelScopes(groupIn.SupportedModelScopes) + if groupIn.I18n != nil { + builder = builder.SetI18n(groupIn.I18n) + } + created, err := builder.Save(ctx) if err != nil { return translatePersistenceError(err, nil, service.ErrGroupExists) @@ -431,6 +435,12 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er // 处理 SupportedModelScopes(始终设置,空数组表示不限制) builder = builder.SetSupportedModelScopes(groupIn.SupportedModelScopes) + i18n := groupIn.I18n + if i18n == nil { + i18n = service.GroupI18n{} + } + builder = builder.SetI18n(i18n) + updated, err := builder.Save(ctx) if err != nil { return translatePersistenceError(err, service.ErrGroupNotFound, service.ErrGroupExists) diff --git a/backend/internal/repository/group_repo_integration_test.go b/backend/internal/repository/group_repo_integration_test.go index 177fbe7f17..19fa9e7193 100644 --- a/backend/internal/repository/group_repo_integration_test.go +++ b/backend/internal/repository/group_repo_integration_test.go @@ -201,6 +201,48 @@ func (s *GroupRepoSuite) TestUpdate() { s.Require().Equal("updated", got.Name) } +func (s *GroupRepoSuite) TestI18n_RoundTrip() { + group := &service.Group{ + Name: "i18n-roundtrip", + Platform: service.PlatformAnthropic, + RateMultiplier: 1.0, + Status: service.StatusActive, + SubscriptionType: service.SubscriptionTypeStandard, + I18n: service.GroupI18n{ + "en": {Name: "Standard", Description: "Pro + Max pool"}, + }, + } + s.Require().NoError(s.repo.Create(s.ctx, group)) + + got, err := s.repo.GetByID(s.ctx, group.ID) + s.Require().NoError(err) + s.Require().Equal(group.I18n, got.I18n) + + got.I18n = service.GroupI18n{"zh": {Description: "号池"}} + s.Require().NoError(s.repo.Update(s.ctx, got)) + got, err = s.repo.GetByID(s.ctx, group.ID) + s.Require().NoError(err) + s.Require().Equal(service.GroupI18n{"zh": {Description: "号池"}}, got.I18n) + + // 未携带译文的写入落库为空对象,而不是 JSON null。 + got.I18n = nil + s.Require().NoError(s.repo.Update(s.ctx, got)) + var stored string + s.Require().NoError(scanSingleRow(s.ctx, s.tx, "SELECT i18n::text FROM groups WHERE id = $1", []any{group.ID}, &stored)) + s.Require().Equal("{}", stored) + + plain := &service.Group{ + Name: "i18n-default", + Platform: service.PlatformAnthropic, + RateMultiplier: 1.0, + Status: service.StatusActive, + SubscriptionType: service.SubscriptionTypeStandard, + } + s.Require().NoError(s.repo.Create(s.ctx, plain)) + s.Require().NoError(scanSingleRow(s.ctx, s.tx, "SELECT i18n::text FROM groups WHERE id = $1", []any{plain.ID}, &stored)) + s.Require().Equal("{}", stored) +} + func (s *GroupRepoSuite) TestGetByID_PreservesMessagesDispatchModelConfig() { group := &service.Group{ Name: "openai-dispatch", diff --git a/backend/internal/service/admin_group.go b/backend/internal/service/admin_group.go index 41d16bb6c9..96c8092a8e 100644 --- a/backend/internal/service/admin_group.go +++ b/backend/internal/service/admin_group.go @@ -372,7 +372,7 @@ func normalizeCreateGroupInputForSimpleMode(input *CreateGroupInput) { return } *input = CreateGroupInput{ - Name: input.Name, Description: input.Description, Platform: input.Platform, + Name: input.Name, Description: input.Description, I18n: input.I18n, Platform: input.Platform, RateMultiplier: 1, SubscriptionType: SubscriptionTypeStandard, } } @@ -381,7 +381,7 @@ func normalizeUpdateGroupInputForSimpleMode(input *UpdateGroupInput) { if input == nil { return } - *input = UpdateGroupInput{Name: input.Name, Description: input.Description} + *input = UpdateGroupInput{Name: input.Name, Description: input.Description, I18n: input.I18n} } func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupInput) (*Group, error) { @@ -394,6 +394,10 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn if input.RateMultiplier <= 0 { return nil, errors.New("rate_multiplier must be > 0") } + i18n, err := normalizeGroupI18n(input.I18n) + if err != nil { + return nil, err + } platform := NormalizeGroupPlatform(input.Platform) // 固定账号 manifest 配置:账号绑定发生在创建之后,创建时无法校验成员关系, @@ -565,6 +569,7 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn group := &Group{ Name: input.Name, Description: input.Description, + I18n: i18n, Platform: platform, RateMultiplier: input.RateMultiplier, IsExclusive: input.IsExclusive, @@ -776,6 +781,13 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd if input.Description != nil { group.Description = *input.Description } + if input.I18n != nil { + i18n, err := normalizeGroupI18n(*input.I18n) + if err != nil { + return nil, err + } + group.I18n = i18n + } if input.Platform != "" { group.Platform = input.Platform } diff --git a/backend/internal/service/admin_group_duplicate.go b/backend/internal/service/admin_group_duplicate.go index 859d19d6f2..b31a4ab337 100644 --- a/backend/internal/service/admin_group_duplicate.go +++ b/backend/internal/service/admin_group_duplicate.go @@ -97,6 +97,7 @@ func cloneGroupForDuplicate(source *Group, operationID string) *Group { return &Group{ Name: duplicateGroupName(source.Name, 1), Description: source.Description, + I18n: cloneGroupI18nForDuplicate(source.I18n), Platform: source.Platform, RateMultiplier: source.RateMultiplier, PeakRateEnabled: source.PeakRateEnabled, diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 1c90cffc22..a5e0ad635c 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -235,6 +235,7 @@ type AdminBoundAuthIdentityChannel struct { type CreateGroupInput struct { Name string Description string + I18n GroupI18n // 名称与描述的界面语言译文 Platform string RateMultiplier float64 IsExclusive bool @@ -315,6 +316,7 @@ type CreateGroupInput struct { type UpdateGroupInput struct { Name string Description *string + I18n *GroupI18n // nil 表示不修改;非 nil 整体替换 Platform string RateMultiplier *float64 // 使用指针以支持设置为0 IsExclusive *bool diff --git a/backend/internal/service/admin_service_group_i18n_test.go b/backend/internal/service/admin_service_group_i18n_test.go new file mode 100644 index 0000000000..baa12808c5 --- /dev/null +++ b/backend/internal/service/admin_service_group_i18n_test.go @@ -0,0 +1,132 @@ +//go:build unit + +package service + +import ( + "context" + "net/http" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/stretchr/testify/require" +) + +func TestAdminService_CreateGroup_NormalizesI18n(t *testing.T) { + repo := &groupRepoStubForAdmin{} + svc := &adminServiceImpl{groupRepo: repo} + + _, err := svc.CreateGroup(context.Background(), &CreateGroupInput{ + Name: "Claude Code - 标准", + Platform: PlatformAnthropic, + RateMultiplier: 1.0, + I18n: GroupI18n{ + "en": {Name: " Claude Code - Standard ", Description: "Pro + Max pool"}, + "zh": {}, + }, + }) + + require.NoError(t, err) + require.NotNil(t, repo.created) + require.Equal(t, GroupI18n{ + "en": {Name: "Claude Code - Standard", Description: "Pro + Max pool"}, + }, repo.created.I18n) +} + +func TestAdminService_CreateGroup_RejectsInvalidI18nLocale(t *testing.T) { + repo := &groupRepoStubForAdmin{} + svc := &adminServiceImpl{groupRepo: repo} + + _, err := svc.CreateGroup(context.Background(), &CreateGroupInput{ + Name: "g", + Platform: PlatformAnthropic, + RateMultiplier: 1.0, + I18n: GroupI18n{"en_US": {Name: "x"}}, + }) + + 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{ + ID: 1, Name: "标准", Platform: PlatformAnthropic, Status: StatusActive, RateMultiplier: 1, + I18n: GroupI18n{"en": {Name: "Standard"}}, + } + } + + t.Run("omitted i18n keeps stored translations", func(t *testing.T) { + repo := &groupRepoStubForAdmin{getByID: newExisting()} + svc := &adminServiceImpl{groupRepo: repo} + + _, err := svc.UpdateGroup(context.Background(), 1, &UpdateGroupInput{Name: "标准版"}) + + require.NoError(t, err) + require.Equal(t, "标准版", repo.updated.Name) + require.Equal(t, GroupI18n{"en": {Name: "Standard"}}, repo.updated.I18n) + }) + + t.Run("provided i18n replaces stored translations as a whole", func(t *testing.T) { + repo := &groupRepoStubForAdmin{getByID: newExisting()} + svc := &adminServiceImpl{groupRepo: repo} + next := GroupI18n{"zh": {Description: " 号池 "}} + + _, err := svc.UpdateGroup(context.Background(), 1, &UpdateGroupInput{I18n: &next}) + + require.NoError(t, err) + require.Equal(t, GroupI18n{"zh": {Description: "号池"}}, repo.updated.I18n) + }) + + t.Run("empty i18n clears stored translations", func(t *testing.T) { + repo := &groupRepoStubForAdmin{getByID: newExisting()} + svc := &adminServiceImpl{groupRepo: repo} + next := GroupI18n{} + + _, err := svc.UpdateGroup(context.Background(), 1, &UpdateGroupInput{I18n: &next}) + + require.NoError(t, err) + require.NotNil(t, repo.updated.I18n) + require.Empty(t, repo.updated.I18n) + }) + + t.Run("invalid i18n is rejected before persisting", func(t *testing.T) { + repo := &groupRepoStubForAdmin{getByID: newExisting()} + svc := &adminServiceImpl{groupRepo: repo} + next := GroupI18n{"not a locale": {Name: "x"}} + + _, err := svc.UpdateGroup(context.Background(), 1, &UpdateGroupInput{I18n: &next}) + + require.Error(t, err) + require.Equal(t, "INVALID_GROUP_I18N", infraerrors.FromError(err).Reason) + require.Nil(t, repo.updated) + }) + + // 简易模式只放行名称与描述,译文属于同一类展示文案,同样放行。 + t.Run("simple mode keeps i18n editable", func(t *testing.T) { + repo := &groupRepoStubForAdmin{getByID: newExisting()} + svc := &adminServiceImpl{cfg: &config.Config{RunMode: config.RunModeSimple}, groupRepo: repo} + next := GroupI18n{"en": {Name: "Standard v2"}} + rate := 9.0 + + _, err := svc.UpdateGroup(context.Background(), 1, &UpdateGroupInput{I18n: &next, RateMultiplier: &rate}) + + require.NoError(t, err) + require.Equal(t, GroupI18n{"en": {Name: "Standard v2"}}, repo.updated.I18n) + require.Equal(t, 1.0, repo.updated.RateMultiplier) + }) +} + +func TestCloneGroupForDuplicate_DropsNameTranslations(t *testing.T) { + source := &Group{ + ID: 7, Name: "标准", Platform: PlatformAnthropic, + I18n: GroupI18n{"en": {Name: "Standard", Description: "Pro + Max pool"}}, + } + + clone := cloneGroupForDuplicate(source, "op-1") + + require.Equal(t, GroupI18n{"en": {Description: "Pro + Max pool"}}, clone.I18n) +} diff --git a/backend/internal/service/channel_available.go b/backend/internal/service/channel_available.go index 262a384821..3a6c5aba2f 100644 --- a/backend/internal/service/channel_available.go +++ b/backend/internal/service/channel_available.go @@ -16,6 +16,7 @@ import ( type AvailableGroupRef struct { ID int64 Name string + I18n GroupI18n Platform string SubscriptionType string RateMultiplier float64 @@ -66,6 +67,7 @@ func (s *ChannelService) ListAvailable(ctx context.Context) ([]AvailableChannel, groupByID[g.ID] = AvailableGroupRef{ ID: g.ID, Name: g.Name, + I18n: g.I18n, Platform: g.Platform, SubscriptionType: g.SubscriptionType, RateMultiplier: g.RateMultiplier, diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go index bb6804a823..0489fe35cc 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -19,6 +19,7 @@ type Group struct { ID int64 Name string Description string + I18n GroupI18n // 名称与描述的界面语言译文;缺失的语言沿用 Name/Description Platform string RateMultiplier float64 // 高峰时段倍率:peak_rate_enabled 为 true 且当前时刻处于 [PeakStart, PeakEnd) 时, diff --git a/backend/internal/service/group_i18n.go b/backend/internal/service/group_i18n.go new file mode 100644 index 0000000000..d45b9adaf8 --- /dev/null +++ b/backend/internal/service/group_i18n.go @@ -0,0 +1,54 @@ +package service + +import ( + "fmt" + "regexp" + "strings" + "unicode/utf8" + + "github.com/Wei-Shaw/sub2api/internal/domain" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" +) + +type GroupI18n = domain.GroupI18n +type GroupLocaleText = domain.GroupLocaleText + +// groupI18nNameMaxRunes 与 groups.name 的长度上限一致。 +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 对象。 +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)) + } + name := strings.TrimSpace(text.Name) + description := strings.TrimSpace(text.Description) + if utf8.RuneCountInString(name) > groupI18nNameMaxRunes { + return nil, infraerrors.BadRequest("INVALID_GROUP_I18N", fmt.Sprintf("i18n name for locale %q must be at most %d characters", locale, groupI18nNameMaxRunes)) + } + if name == "" && description == "" { + continue + } + out[locale] = GroupLocaleText{Name: name, Description: description} + } + return out, nil +} + +// cloneGroupI18nForDuplicate 复制译文中的描述。名称译文不随复制带走: +// 副本的 name 已改写,沿用源分组的名称译文会让两个分组在该语言下同名。 +func cloneGroupI18nForDuplicate(in GroupI18n) GroupI18n { + out := make(GroupI18n, len(in)) + for locale, text := range in { + if text.Description == "" { + continue + } + out[locale] = GroupLocaleText{Description: text.Description} + } + return out +} diff --git a/backend/internal/service/group_i18n_test.go b/backend/internal/service/group_i18n_test.go new file mode 100644 index 0000000000..009c9a5d89 --- /dev/null +++ b/backend/internal/service/group_i18n_test.go @@ -0,0 +1,61 @@ +package service + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNormalizeGroupI18n(t *testing.T) { + t.Run("nil input yields an empty non-nil map", func(t *testing.T) { + got, err := normalizeGroupI18n(nil) + require.NoError(t, err) + require.NotNil(t, got) + require.Empty(t, got) + }) + + t.Run("trims text and drops locales with nothing left", func(t *testing.T) { + got, err := normalizeGroupI18n(GroupI18n{ + "en": {Name: " Standard ", Description: "\tPro + Max pool\n"}, + "zh": {Name: " ", Description: ""}, + "zh-TW": {Description: "僅描述"}, + }) + require.NoError(t, err) + require.Equal(t, GroupI18n{ + "en": {Name: "Standard", Description: "Pro + Max pool"}, + "zh-TW": {Description: "僅描述"}, + }, got) + }) + + 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"}}) + require.Errorf(t, err, "locale %q should be rejected", locale) + } + }) + + t.Run("name length is capped in characters like groups.name", func(t *testing.T) { + atLimit := strings.Repeat("名", groupI18nNameMaxRunes) + got, err := normalizeGroupI18n(GroupI18n{"zh": {Name: atLimit}}) + require.NoError(t, err) + require.Equal(t, atLimit, got["zh"].Name) + + _, err = normalizeGroupI18n(GroupI18n{"zh": {Name: atLimit + "名"}}) + require.Error(t, err) + }) +} + +func TestCloneGroupI18nForDuplicate(t *testing.T) { + source := GroupI18n{ + "en": {Name: "Standard", Description: "Pro + Max pool"}, + "zh": {Name: "标准"}, + } + + got := cloneGroupI18nForDuplicate(source) + + // 副本改了 name;名称译文若照搬,副本与源分组在该语言下会同名。 + require.Equal(t, GroupI18n{"en": {Description: "Pro + Max pool"}}, got) + require.Equal(t, "Standard", source["en"].Name, "source must not be mutated") + require.NotNil(t, cloneGroupI18nForDuplicate(nil)) +} diff --git a/backend/internal/service/model_plaza_service.go b/backend/internal/service/model_plaza_service.go index 0f24a13f1e..37ef1e303d 100644 --- a/backend/internal/service/model_plaza_service.go +++ b/backend/internal/service/model_plaza_service.go @@ -40,6 +40,7 @@ type PlazaGroup struct { ID int64 Name string Description string + I18n GroupI18n Platform string SubscriptionType string RateMultiplier float64 @@ -125,6 +126,7 @@ func (s *ModelPlazaService) ListGroups(ctx context.Context) ([]PlazaGroup, error ID: g.ID, Name: g.Name, Description: g.Description, + I18n: g.I18n, Platform: g.Platform, SubscriptionType: g.SubscriptionType, RateMultiplier: g.RateMultiplier, diff --git a/backend/internal/service/payment_config_plans.go b/backend/internal/service/payment_config_plans.go index 095730c1e8..6ddfb4bfc9 100644 --- a/backend/internal/service/payment_config_plans.go +++ b/backend/internal/service/payment_config_plans.go @@ -86,6 +86,9 @@ type PlanGroupInfo struct { WeeklyLimitUSD *float64 `json:"weekly_limit_usd"` MonthlyLimitUSD *float64 `json:"monthly_limit_usd"` ModelScopes []string `json:"supported_model_scopes"` + + // 不对外输出;handler 按请求语言用它换出 Name 的译文。 + I18n GroupI18n `json:"-"` } // GetGroupInfoMap returns a map of group_id → PlanGroupInfo for the given plans. @@ -119,6 +122,7 @@ func (s *PaymentConfigService) GetGroupInfoMap(ctx context.Context, plans []*dbe WeeklyLimitUSD: g.WeeklyLimitUsd, MonthlyLimitUSD: g.MonthlyLimitUsd, ModelScopes: g.SupportedModelScopes, + I18n: g.I18n, } } return m diff --git a/backend/migrations/242_group_i18n.sql b/backend/migrations/242_group_i18n.sql new file mode 100644 index 0000000000..2e0ab2c63a --- /dev/null +++ b/backend/migrations/242_group_i18n.sql @@ -0,0 +1,5 @@ +ALTER TABLE groups + ADD COLUMN IF NOT EXISTS i18n JSONB NOT NULL DEFAULT '{}'::jsonb; + +COMMENT ON COLUMN groups.i18n IS + 'Per-locale display text for the group: {"":{"name":string,"description":string}}; locales or fields that are absent fall back to groups.name / groups.description'; diff --git a/frontend/src/components/admin/group/GroupI18nField.vue b/frontend/src/components/admin/group/GroupI18nField.vue new file mode 100644 index 0000000000..6605b6d904 --- /dev/null +++ b/frontend/src/components/admin/group/GroupI18nField.vue @@ -0,0 +1,179 @@ + + + diff --git a/frontend/src/components/admin/group/__tests__/GroupI18nField.spec.ts b/frontend/src/components/admin/group/__tests__/GroupI18nField.spec.ts new file mode 100644 index 0000000000..c91176a077 --- /dev/null +++ b/frontend/src/components/admin/group/__tests__/GroupI18nField.spec.ts @@ -0,0 +1,197 @@ +import { enableAutoUnmount, mount } from '@vue/test-utils' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, h, reactive } from 'vue' + +import GroupI18nField from '../GroupI18nField.vue' +import type { GroupI18n } from '@/types' + +vi.mock('vue-i18n', async () => { + const actual = await vi.importActual('vue-i18n') + return { + ...actual, + useI18n: () => ({ + t: (key: string, params?: Record) => + params ? `${key}:${params.language}` : key + }) + } +}) + +// 用例把组件挂到 document.body 上以断言焦点,逐例卸载避免互相残留 +enableAutoUnmount(afterEach) + +// 挂载一个 v-model 双向绑定的宿主,贴近分组表单里的真实用法。 +const mountField = ( + initial: { text: string; i18n: GroupI18n }, + fieldProps: Record = {} +) => { + const state = reactive({ text: initial.text, i18n: initial.i18n }) + const host = defineComponent({ + setup() { + return () => + h(GroupI18nField, { + label: 'Name', + field: 'name', + ...fieldProps, + modelValue: state.text, + 'onUpdate:modelValue': (value: string) => { + state.text = value + }, + i18n: state.i18n, + 'onUpdate:i18n': (value: GroupI18n) => { + state.i18n = value + } + }) + } + }) + return { wrapper: mount(host, { attachTo: document.body }), state } +} + +type Wrapper = ReturnType['wrapper'] + +const trigger = (wrapper: Wrapper) => wrapper.get('[data-testid="group-i18n-menu"]') +const menuItems = (wrapper: Wrapper) => wrapper.findAll('button[data-locale]') +const choose = async (wrapper: Wrapper, locale: string) => { + await trigger(wrapper).trigger('click') + await wrapper.get(`button[data-locale="${locale}"]`).trigger('click') +} + +describe('GroupI18nField', () => { + it('renders the label and the switcher on one row above a single input', () => { + const { wrapper } = mountField({ text: '标准', i18n: { en: { name: 'Standard' } } }) + + const labelRow = wrapper.get('label').element.parentElement! + expect(wrapper.get('label').text()).toBe('Name') + expect(labelRow.contains(trigger(wrapper).element)).toBe(true) + expect(labelRow.contains(wrapper.get('input').element)).toBe(false) + expect(wrapper.findAll('input')).toHaveLength(1) + }) + + it('starts on the default content', async () => { + const { wrapper, state } = mountField({ text: '标准', i18n: { en: { name: 'Standard' } } }) + const input = wrapper.get('input') + + expect(input.element.value).toBe('标准') + expect(trigger(wrapper).text()).toBe('admin.groups.i18nField.default') + + await input.setValue('标准版') + + expect(state.text).toBe('标准版') + expect(state.i18n).toEqual({ en: { name: 'Standard' } }) + }) + + it('lists the default content and an add or edit entry per interface language', async () => { + const { wrapper } = mountField({ + text: '标准', + i18n: { en: { name: 'Standard' }, zh: { description: '仅描述' } } + }) + + await trigger(wrapper).trigger('click') + + expect(menuItems(wrapper).map((item) => [item.attributes('data-locale'), item.text()])).toEqual([ + ['default', 'admin.groups.i18nField.default'], + ['en', 'admin.groups.i18nField.edit:English'], + ['zh', 'admin.groups.i18nField.add:中文'] + ]) + }) + + it('switches the same input to the chosen language version', async () => { + const { wrapper, state } = mountField({ text: '标准', i18n: { en: { name: 'Standard' } } }) + + await choose(wrapper, 'en') + + expect(menuItems(wrapper)).toHaveLength(0) + expect(wrapper.findAll('input')).toHaveLength(1) + const input = wrapper.get('input') + expect(input.element.value).toBe('Standard') + expect(document.activeElement).toBe(input.element) + expect(trigger(wrapper).text()).toBe('English') + + await input.setValue('Standard v2') + + expect(state.text).toBe('标准') + expect(state.i18n).toEqual({ en: { name: 'Standard v2' } }) + }) + + it('starts a new language version empty and keeps the other field of that language', async () => { + const { wrapper, state } = mountField({ + text: '标准', + i18n: { en: { description: 'Pro + Max pool' } } + }) + + await choose(wrapper, 'en') + const input = wrapper.get('input') + expect(input.element.value).toBe('') + expect(input.attributes('placeholder')).toBe('admin.groups.i18nField.placeholder:English') + + await input.setValue('Standard') + + expect(state.i18n).toEqual({ en: { name: 'Standard', description: 'Pro + Max pool' } }) + }) + + it('returns to the default content from a language version', async () => { + const { wrapper } = mountField({ text: '标准', i18n: { en: { name: 'Standard' } } }) + + await choose(wrapper, 'en') + await choose(wrapper, 'zh') + expect(wrapper.get('input').element.value).toBe('') + + await choose(wrapper, 'default') + + expect(wrapper.get('input').element.value).toBe('标准') + expect(trigger(wrapper).text()).toBe('admin.groups.i18nField.default') + }) + + it('marks the version being edited in the menu', async () => { + const { wrapper } = mountField({ text: '标准', i18n: {} }) + + await choose(wrapper, 'zh') + await trigger(wrapper).trigger('click') + + const checked = menuItems(wrapper) + .filter((item) => item.find('svg').exists()) + .map((item) => item.attributes('data-locale')) + expect(checked).toEqual(['zh']) + }) + + it('requires only the default content and keeps the form placeholder for it', async () => { + const { wrapper } = mountField( + { text: '', i18n: {} }, + { required: true, placeholder: 'Enter name', 'data-tour': 'group-form-name' } + ) + const input = wrapper.get('input') + expect(input.attributes('required')).toBeDefined() + expect(input.attributes('placeholder')).toBe('Enter name') + expect(input.attributes('data-tour')).toBe('group-form-name') + + await choose(wrapper, 'en') + + expect(wrapper.get('input').attributes('required')).toBeUndefined() + }) + + it('switches a single textarea for multiline fields', async () => { + const { wrapper, state } = mountField( + { text: '号池', i18n: {} }, + { field: 'description', multiline: true, rows: '3' } + ) + expect(wrapper.findAll('textarea')).toHaveLength(1) + expect(wrapper.get('textarea').attributes('rows')).toBe('3') + + await choose(wrapper, 'en') + await wrapper.get('textarea').setValue('Pro + Max pool') + + expect(wrapper.findAll('textarea')).toHaveLength(1) + expect(state.i18n).toEqual({ en: { description: 'Pro + Max pool' } }) + expect(state.text).toBe('号池') + }) + + it('closes the menu when clicking elsewhere', async () => { + const { wrapper } = mountField({ text: '标准', i18n: {} }) + await trigger(wrapper).trigger('click') + expect(menuItems(wrapper)).toHaveLength(3) + + document.body.click() + await wrapper.vm.$nextTick() + + expect(menuItems(wrapper)).toHaveLength(0) + }) +}) diff --git a/frontend/src/components/common/LocaleSwitcher.vue b/frontend/src/components/common/LocaleSwitcher.vue index 417e5a23b2..5dd2fb35f4 100644 --- a/frontend/src/components/common/LocaleSwitcher.vue +++ b/frontend/src/components/common/LocaleSwitcher.vue @@ -69,6 +69,8 @@ async function selectLocale(code: string) { try { await setLocale(code) isOpen.value = false + // 分组名称等内容由后端按请求语言返回,整页刷新让已加载的数据换成新语言 + window.location.reload() } finally { switching.value = false } diff --git a/frontend/src/components/common/__tests__/LocaleSwitcher.spec.ts b/frontend/src/components/common/__tests__/LocaleSwitcher.spec.ts new file mode 100644 index 0000000000..ddd9b4e44a --- /dev/null +++ b/frontend/src/components/common/__tests__/LocaleSwitcher.spec.ts @@ -0,0 +1,60 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import LocaleSwitcher from '../LocaleSwitcher.vue' + +const { setLocale } = vi.hoisted(() => ({ setLocale: vi.fn() })) + +vi.mock('@/i18n', () => ({ + setLocale, + availableLocales: [ + { code: 'en', name: 'English', flag: '🇺🇸' }, + { code: 'zh', name: '中文', flag: '🇨🇳' } + ] +})) + +vi.mock('vue-i18n', () => ({ useI18n: () => ({ locale: { value: 'zh' } }) })) + +const reload = vi.fn() + +const mountSwitcher = () => + mount(LocaleSwitcher, { global: { stubs: { Icon: true, transition: false } } }) + +const choose = async (wrapper: ReturnType, name: string) => { + await wrapper.get('button').trigger('click') + const option = wrapper.findAll('button').find((button) => button.text().includes(name)) + await option!.trigger('click') + await flushPromises() +} + +describe('LocaleSwitcher', () => { + beforeEach(() => { + setLocale.mockReset().mockResolvedValue(undefined) + reload.mockReset() + vi.stubGlobal('location', { ...window.location, reload }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + // 分组名称等内容由后端按请求语言返回,已加载的数据要靠整页刷新换成新语言 + it('reloads the page after switching to another language', async () => { + const wrapper = mountSwitcher() + + await choose(wrapper, 'English') + + expect(setLocale).toHaveBeenCalledWith('en') + expect(reload).toHaveBeenCalledTimes(1) + expect(setLocale.mock.invocationCallOrder[0]).toBeLessThan(reload.mock.invocationCallOrder[0]) + }) + + it('does nothing when the current language is chosen again', async () => { + const wrapper = mountSwitcher() + + await choose(wrapper, '中文') + + expect(setLocale).not.toHaveBeenCalled() + expect(reload).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index e6e07ddd65..1b79627bd0 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -973,6 +973,12 @@ export default { yes: 'Yes', no: 'No' }, + i18nField: { + default: 'Default', + add: 'Add {language} version', + edit: 'Edit {language} version', + placeholder: '{language} version; leave empty to show the default text' + }, enterGroupName: 'Enter group name', optionalDescription: 'Optional description', platformHint: 'Select the platform this group is associated with', diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index 85426f04b6..9088319bc6 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -1007,6 +1007,12 @@ export default { privacySetOnlyEnabled: '已启用 — Privacy 未设置的账号将被排除', disabled: '未启用' }, + i18nField: { + default: '默认', + add: '新增 {language} 版', + edit: '编辑 {language} 版', + placeholder: '{language} 版,留空则显示默认内容' + }, enterGroupName: '请输入分组名称', optionalDescription: '可选描述', platformHint: '选择此分组关联的平台', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index cfd3f2f167..7ccdf383ef 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -564,6 +564,16 @@ export interface ReasoningEffortMapping { model?: string } +/** 分组名称与描述在某个语言下的译文;字段为空表示沿用分组自身的 name/description。 */ +export interface GroupLocaleText { + name?: string + description?: string +} + +/** 以语言代码(en、zh…)为键的分组文案译文。 */ +export type GroupI18n = Record + +// 用户侧接口返回的 name / description 已由后端按请求语言(Accept-Language)选好 export interface Group { id: number name: string @@ -627,6 +637,8 @@ export interface Group { } export interface AdminGroup extends Group { + // 管理端的 name / description 始终是原字段,各语言译文另放在 i18n 里 + i18n?: GroupI18n force_openai_fast: boolean free_openai_fast: boolean model_pricing: import('@/api/admin/channels').ChannelModelPricing[] @@ -793,6 +805,7 @@ export interface UpdateApiKeyRequest { export interface CreateGroupRequest { name: string description?: string | null + i18n?: GroupI18n platform?: GroupPlatform rate_multiplier?: number is_exclusive?: boolean @@ -858,6 +871,7 @@ export interface CreateGroupRequest { export interface UpdateGroupRequest { name?: string description?: string | null + i18n?: GroupI18n platform?: GroupPlatform rate_multiplier?: number is_exclusive?: boolean diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index 3a0714cf89..3fb2c95cd5 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -488,26 +488,26 @@ class="space-y-5" >
- -
- - + />