mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 03:52:26 +08:00
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:
+14
-1
@@ -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(", ")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) 时,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '选择此分组关联的平台',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user