From 64a2a31729537c76d628da854c3556b9c2311756 Mon Sep 17 00:00:00 2001 From: shaw Date: Mon, 13 Jul 2026 10:14:53 +0800 Subject: [PATCH] =?UTF-8?q?fix(billing):=20=E4=BF=AE=E5=A4=8D=E5=A4=8D?= =?UTF-8?q?=E5=AE=A1=E5=8F=91=E7=8E=B0=E7=9A=84=E4=B8=89=E5=A4=84=E6=8C=89?= =?UTF-8?q?=E6=AC=A1=E8=AE=A1=E8=B4=B9=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HIGH: GetByKeyForAuth 的 WithGroup 显式 Select 补 web_search_price_per_call 列, 否则 auth 快照里分组覆盖价恒为 nil,覆盖价/免费配置在实际计费中静默失效 - MEDIUM: 按次搜索倍率改用不含高峰因子的基础倍率(与 image/video 按次不变式 及分组表单价格预览承诺一致),calculateOpenAIRecordUsageCost 新增独立倍率参数 - LOW: 计费门槛从 <400 收紧为严格 2xx,1xx/3xx 透传不计费 --- backend/internal/repository/api_key_repo.go | 1 + backend/internal/service/openai_alpha_search.go | 4 ++-- .../service/openai_alpha_search_billing_test.go | 9 +++++---- backend/internal/service/openai_gateway_usage.go | 11 +++++++---- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index a091c3c1d7..ee4ed4785f 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -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, diff --git a/backend/internal/service/openai_alpha_search.go b/backend/internal/service/openai_alpha_search.go index 8275626d6e..50d37f95bb 100644 --- a/backend/internal/service/openai_alpha_search.go +++ b/backend/internal/service/openai_alpha_search.go @@ -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{ diff --git a/backend/internal/service/openai_alpha_search_billing_test.go b/backend/internal/service/openai_alpha_search_billing_test.go index 3c5fb6cc81..1251ee43f9 100644 --- a/backend/internal/service/openai_alpha_search_billing_test.go +++ b/backend/internal/service/openai_alpha_search_billing_test.go @@ -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) } diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 08eab1248a..9431b73ecb 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -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 {