feat(groups): 分组名称与描述支持多语言

- groups 新增 i18n JSONB 列(迁移 242):语言代码 -> {name, description},只存译文;
  name/description 原列语义不变,仍用于唯一性校验、搜索与各类日志
- 用户侧接口由后端按请求语言输出:取 Accept-Language 中权重最高的一项,先按完整
  语言代码、再按主语言匹配;该语言有译文的字段换成译文,否则保持原字段,且不再带出
  译文表。覆盖密钥、可用分组、订阅(含进度与汇总)、兑换、使用记录、可用渠道、
  模型广场、套餐列表
- 管理端接口一律返回原字段;分组管理接口额外带 i18n 供编辑
- 创建/编辑分组:名称、描述的标题行右侧新增语言下拉,同一个输入框在默认内容与各语言
  版本之间切换;语种跟随前端 availableLocales
- 管理端写入:创建/更新接受 i18n(更新时缺省不修改,传入则整体替换),校验语言代码
  形态与名称长度并丢弃空译文;简易模式同样放行;复制分组只带走描述译文
- 切换界面语言后整页刷新,让已加载的数据换成新语言;模型广场响应加
  Vary: Accept-Language
- 网关路径,以及只带 group_name 字符串的统计、错误记录出口不做本地化
This commit is contained in:
feeeei
2026-10-09 11:39:18 +08:00
parent f2669c8cf6
commit 9a51333101
45 changed files with 1459 additions and 98 deletions
+14 -1
View File
@@ -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(", ")
+5
View File
@@ -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.
+57
View File
@@ -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) {
+18
View File
@@ -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)
}
+7 -6
View File
@@ -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",
},
+55 -1
View File
@@ -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
+50 -46
View File
@@ -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()
+5
View File
@@ -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),
+81
View File
@@ -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
}
@@ -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)
}
}
@@ -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,
+5 -5
View File
@@ -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)
}
@@ -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,
@@ -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
}
@@ -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)
}
+1
View File
@@ -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,
+4
View File
@@ -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"`
@@ -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")
}
@@ -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))
+2 -2
View File
@@ -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,
+3 -3
View File
@@ -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)
}
@@ -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
}
+2 -2
View File
@@ -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
@@ -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,
+10
View File
@@ -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)
@@ -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",
+14 -2
View File
@@ -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
}
@@ -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,
@@ -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
@@ -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)
}
@@ -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,
+1
View File
@@ -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) 时,
+54
View File
@@ -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
}
@@ -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))
}
@@ -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,
@@ -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
+5
View File
@@ -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: {"<locale>":{"name":string,"description":string}}; locales or fields that are absent fall back to groups.name / groups.description';
@@ -0,0 +1,179 @@
<template>
<div>
<div class="mb-1.5 flex items-center justify-between gap-3">
<label class="input-label mb-0">{{ label }}</label>
<div ref="menuRef" class="relative">
<button
type="button"
data-testid="group-i18n-menu"
class="flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs font-medium transition-colors"
:class="
activeLocale
? 'bg-primary-50 text-primary-600 dark:bg-primary-900/20 dark:text-primary-400'
: 'text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:text-dark-400 dark:hover:bg-dark-700 dark:hover:text-gray-200'
"
:aria-expanded="menuOpen"
@click="menuOpen = !menuOpen"
>
<Icon name="globe" size="sm" />
<span>{{ activeLabel }}</span>
<Icon
name="chevronDown"
size="xs"
class="transition-transform duration-200"
:class="{ 'rotate-180': menuOpen }"
/>
</button>
<div v-if="menuOpen" class="dropdown right-0 mt-1 min-w-[10rem]">
<button
v-for="option in options"
:key="option.code"
type="button"
:data-locale="option.code || 'default'"
class="dropdown-item w-full whitespace-nowrap text-left"
:class="{
'bg-primary-50 text-primary-600 dark:bg-primary-900/20 dark:text-primary-400':
option.active
}"
@click="selectLocale(option.code)"
>
<span>{{ option.label }}</span>
<Icon v-if="option.active" name="check" size="sm" class="ml-auto text-primary-500" />
</button>
</div>
</div>
</div>
<textarea
v-if="multiline"
v-bind="$attrs"
ref="controlRef"
class="input"
:value="currentValue"
:required="required && !activeLocale"
:placeholder="currentPlaceholder"
@input="onInput"
></textarea>
<input
v-else
v-bind="$attrs"
ref="controlRef"
type="text"
class="input"
:value="currentValue"
:required="required && !activeLocale"
:placeholder="currentPlaceholder"
@input="onInput"
/>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue'
import { availableLocales } from '@/i18n'
import type { GroupI18n } from '@/types'
// 透传的属性(rows、data-tour 等)落在输入控件上,而不是外层容器
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
label: string
/** 默认内容,即分组自身的 name / description */
modelValue: string
/** 全部语言的译文;本组件只读写其中的 field 字段 */
i18n: GroupI18n
field: 'name' | 'description'
multiline?: boolean
/** 仅约束默认内容,译文始终可留空 */
required?: boolean
placeholder?: string
}>(),
{ multiline: false, required: false, placeholder: '' }
)
const emit = defineEmits<{
'update:modelValue': [value: string]
'update:i18n': [value: GroupI18n]
}>()
const { t } = useI18n()
// 输入控件当前编辑的版本:空串为默认内容,其余为界面语言代码
const activeLocale = ref('')
const options = computed(() => [
{
code: '',
label: t('admin.groups.i18nField.default'),
active: activeLocale.value === ''
},
...availableLocales.map((locale) => ({
code: locale.code as string,
label: t(
props.i18n[locale.code]?.[props.field]
? 'admin.groups.i18nField.edit'
: 'admin.groups.i18nField.add',
{ language: locale.name }
),
active: activeLocale.value === locale.code
}))
])
const activeLocaleName = computed(
() => availableLocales.find((locale) => locale.code === activeLocale.value)?.name ?? ''
)
const activeLabel = computed(
() => activeLocaleName.value || t('admin.groups.i18nField.default')
)
const currentValue = computed(() =>
activeLocale.value ? (props.i18n[activeLocale.value]?.[props.field] ?? '') : props.modelValue
)
const currentPlaceholder = computed(() =>
activeLocale.value
? t('admin.groups.i18nField.placeholder', { language: activeLocaleName.value })
: props.placeholder
)
function onInput(event: Event) {
const value = (event.target as HTMLInputElement | HTMLTextAreaElement).value
if (!activeLocale.value) {
emit('update:modelValue', value)
return
}
emit('update:i18n', {
...props.i18n,
[activeLocale.value]: { ...props.i18n[activeLocale.value], [props.field]: value }
})
}
const controlRef = ref<HTMLInputElement | HTMLTextAreaElement | null>(null)
async function selectLocale(code: string) {
menuOpen.value = false
activeLocale.value = code
await nextTick()
controlRef.value?.focus()
}
const menuOpen = ref(false)
const menuRef = ref<HTMLElement | null>(null)
function handleClickOutside(event: MouseEvent) {
if (menuRef.value && !menuRef.value.contains(event.target as Node)) {
menuOpen.value = false
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>
@@ -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<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string, params?: Record<string, unknown>) =>
params ? `${key}:${params.language}` : key
})
}
})
// 用例把组件挂到 document.body 上以断言焦点,逐例卸载避免互相残留
enableAutoUnmount(afterEach)
// 挂载一个 v-model 双向绑定的宿主,贴近分组表单里的真实用法。
const mountField = (
initial: { text: string; i18n: GroupI18n },
fieldProps: Record<string, unknown> = {}
) => {
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<typeof mountField>['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)
})
})
@@ -69,6 +69,8 @@ async function selectLocale(code: string) {
try {
await setLocale(code)
isOpen.value = false
// 分组名称等内容由后端按请求语言返回,整页刷新让已加载的数据换成新语言
window.location.reload()
} finally {
switching.value = false
}
@@ -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<typeof mountSwitcher>, 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()
})
})
@@ -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',
@@ -1007,6 +1007,12 @@ export default {
privacySetOnlyEnabled: '已启用 — Privacy 未设置的账号将被排除',
disabled: '未启用'
},
i18nField: {
default: '默认',
add: '新增 {language} 版',
edit: '编辑 {language} 版',
placeholder: '{language} 版,留空则显示默认内容'
},
enterGroupName: '请输入分组名称',
optionalDescription: '可选描述',
platformHint: '选择此分组关联的平台',
+14
View File
@@ -564,6 +564,16 @@ export interface ReasoningEffortMapping {
model?: string
}
/** 分组名称与描述在某个语言下的译文;字段为空表示沿用分组自身的 name/description。 */
export interface GroupLocaleText {
name?: string
description?: string
}
/** 以语言代码(en、zh…)为键的分组文案译文。 */
export type GroupI18n = Record<string, GroupLocaleText>
// 用户侧接口返回的 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
+33 -20
View File
@@ -488,26 +488,26 @@
class="space-y-5"
>
<div>
<label class="input-label">{{ t("admin.groups.form.name") }}</label>
<input
<GroupI18nField
v-model="createForm.name"
type="text"
v-model:i18n="createForm.i18n"
:label="t('admin.groups.form.name')"
field="name"
required
class="input"
:placeholder="t('admin.groups.enterGroupName')"
data-tour="group-form-name"
/>
</div>
<div>
<label class="input-label">{{
t("admin.groups.form.description")
}}</label>
<textarea
<GroupI18nField
v-model="createForm.description"
v-model:i18n="createForm.i18n"
:label="t('admin.groups.form.description')"
field="description"
multiline
rows="3"
class="input"
:placeholder="t('admin.groups.optionalDescription')"
></textarea>
/>
</div>
<div>
<label class="input-label">{{
@@ -2127,24 +2127,24 @@
class="space-y-5"
>
<div>
<label class="input-label">{{ t("admin.groups.form.name") }}</label>
<input
<GroupI18nField
v-model="editForm.name"
type="text"
v-model:i18n="editForm.i18n"
:label="t('admin.groups.form.name')"
field="name"
required
class="input"
data-tour="edit-group-form-name"
/>
</div>
<div>
<label class="input-label">{{
t("admin.groups.form.description")
}}</label>
<textarea
<GroupI18nField
v-model="editForm.description"
v-model:i18n="editForm.i18n"
:label="t('admin.groups.form.description')"
field="description"
multiline
rows="3"
class="input"
></textarea>
/>
</div>
<div>
<label class="input-label">{{
@@ -4279,6 +4279,7 @@ import type {
CompositeRouteDecision,
CompositeRouteEndpoint,
CompositeRouteMatchType,
GroupI18n,
GroupPlatform,
SubscriptionType,
} from "@/types";
@@ -4299,6 +4300,7 @@ import Select from "@/components/common/Select.vue";
import PlatformIcon from "@/components/common/PlatformIcon.vue";
import { platformLabel } from "@/utils/platformColors";
import Icon from "@/components/icons/Icon.vue";
import GroupI18nField from "@/components/admin/group/GroupI18nField.vue";
import GroupRateMultipliersModal from "@/components/admin/group/GroupRateMultipliersModal.vue";
import GroupRPMOverridesModal from "@/components/admin/group/GroupRPMOverridesModal.vue";
import GroupCapacityBadge from "@/components/common/GroupCapacityBadge.vue";
@@ -4941,6 +4943,7 @@ const submitEditAllowlistCustomEntry = () => {
const createForm = reactive({
name: "",
description: "",
i18n: {} as GroupI18n,
platform: "anthropic" as GroupPlatform,
rate_multiplier: 1.0,
is_exclusive: false,
@@ -5305,6 +5308,7 @@ const convertApiFormatToRoutingRules = async (
const editForm = reactive({
name: "",
description: "",
i18n: {} as GroupI18n,
platform: "anthropic" as GroupPlatform,
rate_multiplier: 1.0,
is_exclusive: false,
@@ -5768,6 +5772,7 @@ const closeCreateModal = () => {
clearAllAccountSearchState();
createForm.name = "";
createForm.description = "";
createForm.i18n = {};
createForm.platform = "anthropic";
createForm.rate_multiplier = 1.0;
createForm.is_exclusive = false;
@@ -6023,6 +6028,7 @@ const handleCreateGroup = async () => {
? {
name: createForm.name,
description: createForm.description,
i18n: createForm.i18n,
platform: createForm.platform,
}
: requestData;
@@ -6049,6 +6055,12 @@ const handleEdit = async (group: AdminGroup) => {
editingGroup.value = group;
editForm.name = group.name;
editForm.description = group.description || "";
editForm.i18n = Object.fromEntries(
Object.entries(group.i18n ?? {}).map(([locale, text]) => [
locale,
{ ...text },
]),
);
editForm.platform = group.platform;
editForm.rate_multiplier = group.rate_multiplier;
editForm.is_exclusive = group.is_exclusive;
@@ -6374,6 +6386,7 @@ const handleUpdateGroup = async () => {
? {
name: editForm.name,
description: editForm.description,
i18n: editForm.i18n,
}
: payload;
await adminAPI.groups.update(editingGroup.value.id, requestData);