fix: 高峰倍率全链路透传、计费术语修正与边界处理

- 高峰倍率信息透传至可用渠道、支付计划、结算信息等 API,前端
  GroupBadge / GroupOptionItem / SubscriptionPlanCard / PaymentView /
  SubscriptionsView 统一展示高峰时段与倍率标签
- 修正计费术语:"文本倍率" → "token 倍率",明确高峰倍率同时作用于
  token 计费的图片 token,图片按次计费不受高峰影响
- 允许高峰倍率 multiplier=0,支持高峰时段免费策略
- 切换分组类型为 standard 时自动清除高峰倍率配置
- 长上下文计费与标准计费路径改用内部实现,移除冗余中间调用
- 前端高峰倍率相关控件文案改为 i18n
- 新增多组高峰倍率相关单元测试
This commit is contained in:
xueshiji
2026-07-01 17:46:12 +08:00
parent ebd83dc0f9
commit 1034f576d7
32 changed files with 686 additions and 145 deletions
+1 -1
View File
@@ -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("是否启用高峰时段倍率"),
@@ -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
@@ -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)
}
+50 -33
View File
@@ -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.
@@ -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 {
@@ -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}
@@ -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)
}
@@ -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"`
+8 -4
View File
@@ -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)
}
@@ -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)
@@ -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{
+22 -14
View File
@@ -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,
}
}
@@ -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{}
+3 -6
View File
@@ -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)
+5 -4
View File
@@ -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
@@ -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)
@@ -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)
@@ -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())
@@ -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
+4
View File
@@ -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
}
@@ -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"
/>
<span v-else class="text-gray-400 italic">{{ t('admin.users.none') }}</span>
<svg v-if="updatingKeyIds.has(key.id)" class="h-3 w-3 animate-spin text-primary-500" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
@@ -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"
/>
@@ -85,16 +85,28 @@
<Icon name="shield" size="xs" class="h-3 w-3" />
{{ t('availableChannels.exclusive') }}
</span>
<GroupBadge
<div
v-for="g in exclusiveGroups(section)"
:key="`ex-${g.id}`"
:name="g.name"
:platform="g.platform as GroupPlatform"
:subscription-type="(g.subscription_type || 'standard') as SubscriptionType"
:rate-multiplier="g.rate_multiplier"
:user-rate-multiplier="userGroupRates[g.id] ?? null"
always-show-rate
/>
class="inline-flex flex-wrap items-center gap-1"
>
<GroupBadge
:name="g.name"
:platform="g.platform as GroupPlatform"
:subscription-type="(g.subscription_type || 'standard') as SubscriptionType"
:rate-multiplier="g.rate_multiplier"
:user-rate-multiplier="userGroupRates[g.id] ?? null"
always-show-rate
/>
<span
v-if="hasPeakRate(g)"
class="inline-flex items-center gap-1 rounded-md bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:bg-amber-900/20 dark:text-amber-300"
:title="peakRateTitle(g)"
>
<Icon name="clock" size="xs" class="h-3 w-3" />
{{ peakRateLabel(g) }}
</span>
</div>
</div>
<div
v-if="publicGroups(section).length > 0"
@@ -107,16 +119,28 @@
<Icon name="globe" size="xs" class="h-3 w-3" />
{{ t('availableChannels.public') }}
</span>
<GroupBadge
<div
v-for="g in publicGroups(section)"
:key="`pub-${g.id}`"
:name="g.name"
:platform="g.platform as GroupPlatform"
:subscription-type="(g.subscription_type || 'standard') as SubscriptionType"
:rate-multiplier="g.rate_multiplier"
:user-rate-multiplier="userGroupRates[g.id] ?? null"
always-show-rate
/>
class="inline-flex flex-wrap items-center gap-1"
>
<GroupBadge
:name="g.name"
:platform="g.platform as GroupPlatform"
:subscription-type="(g.subscription_type || 'standard') as SubscriptionType"
:rate-multiplier="g.rate_multiplier"
:user-rate-multiplier="userGroupRates[g.id] ?? null"
always-show-rate
/>
<span
v-if="hasPeakRate(g)"
class="inline-flex items-center gap-1 rounded-md bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:bg-amber-900/20 dark:text-amber-300"
:title="peakRateTitle(g)"
>
<Icon name="clock" size="xs" class="h-3 w-3" />
{{ peakRateLabel(g) }}
</span>
</div>
</div>
<span v-if="section.groups.length === 0" class="text-xs text-gray-400">-</span>
</div>
@@ -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 同样适用,图片按次计费不受高峰影响`
}
</script>
@@ -20,6 +20,9 @@
{{ labelText }}
</template>
</span>
<span v-if="hasPeakRate" :class="peakRateClass" :title="peakRateTitle">
{{ peakRateText }}
</span>
</span>
</template>
@@ -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<Props>(), {
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') {
@@ -24,16 +24,25 @@
<!-- Right: rate pill + checkmark (vertically centered to first row) -->
<div class="flex shrink-0 items-center gap-2 pt-0.5">
<!-- Rate pill (platform color) -->
<span v-if="rateMultiplier !== undefined" :class="['inline-flex items-center whitespace-nowrap rounded-full px-3 py-1 text-xs font-semibold', ratePillClass]">
<template v-if="hasCustomRate">
<span class="mr-1 line-through opacity-50">{{ rateMultiplier }}x</span>
<span class="font-bold">{{ userRateMultiplier }}x</span>
</template>
<template v-else>
{{ rateMultiplier }}x {{ t('admin.groups.rateLabel') }}
</template>
</span>
<div class="flex shrink-0 flex-col items-end gap-1">
<!-- Rate pill (platform color) -->
<span v-if="rateMultiplier !== undefined" :class="['inline-flex items-center whitespace-nowrap rounded-full px-3 py-1 text-xs font-semibold', ratePillClass]">
<template v-if="hasCustomRate">
<span class="mr-1 line-through opacity-50">{{ rateMultiplier }}x</span>
<span class="font-bold">{{ userRateMultiplier }}x</span>
</template>
<template v-else>
{{ rateMultiplier }}x {{ t('admin.groups.rateLabel') }}
</template>
</span>
<span
v-if="hasPeakRate"
class="inline-flex items-center whitespace-nowrap rounded-full bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700 dark:bg-amber-900/20 dark:text-amber-300"
:title="peakRateTitle"
>
{{ peakRateText }}
</span>
</div>
<!-- Checkmark -->
<svg
v-if="showCheckmark && selected"
@@ -63,6 +72,10 @@ interface Props {
subscriptionType?: SubscriptionType
rateMultiplier?: number
userRateMultiplier?: number | null
peakRateEnabled?: boolean
peakStart?: string
peakEnd?: string
peakRateMultiplier?: number
description?: string | null
selected?: boolean
showCheckmark?: boolean
@@ -72,7 +85,8 @@ const props = withDefaults(defineProps<Props>(), {
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) {
@@ -43,6 +43,10 @@
<span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.rate') }}</span>
<span class="font-medium text-gray-700 dark:text-gray-300">{{ rateDisplay }}</span>
</div>
<div v-if="hasPeakRate" class="col-span-2 flex items-center justify-between gap-2">
<span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.peakRate') }}</span>
<span class="text-right font-medium text-amber-700 dark:text-amber-300">{{ peakRateDisplay }}</span>
</div>
<div v-if="plan.daily_limit_usd != null" class="flex items-center justify-between">
<span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.dailyLimit') }}</span>
<span class="font-medium text-gray-700 dark:text-gray-300">${{ plan.daily_limit_usd }}</span>
@@ -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<string, string> = {
claude: 'Claude',
gemini_text: 'Gemini',
+8
View File
@@ -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',
+8
View File
@@ -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: '月限额',
+4
View File
@@ -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
+10 -8
View File
@@ -866,7 +866,7 @@
type="checkbox"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span>启用高峰时段倍率</span>
<span>{{ t("admin.groups.peakRate.enable") }}</span>
</label>
</div>
<div
@@ -874,7 +874,7 @@
class="mb-4 grid grid-cols-3 gap-3"
>
<div>
<label class="input-label">高峰开始</label>
<label class="input-label">{{ t("admin.groups.peakRate.peakStart") }}</label>
<input
v-model="createForm.peak_start"
type="time"
@@ -882,7 +882,7 @@
/>
</div>
<div>
<label class="input-label">高峰结束</label>
<label class="input-label">{{ t("admin.groups.peakRate.peakEnd") }}</label>
<input
v-model="createForm.peak_end"
type="time"
@@ -890,7 +890,7 @@
/>
</div>
<div>
<label class="input-label">高峰倍率</label>
<label class="input-label">{{ t("admin.groups.peakRate.peakMultiplier") }}</label>
<input
v-model.number="createForm.peak_rate_multiplier"
type="number"
@@ -898,6 +898,7 @@
min="0"
class="input"
placeholder="1"
:title="t('admin.groups.peakRate.multiplierHint')"
/>
</div>
</div>
@@ -2204,7 +2205,7 @@
type="checkbox"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span>启用高峰时段倍率</span>
<span>{{ t("admin.groups.peakRate.enable") }}</span>
</label>
</div>
<div
@@ -2212,7 +2213,7 @@
class="mb-4 grid grid-cols-3 gap-3"
>
<div>
<label class="input-label">高峰开始</label>
<label class="input-label">{{ t("admin.groups.peakRate.peakStart") }}</label>
<input
v-model="editForm.peak_start"
type="time"
@@ -2220,7 +2221,7 @@
/>
</div>
<div>
<label class="input-label">高峰结束</label>
<label class="input-label">{{ t("admin.groups.peakRate.peakEnd") }}</label>
<input
v-model="editForm.peak_end"
type="time"
@@ -2228,7 +2229,7 @@
/>
</div>
<div>
<label class="input-label">高峰倍率</label>
<label class="input-label">{{ t("admin.groups.peakRate.peakMultiplier") }}</label>
<input
v-model.number="editForm.peak_rate_multiplier"
type="number"
@@ -2236,6 +2237,7 @@
min="0"
class="input"
placeholder="1"
:title="t('admin.groups.peakRate.multiplierHint')"
/>
</div>
</div>
+24
View File
@@ -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"
/>
<span v-else class="text-sm text-gray-400 dark:text-dark-500">{{
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"
/>
<span v-else class="text-gray-400">{{ t('keys.selectGroup') }}</span>
</template>
@@ -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
}))
+25
View File
@@ -121,6 +121,12 @@
<span :class="['text-lg font-bold', planTextClass]">×{{ selectedPlan.rate_multiplier ?? 1 }}</span>
</div>
</div>
<div v-if="planHasPeakRate(selectedPlan)">
<span class="text-xs text-gray-400 dark:text-gray-500">{{ t('payment.planCard.peakRate') }}</span>
<div class="text-sm font-semibold text-amber-700 dark:text-amber-300">
{{ planPeakRateLabel(selectedPlan) }}
</div>
</div>
<div v-if="selectedPlan.daily_limit_usd != null">
<span class="text-xs text-gray-400 dark:text-gray-500">{{ t('payment.planCard.dailyLimit') }}</span>
<div class="text-lg font-semibold text-gray-800 dark:text-gray-200">${{ selectedPlan.daily_limit_usd }}</div>
@@ -194,6 +200,7 @@
</div>
<div class="flex flex-wrap gap-x-3 text-[11px] text-gray-400 dark:text-gray-500">
<span>{{ t('payment.planCard.rate') }}: ×{{ sub.group?.rate_multiplier ?? 1 }}</span>
<span v-if="subscriptionHasPeakRate(sub)">{{ t('payment.planCard.peakRate') }}: {{ subscriptionPeakRateLabel(sub) }}</span>
<span v-if="sub.group?.daily_limit_usd == null && sub.group?.weekly_limit_usd == null && sub.group?.monthly_limit_usd == null">{{ t('payment.planCard.quota') }}: {{ t('payment.planCard.unlimited') }}</span>
<span v-if="sub.expires_at">{{ t('userSubscriptions.daysRemaining', { days: getDaysRemaining(sub.expires_at) }) }}</span>
<span v-else>{{ t('userSubscriptions.noExpiration') }}</span>
@@ -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 = ''
@@ -49,6 +49,12 @@
<p v-if="subscription.group?.description" class="mt-0.5 text-xs text-gray-500 dark:text-dark-400">
{{ subscription.group.description }}
</p>
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-gray-400 dark:text-gray-500">
<span>{{ t('payment.planCard.rate') }}: ×{{ subscription.group?.rate_multiplier ?? 1 }}</span>
<span v-if="subscriptionHasPeakRate(subscription)" class="text-amber-700 dark:text-amber-300">
{{ t('payment.planCard.peakRate') }}: {{ subscriptionPeakRateLabel(subscription) }}
</span>
</div>
</div>
</div>
<div class="flex items-center gap-2">
@@ -271,6 +277,16 @@ const appStore = useAppStore()
const subscriptions = ref<UserSubscription[]>([])
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