From 1034f576d79902aa910b14c6f1db636f798e023b Mon Sep 17 00:00:00 2001 From: xueshiji Date: Wed, 1 Jul 2026 16:22:52 +0800 Subject: [PATCH] =?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