fix(billing): 修复复审发现的三处按次计费问题

- HIGH: GetByKeyForAuth 的 WithGroup 显式 Select 补 web_search_price_per_call 列,
  否则 auth 快照里分组覆盖价恒为 nil,覆盖价/免费配置在实际计费中静默失效
- MEDIUM: 按次搜索倍率改用不含高峰因子的基础倍率(与 image/video 按次不变式
  及分组表单价格预览承诺一致),calculateOpenAIRecordUsageCost 新增独立倍率参数
- LOW: 计费门槛从 <400 收紧为严格 2xx,1xx/3xx 透传不计费
This commit is contained in:
shaw
2026-07-13 10:14:53 +08:00
parent 7cbb36f278
commit 64a2a31729
4 changed files with 15 additions and 10 deletions
@@ -190,6 +190,7 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se
group.FieldVideoPrice480p,
group.FieldVideoPrice720p,
group.FieldVideoPrice1080p,
group.FieldWebSearchPricePerCall,
group.FieldClaudeCodeOnly,
group.FieldFallbackGroupID,
group.FieldFallbackGroupIDOnInvalidRequest,
@@ -89,8 +89,8 @@ func (s *OpenAIGatewayService) ForwardAlphaSearch(ctx context.Context, c *gin.Co
contentType = "application/json"
}
c.Data(resp.StatusCode, contentType, respBody)
if resp.StatusCode >= http.StatusBadRequest {
// 上游错误已原样透传给客户端:不是一次成功的搜索,不计费。
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
// 非 2xx(错误/重定向)已原样透传给客户端:不是一次成功的搜索,不计费。
return nil, nil
}
return &OpenAIForwardResult{
@@ -46,10 +46,11 @@ func TestCalculateOpenAIRecordUsageCostWebSearchPerCall(t *testing.T) {
svc := &OpenAIGatewayService{billingService: &BillingService{}}
groupID := int64(11)
// 分组未配置单价:默认 0.01,倍率 2.0
// 分组未配置单价:默认 0.01。按次搜索使用不含高峰因子的基础倍率(第 4 个倍率参数 2.0),
// 即使 token 倍率(含高峰,3.0)更高也不采用。
apiKey := &APIKey{ID: 1, GroupID: &groupID, Group: &Group{ID: groupID, Platform: PlatformOpenAI}}
result := &OpenAIForwardResult{Model: "gpt-5.6-sol", UpstreamModel: "gpt-5.6-sol", WebSearchCalls: 1}
cost, err := svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 2.0, 1.0, 1.0, UsageTokens{}, "")
cost, err := svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 3.0, 1.0, 1.0, 2.0, UsageTokens{}, "")
require.NoError(t, err)
require.Equal(t, string(BillingModePerRequest), cost.BillingMode)
require.InDelta(t, 0.01, cost.TotalCost, 1e-12)
@@ -57,7 +58,7 @@ func TestCalculateOpenAIRecordUsageCostWebSearchPerCall(t *testing.T) {
// 分组配置单价 0.005
apiKey.Group.WebSearchPricePerCall = float64Ptr(0.005)
cost, err = svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 1.0, 1.0, 1.0, UsageTokens{}, "")
cost, err = svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 1.0, 1.0, 1.0, 1.0, UsageTokens{}, "")
require.NoError(t, err)
require.InDelta(t, 0.005, cost.TotalCost, 1e-12)
require.InDelta(t, 0.005, cost.ActualCost, 1e-12)
@@ -65,7 +66,7 @@ func TestCalculateOpenAIRecordUsageCostWebSearchPerCall(t *testing.T) {
// WebSearchCalls = 0 时不得走按次分支(无定价数据会返回 pricing 错误,
// 证明回落到了 token 路径而不是被按次分支吞掉)。
result.WebSearchCalls = 0
_, err = svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 1.0, 1.0, 1.0, UsageTokens{InputTokens: 10}, "")
_, err = svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 1.0, 1.0, 1.0, 1.0, UsageTokens{InputTokens: 10}, "")
require.Error(t, err)
}
@@ -178,7 +178,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
if result.ServiceTier != nil {
serviceTier = strings.TrimSpace(*result.ServiceTier)
}
cost, err = s.calculateOpenAIRecordUsageCost(ctx, result, apiKey, billingModels, multiplier, imageMultiplier, videoMultiplier, tokens, serviceTier)
cost, err = s.calculateOpenAIRecordUsageCost(ctx, result, apiKey, billingModels, multiplier, imageMultiplier, videoMultiplier, baseMultiplier, tokens, serviceTier)
if err != nil {
if !isUsagePricingUnavailableError(err) {
return err
@@ -363,14 +363,17 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost(
multiplier float64,
imageMultiplier float64,
videoMultiplier float64,
webSearchMultiplier float64,
tokens UsageTokens,
serviceTier string,
) (*CostBreakdown, error) {
billingModel := firstUsageBillingModel(billingModels)
if result != nil && result.WebSearchCalls > 0 {
// Codex alpha/search 网页搜索按次计费:上游不返回 usage/token 字段,
// 单价取分组覆盖价(nil 时默认 0.01 = 官方 $10/1000 次),倍率与 token 口径一致。
return s.billingService.CalculateWebSearchCost(result.WebSearchCalls, webSearchPricePerCallFromAPIKey(apiKey), multiplier), nil
// Codex alpha/search 网页搜索按次计费:上游不返回 usage/token 字段,单价只取
// 分组覆盖价(nil 时默认 0.01 = 官方 $10/1000 次),不参与渠道级模型定价。
// 倍率与 image/video 按次口径一致:使用不含高峰因子的基础倍率
//(用户专属 > 分组 rate_multiplier > 系统默认),与分组表单的价格预览承诺一致。
return s.billingService.CalculateWebSearchCost(result.WebSearchCalls, webSearchPricePerCallFromAPIKey(apiKey), webSearchMultiplier), nil
}
if isGrokVideoUsageResult(result, billingModels) {
if resolved := s.resolveOpenAIChannelPricing(ctx, billingModel, apiKey); resolved == nil || resolved.Mode != BillingModeToken {