From 915c60b150b6e8821bdd66d98980ff8db4612d9f Mon Sep 17 00:00:00 2001 From: xueshiji Date: Tue, 30 Jun 2026 14:17:05 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat(group):=20=E8=AE=A2=E9=98=85=E5=88=86?= =?UTF-8?q?=E7=BB=84=E6=96=B0=E5=A2=9E=E5=8F=AF=E9=80=89=E7=9A=84=E9=AB=98?= =?UTF-8?q?=E5=B3=B0=E6=97=B6=E6=AE=B5=E5=80=8D=E7=8E=87=EF=BC=8C=E4=BB=A5?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=99=BA=E8=B0=B1=E7=AD=89coding=20plan?= =?UTF-8?q?=E7=9A=84=E9=AB=98=E5=B3=B0=E6=97=B6=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/ent/group.go | 50 ++- backend/ent/group/group.go | 44 +++ backend/ent/group/where.go | 200 ++++++++++++ backend/ent/group_create.go | 290 ++++++++++++++++++ backend/ent/group_update.go | 176 +++++++++++ backend/ent/migrate/schema.go | 14 +- backend/ent/mutation.go | 251 ++++++++++++++- backend/ent/runtime/runtime.go | 60 ++-- backend/ent/schema/group.go | 16 + .../internal/handler/admin/group_handler.go | 28 ++ backend/internal/handler/dto/mappers.go | 4 + backend/internal/handler/dto/types.go | 17 +- backend/internal/repository/api_key_repo.go | 8 + backend/internal/repository/group_repo.go | 12 +- backend/internal/service/admin_service.go | 59 +++- .../internal/service/api_key_auth_cache.go | 8 + .../service/api_key_auth_cache_impl.go | 10 +- backend/internal/service/gateway_service.go | 5 +- backend/internal/service/group.go | 86 +++++- .../internal/service/group_peak_rate_test.go | 238 ++++++++++++++ .../service/openai_gateway_service.go | 5 +- .../158_add_group_peak_rate_multiplier.sql | 4 + frontend/src/types/index.ts | 13 + frontend/src/views/admin/GroupsView.vue | 156 +++++++++- 24 files changed, 1699 insertions(+), 55 deletions(-) create mode 100644 backend/internal/service/group_peak_rate_test.go create mode 100644 backend/migrations/158_add_group_peak_rate_multiplier.sql diff --git a/backend/ent/group.go b/backend/ent/group.go index 298df88abb..172b67777e 100644 --- a/backend/ent/group.go +++ b/backend/ent/group.go @@ -31,6 +31,14 @@ type Group struct { Description *string `json:"description,omitempty"` // RateMultiplier holds the value of the "rate_multiplier" field. RateMultiplier float64 `json:"rate_multiplier,omitempty"` + // 是否启用高峰时段倍率 + PeakRateEnabled bool `json:"peak_rate_enabled,omitempty"` + // 高峰开始时间 HH:MM(含),如 14:00;空表示未配置 + PeakStart string `json:"peak_start,omitempty"` + // 高峰结束时间 HH:MM(不含),如 18:00 + PeakEnd string `json:"peak_end,omitempty"` + // 高峰时段叠加倍率,仅在 peak_rate_enabled 且处于 [peak_start, peak_end) 时乘入文本倍率 + PeakRateMultiplier float64 `json:"peak_rate_multiplier,omitempty"` // IsExclusive holds the value of the "is_exclusive" field. IsExclusive bool `json:"is_exclusive,omitempty"` // Status holds the value of the "status" field. @@ -197,13 +205,13 @@ func (*Group) scanValues(columns []string) ([]any, error) { switch columns[i] { case group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig: values[i] = new([]byte) - case group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldImageRateIndependent, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet: + case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldImageRateIndependent, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet: values[i] = new(sql.NullBool) - case group.FieldRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImageRateMultiplier, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k: + case group.FieldRateMultiplier, group.FieldPeakRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImageRateMultiplier, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k: values[i] = new(sql.NullFloat64) case group.FieldID, group.FieldDefaultValidityDays, group.FieldFallbackGroupID, group.FieldFallbackGroupIDOnInvalidRequest, group.FieldSortOrder, group.FieldRpmLimit: values[i] = new(sql.NullInt64) - case group.FieldName, group.FieldDescription, group.FieldStatus, group.FieldPlatform, group.FieldSubscriptionType, group.FieldDefaultMappedModel: + case group.FieldName, group.FieldDescription, group.FieldPeakStart, group.FieldPeakEnd, group.FieldStatus, group.FieldPlatform, group.FieldSubscriptionType, group.FieldDefaultMappedModel: values[i] = new(sql.NullString) case group.FieldCreatedAt, group.FieldUpdatedAt, group.FieldDeletedAt: values[i] = new(sql.NullTime) @@ -266,6 +274,30 @@ func (_m *Group) assignValues(columns []string, values []any) error { } else if value.Valid { _m.RateMultiplier = value.Float64 } + case group.FieldPeakRateEnabled: + if value, ok := values[i].(*sql.NullBool); !ok { + return fmt.Errorf("unexpected type %T for field peak_rate_enabled", values[i]) + } else if value.Valid { + _m.PeakRateEnabled = value.Bool + } + case group.FieldPeakStart: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field peak_start", values[i]) + } else if value.Valid { + _m.PeakStart = value.String + } + case group.FieldPeakEnd: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field peak_end", values[i]) + } else if value.Valid { + _m.PeakEnd = value.String + } + case group.FieldPeakRateMultiplier: + if value, ok := values[i].(*sql.NullFloat64); !ok { + return fmt.Errorf("unexpected type %T for field peak_rate_multiplier", values[i]) + } else if value.Valid { + _m.PeakRateMultiplier = value.Float64 + } case group.FieldIsExclusive: if value, ok := values[i].(*sql.NullBool); !ok { return fmt.Errorf("unexpected type %T for field is_exclusive", values[i]) @@ -554,6 +586,18 @@ func (_m *Group) String() string { builder.WriteString("rate_multiplier=") builder.WriteString(fmt.Sprintf("%v", _m.RateMultiplier)) builder.WriteString(", ") + builder.WriteString("peak_rate_enabled=") + builder.WriteString(fmt.Sprintf("%v", _m.PeakRateEnabled)) + builder.WriteString(", ") + builder.WriteString("peak_start=") + builder.WriteString(_m.PeakStart) + builder.WriteString(", ") + builder.WriteString("peak_end=") + builder.WriteString(_m.PeakEnd) + builder.WriteString(", ") + builder.WriteString("peak_rate_multiplier=") + builder.WriteString(fmt.Sprintf("%v", _m.PeakRateMultiplier)) + builder.WriteString(", ") builder.WriteString("is_exclusive=") builder.WriteString(fmt.Sprintf("%v", _m.IsExclusive)) builder.WriteString(", ") diff --git a/backend/ent/group/group.go b/backend/ent/group/group.go index ebe9bd7e82..bc95af71b6 100644 --- a/backend/ent/group/group.go +++ b/backend/ent/group/group.go @@ -28,6 +28,14 @@ const ( FieldDescription = "description" // 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. + FieldPeakRateEnabled = "peak_rate_enabled" + // FieldPeakStart holds the string denoting the peak_start field in the database. + FieldPeakStart = "peak_start" + // FieldPeakEnd holds the string denoting the peak_end field in the database. + FieldPeakEnd = "peak_end" + // FieldPeakRateMultiplier holds the string denoting the peak_rate_multiplier field in the database. + FieldPeakRateMultiplier = "peak_rate_multiplier" // FieldIsExclusive holds the string denoting the is_exclusive field in the database. FieldIsExclusive = "is_exclusive" // FieldStatus holds the string denoting the status field in the database. @@ -167,6 +175,10 @@ var Columns = []string{ FieldName, FieldDescription, FieldRateMultiplier, + FieldPeakRateEnabled, + FieldPeakStart, + FieldPeakEnd, + FieldPeakRateMultiplier, FieldIsExclusive, FieldStatus, FieldPlatform, @@ -235,6 +247,18 @@ var ( NameValidator func(string) error // 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. + DefaultPeakRateEnabled bool + // DefaultPeakStart holds the default value on creation for the "peak_start" field. + DefaultPeakStart string + // PeakStartValidator is a validator for the "peak_start" field. It is called by the builders before save. + PeakStartValidator func(string) error + // DefaultPeakEnd holds the default value on creation for the "peak_end" field. + DefaultPeakEnd string + // PeakEndValidator is a validator for the "peak_end" field. It is called by the builders before save. + PeakEndValidator func(string) error + // DefaultPeakRateMultiplier holds the default value on creation for the "peak_rate_multiplier" field. + DefaultPeakRateMultiplier float64 // DefaultIsExclusive holds the default value on creation for the "is_exclusive" field. DefaultIsExclusive bool // DefaultStatus holds the default value on creation for the "status" field. @@ -323,6 +347,26 @@ func ByRateMultiplier(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldRateMultiplier, opts...).ToFunc() } +// ByPeakRateEnabled orders the results by the peak_rate_enabled field. +func ByPeakRateEnabled(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldPeakRateEnabled, opts...).ToFunc() +} + +// ByPeakStart orders the results by the peak_start field. +func ByPeakStart(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldPeakStart, opts...).ToFunc() +} + +// ByPeakEnd orders the results by the peak_end field. +func ByPeakEnd(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldPeakEnd, opts...).ToFunc() +} + +// ByPeakRateMultiplier orders the results by the peak_rate_multiplier field. +func ByPeakRateMultiplier(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldPeakRateMultiplier, opts...).ToFunc() +} + // ByIsExclusive orders the results by the is_exclusive field. func ByIsExclusive(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldIsExclusive, opts...).ToFunc() diff --git a/backend/ent/group/where.go b/backend/ent/group/where.go index d3223a9257..4a7fc01991 100644 --- a/backend/ent/group/where.go +++ b/backend/ent/group/where.go @@ -85,6 +85,26 @@ func RateMultiplier(v float64) predicate.Group { return predicate.Group(sql.FieldEQ(FieldRateMultiplier, v)) } +// PeakRateEnabled applies equality check predicate on the "peak_rate_enabled" field. It's identical to PeakRateEnabledEQ. +func PeakRateEnabled(v bool) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldPeakRateEnabled, v)) +} + +// PeakStart applies equality check predicate on the "peak_start" field. It's identical to PeakStartEQ. +func PeakStart(v string) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldPeakStart, v)) +} + +// PeakEnd applies equality check predicate on the "peak_end" field. It's identical to PeakEndEQ. +func PeakEnd(v string) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldPeakEnd, v)) +} + +// PeakRateMultiplier applies equality check predicate on the "peak_rate_multiplier" field. It's identical to PeakRateMultiplierEQ. +func PeakRateMultiplier(v float64) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldPeakRateMultiplier, v)) +} + // IsExclusive applies equality check predicate on the "is_exclusive" field. It's identical to IsExclusiveEQ. func IsExclusive(v bool) predicate.Group { return predicate.Group(sql.FieldEQ(FieldIsExclusive, v)) @@ -520,6 +540,186 @@ func RateMultiplierLTE(v float64) predicate.Group { return predicate.Group(sql.FieldLTE(FieldRateMultiplier, v)) } +// PeakRateEnabledEQ applies the EQ predicate on the "peak_rate_enabled" field. +func PeakRateEnabledEQ(v bool) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldPeakRateEnabled, v)) +} + +// PeakRateEnabledNEQ applies the NEQ predicate on the "peak_rate_enabled" field. +func PeakRateEnabledNEQ(v bool) predicate.Group { + return predicate.Group(sql.FieldNEQ(FieldPeakRateEnabled, v)) +} + +// PeakStartEQ applies the EQ predicate on the "peak_start" field. +func PeakStartEQ(v string) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldPeakStart, v)) +} + +// PeakStartNEQ applies the NEQ predicate on the "peak_start" field. +func PeakStartNEQ(v string) predicate.Group { + return predicate.Group(sql.FieldNEQ(FieldPeakStart, v)) +} + +// PeakStartIn applies the In predicate on the "peak_start" field. +func PeakStartIn(vs ...string) predicate.Group { + return predicate.Group(sql.FieldIn(FieldPeakStart, vs...)) +} + +// PeakStartNotIn applies the NotIn predicate on the "peak_start" field. +func PeakStartNotIn(vs ...string) predicate.Group { + return predicate.Group(sql.FieldNotIn(FieldPeakStart, vs...)) +} + +// PeakStartGT applies the GT predicate on the "peak_start" field. +func PeakStartGT(v string) predicate.Group { + return predicate.Group(sql.FieldGT(FieldPeakStart, v)) +} + +// PeakStartGTE applies the GTE predicate on the "peak_start" field. +func PeakStartGTE(v string) predicate.Group { + return predicate.Group(sql.FieldGTE(FieldPeakStart, v)) +} + +// PeakStartLT applies the LT predicate on the "peak_start" field. +func PeakStartLT(v string) predicate.Group { + return predicate.Group(sql.FieldLT(FieldPeakStart, v)) +} + +// PeakStartLTE applies the LTE predicate on the "peak_start" field. +func PeakStartLTE(v string) predicate.Group { + return predicate.Group(sql.FieldLTE(FieldPeakStart, v)) +} + +// PeakStartContains applies the Contains predicate on the "peak_start" field. +func PeakStartContains(v string) predicate.Group { + return predicate.Group(sql.FieldContains(FieldPeakStart, v)) +} + +// PeakStartHasPrefix applies the HasPrefix predicate on the "peak_start" field. +func PeakStartHasPrefix(v string) predicate.Group { + return predicate.Group(sql.FieldHasPrefix(FieldPeakStart, v)) +} + +// PeakStartHasSuffix applies the HasSuffix predicate on the "peak_start" field. +func PeakStartHasSuffix(v string) predicate.Group { + return predicate.Group(sql.FieldHasSuffix(FieldPeakStart, v)) +} + +// PeakStartEqualFold applies the EqualFold predicate on the "peak_start" field. +func PeakStartEqualFold(v string) predicate.Group { + return predicate.Group(sql.FieldEqualFold(FieldPeakStart, v)) +} + +// PeakStartContainsFold applies the ContainsFold predicate on the "peak_start" field. +func PeakStartContainsFold(v string) predicate.Group { + return predicate.Group(sql.FieldContainsFold(FieldPeakStart, v)) +} + +// PeakEndEQ applies the EQ predicate on the "peak_end" field. +func PeakEndEQ(v string) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldPeakEnd, v)) +} + +// PeakEndNEQ applies the NEQ predicate on the "peak_end" field. +func PeakEndNEQ(v string) predicate.Group { + return predicate.Group(sql.FieldNEQ(FieldPeakEnd, v)) +} + +// PeakEndIn applies the In predicate on the "peak_end" field. +func PeakEndIn(vs ...string) predicate.Group { + return predicate.Group(sql.FieldIn(FieldPeakEnd, vs...)) +} + +// PeakEndNotIn applies the NotIn predicate on the "peak_end" field. +func PeakEndNotIn(vs ...string) predicate.Group { + return predicate.Group(sql.FieldNotIn(FieldPeakEnd, vs...)) +} + +// PeakEndGT applies the GT predicate on the "peak_end" field. +func PeakEndGT(v string) predicate.Group { + return predicate.Group(sql.FieldGT(FieldPeakEnd, v)) +} + +// PeakEndGTE applies the GTE predicate on the "peak_end" field. +func PeakEndGTE(v string) predicate.Group { + return predicate.Group(sql.FieldGTE(FieldPeakEnd, v)) +} + +// PeakEndLT applies the LT predicate on the "peak_end" field. +func PeakEndLT(v string) predicate.Group { + return predicate.Group(sql.FieldLT(FieldPeakEnd, v)) +} + +// PeakEndLTE applies the LTE predicate on the "peak_end" field. +func PeakEndLTE(v string) predicate.Group { + return predicate.Group(sql.FieldLTE(FieldPeakEnd, v)) +} + +// PeakEndContains applies the Contains predicate on the "peak_end" field. +func PeakEndContains(v string) predicate.Group { + return predicate.Group(sql.FieldContains(FieldPeakEnd, v)) +} + +// PeakEndHasPrefix applies the HasPrefix predicate on the "peak_end" field. +func PeakEndHasPrefix(v string) predicate.Group { + return predicate.Group(sql.FieldHasPrefix(FieldPeakEnd, v)) +} + +// PeakEndHasSuffix applies the HasSuffix predicate on the "peak_end" field. +func PeakEndHasSuffix(v string) predicate.Group { + return predicate.Group(sql.FieldHasSuffix(FieldPeakEnd, v)) +} + +// PeakEndEqualFold applies the EqualFold predicate on the "peak_end" field. +func PeakEndEqualFold(v string) predicate.Group { + return predicate.Group(sql.FieldEqualFold(FieldPeakEnd, v)) +} + +// PeakEndContainsFold applies the ContainsFold predicate on the "peak_end" field. +func PeakEndContainsFold(v string) predicate.Group { + return predicate.Group(sql.FieldContainsFold(FieldPeakEnd, v)) +} + +// PeakRateMultiplierEQ applies the EQ predicate on the "peak_rate_multiplier" field. +func PeakRateMultiplierEQ(v float64) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldPeakRateMultiplier, v)) +} + +// PeakRateMultiplierNEQ applies the NEQ predicate on the "peak_rate_multiplier" field. +func PeakRateMultiplierNEQ(v float64) predicate.Group { + return predicate.Group(sql.FieldNEQ(FieldPeakRateMultiplier, v)) +} + +// PeakRateMultiplierIn applies the In predicate on the "peak_rate_multiplier" field. +func PeakRateMultiplierIn(vs ...float64) predicate.Group { + return predicate.Group(sql.FieldIn(FieldPeakRateMultiplier, vs...)) +} + +// PeakRateMultiplierNotIn applies the NotIn predicate on the "peak_rate_multiplier" field. +func PeakRateMultiplierNotIn(vs ...float64) predicate.Group { + return predicate.Group(sql.FieldNotIn(FieldPeakRateMultiplier, vs...)) +} + +// PeakRateMultiplierGT applies the GT predicate on the "peak_rate_multiplier" field. +func PeakRateMultiplierGT(v float64) predicate.Group { + return predicate.Group(sql.FieldGT(FieldPeakRateMultiplier, v)) +} + +// PeakRateMultiplierGTE applies the GTE predicate on the "peak_rate_multiplier" field. +func PeakRateMultiplierGTE(v float64) predicate.Group { + return predicate.Group(sql.FieldGTE(FieldPeakRateMultiplier, v)) +} + +// PeakRateMultiplierLT applies the LT predicate on the "peak_rate_multiplier" field. +func PeakRateMultiplierLT(v float64) predicate.Group { + return predicate.Group(sql.FieldLT(FieldPeakRateMultiplier, v)) +} + +// PeakRateMultiplierLTE applies the LTE predicate on the "peak_rate_multiplier" field. +func PeakRateMultiplierLTE(v float64) predicate.Group { + return predicate.Group(sql.FieldLTE(FieldPeakRateMultiplier, v)) +} + // IsExclusiveEQ applies the EQ predicate on the "is_exclusive" field. func IsExclusiveEQ(v bool) predicate.Group { return predicate.Group(sql.FieldEQ(FieldIsExclusive, v)) diff --git a/backend/ent/group_create.go b/backend/ent/group_create.go index d5ed0c19a4..0f35f070ee 100644 --- a/backend/ent/group_create.go +++ b/backend/ent/group_create.go @@ -105,6 +105,62 @@ func (_c *GroupCreate) SetNillableRateMultiplier(v *float64) *GroupCreate { return _c } +// SetPeakRateEnabled sets the "peak_rate_enabled" field. +func (_c *GroupCreate) SetPeakRateEnabled(v bool) *GroupCreate { + _c.mutation.SetPeakRateEnabled(v) + return _c +} + +// SetNillablePeakRateEnabled sets the "peak_rate_enabled" field if the given value is not nil. +func (_c *GroupCreate) SetNillablePeakRateEnabled(v *bool) *GroupCreate { + if v != nil { + _c.SetPeakRateEnabled(*v) + } + return _c +} + +// SetPeakStart sets the "peak_start" field. +func (_c *GroupCreate) SetPeakStart(v string) *GroupCreate { + _c.mutation.SetPeakStart(v) + return _c +} + +// SetNillablePeakStart sets the "peak_start" field if the given value is not nil. +func (_c *GroupCreate) SetNillablePeakStart(v *string) *GroupCreate { + if v != nil { + _c.SetPeakStart(*v) + } + return _c +} + +// SetPeakEnd sets the "peak_end" field. +func (_c *GroupCreate) SetPeakEnd(v string) *GroupCreate { + _c.mutation.SetPeakEnd(v) + return _c +} + +// SetNillablePeakEnd sets the "peak_end" field if the given value is not nil. +func (_c *GroupCreate) SetNillablePeakEnd(v *string) *GroupCreate { + if v != nil { + _c.SetPeakEnd(*v) + } + return _c +} + +// SetPeakRateMultiplier sets the "peak_rate_multiplier" field. +func (_c *GroupCreate) SetPeakRateMultiplier(v float64) *GroupCreate { + _c.mutation.SetPeakRateMultiplier(v) + return _c +} + +// SetNillablePeakRateMultiplier sets the "peak_rate_multiplier" field if the given value is not nil. +func (_c *GroupCreate) SetNillablePeakRateMultiplier(v *float64) *GroupCreate { + if v != nil { + _c.SetPeakRateMultiplier(*v) + } + return _c +} + // SetIsExclusive sets the "is_exclusive" field. func (_c *GroupCreate) SetIsExclusive(v bool) *GroupCreate { _c.mutation.SetIsExclusive(v) @@ -640,6 +696,22 @@ func (_c *GroupCreate) defaults() error { v := group.DefaultRateMultiplier _c.mutation.SetRateMultiplier(v) } + if _, ok := _c.mutation.PeakRateEnabled(); !ok { + v := group.DefaultPeakRateEnabled + _c.mutation.SetPeakRateEnabled(v) + } + if _, ok := _c.mutation.PeakStart(); !ok { + v := group.DefaultPeakStart + _c.mutation.SetPeakStart(v) + } + if _, ok := _c.mutation.PeakEnd(); !ok { + v := group.DefaultPeakEnd + _c.mutation.SetPeakEnd(v) + } + if _, ok := _c.mutation.PeakRateMultiplier(); !ok { + v := group.DefaultPeakRateMultiplier + _c.mutation.SetPeakRateMultiplier(v) + } if _, ok := _c.mutation.IsExclusive(); !ok { v := group.DefaultIsExclusive _c.mutation.SetIsExclusive(v) @@ -742,6 +814,28 @@ func (_c *GroupCreate) check() error { if _, ok := _c.mutation.RateMultiplier(); !ok { return &ValidationError{Name: "rate_multiplier", err: errors.New(`ent: missing required field "Group.rate_multiplier"`)} } + if _, ok := _c.mutation.PeakRateEnabled(); !ok { + return &ValidationError{Name: "peak_rate_enabled", err: errors.New(`ent: missing required field "Group.peak_rate_enabled"`)} + } + if _, ok := _c.mutation.PeakStart(); !ok { + return &ValidationError{Name: "peak_start", err: errors.New(`ent: missing required field "Group.peak_start"`)} + } + if v, ok := _c.mutation.PeakStart(); ok { + if err := group.PeakStartValidator(v); err != nil { + return &ValidationError{Name: "peak_start", err: fmt.Errorf(`ent: validator failed for field "Group.peak_start": %w`, err)} + } + } + if _, ok := _c.mutation.PeakEnd(); !ok { + return &ValidationError{Name: "peak_end", err: errors.New(`ent: missing required field "Group.peak_end"`)} + } + if v, ok := _c.mutation.PeakEnd(); ok { + if err := group.PeakEndValidator(v); err != nil { + return &ValidationError{Name: "peak_end", err: fmt.Errorf(`ent: validator failed for field "Group.peak_end": %w`, err)} + } + } + if _, ok := _c.mutation.PeakRateMultiplier(); !ok { + return &ValidationError{Name: "peak_rate_multiplier", err: errors.New(`ent: missing required field "Group.peak_rate_multiplier"`)} + } if _, ok := _c.mutation.IsExclusive(); !ok { return &ValidationError{Name: "is_exclusive", err: errors.New(`ent: missing required field "Group.is_exclusive"`)} } @@ -873,6 +967,22 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) { _spec.SetField(group.FieldRateMultiplier, field.TypeFloat64, value) _node.RateMultiplier = value } + if value, ok := _c.mutation.PeakRateEnabled(); ok { + _spec.SetField(group.FieldPeakRateEnabled, field.TypeBool, value) + _node.PeakRateEnabled = value + } + if value, ok := _c.mutation.PeakStart(); ok { + _spec.SetField(group.FieldPeakStart, field.TypeString, value) + _node.PeakStart = value + } + if value, ok := _c.mutation.PeakEnd(); ok { + _spec.SetField(group.FieldPeakEnd, field.TypeString, value) + _node.PeakEnd = value + } + if value, ok := _c.mutation.PeakRateMultiplier(); ok { + _spec.SetField(group.FieldPeakRateMultiplier, field.TypeFloat64, value) + _node.PeakRateMultiplier = value + } if value, ok := _c.mutation.IsExclusive(); ok { _spec.SetField(group.FieldIsExclusive, field.TypeBool, value) _node.IsExclusive = value @@ -1223,6 +1333,60 @@ func (u *GroupUpsert) AddRateMultiplier(v float64) *GroupUpsert { return u } +// SetPeakRateEnabled sets the "peak_rate_enabled" field. +func (u *GroupUpsert) SetPeakRateEnabled(v bool) *GroupUpsert { + u.Set(group.FieldPeakRateEnabled, v) + return u +} + +// UpdatePeakRateEnabled sets the "peak_rate_enabled" field to the value that was provided on create. +func (u *GroupUpsert) UpdatePeakRateEnabled() *GroupUpsert { + u.SetExcluded(group.FieldPeakRateEnabled) + return u +} + +// SetPeakStart sets the "peak_start" field. +func (u *GroupUpsert) SetPeakStart(v string) *GroupUpsert { + u.Set(group.FieldPeakStart, v) + return u +} + +// UpdatePeakStart sets the "peak_start" field to the value that was provided on create. +func (u *GroupUpsert) UpdatePeakStart() *GroupUpsert { + u.SetExcluded(group.FieldPeakStart) + return u +} + +// SetPeakEnd sets the "peak_end" field. +func (u *GroupUpsert) SetPeakEnd(v string) *GroupUpsert { + u.Set(group.FieldPeakEnd, v) + return u +} + +// UpdatePeakEnd sets the "peak_end" field to the value that was provided on create. +func (u *GroupUpsert) UpdatePeakEnd() *GroupUpsert { + u.SetExcluded(group.FieldPeakEnd) + return u +} + +// SetPeakRateMultiplier sets the "peak_rate_multiplier" field. +func (u *GroupUpsert) SetPeakRateMultiplier(v float64) *GroupUpsert { + u.Set(group.FieldPeakRateMultiplier, v) + return u +} + +// UpdatePeakRateMultiplier sets the "peak_rate_multiplier" field to the value that was provided on create. +func (u *GroupUpsert) UpdatePeakRateMultiplier() *GroupUpsert { + u.SetExcluded(group.FieldPeakRateMultiplier) + return u +} + +// AddPeakRateMultiplier adds v to the "peak_rate_multiplier" field. +func (u *GroupUpsert) AddPeakRateMultiplier(v float64) *GroupUpsert { + u.Add(group.FieldPeakRateMultiplier, v) + return u +} + // SetIsExclusive sets the "is_exclusive" field. func (u *GroupUpsert) SetIsExclusive(v bool) *GroupUpsert { u.Set(group.FieldIsExclusive, v) @@ -1833,6 +1997,69 @@ func (u *GroupUpsertOne) UpdateRateMultiplier() *GroupUpsertOne { }) } +// SetPeakRateEnabled sets the "peak_rate_enabled" field. +func (u *GroupUpsertOne) SetPeakRateEnabled(v bool) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetPeakRateEnabled(v) + }) +} + +// UpdatePeakRateEnabled sets the "peak_rate_enabled" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdatePeakRateEnabled() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdatePeakRateEnabled() + }) +} + +// SetPeakStart sets the "peak_start" field. +func (u *GroupUpsertOne) SetPeakStart(v string) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetPeakStart(v) + }) +} + +// UpdatePeakStart sets the "peak_start" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdatePeakStart() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdatePeakStart() + }) +} + +// SetPeakEnd sets the "peak_end" field. +func (u *GroupUpsertOne) SetPeakEnd(v string) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetPeakEnd(v) + }) +} + +// UpdatePeakEnd sets the "peak_end" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdatePeakEnd() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdatePeakEnd() + }) +} + +// SetPeakRateMultiplier sets the "peak_rate_multiplier" field. +func (u *GroupUpsertOne) SetPeakRateMultiplier(v float64) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetPeakRateMultiplier(v) + }) +} + +// AddPeakRateMultiplier adds v to the "peak_rate_multiplier" field. +func (u *GroupUpsertOne) AddPeakRateMultiplier(v float64) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.AddPeakRateMultiplier(v) + }) +} + +// UpdatePeakRateMultiplier sets the "peak_rate_multiplier" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdatePeakRateMultiplier() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdatePeakRateMultiplier() + }) +} + // SetIsExclusive sets the "is_exclusive" field. func (u *GroupUpsertOne) SetIsExclusive(v bool) *GroupUpsertOne { return u.Update(func(s *GroupUpsert) { @@ -2688,6 +2915,69 @@ func (u *GroupUpsertBulk) UpdateRateMultiplier() *GroupUpsertBulk { }) } +// SetPeakRateEnabled sets the "peak_rate_enabled" field. +func (u *GroupUpsertBulk) SetPeakRateEnabled(v bool) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetPeakRateEnabled(v) + }) +} + +// UpdatePeakRateEnabled sets the "peak_rate_enabled" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdatePeakRateEnabled() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdatePeakRateEnabled() + }) +} + +// SetPeakStart sets the "peak_start" field. +func (u *GroupUpsertBulk) SetPeakStart(v string) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetPeakStart(v) + }) +} + +// UpdatePeakStart sets the "peak_start" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdatePeakStart() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdatePeakStart() + }) +} + +// SetPeakEnd sets the "peak_end" field. +func (u *GroupUpsertBulk) SetPeakEnd(v string) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetPeakEnd(v) + }) +} + +// UpdatePeakEnd sets the "peak_end" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdatePeakEnd() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdatePeakEnd() + }) +} + +// SetPeakRateMultiplier sets the "peak_rate_multiplier" field. +func (u *GroupUpsertBulk) SetPeakRateMultiplier(v float64) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetPeakRateMultiplier(v) + }) +} + +// AddPeakRateMultiplier adds v to the "peak_rate_multiplier" field. +func (u *GroupUpsertBulk) AddPeakRateMultiplier(v float64) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.AddPeakRateMultiplier(v) + }) +} + +// UpdatePeakRateMultiplier sets the "peak_rate_multiplier" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdatePeakRateMultiplier() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdatePeakRateMultiplier() + }) +} + // SetIsExclusive sets the "is_exclusive" field. func (u *GroupUpsertBulk) SetIsExclusive(v bool) *GroupUpsertBulk { return u.Update(func(s *GroupUpsert) { diff --git a/backend/ent/group_update.go b/backend/ent/group_update.go index c10d60ecb8..55555f323c 100644 --- a/backend/ent/group_update.go +++ b/backend/ent/group_update.go @@ -117,6 +117,69 @@ func (_u *GroupUpdate) AddRateMultiplier(v float64) *GroupUpdate { return _u } +// SetPeakRateEnabled sets the "peak_rate_enabled" field. +func (_u *GroupUpdate) SetPeakRateEnabled(v bool) *GroupUpdate { + _u.mutation.SetPeakRateEnabled(v) + return _u +} + +// SetNillablePeakRateEnabled sets the "peak_rate_enabled" field if the given value is not nil. +func (_u *GroupUpdate) SetNillablePeakRateEnabled(v *bool) *GroupUpdate { + if v != nil { + _u.SetPeakRateEnabled(*v) + } + return _u +} + +// SetPeakStart sets the "peak_start" field. +func (_u *GroupUpdate) SetPeakStart(v string) *GroupUpdate { + _u.mutation.SetPeakStart(v) + return _u +} + +// SetNillablePeakStart sets the "peak_start" field if the given value is not nil. +func (_u *GroupUpdate) SetNillablePeakStart(v *string) *GroupUpdate { + if v != nil { + _u.SetPeakStart(*v) + } + return _u +} + +// SetPeakEnd sets the "peak_end" field. +func (_u *GroupUpdate) SetPeakEnd(v string) *GroupUpdate { + _u.mutation.SetPeakEnd(v) + return _u +} + +// SetNillablePeakEnd sets the "peak_end" field if the given value is not nil. +func (_u *GroupUpdate) SetNillablePeakEnd(v *string) *GroupUpdate { + if v != nil { + _u.SetPeakEnd(*v) + } + return _u +} + +// SetPeakRateMultiplier sets the "peak_rate_multiplier" field. +func (_u *GroupUpdate) SetPeakRateMultiplier(v float64) *GroupUpdate { + _u.mutation.ResetPeakRateMultiplier() + _u.mutation.SetPeakRateMultiplier(v) + return _u +} + +// SetNillablePeakRateMultiplier sets the "peak_rate_multiplier" field if the given value is not nil. +func (_u *GroupUpdate) SetNillablePeakRateMultiplier(v *float64) *GroupUpdate { + if v != nil { + _u.SetPeakRateMultiplier(*v) + } + return _u +} + +// AddPeakRateMultiplier adds value to the "peak_rate_multiplier" field. +func (_u *GroupUpdate) AddPeakRateMultiplier(v float64) *GroupUpdate { + _u.mutation.AddPeakRateMultiplier(v) + return _u +} + // SetIsExclusive sets the "is_exclusive" field. func (_u *GroupUpdate) SetIsExclusive(v bool) *GroupUpdate { _u.mutation.SetIsExclusive(v) @@ -921,6 +984,16 @@ func (_u *GroupUpdate) check() error { return &ValidationError{Name: "name", err: fmt.Errorf(`ent: validator failed for field "Group.name": %w`, err)} } } + if v, ok := _u.mutation.PeakStart(); ok { + if err := group.PeakStartValidator(v); err != nil { + return &ValidationError{Name: "peak_start", err: fmt.Errorf(`ent: validator failed for field "Group.peak_start": %w`, err)} + } + } + if v, ok := _u.mutation.PeakEnd(); ok { + if err := group.PeakEndValidator(v); err != nil { + return &ValidationError{Name: "peak_end", err: fmt.Errorf(`ent: validator failed for field "Group.peak_end": %w`, err)} + } + } if v, ok := _u.mutation.Status(); ok { if err := group.StatusValidator(v); err != nil { return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "Group.status": %w`, err)} @@ -980,6 +1053,21 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) { if value, ok := _u.mutation.AddedRateMultiplier(); ok { _spec.AddField(group.FieldRateMultiplier, field.TypeFloat64, value) } + if value, ok := _u.mutation.PeakRateEnabled(); ok { + _spec.SetField(group.FieldPeakRateEnabled, field.TypeBool, value) + } + if value, ok := _u.mutation.PeakStart(); ok { + _spec.SetField(group.FieldPeakStart, field.TypeString, value) + } + if value, ok := _u.mutation.PeakEnd(); ok { + _spec.SetField(group.FieldPeakEnd, field.TypeString, value) + } + if value, ok := _u.mutation.PeakRateMultiplier(); ok { + _spec.SetField(group.FieldPeakRateMultiplier, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedPeakRateMultiplier(); ok { + _spec.AddField(group.FieldPeakRateMultiplier, field.TypeFloat64, value) + } if value, ok := _u.mutation.IsExclusive(); ok { _spec.SetField(group.FieldIsExclusive, field.TypeBool, value) } @@ -1530,6 +1618,69 @@ func (_u *GroupUpdateOne) AddRateMultiplier(v float64) *GroupUpdateOne { return _u } +// SetPeakRateEnabled sets the "peak_rate_enabled" field. +func (_u *GroupUpdateOne) SetPeakRateEnabled(v bool) *GroupUpdateOne { + _u.mutation.SetPeakRateEnabled(v) + return _u +} + +// SetNillablePeakRateEnabled sets the "peak_rate_enabled" field if the given value is not nil. +func (_u *GroupUpdateOne) SetNillablePeakRateEnabled(v *bool) *GroupUpdateOne { + if v != nil { + _u.SetPeakRateEnabled(*v) + } + return _u +} + +// SetPeakStart sets the "peak_start" field. +func (_u *GroupUpdateOne) SetPeakStart(v string) *GroupUpdateOne { + _u.mutation.SetPeakStart(v) + return _u +} + +// SetNillablePeakStart sets the "peak_start" field if the given value is not nil. +func (_u *GroupUpdateOne) SetNillablePeakStart(v *string) *GroupUpdateOne { + if v != nil { + _u.SetPeakStart(*v) + } + return _u +} + +// SetPeakEnd sets the "peak_end" field. +func (_u *GroupUpdateOne) SetPeakEnd(v string) *GroupUpdateOne { + _u.mutation.SetPeakEnd(v) + return _u +} + +// SetNillablePeakEnd sets the "peak_end" field if the given value is not nil. +func (_u *GroupUpdateOne) SetNillablePeakEnd(v *string) *GroupUpdateOne { + if v != nil { + _u.SetPeakEnd(*v) + } + return _u +} + +// SetPeakRateMultiplier sets the "peak_rate_multiplier" field. +func (_u *GroupUpdateOne) SetPeakRateMultiplier(v float64) *GroupUpdateOne { + _u.mutation.ResetPeakRateMultiplier() + _u.mutation.SetPeakRateMultiplier(v) + return _u +} + +// SetNillablePeakRateMultiplier sets the "peak_rate_multiplier" field if the given value is not nil. +func (_u *GroupUpdateOne) SetNillablePeakRateMultiplier(v *float64) *GroupUpdateOne { + if v != nil { + _u.SetPeakRateMultiplier(*v) + } + return _u +} + +// AddPeakRateMultiplier adds value to the "peak_rate_multiplier" field. +func (_u *GroupUpdateOne) AddPeakRateMultiplier(v float64) *GroupUpdateOne { + _u.mutation.AddPeakRateMultiplier(v) + return _u +} + // SetIsExclusive sets the "is_exclusive" field. func (_u *GroupUpdateOne) SetIsExclusive(v bool) *GroupUpdateOne { _u.mutation.SetIsExclusive(v) @@ -2347,6 +2498,16 @@ func (_u *GroupUpdateOne) check() error { return &ValidationError{Name: "name", err: fmt.Errorf(`ent: validator failed for field "Group.name": %w`, err)} } } + if v, ok := _u.mutation.PeakStart(); ok { + if err := group.PeakStartValidator(v); err != nil { + return &ValidationError{Name: "peak_start", err: fmt.Errorf(`ent: validator failed for field "Group.peak_start": %w`, err)} + } + } + if v, ok := _u.mutation.PeakEnd(); ok { + if err := group.PeakEndValidator(v); err != nil { + return &ValidationError{Name: "peak_end", err: fmt.Errorf(`ent: validator failed for field "Group.peak_end": %w`, err)} + } + } if v, ok := _u.mutation.Status(); ok { if err := group.StatusValidator(v); err != nil { return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "Group.status": %w`, err)} @@ -2423,6 +2584,21 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error) if value, ok := _u.mutation.AddedRateMultiplier(); ok { _spec.AddField(group.FieldRateMultiplier, field.TypeFloat64, value) } + if value, ok := _u.mutation.PeakRateEnabled(); ok { + _spec.SetField(group.FieldPeakRateEnabled, field.TypeBool, value) + } + if value, ok := _u.mutation.PeakStart(); ok { + _spec.SetField(group.FieldPeakStart, field.TypeString, value) + } + if value, ok := _u.mutation.PeakEnd(); ok { + _spec.SetField(group.FieldPeakEnd, field.TypeString, value) + } + if value, ok := _u.mutation.PeakRateMultiplier(); ok { + _spec.SetField(group.FieldPeakRateMultiplier, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedPeakRateMultiplier(); ok { + _spec.AddField(group.FieldPeakRateMultiplier, field.TypeFloat64, value) + } if value, ok := _u.mutation.IsExclusive(); ok { _spec.SetField(group.FieldIsExclusive, field.TypeBool, value) } diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index d5e9130416..d48d988a5b 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -644,6 +644,10 @@ var ( {Name: "name", Type: field.TypeString, Size: 100}, {Name: "description", Type: field.TypeString, Nullable: true, SchemaType: map[string]string{"postgres": "text"}}, {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: ""}, + {Name: "peak_end", Type: field.TypeString, Size: 5, Default: ""}, + {Name: "peak_rate_multiplier", Type: field.TypeFloat64, Default: 1, SchemaType: map[string]string{"postgres": "decimal(10,4)"}}, {Name: "is_exclusive", Type: field.TypeBool, Default: false}, {Name: "status", Type: field.TypeString, Size: 20, Default: "active"}, {Name: "platform", Type: field.TypeString, Size: 50, Default: "anthropic"}, @@ -683,22 +687,22 @@ var ( { Name: "group_status", Unique: false, - Columns: []*schema.Column{GroupsColumns[8]}, + Columns: []*schema.Column{GroupsColumns[12]}, }, { Name: "group_platform", Unique: false, - Columns: []*schema.Column{GroupsColumns[9]}, + Columns: []*schema.Column{GroupsColumns[13]}, }, { Name: "group_subscription_type", Unique: false, - Columns: []*schema.Column{GroupsColumns[10]}, + Columns: []*schema.Column{GroupsColumns[14]}, }, { Name: "group_is_exclusive", Unique: false, - Columns: []*schema.Column{GroupsColumns[7]}, + Columns: []*schema.Column{GroupsColumns[11]}, }, { Name: "group_deleted_at", @@ -708,7 +712,7 @@ var ( { Name: "group_sort_order", Unique: false, - Columns: []*schema.Column{GroupsColumns[28]}, + Columns: []*schema.Column{GroupsColumns[32]}, }, }, } diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index d62620bc7b..9b5ab14512 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -15056,6 +15056,11 @@ type GroupMutation struct { description *string rate_multiplier *float64 addrate_multiplier *float64 + peak_rate_enabled *bool + peak_start *string + peak_end *string + peak_rate_multiplier *float64 + addpeak_rate_multiplier *float64 is_exclusive *bool status *string platform *string @@ -15482,6 +15487,170 @@ func (m *GroupMutation) ResetRateMultiplier() { m.addrate_multiplier = nil } +// SetPeakRateEnabled sets the "peak_rate_enabled" field. +func (m *GroupMutation) SetPeakRateEnabled(b bool) { + m.peak_rate_enabled = &b +} + +// PeakRateEnabled returns the value of the "peak_rate_enabled" field in the mutation. +func (m *GroupMutation) PeakRateEnabled() (r bool, exists bool) { + v := m.peak_rate_enabled + if v == nil { + return + } + return *v, true +} + +// OldPeakRateEnabled returns the old "peak_rate_enabled" 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) OldPeakRateEnabled(ctx context.Context) (v bool, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldPeakRateEnabled is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldPeakRateEnabled requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldPeakRateEnabled: %w", err) + } + return oldValue.PeakRateEnabled, nil +} + +// ResetPeakRateEnabled resets all changes to the "peak_rate_enabled" field. +func (m *GroupMutation) ResetPeakRateEnabled() { + m.peak_rate_enabled = nil +} + +// SetPeakStart sets the "peak_start" field. +func (m *GroupMutation) SetPeakStart(s string) { + m.peak_start = &s +} + +// PeakStart returns the value of the "peak_start" field in the mutation. +func (m *GroupMutation) PeakStart() (r string, exists bool) { + v := m.peak_start + if v == nil { + return + } + return *v, true +} + +// OldPeakStart returns the old "peak_start" 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) OldPeakStart(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldPeakStart is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldPeakStart requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldPeakStart: %w", err) + } + return oldValue.PeakStart, nil +} + +// ResetPeakStart resets all changes to the "peak_start" field. +func (m *GroupMutation) ResetPeakStart() { + m.peak_start = nil +} + +// SetPeakEnd sets the "peak_end" field. +func (m *GroupMutation) SetPeakEnd(s string) { + m.peak_end = &s +} + +// PeakEnd returns the value of the "peak_end" field in the mutation. +func (m *GroupMutation) PeakEnd() (r string, exists bool) { + v := m.peak_end + if v == nil { + return + } + return *v, true +} + +// OldPeakEnd returns the old "peak_end" 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) OldPeakEnd(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldPeakEnd is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldPeakEnd requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldPeakEnd: %w", err) + } + return oldValue.PeakEnd, nil +} + +// ResetPeakEnd resets all changes to the "peak_end" field. +func (m *GroupMutation) ResetPeakEnd() { + m.peak_end = nil +} + +// SetPeakRateMultiplier sets the "peak_rate_multiplier" field. +func (m *GroupMutation) SetPeakRateMultiplier(f float64) { + m.peak_rate_multiplier = &f + m.addpeak_rate_multiplier = nil +} + +// PeakRateMultiplier returns the value of the "peak_rate_multiplier" field in the mutation. +func (m *GroupMutation) PeakRateMultiplier() (r float64, exists bool) { + v := m.peak_rate_multiplier + if v == nil { + return + } + return *v, true +} + +// OldPeakRateMultiplier returns the old "peak_rate_multiplier" 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) OldPeakRateMultiplier(ctx context.Context) (v float64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldPeakRateMultiplier is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldPeakRateMultiplier requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldPeakRateMultiplier: %w", err) + } + return oldValue.PeakRateMultiplier, nil +} + +// AddPeakRateMultiplier adds f to the "peak_rate_multiplier" field. +func (m *GroupMutation) AddPeakRateMultiplier(f float64) { + if m.addpeak_rate_multiplier != nil { + *m.addpeak_rate_multiplier += f + } else { + m.addpeak_rate_multiplier = &f + } +} + +// AddedPeakRateMultiplier returns the value that was added to the "peak_rate_multiplier" field in this mutation. +func (m *GroupMutation) AddedPeakRateMultiplier() (r float64, exists bool) { + v := m.addpeak_rate_multiplier + if v == nil { + return + } + return *v, true +} + +// ResetPeakRateMultiplier resets all changes to the "peak_rate_multiplier" field. +func (m *GroupMutation) ResetPeakRateMultiplier() { + m.peak_rate_multiplier = nil + m.addpeak_rate_multiplier = nil +} + // SetIsExclusive sets the "is_exclusive" field. func (m *GroupMutation) SetIsExclusive(b bool) { m.is_exclusive = &b @@ -17264,7 +17433,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, 35) + fields := make([]string, 0, 39) if m.created_at != nil { fields = append(fields, group.FieldCreatedAt) } @@ -17283,6 +17452,18 @@ func (m *GroupMutation) Fields() []string { if m.rate_multiplier != nil { fields = append(fields, group.FieldRateMultiplier) } + if m.peak_rate_enabled != nil { + fields = append(fields, group.FieldPeakRateEnabled) + } + if m.peak_start != nil { + fields = append(fields, group.FieldPeakStart) + } + if m.peak_end != nil { + fields = append(fields, group.FieldPeakEnd) + } + if m.peak_rate_multiplier != nil { + fields = append(fields, group.FieldPeakRateMultiplier) + } if m.is_exclusive != nil { fields = append(fields, group.FieldIsExclusive) } @@ -17390,6 +17571,14 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) { return m.Description() case group.FieldRateMultiplier: return m.RateMultiplier() + case group.FieldPeakRateEnabled: + return m.PeakRateEnabled() + case group.FieldPeakStart: + return m.PeakStart() + case group.FieldPeakEnd: + return m.PeakEnd() + case group.FieldPeakRateMultiplier: + return m.PeakRateMultiplier() case group.FieldIsExclusive: return m.IsExclusive() case group.FieldStatus: @@ -17469,6 +17658,14 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e return m.OldDescription(ctx) case group.FieldRateMultiplier: return m.OldRateMultiplier(ctx) + case group.FieldPeakRateEnabled: + return m.OldPeakRateEnabled(ctx) + case group.FieldPeakStart: + return m.OldPeakStart(ctx) + case group.FieldPeakEnd: + return m.OldPeakEnd(ctx) + case group.FieldPeakRateMultiplier: + return m.OldPeakRateMultiplier(ctx) case group.FieldIsExclusive: return m.OldIsExclusive(ctx) case group.FieldStatus: @@ -17578,6 +17775,34 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error { } m.SetRateMultiplier(v) return nil + case group.FieldPeakRateEnabled: + v, ok := value.(bool) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetPeakRateEnabled(v) + return nil + case group.FieldPeakStart: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetPeakStart(v) + return nil + case group.FieldPeakEnd: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetPeakEnd(v) + return nil + case group.FieldPeakRateMultiplier: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetPeakRateMultiplier(v) + return nil case group.FieldIsExclusive: v, ok := value.(bool) if !ok { @@ -17792,6 +18017,9 @@ func (m *GroupMutation) AddedFields() []string { if m.addrate_multiplier != nil { fields = append(fields, group.FieldRateMultiplier) } + if m.addpeak_rate_multiplier != nil { + fields = append(fields, group.FieldPeakRateMultiplier) + } if m.adddaily_limit_usd != nil { fields = append(fields, group.FieldDailyLimitUsd) } @@ -17838,6 +18066,8 @@ func (m *GroupMutation) AddedField(name string) (ent.Value, bool) { switch name { case group.FieldRateMultiplier: return m.AddedRateMultiplier() + case group.FieldPeakRateMultiplier: + return m.AddedPeakRateMultiplier() case group.FieldDailyLimitUsd: return m.AddedDailyLimitUsd() case group.FieldWeeklyLimitUsd: @@ -17878,6 +18108,13 @@ func (m *GroupMutation) AddField(name string, value ent.Value) error { } m.AddRateMultiplier(v) return nil + case group.FieldPeakRateMultiplier: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddPeakRateMultiplier(v) + return nil case group.FieldDailyLimitUsd: v, ok := value.(float64) if !ok { @@ -18076,6 +18313,18 @@ func (m *GroupMutation) ResetField(name string) error { case group.FieldRateMultiplier: m.ResetRateMultiplier() return nil + case group.FieldPeakRateEnabled: + m.ResetPeakRateEnabled() + return nil + case group.FieldPeakStart: + m.ResetPeakStart() + return nil + case group.FieldPeakEnd: + m.ResetPeakEnd() + return nil + case group.FieldPeakRateMultiplier: + m.ResetPeakRateMultiplier() + return nil case group.FieldIsExclusive: m.ResetIsExclusive() return nil diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index 6a467e26b3..b86f6a0560 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -796,92 +796,112 @@ func init() { groupDescRateMultiplier := groupFields[2].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() + // 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() + // 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() + // 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() + // 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[3].Descriptor() + groupDescIsExclusive := groupFields[7].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[4].Descriptor() + groupDescStatus := groupFields[8].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) // groupDescPlatform is the schema descriptor for platform field. - groupDescPlatform := groupFields[5].Descriptor() + groupDescPlatform := groupFields[9].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[6].Descriptor() + groupDescSubscriptionType := groupFields[10].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[10].Descriptor() + groupDescDefaultValidityDays := groupFields[14].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[11].Descriptor() + groupDescAllowImageGeneration := groupFields[15].Descriptor() // group.DefaultAllowImageGeneration holds the default value on creation for the allow_image_generation field. group.DefaultAllowImageGeneration = groupDescAllowImageGeneration.Default.(bool) // groupDescImageRateIndependent is the schema descriptor for image_rate_independent field. - groupDescImageRateIndependent := groupFields[12].Descriptor() + groupDescImageRateIndependent := groupFields[16].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[13].Descriptor() + groupDescImageRateMultiplier := groupFields[17].Descriptor() // group.DefaultImageRateMultiplier holds the default value on creation for the image_rate_multiplier field. group.DefaultImageRateMultiplier = groupDescImageRateMultiplier.Default.(float64) // groupDescClaudeCodeOnly is the schema descriptor for claude_code_only field. - groupDescClaudeCodeOnly := groupFields[17].Descriptor() + groupDescClaudeCodeOnly := groupFields[21].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[21].Descriptor() + groupDescModelRoutingEnabled := groupFields[25].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[22].Descriptor() + groupDescMcpXMLInject := groupFields[26].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[23].Descriptor() + groupDescSupportedModelScopes := groupFields[27].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[24].Descriptor() + groupDescSortOrder := groupFields[28].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[25].Descriptor() + groupDescAllowMessagesDispatch := groupFields[29].Descriptor() // group.DefaultAllowMessagesDispatch holds the default value on creation for the allow_messages_dispatch field. group.DefaultAllowMessagesDispatch = groupDescAllowMessagesDispatch.Default.(bool) // groupDescRequireOauthOnly is the schema descriptor for require_oauth_only field. - groupDescRequireOauthOnly := groupFields[26].Descriptor() + groupDescRequireOauthOnly := groupFields[30].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[27].Descriptor() + groupDescRequirePrivacySet := groupFields[31].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[28].Descriptor() + groupDescDefaultMappedModel := groupFields[32].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[29].Descriptor() + groupDescMessagesDispatchModelConfig := groupFields[33].Descriptor() // group.DefaultMessagesDispatchModelConfig holds the default value on creation for the messages_dispatch_model_config field. group.DefaultMessagesDispatchModelConfig = groupDescMessagesDispatchModelConfig.Default.(domain.OpenAIMessagesDispatchModelConfig) // groupDescModelsListConfig is the schema descriptor for models_list_config field. - groupDescModelsListConfig := groupFields[30].Descriptor() + groupDescModelsListConfig := groupFields[34].Descriptor() // group.DefaultModelsListConfig holds the default value on creation for the models_list_config field. group.DefaultModelsListConfig = groupDescModelsListConfig.Default.(domain.GroupModelsListConfig) // groupDescRpmLimit is the schema descriptor for rpm_limit field. - groupDescRpmLimit := groupFields[31].Descriptor() + groupDescRpmLimit := groupFields[35].Descriptor() // group.DefaultRpmLimit holds the default value on creation for the rpm_limit field. group.DefaultRpmLimit = groupDescRpmLimit.Default.(int) idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin() diff --git a/backend/ent/schema/group.go b/backend/ent/schema/group.go index 2a1715f8b2..306d088682 100644 --- a/backend/ent/schema/group.go +++ b/backend/ent/schema/group.go @@ -45,6 +45,22 @@ func (Group) Fields() []ent.Field { field.Float("rate_multiplier"). SchemaType(map[string]string{dialect.Postgres: "decimal(10,4)"}). Default(1.0), + // 高峰时段倍率(added by migration 156) + field.Bool("peak_rate_enabled"). + Default(false). + Comment("是否启用高峰时段倍率"), + field.String("peak_start"). + MaxLen(5). + Default(""). + Comment("高峰开始时间 HH:MM(含),如 14:00;空表示未配置;不支持跨天"), + field.String("peak_end"). + MaxLen(5). + Default(""). + Comment("高峰结束时间 HH:MM(不含),必须大于 peak_start;不支持跨天,如 22:00-02:00"), + field.Float("peak_rate_multiplier"). + SchemaType(map[string]string{dialect.Postgres: "decimal(10,4)"}). + Default(1.0). + Comment("高峰时段叠加倍率,仅在 peak_rate_enabled 且处于 [peak_start, peak_end) 时乘入文本倍率"), field.Bool("is_exclusive"). Default(false), field.String("status"). diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index 061611771d..9a1c2b87fe 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -71,6 +71,13 @@ func (f optionalLimitField) ToServiceInput() *float64 { return &zero } +func derefFloat64Default(p *float64, def float64) float64 { + if p != nil { + return *p + } + return def +} + // NewGroupHandler creates a new admin group handler func NewGroupHandler(adminService service.AdminService, dashboardService *service.DashboardService, groupCapacityService *service.GroupCapacityService) *GroupHandler { return &GroupHandler{ @@ -95,6 +102,10 @@ type CreateGroupRequest struct { AllowImageGeneration bool `json:"allow_image_generation"` ImageRateIndependent bool `json:"image_rate_independent"` ImageRateMultiplier *float64 `json:"image_rate_multiplier"` + PeakRateEnabled bool `json:"peak_rate_enabled"` + PeakStart string `json:"peak_start"` + PeakEnd string `json:"peak_end"` + PeakRateMultiplier *float64 `json:"peak_rate_multiplier"` ImagePrice1K *float64 `json:"image_price_1k"` ImagePrice2K *float64 `json:"image_price_2k"` ImagePrice4K *float64 `json:"image_price_4k"` @@ -136,6 +147,10 @@ type UpdateGroupRequest struct { AllowImageGeneration *bool `json:"allow_image_generation"` ImageRateIndependent *bool `json:"image_rate_independent"` ImageRateMultiplier *float64 `json:"image_rate_multiplier"` + PeakRateEnabled *bool `json:"peak_rate_enabled"` + PeakStart *string `json:"peak_start"` + PeakEnd *string `json:"peak_end"` + PeakRateMultiplier *float64 `json:"peak_rate_multiplier"` ImagePrice1K *float64 `json:"image_price_1k"` ImagePrice2K *float64 `json:"image_price_2k"` ImagePrice4K *float64 `json:"image_price_4k"` @@ -277,6 +292,11 @@ func (h *GroupHandler) Create(c *gin.Context) { return } + if err := service.ValidatePeakRateConfig(req.SubscriptionType, req.PeakRateEnabled, req.PeakStart, req.PeakEnd, derefFloat64Default(req.PeakRateMultiplier, 1.0)); err != nil { + response.BadRequest(c, err.Error()) + return + } + group, err := h.adminService.CreateGroup(c.Request.Context(), &service.CreateGroupInput{ Name: req.Name, Description: req.Description, @@ -290,6 +310,10 @@ func (h *GroupHandler) Create(c *gin.Context) { AllowImageGeneration: req.AllowImageGeneration, ImageRateIndependent: req.ImageRateIndependent, ImageRateMultiplier: req.ImageRateMultiplier, + PeakRateEnabled: req.PeakRateEnabled, + PeakStart: req.PeakStart, + PeakEnd: req.PeakEnd, + PeakRateMultiplier: req.PeakRateMultiplier, ImagePrice1K: req.ImagePrice1K, ImagePrice2K: req.ImagePrice2K, ImagePrice4K: req.ImagePrice4K, @@ -346,6 +370,10 @@ func (h *GroupHandler) Update(c *gin.Context) { AllowImageGeneration: req.AllowImageGeneration, ImageRateIndependent: req.ImageRateIndependent, ImageRateMultiplier: req.ImageRateMultiplier, + PeakRateEnabled: req.PeakRateEnabled, + PeakStart: req.PeakStart, + PeakEnd: req.PeakEnd, + PeakRateMultiplier: req.PeakRateMultiplier, ImagePrice1K: req.ImagePrice1K, ImagePrice2K: req.ImagePrice2K, ImagePrice4K: req.ImagePrice4K, diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 415d18dcaa..11d06331af 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -181,6 +181,10 @@ func groupFromServiceBase(g *service.Group) Group { AllowImageGeneration: g.AllowImageGeneration, ImageRateIndependent: g.ImageRateIndependent, ImageRateMultiplier: g.ImageRateMultiplier, + PeakRateEnabled: g.PeakRateEnabled, + PeakStart: g.PeakStart, + PeakEnd: g.PeakEnd, + PeakRateMultiplier: g.PeakRateMultiplier, ImagePrice1K: g.ImagePrice1K, ImagePrice2K: g.ImagePrice2K, ImagePrice4K: g.ImagePrice4K, diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index d8748baa34..1832b6e490 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -97,12 +97,17 @@ type Group struct { MonthlyLimitUSD *float64 `json:"monthly_limit_usd"` // 图片生成计费配置(仅 antigravity 平台使用) - AllowImageGeneration bool `json:"allow_image_generation"` - ImageRateIndependent bool `json:"image_rate_independent"` - ImageRateMultiplier float64 `json:"image_rate_multiplier"` - ImagePrice1K *float64 `json:"image_price_1k"` - ImagePrice2K *float64 `json:"image_price_2k"` - ImagePrice4K *float64 `json:"image_price_4k"` + AllowImageGeneration bool `json:"allow_image_generation"` + ImageRateIndependent bool `json:"image_rate_independent"` + ImageRateMultiplier float64 `json:"image_rate_multiplier"` + // 高峰时段倍率配置 + PeakRateEnabled bool `json:"peak_rate_enabled"` + PeakStart string `json:"peak_start"` + PeakEnd string `json:"peak_end"` + PeakRateMultiplier float64 `json:"peak_rate_multiplier"` + ImagePrice1K *float64 `json:"image_price_1k"` + ImagePrice2K *float64 `json:"image_price_2k"` + ImagePrice4K *float64 `json:"image_price_4k"` // Claude Code 客户端限制 ClaudeCodeOnly bool `json:"claude_code_only"` diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index e5eb34db76..76cad809c3 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -194,6 +194,10 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig, group.FieldRpmLimit, + group.FieldPeakRateEnabled, + group.FieldPeakStart, + group.FieldPeakEnd, + group.FieldPeakRateMultiplier, ) }). Only(ctx) @@ -814,6 +818,10 @@ func groupEntityToService(g *dbent.Group) *service.Group { MessagesDispatchModelConfig: g.MessagesDispatchModelConfig, ModelsListConfig: g.ModelsListConfig, RPMLimit: g.RpmLimit, + PeakRateEnabled: g.PeakRateEnabled, + PeakStart: g.PeakStart, + PeakEnd: g.PeakEnd, + PeakRateMultiplier: g.PeakRateMultiplier, CreatedAt: g.CreatedAt, UpdatedAt: g.UpdatedAt, } diff --git a/backend/internal/repository/group_repo.go b/backend/internal/repository/group_repo.go index ac8669abdd..4e839b6a12 100644 --- a/backend/internal/repository/group_repo.go +++ b/backend/internal/repository/group_repo.go @@ -67,7 +67,11 @@ func (r *groupRepository) Create(ctx context.Context, groupIn *service.Group) er SetDefaultMappedModel(groupIn.DefaultMappedModel). SetMessagesDispatchModelConfig(groupIn.MessagesDispatchModelConfig). SetModelsListConfig(groupIn.ModelsListConfig). - SetRpmLimit(groupIn.RPMLimit) + SetRpmLimit(groupIn.RPMLimit). + SetPeakRateEnabled(groupIn.PeakRateEnabled). + SetPeakStart(groupIn.PeakStart). + SetPeakEnd(groupIn.PeakEnd). + SetPeakRateMultiplier(groupIn.PeakRateMultiplier) // 设置模型路由配置 if groupIn.ModelRouting != nil { @@ -143,7 +147,11 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er SetDefaultMappedModel(groupIn.DefaultMappedModel). SetMessagesDispatchModelConfig(groupIn.MessagesDispatchModelConfig). SetModelsListConfig(groupIn.ModelsListConfig). - SetRpmLimit(groupIn.RPMLimit) + SetRpmLimit(groupIn.RPMLimit). + SetPeakRateEnabled(groupIn.PeakRateEnabled). + SetPeakStart(groupIn.PeakStart). + SetPeakEnd(groupIn.PeakEnd). + SetPeakRateMultiplier(groupIn.PeakRateMultiplier) // 显式处理可空字段:nil 需要 clear,非 nil 需要 set。 if groupIn.DailyLimitUSD != nil { diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 7a5637dc75..c38bc74529 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -209,11 +209,16 @@ type CreateGroupInput struct { AllowImageGeneration bool ImageRateIndependent bool ImageRateMultiplier *float64 - ImagePrice1K *float64 - ImagePrice2K *float64 - ImagePrice4K *float64 - ClaudeCodeOnly bool // 仅允许 Claude Code 客户端 - FallbackGroupID *int64 // 降级分组 ID + // 高峰时段倍率配置(PeakRateMultiplier 为 nil 时按 1.0 处理) + PeakRateEnabled bool + PeakStart string + PeakEnd string + PeakRateMultiplier *float64 + ImagePrice1K *float64 + ImagePrice2K *float64 + ImagePrice4K *float64 + ClaudeCodeOnly bool // 仅允许 Claude Code 客户端 + FallbackGroupID *int64 // 降级分组 ID // 无效请求兜底分组 ID(仅 anthropic 平台使用) FallbackGroupIDOnInvalidRequest *int64 // 模型路由配置(仅 anthropic 平台使用) @@ -250,11 +255,16 @@ type UpdateGroupInput struct { AllowImageGeneration *bool ImageRateIndependent *bool ImageRateMultiplier *float64 - ImagePrice1K *float64 - ImagePrice2K *float64 - ImagePrice4K *float64 - ClaudeCodeOnly *bool // 仅允许 Claude Code 客户端 - FallbackGroupID *int64 // 降级分组 ID + // 高峰时段倍率配置(nil 表示不修改) + PeakRateEnabled *bool + PeakStart *string + PeakEnd *string + PeakRateMultiplier *float64 + ImagePrice1K *float64 + ImagePrice2K *float64 + ImagePrice4K *float64 + ClaudeCodeOnly *bool // 仅允许 Claude Code 客户端 + FallbackGroupID *int64 // 降级分组 ID // 无效请求兜底分组 ID(仅 anthropic 平台使用) FallbackGroupIDOnInvalidRequest *int64 // 模型路由配置(仅 anthropic 平台使用) @@ -1824,6 +1834,14 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn imageRateMultiplier = *input.ImageRateMultiplier } + peakRateMultiplier := 1.0 + if input.PeakRateMultiplier != nil { + peakRateMultiplier = *input.PeakRateMultiplier + } + if err := ValidatePeakRateConfig(subscriptionType, input.PeakRateEnabled, input.PeakStart, input.PeakEnd, peakRateMultiplier); err != nil { + return nil, err + } + // 校验降级分组 if input.FallbackGroupID != nil { if err := s.validateFallbackGroup(ctx, 0, *input.FallbackGroupID); err != nil { @@ -1893,6 +1911,10 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn AllowImageGeneration: input.AllowImageGeneration, ImageRateIndependent: input.ImageRateIndependent, ImageRateMultiplier: imageRateMultiplier, + PeakRateEnabled: input.PeakRateEnabled, + PeakStart: input.PeakStart, + PeakEnd: input.PeakEnd, + PeakRateMultiplier: peakRateMultiplier, ImagePrice1K: imagePrice1K, ImagePrice2K: imagePrice2K, ImagePrice4K: imagePrice4K, @@ -2082,6 +2104,23 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd } group.ImageRateMultiplier = *input.ImageRateMultiplier } + if input.PeakRateEnabled != nil { + group.PeakRateEnabled = *input.PeakRateEnabled + } + if input.PeakStart != nil { + group.PeakStart = *input.PeakStart + } + if input.PeakEnd != nil { + group.PeakEnd = *input.PeakEnd + } + if input.PeakRateMultiplier != nil { + group.PeakRateMultiplier = *input.PeakRateMultiplier + } + // 收敛校验:Update 可能只传部分 peak 字段,需对合并后的最终配置统一校验, + // 防止单独修改 start/end 导致最终 start>=end 等非法配置入库。 + if err := ValidatePeakRateConfig(group.SubscriptionType, group.PeakRateEnabled, group.PeakStart, group.PeakEnd, group.PeakRateMultiplier); err != nil { + return nil, err + } if input.ImagePrice1K != nil { group.ImagePrice1K = normalizePrice(input.ImagePrice1K) } diff --git a/backend/internal/service/api_key_auth_cache.go b/backend/internal/service/api_key_auth_cache.go index 1b1f2d3b69..8089e90b97 100644 --- a/backend/internal/service/api_key_auth_cache.go +++ b/backend/internal/service/api_key_auth_cache.go @@ -93,6 +93,14 @@ type APIKeyAuthGroupSnapshot struct { // RPMLimit 分组级每分钟请求数上限(0 = 不限制);用于 billing_cache_service.checkRPM 级联判断。 RPMLimit int `json:"rpm_limit"` + + // 高峰时段倍率:PeakRateEnabled 为 true 且请求时刻处于 [PeakStart, PeakEnd) 时, + // 文本计费倍率额外乘以 PeakRateMultiplier(详见 Group.PeakMultiplierAt)。 + // 必须随快照缓存,否则扣费路径拿到的 apiKey.Group 缺字段、高峰倍率失效。 + PeakRateEnabled bool `json:"peak_rate_enabled"` + PeakStart string `json:"peak_start"` + PeakEnd string `json:"peak_end"` + PeakRateMultiplier float64 `json:"peak_rate_multiplier"` } // APIKeyAuthCacheEntry 缓存条目,支持负缓存 diff --git a/backend/internal/service/api_key_auth_cache_impl.go b/backend/internal/service/api_key_auth_cache_impl.go index 5ab557f6f9..b5aedf271e 100644 --- a/backend/internal/service/api_key_auth_cache_impl.go +++ b/backend/internal/service/api_key_auth_cache_impl.go @@ -14,7 +14,7 @@ import ( "github.com/dgraph-io/ristretto" ) -const apiKeyAuthSnapshotVersion = 12 // v12: include exclusive group authorization fields +const apiKeyAuthSnapshotVersion = 13 // v13: include group peak rate fields type apiKeyAuthCacheConfig struct { l1Size int @@ -276,6 +276,10 @@ func (s *APIKeyService) snapshotFromAPIKey(ctx context.Context, apiKey *APIKey) MessagesDispatchModelConfig: apiKey.Group.MessagesDispatchModelConfig, ModelsListConfig: apiKey.Group.ModelsListConfig, RPMLimit: apiKey.Group.RPMLimit, + PeakRateEnabled: apiKey.Group.PeakRateEnabled, + PeakStart: apiKey.Group.PeakStart, + PeakEnd: apiKey.Group.PeakEnd, + PeakRateMultiplier: apiKey.Group.PeakRateMultiplier, } } return snapshot @@ -349,6 +353,10 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho MessagesDispatchModelConfig: snapshot.Group.MessagesDispatchModelConfig, ModelsListConfig: snapshot.Group.ModelsListConfig, RPMLimit: snapshot.Group.RPMLimit, + PeakRateEnabled: snapshot.Group.PeakRateEnabled, + PeakStart: snapshot.Group.PeakStart, + PeakEnd: snapshot.Group.PeakEnd, + PeakRateMultiplier: snapshot.Group.PeakRateMultiplier, } } s.compileAPIKeyIPRules(apiKey) diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index d9f3ef93b6..4781da2435 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -30,6 +30,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats" "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator" @@ -9436,7 +9437,9 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage groupDefault := apiKey.Group.RateMultiplier multiplier = s.getUserGroupRateMultiplier(ctx, user.ID, *apiKey.GroupID, groupDefault) } - imageMultiplier := resolveImageRateMultiplier(apiKey, multiplier) + // 文本倍率叠加高峰因子(仅文本,图片倍率不受影响)。高峰因子按请求时刻现算, + // 不并入上面的 getUserGroupRateMultiplier,以免污染 user:group 倍率缓存。 + multiplier, imageMultiplier := computePeakAwareMultipliers(apiKey, multiplier, timezone.Now()) // 确定计费模型 billingModel := forwardResultBillingModel(result.Model, result.UpstreamModel) diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go index 9aa2a52f4a..f1e7fcb283 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -1,10 +1,13 @@ package service import ( + "errors" + "fmt" "strings" "time" "github.com/Wei-Shaw/sub2api/internal/domain" + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" ) type OpenAIMessagesDispatchModelConfig = domain.OpenAIMessagesDispatchModelConfig @@ -16,7 +19,13 @@ type Group struct { Description string Platform string RateMultiplier float64 - IsExclusive bool + // 高峰时段倍率:peak_rate_enabled 为 true 且当前时刻处于 [PeakStart, PeakEnd) 时, + // 文本计费倍率额外乘以 PeakRateMultiplier。详见 PeakMultiplierAt。 + PeakRateEnabled bool + PeakStart string + PeakEnd string + PeakRateMultiplier float64 + IsExclusive bool Status string Hydrated bool // indicates the group was loaded from a trusted repository source @@ -167,3 +176,78 @@ func matchModelPattern(pattern, model string) bool { return false } + +// parseMinutes 把 "HH:MM" 解析为当日分钟数(0..1439)。格式非法返回 (0,false)。 +func parseMinutes(hhmm string) (int, bool) { + t, err := time.Parse("15:04", hhmm) + if err != nil { + return 0, false + } + return t.Hour()*60 + t.Minute(), true +} + +// PeakMultiplierAt 返回指定时刻 now 的高峰因子。 +// - 未启用 / 未配置 / 配置非法(start>=end 或格式错误) / 非高峰时段 → 返回 1.0(安全降级) +// - 区间为左闭右开 [PeakStart, PeakEnd),仅支持当日区间,不支持跨天(如 22:00-次日02:00) +// - 时刻基于全局系统时区(timezone.Location)判定 +// 该方法是纯函数,不读取任何外部状态,便于单测。 +func (g *Group) PeakMultiplierAt(now time.Time) float64 { + if g == nil || !g.IsSubscriptionType() || !g.PeakRateEnabled || g.PeakStart == "" || g.PeakEnd == "" { + return 1.0 + } + start, ok1 := parseMinutes(g.PeakStart) + end, ok2 := parseMinutes(g.PeakEnd) + if !ok1 || !ok2 || start >= end { + return 1.0 + } + t := now.In(timezone.Location()) + cur := t.Hour()*60 + t.Minute() + if cur >= start && cur < end { + return g.PeakRateMultiplier + } + return 1.0 +} + +// ValidatePeakRateConfig 是高峰倍率配置的唯一校验来源,供 handler 与 service 层共用。 +// enabled=true 时仅允许订阅类型分组;并要求 start/end 合法且 end>start(不支持跨天),multiplier>=0。 +// enabled=false 时放行(不关心类型)。subscriptionType 为空按 standard 处理。 +func ValidatePeakRateConfig(subscriptionType string, enabled bool, start, end string, multiplier float64) error { + if !enabled { + return nil + } + if subscriptionType != SubscriptionTypeSubscription { + return errors.New("高峰时段倍率仅支持订阅类型分组") + } + if start == "" || end == "" { + return errors.New("peak_rate_enabled 为 true 时 peak_start 与 peak_end 必填") + } + st, err1 := time.Parse("15:04", start) + if err1 != nil { + return fmt.Errorf("peak_start 格式应为 HH:MM,got %q", start) + } + en, err2 := time.Parse("15:04", end) + if err2 != nil { + return fmt.Errorf("peak_end 格式应为 HH:MM,got %q", end) + } + if st.Hour()*60+st.Minute() >= en.Hour()*60+en.Minute() { + return errors.New("peak_end 必须大于 peak_start(不支持跨天区间,如 22:00-02:00)") + } + if multiplier < 0 { + return errors.New("peak_rate_multiplier 不能为负") + } + return nil +} + +// computePeakAwareMultipliers 把"基础文本倍率 base"(已含系统/分组/用户级倍率,但不含高峰) +// 拆分为最终文本倍率与图片倍率:图片倍率基于 base 现算、不受高峰影响;文本倍率在 base 上叠加高峰因子。 +// gateway_service.recordUsageCore 与 openai_gateway_service.RecordUsage 共用此函数, +// 锁死"高峰因子只乘入文本倍率、图片倍率不受影响"这一叠加顺序——任何调换都会被 group_peak_rate_test 覆盖。 +func computePeakAwareMultipliers(apiKey *APIKey, base float64, now time.Time) (text, image float64) { + image = resolveImageRateMultiplier(apiKey, base) + peak := 1.0 + if apiKey != nil && apiKey.Group != nil { + peak = apiKey.Group.PeakMultiplierAt(now) + } + text = base * peak + return +} diff --git a/backend/internal/service/group_peak_rate_test.go b/backend/internal/service/group_peak_rate_test.go new file mode 100644 index 0000000000..f63d298da6 --- /dev/null +++ b/backend/internal/service/group_peak_rate_test.go @@ -0,0 +1,238 @@ +package service + +import ( + "context" + "math" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" +) + +func init() { + // 测试固定全局时区为 UTC,确保判定可复现。 + _ = timezone.Init("UTC") +} + +func newPeakGroup(enabled bool, start, end string, mult float64) *Group { + return &Group{ + SubscriptionType: "subscription", + PeakRateEnabled: enabled, + PeakStart: start, + PeakEnd: end, + PeakRateMultiplier: mult, + } +} + +func at(hour, min int) time.Time { + return time.Date(2026, 6, 29, hour, min, 0, 0, time.UTC) +} + +func TestPeakMultiplierAt_DisabledOrUnconfigured(t *testing.T) { + cases := []struct { + name string + g *Group + }{ + {"disabled", newPeakGroup(false, "14:00", "18:00", 3.0)}, + {"empty start", newPeakGroup(true, "", "18:00", 3.0)}, + {"empty end", newPeakGroup(true, "14:00", "", 3.0)}, + {"invalid start>=end", newPeakGroup(true, "18:00", "14:00", 3.0)}, + {"equal start==end", newPeakGroup(true, "14:00", "14:00", 3.0)}, + {"malformed start", newPeakGroup(true, "99:99", "18:00", 3.0)}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := c.g.PeakMultiplierAt(at(15, 0)); got != 1.0 { + t.Fatalf("expect 1.0, got %v", got) + } + }) + } +} + +func TestPeakMultiplierAt_NilReceiver(t *testing.T) { + var g *Group + if got := g.PeakMultiplierAt(at(15, 0)); got != 1.0 { + t.Fatalf("expect 1.0, got %v", got) + } +} + +func TestPeakMultiplierAt_Boundaries(t *testing.T) { + g := newPeakGroup(true, "14:00", "18:00", 3.0) + cases := []struct { + t time.Time + want float64 + }{ + {at(13, 59), 1.0}, + {at(14, 0), 3.0}, + {at(15, 30), 3.0}, + {at(17, 59), 3.0}, + {at(18, 0), 1.0}, + {at(23, 0), 1.0}, + } + for _, c := range cases { + t.Run(c.t.Format("15:04"), func(t *testing.T) { + if got := g.PeakMultiplierAt(c.t); got != c.want { + t.Fatalf("at %s: expect %v, got %v", c.t.Format("15:04"), c.want, got) + } + }) + } +} + +func TestPeakMultiplierAt_RespectsTimezoneLocation(t *testing.T) { + // 全局时区为 UTC。北京 15:00 = UTC 07:00,不在 [14:00,18:00)。 + nonUTC := time.Date(2026, 6, 29, 15, 0, 0, 0, mustLoad("Asia/Shanghai")) + g := newPeakGroup(true, "14:00", "18:00", 3.0) + if got := g.PeakMultiplierAt(nonUTC); got != 1.0 { + t.Fatalf("expect 1.0 (converted to UTC 07:00), got %v", got) + } +} + +func mustLoad(name string) *time.Location { + loc, err := time.LoadLocation(name) + if err != nil { + panic(err) + } + return loc +} + +func TestValidatePeakRateConfig(t *testing.T) { + cases := []struct { + name string + subType string + enabled bool + start string + end string + mult float64 + wantErr bool + }{ + {"disabled passes through", "subscription", false, "", "", 0, false}, + {"subscription enabled valid", "subscription", true, "14:00", "18:00", 3.0, false}, + {"standard enabled rejected", "standard", true, "14:00", "18:00", 3.0, true}, + {"empty type treated as standard", "", true, "14:00", "18:00", 3.0, true}, + {"standard disabled passes", "standard", false, "", "", 0, false}, + {"enabled empty start", "subscription", true, "", "18:00", 1.0, true}, + {"enabled empty end", "subscription", true, "14:00", "", 1.0, true}, + {"enabled malformed start", "subscription", true, "99:99", "18:00", 1.0, true}, + {"enabled malformed end", "subscription", true, "14:00", "25:00", 1.0, true}, + {"enabled equal start==end", "subscription", true, "14:00", "14:00", 1.0, true}, + {"enabled cross-day rejected", "subscription", true, "22:00", "02:00", 1.0, true}, + {"enabled negative multiplier", "subscription", true, "14:00", "18:00", -0.5, true}, + {"enabled zero multiplier allowed", "subscription", true, "14:00", "18:00", 0, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + err := ValidatePeakRateConfig(c.subType, c.enabled, c.start, c.end, c.mult) + if c.wantErr && err == nil { + t.Fatalf("expect error, got nil") + } + if !c.wantErr && err != nil { + t.Fatalf("expect no error, got %v", err) + } + }) + } +} + +func TestPeakMultiplierAt_StandardTypeDegradesToOne(t *testing.T) { + g := newPeakGroup(true, "14:00", "18:00", 3.0) + g.SubscriptionType = "standard" + if got := g.PeakMultiplierAt(at(15, 30)); got != 1.0 { + t.Fatalf("standard group must degrade to 1.0, got %v", got) + } + + sub := newPeakGroup(true, "14:00", "18:00", 3.0) + sub.SubscriptionType = "subscription" + if got := sub.PeakMultiplierAt(at(15, 30)); got != 3.0 { + t.Fatalf("subscription group peak multiplier: got %v, want 3.0", got) + } +} + +// TestPeakMultiplier_GatewayBillingSequence 调用 gateway_service.recordUsageCore 与 +// openai_gateway_service.RecordUsage 共用的 computePeakAwareMultipliers,验证计费叠加顺序: +// 图片倍率基于基础倍率算出且不受高峰影响,高峰因子只乘入文本倍率。 +// 若有人调换叠加顺序或把高峰并入 imageMultiplier,此测试会失败。 +func TestPeakMultiplier_GatewayBillingSequence(t *testing.T) { + const baseMultiplier = 0.8 + apiKey := &APIKey{Group: newPeakGroup(true, "14:00", "18:00", 3.0)} + approxEq := func(a, b float64) bool { return math.Abs(a-b) < 1e-9 } + + t.Run("peak hour amplifies text only", func(t *testing.T) { + now := at(15, 30) // 处于 [14:00, 18:00) + textMultiplier, imageMultiplier := computePeakAwareMultipliers(apiKey, baseMultiplier, now) + if !approxEq(imageMultiplier, baseMultiplier) { + t.Fatalf("image multiplier must not be affected by peak: got %v, want %v", imageMultiplier, baseMultiplier) + } + if want := baseMultiplier * 3.0; !approxEq(textMultiplier, want) { + t.Fatalf("text multiplier should include peak factor: got %v, want %v", textMultiplier, want) + } + }) + + t.Run("off-peak leaves both multipliers at base", func(t *testing.T) { + now := at(20, 0) + textMultiplier, imageMultiplier := computePeakAwareMultipliers(apiKey, baseMultiplier, now) + if !approxEq(imageMultiplier, baseMultiplier) { + t.Fatalf("image multiplier: got %v, want %v", imageMultiplier, baseMultiplier) + } + if !approxEq(textMultiplier, baseMultiplier) { + t.Fatalf("text multiplier should equal base off-peak: got %v, want %v", textMultiplier, baseMultiplier) + } + }) + + t.Run("image independent mode decoupled from peak", func(t *testing.T) { + indGroup := newPeakGroup(true, "14:00", "18:00", 3.0) + indGroup.ImageRateIndependent = true + indGroup.ImageRateMultiplier = 0.5 + indKey := &APIKey{Group: indGroup} + now := at(15, 30) + textMultiplier, imageMultiplier := computePeakAwareMultipliers(indKey, baseMultiplier, now) + if !approxEq(imageMultiplier, 0.5) { + t.Fatalf("independent image multiplier: got %v, want 0.5", imageMultiplier) + } + if want := baseMultiplier * 3.0; !approxEq(textMultiplier, want) { + t.Fatalf("text multiplier should include peak factor: got %v, want %v", textMultiplier, want) + } + }) + + t.Run("nil api key degrades to base multipliers", func(t *testing.T) { + now := at(15, 30) + textMultiplier, imageMultiplier := computePeakAwareMultipliers(nil, baseMultiplier, now) + if !approxEq(textMultiplier, baseMultiplier) { + t.Fatalf("nil group text multiplier: got %v, want %v", textMultiplier, baseMultiplier) + } + if !approxEq(imageMultiplier, baseMultiplier) { + t.Fatalf("nil group image multiplier: got %v, want %v", imageMultiplier, baseMultiplier) + } + }) +} + +// TestPeakMultiplier_SnapshotRoundTrip 防回归:认证缓存快照(APIKeyAuthGroupSnapshot) +// 必须携带高峰倍率 4 字段,否则扣费路径拿到的 apiKey.Group 会缺字段、PeakMultiplierAt 恒降级为 1.0。 +// 调用真实链路 snapshotFromAPIKey → snapshotToAPIKey,验证 peak 配置经快照往返后仍生效。 +func TestPeakMultiplier_SnapshotRoundTrip(t *testing.T) { + apiKey := &APIKey{ + User: &User{ID: 1, Status: StatusActive, Role: RoleUser}, + Group: newPeakGroup(true, "14:00", "18:00", 3.0), + } + svc := &APIKeyService{} + + snapshot := svc.snapshotFromAPIKey(context.Background(), apiKey) + if snapshot == nil || snapshot.Group == nil { + t.Fatalf("snapshot or snapshot.Group must not be nil") + } + restored := svc.snapshotToAPIKey("k", snapshot) + if restored.Group == nil { + t.Fatalf("restored.Group must not be nil") + } + + if !restored.Group.PeakRateEnabled || + restored.Group.PeakStart != "14:00" || + restored.Group.PeakEnd != "18:00" || + restored.Group.PeakRateMultiplier != 3.0 { + t.Fatalf("peak fields lost in snapshot round-trip: %+v", restored.Group) + } + if got := restored.Group.PeakMultiplierAt(at(15, 30)); got != 3.0 { + t.Fatalf("peak hour multiplier after round-trip: got %v, want 3.0", got) + } + if got := restored.Group.PeakMultiplierAt(at(20, 0)); got != 1.0 { + t.Fatalf("off-peak multiplier after round-trip: got %v, want 1.0", got) + } +} diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index af0062db75..8a15438a43 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -24,6 +24,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" "github.com/Wei-Shaw/sub2api/internal/pkg/ip" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" @@ -6283,7 +6284,9 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec } multiplier = resolver.Resolve(ctx, user.ID, *apiKey.GroupID, apiKey.Group.RateMultiplier) } - imageMultiplier := resolveImageRateMultiplier(apiKey, multiplier) + // 文本倍率叠加高峰因子(仅文本,图片倍率不受影响)。高峰因子按请求时刻现算, + // 不并入上面的 Resolve,以免污染 user:group 倍率缓存。 + multiplier, imageMultiplier := computePeakAwareMultipliers(apiKey, multiplier, timezone.Now()) var cost *CostBreakdown var err error diff --git a/backend/migrations/158_add_group_peak_rate_multiplier.sql b/backend/migrations/158_add_group_peak_rate_multiplier.sql new file mode 100644 index 0000000000..f9924de751 --- /dev/null +++ b/backend/migrations/158_add_group_peak_rate_multiplier.sql @@ -0,0 +1,4 @@ +ALTER TABLE groups ADD COLUMN IF NOT EXISTS peak_rate_enabled BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE groups ADD COLUMN IF NOT EXISTS peak_start VARCHAR(5) NOT NULL DEFAULT ''; +ALTER TABLE groups ADD COLUMN IF NOT EXISTS peak_end VARCHAR(5) NOT NULL DEFAULT ''; +ALTER TABLE groups ADD COLUMN IF NOT EXISTS peak_rate_multiplier DECIMAL(10,4) NOT NULL DEFAULT 1.0; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 4c5e58f777..790beee8d3 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -518,6 +518,11 @@ export interface Group { image_price_1k: number | null image_price_2k: number | null image_price_4k: number | null + // 高峰时段倍率配置 + peak_rate_enabled: boolean + peak_start: string + peak_end: string + peak_rate_multiplier: number // Claude Code 客户端限制 claude_code_only: boolean fallback_group_id: number | null @@ -636,6 +641,10 @@ export interface CreateGroupRequest { image_price_1k?: number | null image_price_2k?: number | null image_price_4k?: number | null + peak_rate_enabled?: boolean + peak_start?: string + peak_end?: string + peak_rate_multiplier?: number claude_code_only?: boolean fallback_group_id?: number | null fallback_group_id_on_invalid_request?: number | null @@ -671,6 +680,10 @@ export interface UpdateGroupRequest { image_price_1k?: number | null image_price_2k?: number | null image_price_4k?: number | null + peak_rate_enabled?: boolean + peak_start?: string + peak_end?: string + peak_rate_multiplier?: number claude_code_only?: boolean fallback_group_id?: number | null fallback_group_id_on_invalid_request?: number | null diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index 4fa1596eed..8c584f80b3 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -853,6 +853,52 @@ + +
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
@@ -2141,6 +2187,52 @@
+ +
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
@@ -3342,6 +3434,11 @@ const createForm = reactive({ image_price_1k: null as number | null, image_price_2k: null as number | null, image_price_4k: null as number | null, + // 高峰时段倍率配置 + peak_rate_enabled: false, + peak_start: "", + peak_end: "", + peak_rate_multiplier: 1.0, // Claude Code 客户端限制(仅 anthropic 平台使用) claude_code_only: false, fallback_group_id: null as number | null, @@ -3673,6 +3770,11 @@ const editForm = reactive({ image_price_1k: null as number | null, image_price_2k: null as number | null, image_price_4k: null as number | null, + // 高峰时段倍率配置 + peak_rate_enabled: false, + peak_start: "", + peak_end: "", + peak_rate_multiplier: 1.0, // Claude Code 客户端限制(仅 anthropic 平台使用) claude_code_only: false, fallback_group_id: null as number | null, @@ -3706,6 +3808,10 @@ type ImagePricingFormState = { image_price_1k: number | string | null; image_price_2k: number | string | null; image_price_4k: number | string | null; + peak_rate_enabled: boolean; + peak_start: string; + peak_end: string; + peak_rate_multiplier: number; }; const imagePricingTiers = [ @@ -3924,6 +4030,10 @@ const closeCreateModal = () => { createForm.image_price_1k = null; createForm.image_price_2k = null; createForm.image_price_4k = null; + createForm.peak_rate_enabled = false; + createForm.peak_start = ""; + createForm.peak_end = ""; + createForm.peak_rate_multiplier = 1.0; createForm.claude_code_only = false; createForm.fallback_group_id = null; createForm.fallback_group_id_on_invalid_request = null; @@ -3957,7 +4067,7 @@ const normalizeOptionalLimit = ( return Number.isFinite(value) && value > 0 ? value : null; }; -const normalizeImageRateMultiplier = ( +const normalizeRateMultiplier = ( value: number | string | null | undefined, ): number => { if (value === null || value === undefined || value === "") { @@ -4010,9 +4120,15 @@ const handleCreateGroup = async () => { requestData.daily_limit_usd = emptyToNull(requestData.daily_limit_usd); requestData.weekly_limit_usd = emptyToNull(requestData.weekly_limit_usd); requestData.monthly_limit_usd = emptyToNull(requestData.monthly_limit_usd); - requestData.image_rate_multiplier = normalizeImageRateMultiplier( + requestData.image_rate_multiplier = normalizeRateMultiplier( requestData.image_rate_multiplier, ); + requestData.peak_rate_enabled = createForm.peak_rate_enabled; + requestData.peak_start = createForm.peak_start; + requestData.peak_end = createForm.peak_end; + requestData.peak_rate_multiplier = normalizeRateMultiplier( + createForm.peak_rate_multiplier, + ); await adminAPI.groups.create(requestData); appStore.showSuccess(t("admin.groups.groupCreated")); closeCreateModal(); @@ -4050,6 +4166,10 @@ const handleEdit = async (group: AdminGroup) => { editForm.image_price_1k = group.image_price_1k; editForm.image_price_2k = group.image_price_2k; editForm.image_price_4k = group.image_price_4k; + editForm.peak_rate_enabled = group.peak_rate_enabled ?? false; + editForm.peak_start = group.peak_start ?? ""; + editForm.peak_end = group.peak_end ?? ""; + editForm.peak_rate_multiplier = group.peak_rate_multiplier ?? 1.0; editForm.claude_code_only = group.claude_code_only || false; editForm.fallback_group_id = group.fallback_group_id; editForm.fallback_group_id_on_invalid_request = @@ -4094,6 +4214,10 @@ const closeEditModal = () => { editingGroup.value = null; editModelRoutingRules.value = []; editForm.copy_accounts_from_group_ids = []; + editForm.peak_rate_enabled = false; + editForm.peak_start = ""; + editForm.peak_end = ""; + editForm.peak_rate_multiplier = 1.0; resetMessagesDispatchFormState(editForm); resetModelsListState(editModelsListState); }; @@ -4149,9 +4273,15 @@ const handleUpdateGroup = async () => { payload.daily_limit_usd = emptyToNull(payload.daily_limit_usd); payload.weekly_limit_usd = emptyToNull(payload.weekly_limit_usd); payload.monthly_limit_usd = emptyToNull(payload.monthly_limit_usd); - payload.image_rate_multiplier = normalizeImageRateMultiplier( + payload.image_rate_multiplier = normalizeRateMultiplier( payload.image_rate_multiplier, ); + payload.peak_rate_enabled = editForm.peak_rate_enabled; + payload.peak_start = editForm.peak_start; + payload.peak_end = editForm.peak_end; + payload.peak_rate_multiplier = normalizeRateMultiplier( + editForm.peak_rate_multiplier, + ); await adminAPI.groups.update(editingGroup.value.id, payload); appStore.showSuccess(t("admin.groups.groupUpdated")); closeEditModal(); @@ -4222,13 +4352,31 @@ const confirmDelete = async () => { } }; -// 监听 subscription_type 变化,订阅模式时 is_exclusive 默认为 true +// 监听 subscription_type 变化,订阅模式时 is_exclusive 默认为 true;标准模式清空高峰配置 watch( () => createForm.subscription_type, (newVal) => { if (newVal === "subscription") { createForm.is_exclusive = true; createForm.fallback_group_id_on_invalid_request = null; + } else { + createForm.peak_rate_enabled = false; + createForm.peak_start = ""; + createForm.peak_end = ""; + createForm.peak_rate_multiplier = 1.0; + } + }, +); + +// 编辑表单:切回标准模式时清空高峰配置,避免残留随更新请求提交被后端拒绝 +watch( + () => editForm.subscription_type, + (newVal) => { + if (newVal !== "subscription") { + editForm.peak_rate_enabled = false; + editForm.peak_start = ""; + editForm.peak_end = ""; + editForm.peak_rate_multiplier = 1.0; } }, ); From e998f8044bfcff257be3752cdc66100e68c25728 Mon Sep 17 00:00:00 2001 From: xueshiji Date: Wed, 1 Jul 2026 14:34:57 +0800 Subject: [PATCH 2/6] fix: ci --- backend/internal/server/api_contract_test.go | 4 ++++ backend/internal/service/group.go | 5 +++-- backend/internal/service/openai_gateway_service.go | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index c8463da39d..6a6396bc07 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -344,6 +344,10 @@ func TestAPIContracts(t *testing.T) { "description": "desc", "platform": "anthropic", "rate_multiplier": 1.5, + "peak_rate_enabled": false, + "peak_start": "", + "peak_end": "", + "peak_rate_multiplier": 0, "is_exclusive": false, "status": "active", "subscription_type": "standard", diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go index f1e7fcb283..d116d1f83f 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -26,8 +26,8 @@ type Group struct { PeakEnd string PeakRateMultiplier float64 IsExclusive bool - Status string - Hydrated bool // indicates the group was loaded from a trusted repository source + Status string + Hydrated bool // indicates the group was loaded from a trusted repository source SubscriptionType string DailyLimitUSD *float64 @@ -190,6 +190,7 @@ func parseMinutes(hhmm string) (int, bool) { // - 未启用 / 未配置 / 配置非法(start>=end 或格式错误) / 非高峰时段 → 返回 1.0(安全降级) // - 区间为左闭右开 [PeakStart, PeakEnd),仅支持当日区间,不支持跨天(如 22:00-次日02:00) // - 时刻基于全局系统时区(timezone.Location)判定 +// // 该方法是纯函数,不读取任何外部状态,便于单测。 func (g *Group) PeakMultiplierAt(now time.Time) float64 { if g == nil || !g.IsSubscriptionType() || !g.PeakRateEnabled || g.PeakStart == "" || g.PeakEnd == "" { diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 0f1a6efee2..6626292376 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -24,9 +24,9 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" "github.com/Wei-Shaw/sub2api/internal/pkg/ip" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" - "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator" From ebd83dc0f9f84a6f0dee58e58bd0bab8220787bf Mon Sep 17 00:00:00 2001 From: xueshiji Date: Wed, 1 Jul 2026 14:43:22 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20=E8=A7=A6=E5=8F=91ci?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/service/group.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go index d116d1f83f..70df6714ff 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -177,7 +177,7 @@ func matchModelPattern(pattern, model string) bool { return false } -// parseMinutes 把 "HH:MM" 解析为当日分钟数(0..1439)。格式非法返回 (0,false)。 +// parseMinutes 把 "HH:MM" 解析为当日分钟数(0..1439),格式非法返回 (0,false)。 func parseMinutes(hhmm string) (int, bool) { t, err := time.Parse("15:04", hhmm) if err != nil { From 1034f576d79902aa910b14c6f1db636f798e023b Mon Sep 17 00:00:00 2001 From: xueshiji Date: Wed, 1 Jul 2026 16:22:52 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20=E9=AB=98=E5=B3=B0=E5=80=8D=E7=8E=87?= =?UTF-8?q?=E5=85=A8=E9=93=BE=E8=B7=AF=E9=80=8F=E4=BC=A0=E3=80=81=E8=AE=A1?= =?UTF-8?q?=E8=B4=B9=E6=9C=AF=E8=AF=AD=E4=BF=AE=E6=AD=A3=E4=B8=8E=E8=BE=B9?= =?UTF-8?q?=E7=95=8C=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 高峰倍率信息透传至可用渠道、支付计划、结算信息等 API,前端 GroupBadge / GroupOptionItem / SubscriptionPlanCard / PaymentView / SubscriptionsView 统一展示高峰时段与倍率标签 - 修正计费术语:"文本倍率" → "token 倍率",明确高峰倍率同时作用于 token 计费的图片 token,图片按次计费不受高峰影响 - 允许高峰倍率 multiplier=0,支持高峰时段免费策略 - 切换分组类型为 standard 时自动清除高峰倍率配置 - 长上下文计费与标准计费路径改用内部实现,移除冗余中间调用 - 前端高峰倍率相关控件文案改为 i18n - 新增多组高峰倍率相关单元测试 --- backend/ent/schema/group.go | 2 +- .../handler/available_channel_handler.go | 34 +++--- .../handler/available_channel_handler_test.go | 4 +- backend/internal/handler/payment_handler.go | 83 ++++++++------ backend/internal/service/admin_service.go | 6 + .../service/admin_service_group_test.go | 28 +++++ .../service/admin_service_peak_rate_test.go | 106 ++++++++++++++++++ .../internal/service/api_key_auth_cache.go | 2 +- backend/internal/service/billing_service.go | 12 +- .../internal/service/billing_service_test.go | 4 +- .../service/billing_service_unified_test.go | 24 +++- backend/internal/service/channel_available.go | 36 +++--- .../service/gateway_record_usage_test.go | 53 +++++++++ backend/internal/service/gateway_service.go | 9 +- backend/internal/service/group.go | 9 +- .../internal/service/group_peak_rate_test.go | 28 ++--- .../openai_gateway_record_usage_test.go | 86 ++++++++++++++ .../service/openai_gateway_service.go | 2 +- .../internal/service/payment_config_plans.go | 36 +++--- frontend/src/api/channels.ts | 4 + .../admin/user/UserApiKeysModal.vue | 8 ++ .../channels/AvailableChannelsTable.vue | 68 ++++++++--- frontend/src/components/common/GroupBadge.vue | 24 ++++ .../src/components/common/GroupOptionItem.vue | 48 ++++++-- .../payment/SubscriptionPlanCard.vue | 12 ++ frontend/src/i18n/locales/en.ts | 8 ++ frontend/src/i18n/locales/zh.ts | 8 ++ frontend/src/types/payment.ts | 4 + frontend/src/views/admin/GroupsView.vue | 18 +-- frontend/src/views/user/KeysView.vue | 24 ++++ frontend/src/views/user/PaymentView.vue | 25 +++++ frontend/src/views/user/SubscriptionsView.vue | 16 +++ 32 files changed, 686 insertions(+), 145 deletions(-) create mode 100644 backend/internal/service/admin_service_peak_rate_test.go diff --git a/backend/ent/schema/group.go b/backend/ent/schema/group.go index 306d088682..2b8420db6d 100644 --- a/backend/ent/schema/group.go +++ b/backend/ent/schema/group.go @@ -45,7 +45,7 @@ func (Group) Fields() []ent.Field { field.Float("rate_multiplier"). SchemaType(map[string]string{dialect.Postgres: "decimal(10,4)"}). Default(1.0), - // 高峰时段倍率(added by migration 156) + // 高峰时段倍率(added by migration 158) field.Bool("peak_rate_enabled"). Default(false). Comment("是否启用高峰时段倍率"), diff --git a/backend/internal/handler/available_channel_handler.go b/backend/internal/handler/available_channel_handler.go index 8982b80def..8da4c07973 100644 --- a/backend/internal/handler/available_channel_handler.go +++ b/backend/internal/handler/available_channel_handler.go @@ -50,15 +50,19 @@ func (h *AvailableChannelHandler) featureEnabled(c *gin.Context) bool { // userAvailableGroup 用户可见的分组概要(白名单字段)。 // // 前端据此区分专属 vs 公开分组(IsExclusive)、订阅 vs 标准分组(SubscriptionType, -// 订阅视觉加深),并用 RateMultiplier 作为默认倍率;用户专属倍率前端走 +// 订阅视觉加深),并展示默认倍率与高峰倍率规则;用户专属倍率前端走 // /groups/rates,和 API 密钥页面保持一致。 type userAvailableGroup struct { - ID int64 `json:"id"` - Name string `json:"name"` - Platform string `json:"platform"` - SubscriptionType string `json:"subscription_type"` - RateMultiplier float64 `json:"rate_multiplier"` - IsExclusive bool `json:"is_exclusive"` + ID int64 `json:"id"` + Name string `json:"name"` + Platform string `json:"platform"` + SubscriptionType string `json:"subscription_type"` + RateMultiplier float64 `json:"rate_multiplier"` + PeakRateEnabled bool `json:"peak_rate_enabled"` + PeakStart string `json:"peak_start"` + PeakEnd string `json:"peak_end"` + PeakRateMultiplier float64 `json:"peak_rate_multiplier"` + IsExclusive bool `json:"is_exclusive"` } // userSupportedModelPricing 用户可见的定价字段白名单。 @@ -213,12 +217,16 @@ func filterUserVisibleGroups( continue } visible = append(visible, userAvailableGroup{ - ID: g.ID, - Name: g.Name, - Platform: g.Platform, - SubscriptionType: g.SubscriptionType, - RateMultiplier: g.RateMultiplier, - IsExclusive: g.IsExclusive, + ID: g.ID, + Name: g.Name, + Platform: g.Platform, + SubscriptionType: g.SubscriptionType, + RateMultiplier: g.RateMultiplier, + PeakRateEnabled: g.PeakRateEnabled, + PeakStart: g.PeakStart, + PeakEnd: g.PeakEnd, + PeakRateMultiplier: g.PeakRateMultiplier, + IsExclusive: g.IsExclusive, }) } return visible diff --git a/backend/internal/handler/available_channel_handler_test.go b/backend/internal/handler/available_channel_handler_test.go index 0a7ce6c466..bb53297980 100644 --- a/backend/internal/handler/available_channel_handler_test.go +++ b/backend/internal/handler/available_channel_handler_test.go @@ -101,13 +101,13 @@ func TestUserAvailableChannel_FieldWhitelist(t *testing.T) { require.Truef(t, exists, "platform section must expose %q", key) } - // Group DTO 暴露区分专属/公开、订阅类型、默认倍率所需的字段, + // Group DTO 暴露区分专属/公开、订阅类型、默认倍率和高峰倍率规则所需的字段, // 前端据此渲染 GroupBadge 并与 API 密钥页保持一致的视觉。 rawGroup, err := json.Marshal(row.Platforms[0].Groups[0]) require.NoError(t, err) var groupDecoded map[string]any require.NoError(t, json.Unmarshal(rawGroup, &groupDecoded)) - for _, key := range []string{"id", "name", "platform", "subscription_type", "rate_multiplier", "is_exclusive"} { + for _, key := range []string{"id", "name", "platform", "subscription_type", "rate_multiplier", "peak_rate_enabled", "peak_start", "peak_end", "peak_rate_multiplier", "is_exclusive"} { _, exists := groupDecoded[key] require.Truef(t, exists, "group DTO must expose %q", key) } diff --git a/backend/internal/handler/payment_handler.go b/backend/internal/handler/payment_handler.go index 4047cbfd27..7cdf73cd3d 100644 --- a/backend/internal/handler/payment_handler.go +++ b/backend/internal/handler/payment_handler.go @@ -54,25 +54,35 @@ func (h *PaymentHandler) GetPlans(c *gin.Context) { } // Enrich plans with group platform for frontend color coding type planWithPlatform struct { - ID int64 `json:"id"` - GroupID int64 `json:"group_id"` - GroupPlatform string `json:"group_platform"` - Name string `json:"name"` - Description string `json:"description"` - Price float64 `json:"price"` - OriginalPrice *float64 `json:"original_price,omitempty"` - ValidityDays int `json:"validity_days"` - ValidityUnit string `json:"validity_unit"` - Features string `json:"features"` - ProductName string `json:"product_name"` - ForSale bool `json:"for_sale"` - SortOrder int `json:"sort_order"` + ID int64 `json:"id"` + GroupID int64 `json:"group_id"` + GroupPlatform string `json:"group_platform"` + GroupName string `json:"group_name"` + RateMultiplier float64 `json:"rate_multiplier"` + PeakRateEnabled bool `json:"peak_rate_enabled"` + PeakStart string `json:"peak_start"` + PeakEnd string `json:"peak_end"` + PeakRateMultiplier float64 `json:"peak_rate_multiplier"` + Name string `json:"name"` + Description string `json:"description"` + Price float64 `json:"price"` + OriginalPrice *float64 `json:"original_price,omitempty"` + ValidityDays int `json:"validity_days"` + ValidityUnit string `json:"validity_unit"` + Features string `json:"features"` + ProductName string `json:"product_name"` + ForSale bool `json:"for_sale"` + SortOrder int `json:"sort_order"` } - platformMap := h.configService.GetGroupPlatformMap(c.Request.Context(), plans) + groupInfo := h.configService.GetGroupInfoMap(c.Request.Context(), plans) result := make([]planWithPlatform, 0, len(plans)) for _, p := range plans { + gi := groupInfo[p.GroupID] result = append(result, planWithPlatform{ - ID: int64(p.ID), GroupID: p.GroupID, GroupPlatform: platformMap[p.GroupID], + ID: int64(p.ID), GroupID: p.GroupID, + GroupPlatform: gi.Platform, GroupName: 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, ValidityDays: p.ValidityDays, ValidityUnit: p.ValidityUnit, Features: p.Features, ProductName: p.ProductName, ForSale: p.ForSale, SortOrder: p.SortOrder, @@ -121,7 +131,10 @@ func (h *PaymentHandler) GetCheckoutInfo(c *gin.Context) { planList = append(planList, checkoutPlan{ ID: int64(p.ID), GroupID: p.GroupID, GroupPlatform: gi.Platform, GroupName: gi.Name, - RateMultiplier: gi.RateMultiplier, DailyLimitUSD: gi.DailyLimitUSD, + RateMultiplier: gi.RateMultiplier, + PeakRateEnabled: gi.PeakRateEnabled, PeakStart: gi.PeakStart, + PeakEnd: gi.PeakEnd, PeakRateMultiplier: gi.PeakRateMultiplier, + DailyLimitUSD: gi.DailyLimitUSD, WeeklyLimitUSD: gi.WeeklyLimitUSD, MonthlyLimitUSD: gi.MonthlyLimitUSD, ModelScopes: gi.ModelScopes, Name: p.Name, Description: p.Description, Price: p.Price, OriginalPrice: p.OriginalPrice, @@ -160,23 +173,27 @@ type checkoutInfoResponse struct { } type checkoutPlan struct { - ID int64 `json:"id"` - GroupID int64 `json:"group_id"` - GroupPlatform string `json:"group_platform"` - GroupName string `json:"group_name"` - RateMultiplier float64 `json:"rate_multiplier"` - DailyLimitUSD *float64 `json:"daily_limit_usd"` - WeeklyLimitUSD *float64 `json:"weekly_limit_usd"` - MonthlyLimitUSD *float64 `json:"monthly_limit_usd"` - ModelScopes []string `json:"supported_model_scopes"` - Name string `json:"name"` - Description string `json:"description"` - Price float64 `json:"price"` - OriginalPrice *float64 `json:"original_price,omitempty"` - ValidityDays int `json:"validity_days"` - ValidityUnit string `json:"validity_unit"` - Features []string `json:"features"` - ProductName string `json:"product_name"` + ID int64 `json:"id"` + GroupID int64 `json:"group_id"` + GroupPlatform string `json:"group_platform"` + GroupName string `json:"group_name"` + RateMultiplier float64 `json:"rate_multiplier"` + PeakRateEnabled bool `json:"peak_rate_enabled"` + PeakStart string `json:"peak_start"` + PeakEnd string `json:"peak_end"` + PeakRateMultiplier float64 `json:"peak_rate_multiplier"` + DailyLimitUSD *float64 `json:"daily_limit_usd"` + WeeklyLimitUSD *float64 `json:"weekly_limit_usd"` + MonthlyLimitUSD *float64 `json:"monthly_limit_usd"` + ModelScopes []string `json:"supported_model_scopes"` + Name string `json:"name"` + Description string `json:"description"` + Price float64 `json:"price"` + OriginalPrice *float64 `json:"original_price,omitempty"` + ValidityDays int `json:"validity_days"` + ValidityUnit string `json:"validity_unit"` + Features []string `json:"features"` + ProductName string `json:"product_name"` } // parseFeatures splits a newline-separated features string into a string slice. diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index ed83beef89..2a3189f61f 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -2128,6 +2128,12 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd if input.PeakRateMultiplier != nil { group.PeakRateMultiplier = *input.PeakRateMultiplier } + if group.SubscriptionType != SubscriptionTypeSubscription { + group.PeakRateEnabled = false + group.PeakStart = "" + group.PeakEnd = "" + group.PeakRateMultiplier = 1.0 + } // 收敛校验:Update 可能只传部分 peak 字段,需对合并后的最终配置统一校验, // 防止单独修改 start/end 导致最终 start>=end 等非法配置入库。 if err := ValidatePeakRateConfig(group.SubscriptionType, group.PeakRateEnabled, group.PeakStart, group.PeakEnd, group.PeakRateMultiplier); err != nil { diff --git a/backend/internal/service/admin_service_group_test.go b/backend/internal/service/admin_service_group_test.go index eb3eff7f69..2951e13c53 100644 --- a/backend/internal/service/admin_service_group_test.go +++ b/backend/internal/service/admin_service_group_test.go @@ -375,6 +375,34 @@ func TestAdminService_UpdateGroup_InvalidatesAuthCacheOnRPMLimitChange(t *testin require.Equal(t, []int64{1}, invalidator.groupIDs, "分组 RPMLimit 写入 auth snapshot,变更后必须失效 API Key 认证缓存") } +func TestAdminService_UpdateGroup_ClearsPeakRateWhenChangingToStandard(t *testing.T) { + existingGroup := &Group{ + ID: 1, + Name: "existing-group", + Platform: PlatformOpenAI, + Status: StatusActive, + SubscriptionType: SubscriptionTypeSubscription, + PeakRateEnabled: true, + PeakStart: "14:00", + PeakEnd: "18:00", + PeakRateMultiplier: 3, + } + repo := &groupRepoStubForAdmin{getByID: existingGroup} + svc := &adminServiceImpl{groupRepo: repo} + + group, err := svc.UpdateGroup(context.Background(), 1, &UpdateGroupInput{ + SubscriptionType: SubscriptionTypeStandard, + }) + require.NoError(t, err) + require.NotNil(t, group) + require.NotNil(t, repo.updated) + require.Equal(t, SubscriptionTypeStandard, repo.updated.SubscriptionType) + require.False(t, repo.updated.PeakRateEnabled) + require.Equal(t, "", repo.updated.PeakStart) + require.Equal(t, "", repo.updated.PeakEnd) + require.Equal(t, 1.0, repo.updated.PeakRateMultiplier) +} + func TestAdminService_CreateGroup_NormalizesMessagesDispatchModelConfig(t *testing.T) { repo := &groupRepoStubForAdmin{} svc := &adminServiceImpl{groupRepo: repo} diff --git a/backend/internal/service/admin_service_peak_rate_test.go b/backend/internal/service/admin_service_peak_rate_test.go new file mode 100644 index 0000000000..87f6f497b1 --- /dev/null +++ b/backend/internal/service/admin_service_peak_rate_test.go @@ -0,0 +1,106 @@ +package service + +import ( + "context" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/stretchr/testify/require" +) + +type peakRateGroupRepoStub struct { + getByID *Group + updated *Group +} + +func (s *peakRateGroupRepoStub) Create(context.Context, *Group) error { + panic("unexpected Create call") +} + +func (s *peakRateGroupRepoStub) GetByID(context.Context, int64) (*Group, error) { + return s.getByID, nil +} + +func (s *peakRateGroupRepoStub) GetByIDLite(context.Context, int64) (*Group, error) { + return s.getByID, nil +} + +func (s *peakRateGroupRepoStub) Update(_ context.Context, group *Group) error { + s.updated = group + return nil +} + +func (s *peakRateGroupRepoStub) Delete(context.Context, int64) error { + panic("unexpected Delete call") +} + +func (s *peakRateGroupRepoStub) DeleteCascade(context.Context, int64) ([]int64, error) { + panic("unexpected DeleteCascade call") +} + +func (s *peakRateGroupRepoStub) List(context.Context, pagination.PaginationParams) ([]Group, *pagination.PaginationResult, error) { + panic("unexpected List call") +} + +func (s *peakRateGroupRepoStub) ListWithFilters(context.Context, pagination.PaginationParams, string, string, string, *bool) ([]Group, *pagination.PaginationResult, error) { + panic("unexpected ListWithFilters call") +} + +func (s *peakRateGroupRepoStub) ListActive(context.Context) ([]Group, error) { + panic("unexpected ListActive call") +} + +func (s *peakRateGroupRepoStub) ListActiveByPlatform(context.Context, string) ([]Group, error) { + panic("unexpected ListActiveByPlatform call") +} + +func (s *peakRateGroupRepoStub) ExistsByName(context.Context, string) (bool, error) { + panic("unexpected ExistsByName call") +} + +func (s *peakRateGroupRepoStub) GetAccountCount(context.Context, int64) (int64, int64, error) { + panic("unexpected GetAccountCount call") +} + +func (s *peakRateGroupRepoStub) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) { + panic("unexpected DeleteAccountGroupsByGroupID call") +} + +func (s *peakRateGroupRepoStub) GetAccountIDsByGroupIDs(context.Context, []int64) ([]int64, error) { + panic("unexpected GetAccountIDsByGroupIDs call") +} + +func (s *peakRateGroupRepoStub) BindAccountsToGroup(context.Context, int64, []int64) error { + panic("unexpected BindAccountsToGroup call") +} + +func (s *peakRateGroupRepoStub) UpdateSortOrders(context.Context, []GroupSortOrderUpdate) error { + panic("unexpected UpdateSortOrders call") +} + +func TestAdminService_UpdateGroup_ClearsPeakRateWhenChangingToStandardDefault(t *testing.T) { + repo := &peakRateGroupRepoStub{getByID: &Group{ + ID: 1, + Name: "existing-group", + Platform: PlatformOpenAI, + Status: StatusActive, + SubscriptionType: SubscriptionTypeSubscription, + PeakRateEnabled: true, + PeakStart: "14:00", + PeakEnd: "18:00", + PeakRateMultiplier: 3, + }} + svc := &adminServiceImpl{groupRepo: repo} + + group, err := svc.UpdateGroup(context.Background(), 1, &UpdateGroupInput{ + SubscriptionType: SubscriptionTypeStandard, + }) + require.NoError(t, err) + require.NotNil(t, group) + require.NotNil(t, repo.updated) + require.Equal(t, SubscriptionTypeStandard, repo.updated.SubscriptionType) + require.False(t, repo.updated.PeakRateEnabled) + require.Equal(t, "", repo.updated.PeakStart) + require.Equal(t, "", repo.updated.PeakEnd) + require.Equal(t, 1.0, repo.updated.PeakRateMultiplier) +} diff --git a/backend/internal/service/api_key_auth_cache.go b/backend/internal/service/api_key_auth_cache.go index 8089e90b97..32c3910c9d 100644 --- a/backend/internal/service/api_key_auth_cache.go +++ b/backend/internal/service/api_key_auth_cache.go @@ -95,7 +95,7 @@ type APIKeyAuthGroupSnapshot struct { RPMLimit int `json:"rpm_limit"` // 高峰时段倍率:PeakRateEnabled 为 true 且请求时刻处于 [PeakStart, PeakEnd) 时, - // 文本计费倍率额外乘以 PeakRateMultiplier(详见 Group.PeakMultiplierAt)。 + // token 计费倍率额外乘以 PeakRateMultiplier(详见 Group.PeakMultiplierAt)。 // 必须随快照缓存,否则扣费路径拿到的 apiKey.Group 缺字段、高峰倍率失效。 PeakRateEnabled bool `json:"peak_rate_enabled"` PeakStart string `json:"peak_start"` diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index a781936598..891b46c878 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -1080,15 +1080,19 @@ func (s *BillingService) CalculateCostWithConfig(model string, tokens UsageToken // 拆分为:范围内 (200k, 0) + 范围外 (10k, 10k) // 范围内正常计费,范围外 × 2 计费 func (s *BillingService) CalculateCostWithLongContext(model string, tokens UsageTokens, rateMultiplier float64, threshold int, extraMultiplier float64) (*CostBreakdown, error) { + return s.calculateCostWithLongContext(model, tokens, rateMultiplier, threshold, extraMultiplier) +} + +func (s *BillingService) calculateCostWithLongContext(model string, tokens UsageTokens, rateMultiplier float64, threshold int, extraMultiplier float64) (*CostBreakdown, error) { // 未启用长上下文计费,直接走正常计费 if threshold <= 0 || extraMultiplier <= 1 { - return s.CalculateCost(model, tokens, rateMultiplier) + return s.calculateCostInternal(model, tokens, rateMultiplier, "", nil) } // 计算总输入 token(缓存读取 + 新输入) total := tokens.CacheReadTokens + tokens.InputTokens if total <= threshold { - return s.CalculateCost(model, tokens, rateMultiplier) + return s.calculateCostInternal(model, tokens, rateMultiplier, "", nil) } // 拆分成范围内和范围外 @@ -1119,7 +1123,7 @@ func (s *BillingService) CalculateCostWithLongContext(model string, tokens Usage CacheCreation1hTokens: tokens.CacheCreation1hTokens, ImageOutputTokens: tokens.ImageOutputTokens, } - inRangeCost, err := s.CalculateCost(model, inRangeTokens, rateMultiplier) + inRangeCost, err := s.calculateCostInternal(model, inRangeTokens, rateMultiplier, "", nil) if err != nil { return nil, err } @@ -1129,7 +1133,7 @@ func (s *BillingService) CalculateCostWithLongContext(model string, tokens Usage InputTokens: outRangeInputTokens, CacheReadTokens: outRangeCacheTokens, } - outRangeCost, err := s.CalculateCost(model, outRangeTokens, rateMultiplier*extraMultiplier) + outRangeCost, err := s.calculateCostInternal(model, outRangeTokens, rateMultiplier*extraMultiplier, "", nil) if err != nil { return inRangeCost, fmt.Errorf("out-range cost: %w", err) } diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index 92c143c6ff..88debae99a 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -1377,7 +1377,7 @@ func TestComputeTokenBreakdown_ExplicitZeroImagePrice_NoFallback(t *testing.T) { OutputTokens: 200, ImageOutputTokens: 50, } - bd := svc.computeTokenBreakdown(pricing, tokens, 1.0, "", false) + bd := svc.computeTokenBreakdown(pricing, tokens, 1.0, 0, false, "", false) // ImageOutputTokens should NOT fall back to outputPrice require.Equal(t, 0.0, bd.ImageOutputCost) @@ -1399,7 +1399,7 @@ func TestComputeTokenBreakdown_NonExplicitZeroImagePrice_FallsBackToOutput(t *te OutputTokens: 200, ImageOutputTokens: 50, } - bd := svc.computeTokenBreakdown(pricing, tokens, 1.0, "", false) + bd := svc.computeTokenBreakdown(pricing, tokens, 1.0, 0, false, "", false) // Should fall back to outputPrice since not explicit require.InDelta(t, 50*15e-6, bd.ImageOutputCost, 1e-12) diff --git a/backend/internal/service/billing_service_unified_test.go b/backend/internal/service/billing_service_unified_test.go index e6a92d1a8c..db7437d6ba 100644 --- a/backend/internal/service/billing_service_unified_test.go +++ b/backend/internal/service/billing_service_unified_test.go @@ -29,7 +29,7 @@ func TestCalculateCostUnified_NilResolver_FallsBackToOldPath(t *testing.T) { require.NoError(t, err) // Should match the old-path result exactly - expected, err := svc.calculateCostInternal("claude-sonnet-4", tokens, 1.0, "", nil) + expected, err := svc.calculateCostInternal("claude-sonnet-4", tokens, 1.0, 0, false, "", nil) require.NoError(t, err) require.InDelta(t, expected.TotalCost, cost.TotalCost, 1e-10) require.InDelta(t, expected.ActualCost, cost.ActualCost, 1e-10) @@ -60,6 +60,28 @@ func TestCalculateCostUnified_TokenMode(t *testing.T) { require.Equal(t, string(BillingModeToken), cost.BillingMode) } +func TestCalculateCostUnified_TokenModeAppliesRateMultiplierToImageTokens(t *testing.T) { + bs := newTestBillingService() + resolver := NewModelPricingResolver(nil, bs) + + tokens := UsageTokens{InputTokens: 1000, OutputTokens: 600, ImageOutputTokens: 100} + cost, err := bs.CalculateCostUnified(CostInput{ + Ctx: context.Background(), + Model: "claude-sonnet-4", + Tokens: tokens, + RateMultiplier: 3.0, + Resolver: resolver, + }) + require.NoError(t, err) + + textInput := 1000 * 3e-6 + textOutput := 500 * 15e-6 + imageOutput := 100 * 15e-6 + require.InDelta(t, textInput+textOutput+imageOutput, cost.TotalCost, 1e-10) + require.InDelta(t, (textInput+textOutput+imageOutput)*3.0, cost.ActualCost, 1e-10) + require.InDelta(t, imageOutput, cost.ImageOutputCost, 1e-10) +} + func TestCalculateCostUnified_PerRequestMode(t *testing.T) { // Set up a ChannelService with a per-request pricing channel cs := newTestChannelServiceWithCache(t, &channelCache{ diff --git a/backend/internal/service/channel_available.go b/backend/internal/service/channel_available.go index d2d24659a1..eeaf7dac2e 100644 --- a/backend/internal/service/channel_available.go +++ b/backend/internal/service/channel_available.go @@ -10,15 +10,19 @@ import ( // AvailableGroupRef 渠道视图中关联分组的简要信息。 // // 用户侧「可用渠道」页面据此展示:专属分组 vs 公开分组(IsExclusive)、 -// 订阅 vs 标准(SubscriptionType)、默认倍率(RateMultiplier)。用户专属倍率 -// 不在这里暴露,前端自己通过 /groups/rates 拉取,和 API 密钥页面保持一致。 +// 订阅 vs 标准(SubscriptionType)、默认倍率(RateMultiplier)与高峰倍率规则。 +// 用户专属倍率不在这里暴露,前端自己通过 /groups/rates 拉取,和 API 密钥页面保持一致。 type AvailableGroupRef struct { - ID int64 - Name string - Platform string - SubscriptionType string - RateMultiplier float64 - IsExclusive bool + ID int64 + Name string + Platform string + SubscriptionType string + RateMultiplier float64 + PeakRateEnabled bool + PeakStart string + PeakEnd string + PeakRateMultiplier float64 + IsExclusive bool } // AvailableChannel 可用渠道视图:用于「可用渠道」页面展示渠道基础信息 + @@ -59,12 +63,16 @@ func (s *ChannelService) ListAvailable(ctx context.Context) ([]AvailableChannel, for i := range groups { g := groups[i] groupByID[g.ID] = AvailableGroupRef{ - ID: g.ID, - Name: g.Name, - Platform: g.Platform, - SubscriptionType: g.SubscriptionType, - RateMultiplier: g.RateMultiplier, - IsExclusive: g.IsExclusive, + ID: g.ID, + Name: g.Name, + Platform: g.Platform, + SubscriptionType: g.SubscriptionType, + RateMultiplier: g.RateMultiplier, + PeakRateEnabled: g.PeakRateEnabled, + PeakStart: g.PeakStart, + PeakEnd: g.PeakEnd, + PeakRateMultiplier: g.PeakRateMultiplier, + IsExclusive: g.IsExclusive, } } diff --git a/backend/internal/service/gateway_record_usage_test.go b/backend/internal/service/gateway_record_usage_test.go index f8d98e55cd..c819eeca6e 100644 --- a/backend/internal/service/gateway_record_usage_test.go +++ b/backend/internal/service/gateway_record_usage_test.go @@ -233,6 +233,59 @@ func TestGatewayServiceRecordUsage_EmptyImageSizeDefaultsBeforeBillingAndPersist require.InDelta(t, 0.19, usageRepo.lastLog.ActualCost, 1e-12) } +func TestGatewayServiceRecordUsage_PeakRateAffectsTokenModeImageOutputTokens(t *testing.T) { + groupID := int64(902) + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + svc := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}) + svc.resolver = newOpenAITokenImageChannelPricingResolverForTest(t, groupID, "gemini-image") + + err := svc.RecordUsage(context.Background(), &RecordUsageInput{ + Result: &ForwardResult{ + RequestID: "gateway_peak_image_tokens", + Model: "gemini-image", + ImageCount: 1, + Usage: ClaudeUsage{ + InputTokens: 1000, + OutputTokens: 600, + ImageOutputTokens: 100, + }, + Duration: time.Second, + }, + APIKey: &APIKey{ + ID: 802, + GroupID: i64p(groupID), + Group: &Group{ + ID: groupID, + RateMultiplier: 1.0, + SubscriptionType: SubscriptionTypeSubscription, + PeakRateEnabled: true, + PeakStart: "00:00", + PeakEnd: "23:59", + PeakRateMultiplier: 3.0, + }, + }, + User: &User{ID: 602}, + Account: &Account{ID: 702}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.NotNil(t, usageRepo.lastLog.BillingMode) + require.Equal(t, string(BillingModeToken), *usageRepo.lastLog.BillingMode) + require.Equal(t, 3.0, usageRepo.lastLog.RateMultiplier) + + textInput := 1000 * 3e-6 + textOutput := 500 * 15e-6 + imageOutput := 100 * 15e-6 + expectedActual := (textInput + textOutput + imageOutput) * 3.0 + + require.InDelta(t, textInput+textOutput+imageOutput, usageRepo.lastLog.TotalCost, 1e-12) + require.InDelta(t, imageOutput, usageRepo.lastLog.ImageOutputCost, 1e-12) + require.InDelta(t, expectedActual, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, expectedActual, userRepo.lastAmount, 1e-12) +} + func TestGatewayServiceRecordUsage_UsageLogWriteErrorDoesNotSkipBilling(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: false, err: MarkUsageLogCreateNotPersisted(context.Canceled)} userRepo := &openAIRecordUsageUserRepoStub{} diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 9a6b06a28e..e4ccced84b 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -9474,7 +9474,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage groupDefault := apiKey.Group.RateMultiplier multiplier = s.getUserGroupRateMultiplier(ctx, user.ID, *apiKey.GroupID, groupDefault) } - // 文本倍率叠加高峰因子(仅文本,图片倍率不受影响)。高峰因子按请求时刻现算, + // token 倍率叠加高峰因子(token 计费含图片 token,图片按次倍率不受影响)。高峰因子按请求时刻现算, // 不并入上面的 getUserGroupRateMultiplier,以免污染 user:group 倍率缓存。 multiplier, imageMultiplier := computePeakAwareMultipliers(apiKey, multiplier, timezone.Now()) @@ -9679,12 +9679,9 @@ func (s *GatewayService) calculateTokenCost( }) } else if opts.LongContextThreshold > 0 { // 长上下文双倍计费(如 Gemini 200K 阈值) - cost, err = s.billingService.CalculateCostWithLongContext( - billingModel, tokens, multiplier, - opts.LongContextThreshold, opts.LongContextMultiplier, - ) + cost, err = s.billingService.CalculateCostWithLongContext(billingModel, tokens, multiplier, opts.LongContextThreshold, opts.LongContextMultiplier) } else { - cost, err = s.billingService.CalculateCost(billingModel, tokens, multiplier) + cost, err = s.billingService.CalculateCostWithServiceTier(billingModel, tokens, multiplier, "") } if err != nil { logger.LegacyPrintf("service.gateway", "Calculate cost failed: %v", err) diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go index 70df6714ff..f759e83073 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -20,7 +20,7 @@ type Group struct { Platform string RateMultiplier float64 // 高峰时段倍率:peak_rate_enabled 为 true 且当前时刻处于 [PeakStart, PeakEnd) 时, - // 文本计费倍率额外乘以 PeakRateMultiplier。详见 PeakMultiplierAt。 + // token 计费倍率额外乘以 PeakRateMultiplier。详见 PeakMultiplierAt。 PeakRateEnabled bool PeakStart string PeakEnd string @@ -211,6 +211,7 @@ func (g *Group) PeakMultiplierAt(now time.Time) float64 { // ValidatePeakRateConfig 是高峰倍率配置的唯一校验来源,供 handler 与 service 层共用。 // enabled=true 时仅允许订阅类型分组;并要求 start/end 合法且 end>start(不支持跨天),multiplier>=0。 +// multiplier=0 是允许的,表示高峰 token 请求按 0 倍计费,可用于折扣/免费策略。 // enabled=false 时放行(不关心类型)。subscriptionType 为空按 standard 处理。 func ValidatePeakRateConfig(subscriptionType string, enabled bool, start, end string, multiplier float64) error { if !enabled { @@ -239,10 +240,10 @@ func ValidatePeakRateConfig(subscriptionType string, enabled bool, start, end st return nil } -// computePeakAwareMultipliers 把"基础文本倍率 base"(已含系统/分组/用户级倍率,但不含高峰) -// 拆分为最终文本倍率与图片倍率:图片倍率基于 base 现算、不受高峰影响;文本倍率在 base 上叠加高峰因子。 +// computePeakAwareMultipliers 把"基础 token 倍率 base"(已含系统/分组/用户级倍率,但不含高峰) +// 拆分为最终 token 倍率与图片按次倍率:图片按次倍率基于 base 现算、不受高峰影响;token 倍率在 base 上叠加高峰因子。 // gateway_service.recordUsageCore 与 openai_gateway_service.RecordUsage 共用此函数, -// 锁死"高峰因子只乘入文本倍率、图片倍率不受影响"这一叠加顺序——任何调换都会被 group_peak_rate_test 覆盖。 +// 锁死"高峰因子只乘入 token 倍率、图片按次倍率不受影响"这一叠加顺序——任何调换都会被 group_peak_rate_test 覆盖。 func computePeakAwareMultipliers(apiKey *APIKey, base float64, now time.Time) (text, image float64) { image = resolveImageRateMultiplier(apiKey, base) peak := 1.0 diff --git a/backend/internal/service/group_peak_rate_test.go b/backend/internal/service/group_peak_rate_test.go index f63d298da6..222b2b70e0 100644 --- a/backend/internal/service/group_peak_rate_test.go +++ b/backend/internal/service/group_peak_rate_test.go @@ -148,32 +148,32 @@ func TestPeakMultiplierAt_StandardTypeDegradesToOne(t *testing.T) { // TestPeakMultiplier_GatewayBillingSequence 调用 gateway_service.recordUsageCore 与 // openai_gateway_service.RecordUsage 共用的 computePeakAwareMultipliers,验证计费叠加顺序: -// 图片倍率基于基础倍率算出且不受高峰影响,高峰因子只乘入文本倍率。 +// 图片按次倍率基于基础倍率算出且不受高峰影响,高峰因子只乘入 token 倍率。 // 若有人调换叠加顺序或把高峰并入 imageMultiplier,此测试会失败。 func TestPeakMultiplier_GatewayBillingSequence(t *testing.T) { const baseMultiplier = 0.8 apiKey := &APIKey{Group: newPeakGroup(true, "14:00", "18:00", 3.0)} approxEq := func(a, b float64) bool { return math.Abs(a-b) < 1e-9 } - t.Run("peak hour amplifies text only", func(t *testing.T) { + t.Run("peak hour amplifies token multiplier only", func(t *testing.T) { now := at(15, 30) // 处于 [14:00, 18:00) - textMultiplier, imageMultiplier := computePeakAwareMultipliers(apiKey, baseMultiplier, now) + tokenMultiplier, imageMultiplier := computePeakAwareMultipliers(apiKey, baseMultiplier, now) if !approxEq(imageMultiplier, baseMultiplier) { t.Fatalf("image multiplier must not be affected by peak: got %v, want %v", imageMultiplier, baseMultiplier) } - if want := baseMultiplier * 3.0; !approxEq(textMultiplier, want) { - t.Fatalf("text multiplier should include peak factor: got %v, want %v", textMultiplier, want) + if want := baseMultiplier * 3.0; !approxEq(tokenMultiplier, want) { + t.Fatalf("token multiplier should include peak factor: got %v, want %v", tokenMultiplier, want) } }) t.Run("off-peak leaves both multipliers at base", func(t *testing.T) { now := at(20, 0) - textMultiplier, imageMultiplier := computePeakAwareMultipliers(apiKey, baseMultiplier, now) + tokenMultiplier, imageMultiplier := computePeakAwareMultipliers(apiKey, baseMultiplier, now) if !approxEq(imageMultiplier, baseMultiplier) { t.Fatalf("image multiplier: got %v, want %v", imageMultiplier, baseMultiplier) } - if !approxEq(textMultiplier, baseMultiplier) { - t.Fatalf("text multiplier should equal base off-peak: got %v, want %v", textMultiplier, baseMultiplier) + if !approxEq(tokenMultiplier, baseMultiplier) { + t.Fatalf("token multiplier should equal base off-peak: got %v, want %v", tokenMultiplier, baseMultiplier) } }) @@ -183,20 +183,20 @@ func TestPeakMultiplier_GatewayBillingSequence(t *testing.T) { indGroup.ImageRateMultiplier = 0.5 indKey := &APIKey{Group: indGroup} now := at(15, 30) - textMultiplier, imageMultiplier := computePeakAwareMultipliers(indKey, baseMultiplier, now) + tokenMultiplier, imageMultiplier := computePeakAwareMultipliers(indKey, baseMultiplier, now) if !approxEq(imageMultiplier, 0.5) { t.Fatalf("independent image multiplier: got %v, want 0.5", imageMultiplier) } - if want := baseMultiplier * 3.0; !approxEq(textMultiplier, want) { - t.Fatalf("text multiplier should include peak factor: got %v, want %v", textMultiplier, want) + if want := baseMultiplier * 3.0; !approxEq(tokenMultiplier, want) { + t.Fatalf("token multiplier should include peak factor: got %v, want %v", tokenMultiplier, want) } }) t.Run("nil api key degrades to base multipliers", func(t *testing.T) { now := at(15, 30) - textMultiplier, imageMultiplier := computePeakAwareMultipliers(nil, baseMultiplier, now) - if !approxEq(textMultiplier, baseMultiplier) { - t.Fatalf("nil group text multiplier: got %v, want %v", textMultiplier, baseMultiplier) + tokenMultiplier, imageMultiplier := computePeakAwareMultipliers(nil, baseMultiplier, now) + if !approxEq(tokenMultiplier, baseMultiplier) { + t.Fatalf("nil group token multiplier: got %v, want %v", tokenMultiplier, baseMultiplier) } if !approxEq(imageMultiplier, baseMultiplier) { t.Fatalf("nil group image multiplier: got %v, want %v", imageMultiplier, baseMultiplier) diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index 83ca42b733..a7004f5d54 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -408,6 +408,72 @@ func TestOpenAIGatewayServiceRecordUsage_UsesUserSpecificGroupRate(t *testing.T) require.Equal(t, 1, userRepo.deductCalls) } +func TestOpenAIGatewayServiceRecordUsage_PeakRateAffectsTokenModeImageOutputTokens(t *testing.T) { + groupID := int64(14) + groupRate := 1.0 + usage := OpenAIUsage{ + InputTokens: 1000, + OutputTokens: 600, + ImageOutputTokens: 100, + } + + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + subRepo := &openAIRecordUsageSubRepoStub{} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil) + svc.resolver = newOpenAITokenImageChannelPricingResolverForTest(t, groupID, "gpt-5.1") + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "resp_peak_image_tokens", + Usage: usage, + Model: "gpt-5.1", + Duration: time.Second, + ImageCount: 1, + }, + APIKey: &APIKey{ + ID: 1004, + GroupID: i64p(groupID), + Group: &Group{ + ID: groupID, + RateMultiplier: groupRate, + SubscriptionType: "subscription", + PeakRateEnabled: true, + PeakStart: "00:00", + PeakEnd: "23:59", + PeakRateMultiplier: 3.0, + }, + }, + User: &User{ID: 2004}, + Account: &Account{ID: 3004}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.Equal(t, 3.0, usageRepo.lastLog.RateMultiplier) + require.Equal(t, usage.ImageOutputTokens, usageRepo.lastLog.ImageOutputTokens) + + expected, err := svc.billingService.CalculateCostUnified(CostInput{ + Ctx: context.Background(), + Model: "gpt-5.1", + GroupID: i64p(groupID), + Tokens: UsageTokens{ + InputTokens: usage.InputTokens, + OutputTokens: usage.OutputTokens, + ImageOutputTokens: usage.ImageOutputTokens, + }, + RateMultiplier: 1.0, + Resolver: svc.resolver, + }) + require.NoError(t, err) + expectedActual := expected.TotalCost * 3.0 + + require.InDelta(t, expected.TotalCost, usageRepo.lastLog.TotalCost, 1e-12) + require.InDelta(t, expected.ImageOutputCost, usageRepo.lastLog.ImageOutputCost, 1e-12) + require.InDelta(t, expectedActual, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, expectedActual, userRepo.lastAmount, 1e-12) +} + func TestOpenAIGatewayServiceRecordUsage_IncludesEndpointMetadata(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} userRepo := &openAIRecordUsageUserRepoStub{} @@ -1768,6 +1834,26 @@ func newOpenAIImageChannelPricingResolverForTest(t *testing.T, groupID int64, mo return NewModelPricingResolver(cs, NewBillingService(&config.Config{}, nil)) } +func newOpenAITokenImageChannelPricingResolverForTest(t *testing.T, groupID int64, model string) *ModelPricingResolver { + t.Helper() + inputPrice := 3e-6 + outputPrice := 15e-6 + imageOutputPrice := 15e-6 + cache := newEmptyChannelCache() + cache.pricingByGroupModel[channelModelKey{groupID: groupID, model: model}] = &ChannelModelPricing{ + BillingMode: BillingModeToken, + InputPrice: &inputPrice, + OutputPrice: &outputPrice, + ImageOutputPrice: &imageOutputPrice, + } + cache.channelByGroupID[groupID] = &Channel{ID: groupID, Status: StatusActive} + cache.groupPlatform[groupID] = "" + cache.loadedAt = time.Now() + cs := &ChannelService{} + cs.cache.Store(cache) + return NewModelPricingResolver(cs, NewBillingService(&config.Config{}, nil)) +} + func TestGatewayServiceCalculateRecordUsageCost_ChannelImageBillingUsesImageCount(t *testing.T) { groupID := int64(126) billingService := NewBillingService(&config.Config{}, nil) diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 6626292376..da3c3a3636 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -6350,7 +6350,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec } multiplier = resolver.Resolve(ctx, user.ID, *apiKey.GroupID, apiKey.Group.RateMultiplier) } - // 文本倍率叠加高峰因子(仅文本,图片倍率不受影响)。高峰因子按请求时刻现算, + // token 倍率叠加高峰因子(token 计费含图片 token,图片按次倍率不受影响)。高峰因子按请求时刻现算, // 不并入上面的 Resolve,以免污染 user:group 倍率缓存。 multiplier, imageMultiplier := computePeakAwareMultipliers(apiKey, multiplier, timezone.Now()) diff --git a/backend/internal/service/payment_config_plans.go b/backend/internal/service/payment_config_plans.go index bb161a1452..8801a55900 100644 --- a/backend/internal/service/payment_config_plans.go +++ b/backend/internal/service/payment_config_plans.go @@ -61,13 +61,17 @@ func validatePlanPatch(req UpdatePlanRequest) error { // PlanGroupInfo holds the group details needed for subscription plan display. type PlanGroupInfo struct { - Platform string `json:"platform"` - Name string `json:"name"` - RateMultiplier float64 `json:"rate_multiplier"` - DailyLimitUSD *float64 `json:"daily_limit_usd"` - WeeklyLimitUSD *float64 `json:"weekly_limit_usd"` - MonthlyLimitUSD *float64 `json:"monthly_limit_usd"` - ModelScopes []string `json:"supported_model_scopes"` + Platform string `json:"platform"` + Name string `json:"name"` + RateMultiplier float64 `json:"rate_multiplier"` + PeakRateEnabled bool `json:"peak_rate_enabled"` + PeakStart string `json:"peak_start"` + PeakEnd string `json:"peak_end"` + PeakRateMultiplier float64 `json:"peak_rate_multiplier"` + DailyLimitUSD *float64 `json:"daily_limit_usd"` + WeeklyLimitUSD *float64 `json:"weekly_limit_usd"` + MonthlyLimitUSD *float64 `json:"monthly_limit_usd"` + ModelScopes []string `json:"supported_model_scopes"` } // GetGroupPlatformMap returns a map of group_id → platform for the given plans. @@ -100,13 +104,17 @@ func (s *PaymentConfigService) GetGroupInfoMap(ctx context.Context, plans []*dbe m := make(map[int64]PlanGroupInfo, len(groups)) for _, g := range groups { m[int64(g.ID)] = PlanGroupInfo{ - Platform: g.Platform, - Name: g.Name, - RateMultiplier: g.RateMultiplier, - DailyLimitUSD: g.DailyLimitUsd, - WeeklyLimitUSD: g.WeeklyLimitUsd, - MonthlyLimitUSD: g.MonthlyLimitUsd, - ModelScopes: g.SupportedModelScopes, + Platform: g.Platform, + Name: g.Name, + RateMultiplier: g.RateMultiplier, + PeakRateEnabled: g.PeakRateEnabled, + PeakStart: g.PeakStart, + PeakEnd: g.PeakEnd, + PeakRateMultiplier: g.PeakRateMultiplier, + DailyLimitUSD: g.DailyLimitUsd, + WeeklyLimitUSD: g.WeeklyLimitUsd, + MonthlyLimitUSD: g.MonthlyLimitUsd, + ModelScopes: g.SupportedModelScopes, } } return m diff --git a/frontend/src/api/channels.ts b/frontend/src/api/channels.ts index 8962af2c4d..8eaf7eed95 100644 --- a/frontend/src/api/channels.ts +++ b/frontend/src/api/channels.ts @@ -14,6 +14,10 @@ export interface UserAvailableGroup { subscription_type: string /** 分组默认倍率。用户专属倍率(若有)通过 /groups/rates 获取后在前端 join。 */ rate_multiplier: number + peak_rate_enabled: boolean + peak_start: string + peak_end: string + peak_rate_multiplier: number /** true = 专属分组(小范围授权);false = 公开分组。 */ is_exclusive: boolean } diff --git a/frontend/src/components/admin/user/UserApiKeysModal.vue b/frontend/src/components/admin/user/UserApiKeysModal.vue index 5e0a0fea69..ef24fe7eb0 100644 --- a/frontend/src/components/admin/user/UserApiKeysModal.vue +++ b/frontend/src/components/admin/user/UserApiKeysModal.vue @@ -32,6 +32,10 @@ :platform="key.group.platform" :subscription-type="key.group.subscription_type" :rate-multiplier="key.group.rate_multiplier" + :peak-rate-enabled="key.group.peak_rate_enabled" + :peak-start="key.group.peak_start" + :peak-end="key.group.peak_end" + :peak-rate-multiplier="key.group.peak_rate_multiplier" /> {{ t('admin.users.none') }} @@ -88,6 +92,10 @@ :platform="group.platform" :subscription-type="group.subscription_type" :rate-multiplier="group.rate_multiplier" + :peak-rate-enabled="group.peak_rate_enabled" + :peak-start="group.peak_start" + :peak-end="group.peak_end" + :peak-rate-multiplier="group.peak_rate_multiplier" :description="group.description" :selected="selectedKeyForGroup?.group_id === group.id" /> diff --git a/frontend/src/components/channels/AvailableChannelsTable.vue b/frontend/src/components/channels/AvailableChannelsTable.vue index 5b9c0eba20..02d1f2d1af 100644 --- a/frontend/src/components/channels/AvailableChannelsTable.vue +++ b/frontend/src/components/channels/AvailableChannelsTable.vue @@ -85,16 +85,28 @@ {{ t('availableChannels.exclusive') }} - + class="inline-flex flex-wrap items-center gap-1" + > + + + + {{ peakRateLabel(g) }} + +
{{ t('availableChannels.public') }} - + class="inline-flex flex-wrap items-center gap-1" + > + + + + {{ peakRateLabel(g) }} + +
- @@ -186,4 +210,16 @@ function exclusiveGroups(section: UserChannelPlatformSection): UserAvailableGrou function publicGroups(section: UserChannelPlatformSection): UserAvailableGroup[] { return section.groups.filter((g) => !g.is_exclusive) } + +function hasPeakRate(group: UserAvailableGroup): boolean { + return Boolean(group.peak_rate_enabled && group.peak_start && group.peak_end) +} + +function peakRateLabel(group: UserAvailableGroup): string { + return `${group.peak_start}-${group.peak_end} ${group.peak_rate_multiplier}x` +} + +function peakRateTitle(group: UserAvailableGroup): string { + return `高峰倍率:${group.peak_start}-${group.peak_end} ${group.peak_rate_multiplier}x;token 计费的图片 token 同样适用,图片按次计费不受高峰影响` +} diff --git a/frontend/src/components/common/GroupBadge.vue b/frontend/src/components/common/GroupBadge.vue index 3303d90982..2fc48b0185 100644 --- a/frontend/src/components/common/GroupBadge.vue +++ b/frontend/src/components/common/GroupBadge.vue @@ -20,6 +20,9 @@ {{ labelText }} + + {{ peakRateText }} + @@ -35,6 +38,10 @@ interface Props { subscriptionType?: SubscriptionType rateMultiplier?: number userRateMultiplier?: number | null // 用户专属倍率 + peakRateEnabled?: boolean + peakStart?: string + peakEnd?: string + peakRateMultiplier?: number showRate?: boolean daysRemaining?: number | null // 剩余天数(订阅类型时使用) /** @@ -50,6 +57,7 @@ const props = withDefaults(defineProps(), { showRate: true, daysRemaining: null, userRateMultiplier: null, + peakRateEnabled: false, alwaysShowRate: false }) @@ -67,6 +75,18 @@ const hasCustomRate = computed(() => { ) }) +const hasPeakRate = computed(() => { + return Boolean(props.showRate && props.peakRateEnabled && props.peakStart && props.peakEnd) +}) + +const peakRateText = computed(() => { + return `${props.peakStart}-${props.peakEnd} ×${props.peakRateMultiplier ?? 1}` +}) + +const peakRateTitle = computed(() => { + return `高峰倍率:${peakRateText.value}` +}) + // 是否显示右侧标签 const showLabel = computed(() => { if (!props.showRate) return false @@ -127,6 +147,10 @@ const labelClass = computed(() => { return `${base} bg-violet-200/60 text-violet-800 dark:bg-violet-800/40 dark:text-violet-300` }) +const peakRateClass = computed(() => { + return 'px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300' +}) + // Badge color based on platform and subscription type const badgeClass = computed(() => { if (props.platform === 'anthropic') { diff --git a/frontend/src/components/common/GroupOptionItem.vue b/frontend/src/components/common/GroupOptionItem.vue index a46b4117df..44724fdd08 100644 --- a/frontend/src/components/common/GroupOptionItem.vue +++ b/frontend/src/components/common/GroupOptionItem.vue @@ -24,16 +24,25 @@
- - - - - +
+ + + + + + + {{ peakRateText }} + +
(), { subscriptionType: 'standard', selected: false, showCheckmark: true, - userRateMultiplier: null + userRateMultiplier: null, + peakRateEnabled: false }) // Whether user has a custom rate different from default @@ -85,6 +99,18 @@ const hasCustomRate = computed(() => { ) }) +const hasPeakRate = computed(() => { + return Boolean(props.peakRateEnabled && props.peakStart && props.peakEnd) +}) + +const peakRateText = computed(() => { + return `${props.peakStart}-${props.peakEnd} ×${props.peakRateMultiplier ?? 1}` +}) + +const peakRateTitle = computed(() => { + return `高峰倍率:${peakRateText.value}` +}) + // Rate pill color matches platform badge color const ratePillClass = computed(() => { switch (props.platform) { diff --git a/frontend/src/components/payment/SubscriptionPlanCard.vue b/frontend/src/components/payment/SubscriptionPlanCard.vue index 687f119e33..c69aabd898 100644 --- a/frontend/src/components/payment/SubscriptionPlanCard.vue +++ b/frontend/src/components/payment/SubscriptionPlanCard.vue @@ -43,6 +43,10 @@ {{ t('payment.planCard.rate') }} {{ rateDisplay }}
+
+ {{ t('payment.planCard.peakRate') }} + {{ peakRateDisplay }} +
{{ t('payment.planCard.dailyLimit') }} ${{ plan.daily_limit_usd }} @@ -140,6 +144,14 @@ const rateDisplay = computed(() => { return `×${Number(rate.toPrecision(10))}` }) +const hasPeakRate = computed(() => { + return Boolean(props.plan.peak_rate_enabled && props.plan.peak_start && props.plan.peak_end) +}) + +const peakRateDisplay = computed(() => { + return `${props.plan.peak_start}-${props.plan.peak_end} ×${props.plan.peak_rate_multiplier ?? 1}` +}) + const MODEL_SCOPE_LABELS: Record = { claude: 'Claude', gemini_text: 'Gemini', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index eec916cfb5..913c43d930 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -2279,6 +2279,13 @@ export default { finalPricePreview: 'Final per-image price preview', notConfigured: 'Not configured' }, + peakRate: { + enable: 'Enable peak rate multiplier', + peakStart: 'Peak start', + peakEnd: 'Peak end', + peakMultiplier: 'Peak multiplier', + multiplierHint: 'Applies to token billing multiplier; image tokens in token billing are also affected. 0 means peak token requests are billed at 0x.' + }, modelsList: { title: 'Custom /v1/models Model List', hint: 'Only changes the /v1/models response. Whitelist model calls and account routing are unchanged.', @@ -7264,6 +7271,7 @@ export default { planFeatures: 'Features', planCard: { rate: 'Rate', + peakRate: 'Peak Rate', dailyLimit: 'Daily', weeklyLimit: 'Weekly', monthlyLimit: 'Monthly', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 7780147ae4..b551d5159c 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -2361,6 +2361,13 @@ export default { finalPricePreview: '最终单张价格预览', notConfigured: '未配置' }, + peakRate: { + enable: '启用高峰倍率', + peakStart: '高峰开始', + peakEnd: '高峰结束', + peakMultiplier: '高峰倍率', + multiplierHint: '作用于 token 计费倍率;token 计费的图片 token 同样适用,0 表示高峰 token 请求按 0 倍计费' + }, modelsList: { title: '自定义 /v1/models 模型列表', hint: '仅影响 /v1/models 展示结果,不影响白名单模型调用和账号调度。', @@ -7444,6 +7451,7 @@ export default { planFeatures: '功能特性', planCard: { rate: '倍率', + peakRate: '高峰倍率', dailyLimit: '日限额', weeklyLimit: '周限额', monthlyLimit: '月限额', diff --git a/frontend/src/types/payment.ts b/frontend/src/types/payment.ts index ac20b82e4f..a02ef1b78f 100644 --- a/frontend/src/types/payment.ts +++ b/frontend/src/types/payment.ts @@ -108,6 +108,10 @@ export interface SubscriptionPlan { group_platform?: string group_name?: string rate_multiplier?: number + peak_rate_enabled?: boolean + peak_start?: string + peak_end?: string + peak_rate_multiplier?: number daily_limit_usd?: number | null weekly_limit_usd?: number | null monthly_limit_usd?: number | null diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index aba7b89660..cea587111f 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -866,7 +866,7 @@ type="checkbox" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500" /> - 启用高峰时段倍率 + {{ t("admin.groups.peakRate.enable") }}
- +
- +
- +
@@ -2204,7 +2205,7 @@ type="checkbox" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500" /> - 启用高峰时段倍率 + {{ t("admin.groups.peakRate.enable") }}
- +
- +
- +
diff --git a/frontend/src/views/user/KeysView.vue b/frontend/src/views/user/KeysView.vue index 598e88ed96..1aabf6b834 100644 --- a/frontend/src/views/user/KeysView.vue +++ b/frontend/src/views/user/KeysView.vue @@ -144,6 +144,10 @@ :subscription-type="row.group.subscription_type" :rate-multiplier="row.group.rate_multiplier" :user-rate-multiplier="userGroupRates[row.group.id]" + :peak-rate-enabled="row.group.peak_rate_enabled" + :peak-start="row.group.peak_start" + :peak-end="row.group.peak_end" + :peak-rate-multiplier="row.group.peak_rate_multiplier" /> {{ t('keys.noGroup') @@ -454,6 +458,10 @@ :subscription-type="(option as unknown as GroupOption).subscriptionType" :rate-multiplier="(option as unknown as GroupOption).rate" :user-rate-multiplier="(option as unknown as GroupOption).userRate" + :peak-rate-enabled="(option as unknown as GroupOption).peakRateEnabled" + :peak-start="(option as unknown as GroupOption).peakStart" + :peak-end="(option as unknown as GroupOption).peakEnd" + :peak-rate-multiplier="(option as unknown as GroupOption).peakRateMultiplier" /> {{ t('keys.selectGroup') }} @@ -464,6 +472,10 @@ :subscription-type="(option as unknown as GroupOption).subscriptionType" :rate-multiplier="(option as unknown as GroupOption).rate" :user-rate-multiplier="(option as unknown as GroupOption).userRate" + :peak-rate-enabled="(option as unknown as GroupOption).peakRateEnabled" + :peak-start="(option as unknown as GroupOption).peakStart" + :peak-end="(option as unknown as GroupOption).peakEnd" + :peak-rate-multiplier="(option as unknown as GroupOption).peakRateMultiplier" :description="(option as unknown as GroupOption).description" :selected="selected" /> @@ -1059,6 +1071,10 @@ :subscription-type="option.subscriptionType" :rate-multiplier="option.rate" :user-rate-multiplier="option.userRate" + :peak-rate-enabled="option.peakRateEnabled" + :peak-start="option.peakStart" + :peak-end="option.peakEnd" + :peak-rate-multiplier="option.peakRateMultiplier" :description="option.description" :selected=" selectedKeyForGroup?.group_id === option.value || @@ -1123,6 +1139,10 @@ interface GroupOption { description: string | null rate: number userRate: number | null + peakRateEnabled: boolean + peakStart: string + peakEnd: string + peakRateMultiplier: number subscriptionType: SubscriptionType platform: GroupPlatform } @@ -1351,6 +1371,10 @@ const groupOptions = computed(() => description: group.description, rate: group.rate_multiplier, userRate: userGroupRates.value[group.id] ?? null, + peakRateEnabled: group.peak_rate_enabled, + peakStart: group.peak_start, + peakEnd: group.peak_end, + peakRateMultiplier: group.peak_rate_multiplier, subscriptionType: group.subscription_type, platform: group.platform })) diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue index baa0cb244e..092efac9fd 100644 --- a/frontend/src/views/user/PaymentView.vue +++ b/frontend/src/views/user/PaymentView.vue @@ -121,6 +121,12 @@ ×{{ selectedPlan.rate_multiplier ?? 1 }} +
+ {{ t('payment.planCard.peakRate') }} +
+ {{ planPeakRateLabel(selectedPlan) }} +
+
{{ t('payment.planCard.dailyLimit') }}
${{ selectedPlan.daily_limit_usd }}
@@ -194,6 +200,7 @@
{{ t('payment.planCard.rate') }}: ×{{ sub.group?.rate_multiplier ?? 1 }} + {{ t('payment.planCard.peakRate') }}: {{ subscriptionPeakRateLabel(sub) }} {{ t('payment.planCard.quota') }}: {{ t('payment.planCard.unlimited') }} {{ t('userSubscriptions.daysRemaining', { days: getDaysRemaining(sub.expires_at) }) }} {{ t('userSubscriptions.noExpiration') }} @@ -297,6 +304,16 @@ function getDaysRemaining(expiresAt: string): number { return Math.max(0, Math.ceil(diff / (1000 * 60 * 60 * 24))) } +function subscriptionHasPeakRate(sub: { group?: { peak_rate_enabled?: boolean; peak_start?: string; peak_end?: string } | null }): boolean { + const group = sub.group + return Boolean(group?.peak_rate_enabled && group.peak_start && group.peak_end) +} + +function subscriptionPeakRateLabel(sub: { group?: { peak_start?: string; peak_end?: string; peak_rate_multiplier?: number } | null }): string { + const group = sub.group + return `${group?.peak_start}-${group?.peak_end} ×${group?.peak_rate_multiplier ?? 1}` +} + const loading = ref(true) const submitting = ref(false) const errorMessage = ref('') @@ -695,6 +712,14 @@ const planValiditySuffix = computed(() => { return `${selectedPlan.value.validity_days}${t('payment.days')}` }) +function planHasPeakRate(plan: SubscriptionPlan): boolean { + return Boolean(plan.peak_rate_enabled && plan.peak_start && plan.peak_end) +} + +function planPeakRateLabel(plan: SubscriptionPlan): string { + return `${plan.peak_start}-${plan.peak_end} ×${plan.peak_rate_multiplier ?? 1}` +} + function selectPlan(plan: SubscriptionPlan) { selectedPlan.value = plan errorMessage.value = '' diff --git a/frontend/src/views/user/SubscriptionsView.vue b/frontend/src/views/user/SubscriptionsView.vue index 31d63d0d7f..3af64b29e9 100644 --- a/frontend/src/views/user/SubscriptionsView.vue +++ b/frontend/src/views/user/SubscriptionsView.vue @@ -49,6 +49,12 @@

{{ subscription.group.description }}

+
+ {{ t('payment.planCard.rate') }}: ×{{ subscription.group?.rate_multiplier ?? 1 }} + + {{ t('payment.planCard.peakRate') }}: {{ subscriptionPeakRateLabel(subscription) }} + +
@@ -271,6 +277,16 @@ const appStore = useAppStore() const subscriptions = ref([]) const loading = ref(true) +function subscriptionHasPeakRate(subscription: UserSubscription): boolean { + const group = subscription.group + return Boolean(group?.peak_rate_enabled && group.peak_start && group.peak_end) +} + +function subscriptionPeakRateLabel(subscription: UserSubscription): string { + const group = subscription.group + return `${group?.peak_start}-${group?.peak_end} ×${group?.peak_rate_multiplier ?? 1}` +} + async function loadSubscriptions() { try { loading.value = true From a0eaf12a01fa94cc9b67c081f368cb369caec511 Mon Sep 17 00:00:00 2001 From: xueshiji Date: Wed, 1 Jul 2026 17:54:39 +0800 Subject: [PATCH 5/6] fix: test --- backend/internal/service/billing_service_test.go | 4 ++-- backend/internal/service/billing_service_unified_test.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index 88debae99a..92c143c6ff 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -1377,7 +1377,7 @@ func TestComputeTokenBreakdown_ExplicitZeroImagePrice_NoFallback(t *testing.T) { OutputTokens: 200, ImageOutputTokens: 50, } - bd := svc.computeTokenBreakdown(pricing, tokens, 1.0, 0, false, "", false) + bd := svc.computeTokenBreakdown(pricing, tokens, 1.0, "", false) // ImageOutputTokens should NOT fall back to outputPrice require.Equal(t, 0.0, bd.ImageOutputCost) @@ -1399,7 +1399,7 @@ func TestComputeTokenBreakdown_NonExplicitZeroImagePrice_FallsBackToOutput(t *te OutputTokens: 200, ImageOutputTokens: 50, } - bd := svc.computeTokenBreakdown(pricing, tokens, 1.0, 0, false, "", false) + bd := svc.computeTokenBreakdown(pricing, tokens, 1.0, "", false) // Should fall back to outputPrice since not explicit require.InDelta(t, 50*15e-6, bd.ImageOutputCost, 1e-12) diff --git a/backend/internal/service/billing_service_unified_test.go b/backend/internal/service/billing_service_unified_test.go index db7437d6ba..eabbab3dfc 100644 --- a/backend/internal/service/billing_service_unified_test.go +++ b/backend/internal/service/billing_service_unified_test.go @@ -29,7 +29,7 @@ func TestCalculateCostUnified_NilResolver_FallsBackToOldPath(t *testing.T) { require.NoError(t, err) // Should match the old-path result exactly - expected, err := svc.calculateCostInternal("claude-sonnet-4", tokens, 1.0, 0, false, "", nil) + expected, err := svc.calculateCostInternal("claude-sonnet-4", tokens, 1.0, "", nil) require.NoError(t, err) require.InDelta(t, expected.TotalCost, cost.TotalCost, 1e-10) require.InDelta(t, expected.ActualCost, cost.ActualCost, 1e-10) From be26e6ff2c8598907a5c6ef4819be85cff48023d Mon Sep 17 00:00:00 2001 From: xueshiji Date: Wed, 1 Jul 2026 18:09:20 +0800 Subject: [PATCH 6/6] fix: test --- backend/internal/server/api_contract_test.go | 3 +- .../service/admin_service_peak_rate_test.go | 106 ------------------ 2 files changed, 2 insertions(+), 107 deletions(-) delete mode 100644 backend/internal/service/admin_service_peak_rate_test.go diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 414f15416f..7b45b27895 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -317,6 +317,7 @@ func TestAPIContracts(t *testing.T) { Description: "desc", Platform: service.PlatformAnthropic, RateMultiplier: 1.5, + PeakRateMultiplier: 1.0, IsExclusive: false, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard, @@ -347,7 +348,7 @@ func TestAPIContracts(t *testing.T) { "peak_rate_enabled": false, "peak_start": "", "peak_end": "", - "peak_rate_multiplier": 0, + "peak_rate_multiplier": 1, "is_exclusive": false, "status": "active", "subscription_type": "standard", diff --git a/backend/internal/service/admin_service_peak_rate_test.go b/backend/internal/service/admin_service_peak_rate_test.go deleted file mode 100644 index 87f6f497b1..0000000000 --- a/backend/internal/service/admin_service_peak_rate_test.go +++ /dev/null @@ -1,106 +0,0 @@ -package service - -import ( - "context" - "testing" - - "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" - "github.com/stretchr/testify/require" -) - -type peakRateGroupRepoStub struct { - getByID *Group - updated *Group -} - -func (s *peakRateGroupRepoStub) Create(context.Context, *Group) error { - panic("unexpected Create call") -} - -func (s *peakRateGroupRepoStub) GetByID(context.Context, int64) (*Group, error) { - return s.getByID, nil -} - -func (s *peakRateGroupRepoStub) GetByIDLite(context.Context, int64) (*Group, error) { - return s.getByID, nil -} - -func (s *peakRateGroupRepoStub) Update(_ context.Context, group *Group) error { - s.updated = group - return nil -} - -func (s *peakRateGroupRepoStub) Delete(context.Context, int64) error { - panic("unexpected Delete call") -} - -func (s *peakRateGroupRepoStub) DeleteCascade(context.Context, int64) ([]int64, error) { - panic("unexpected DeleteCascade call") -} - -func (s *peakRateGroupRepoStub) List(context.Context, pagination.PaginationParams) ([]Group, *pagination.PaginationResult, error) { - panic("unexpected List call") -} - -func (s *peakRateGroupRepoStub) ListWithFilters(context.Context, pagination.PaginationParams, string, string, string, *bool) ([]Group, *pagination.PaginationResult, error) { - panic("unexpected ListWithFilters call") -} - -func (s *peakRateGroupRepoStub) ListActive(context.Context) ([]Group, error) { - panic("unexpected ListActive call") -} - -func (s *peakRateGroupRepoStub) ListActiveByPlatform(context.Context, string) ([]Group, error) { - panic("unexpected ListActiveByPlatform call") -} - -func (s *peakRateGroupRepoStub) ExistsByName(context.Context, string) (bool, error) { - panic("unexpected ExistsByName call") -} - -func (s *peakRateGroupRepoStub) GetAccountCount(context.Context, int64) (int64, int64, error) { - panic("unexpected GetAccountCount call") -} - -func (s *peakRateGroupRepoStub) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) { - panic("unexpected DeleteAccountGroupsByGroupID call") -} - -func (s *peakRateGroupRepoStub) GetAccountIDsByGroupIDs(context.Context, []int64) ([]int64, error) { - panic("unexpected GetAccountIDsByGroupIDs call") -} - -func (s *peakRateGroupRepoStub) BindAccountsToGroup(context.Context, int64, []int64) error { - panic("unexpected BindAccountsToGroup call") -} - -func (s *peakRateGroupRepoStub) UpdateSortOrders(context.Context, []GroupSortOrderUpdate) error { - panic("unexpected UpdateSortOrders call") -} - -func TestAdminService_UpdateGroup_ClearsPeakRateWhenChangingToStandardDefault(t *testing.T) { - repo := &peakRateGroupRepoStub{getByID: &Group{ - ID: 1, - Name: "existing-group", - Platform: PlatformOpenAI, - Status: StatusActive, - SubscriptionType: SubscriptionTypeSubscription, - PeakRateEnabled: true, - PeakStart: "14:00", - PeakEnd: "18:00", - PeakRateMultiplier: 3, - }} - svc := &adminServiceImpl{groupRepo: repo} - - group, err := svc.UpdateGroup(context.Background(), 1, &UpdateGroupInput{ - SubscriptionType: SubscriptionTypeStandard, - }) - require.NoError(t, err) - require.NotNil(t, group) - require.NotNil(t, repo.updated) - require.Equal(t, SubscriptionTypeStandard, repo.updated.SubscriptionType) - require.False(t, repo.updated.PeakRateEnabled) - require.Equal(t, "", repo.updated.PeakStart) - require.Equal(t, "", repo.updated.PeakEnd) - require.Equal(t, 1.0, repo.updated.PeakRateMultiplier) -}