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 @@
{{ subscription.group.description }}
+