Skip to content

Commit b67ca74

Browse files
committed
feat(payment): 优化支付配置与 Anthropic 兼容处理
- 新增支付配置字段在后台设置接口、DTO 和前端管理页中的读写支持 - 调整支付页展示逻辑和类型定义,完善用户侧支付入口表现 - 优化 Anthropic 到 Responses 的转换,补充相关兼容测试 - 修正账单服务和支付配置服务的边界行为,并补充单元测试 - 将后端版本号更新到 1.0.27 验证: - go test -C backend ./internal/handler ./internal/service ./internal/pkg/apicompat - pnpm --dir frontend exec vitest run src/views/admin/__tests__/SettingsView.spec.ts src/views/user/__tests__/PaymentView.spec.ts
1 parent b7237e1 commit b67ca74

19 files changed

Lines changed: 383 additions & 22 deletions

backend/cmd/server/VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.0.26
1+
1.0.27

backend/internal/handler/admin/setting_handler.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,9 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
289289
PaymentProductNameSuffix: paymentCfg.ProductNameSuffix,
290290
PaymentAnnouncementText: paymentCfg.AnnouncementText,
291291
PaymentRechargeCenterItems: rechargeCenterItemsToDTO(paymentCfg.RechargeCenterItems),
292+
PaymentRechargeCenterTabEnabled: paymentCfg.RechargeCenterTabEnabled,
293+
PaymentRechargeTabEnabled: paymentCfg.RechargeTabEnabled,
294+
PaymentSubscriptionTabEnabled: paymentCfg.SubscriptionTabEnabled,
292295
PaymentHelpImageURL: paymentCfg.HelpImageURL,
293296
PaymentHelpText: paymentCfg.HelpText,
294297
PaymentReceiptCodeOSSEnabled: paymentCfg.ReceiptCodeOSS.Enabled,
@@ -608,6 +611,9 @@ type UpdateSettingsRequest struct {
608611
PaymentProductNameSuffix *string `json:"payment_product_name_suffix"`
609612
PaymentAnnouncementText *string `json:"payment_announcement_text"`
610613
PaymentRechargeCenterItems []dto.PaymentRechargeCenterItem `json:"payment_recharge_center_items"`
614+
PaymentRechargeCenterTabEnabled *bool `json:"payment_recharge_center_tab_enabled"`
615+
PaymentRechargeTabEnabled *bool `json:"payment_recharge_tab_enabled"`
616+
PaymentSubscriptionTabEnabled *bool `json:"payment_subscription_tab_enabled"`
611617
PaymentHelpImageURL *string `json:"payment_help_image_url"`
612618
PaymentHelpText *string `json:"payment_help_text"`
613619

@@ -1722,6 +1728,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
17221728
ProductNameSuffix: req.PaymentProductNameSuffix,
17231729
AnnouncementText: req.PaymentAnnouncementText,
17241730
RechargeCenterItems: rechargeCenterItemsFromDTO(req.PaymentRechargeCenterItems),
1731+
RechargeCenterTabEnabled: req.PaymentRechargeCenterTabEnabled,
1732+
RechargeTabEnabled: req.PaymentRechargeTabEnabled,
1733+
SubscriptionTabEnabled: req.PaymentSubscriptionTabEnabled,
17251734
HelpImageURL: req.PaymentHelpImageURL,
17261735
HelpText: req.PaymentHelpText,
17271736
ReceiptCodeOSSEnabled: req.PaymentReceiptCodeOSSEnabled,
@@ -1934,6 +1943,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
19341943
PaymentProductNameSuffix: updatedPaymentCfg.ProductNameSuffix,
19351944
PaymentAnnouncementText: updatedPaymentCfg.AnnouncementText,
19361945
PaymentRechargeCenterItems: rechargeCenterItemsToDTO(updatedPaymentCfg.RechargeCenterItems),
1946+
PaymentRechargeCenterTabEnabled: updatedPaymentCfg.RechargeCenterTabEnabled,
1947+
PaymentRechargeTabEnabled: updatedPaymentCfg.RechargeTabEnabled,
1948+
PaymentSubscriptionTabEnabled: updatedPaymentCfg.SubscriptionTabEnabled,
19371949
PaymentHelpImageURL: updatedPaymentCfg.HelpImageURL,
19381950
PaymentHelpText: updatedPaymentCfg.HelpText,
19391951
PaymentReceiptCodeOSSEnabled: updatedPaymentCfg.ReceiptCodeOSS.Enabled,
@@ -1978,6 +1990,8 @@ func hasPaymentFields(req UpdateSettingsRequest) bool {
19781990
req.PaymentLoadBalanceStrat != nil || req.PaymentProductNamePrefix != nil ||
19791991
req.PaymentProductNameSuffix != nil || req.PaymentHelpImageURL != nil ||
19801992
req.PaymentAnnouncementText != nil || req.PaymentRechargeCenterItems != nil ||
1993+
req.PaymentRechargeCenterTabEnabled != nil || req.PaymentRechargeTabEnabled != nil ||
1994+
req.PaymentSubscriptionTabEnabled != nil ||
19811995
req.PaymentHelpText != nil || req.PaymentReceiptCodeOSSEnabled != nil ||
19821996
req.PaymentReceiptCodeOSSEndpoint != nil || req.PaymentReceiptCodeOSSRegion != nil ||
19831997
req.PaymentReceiptCodeOSSBucket != nil || req.PaymentReceiptCodeOSSAccessKeyID != nil ||

backend/internal/handler/dto/settings.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,9 @@ type SystemSettings struct {
210210
PaymentProductNameSuffix string `json:"payment_product_name_suffix"`
211211
PaymentAnnouncementText string `json:"payment_announcement_text"`
212212
PaymentRechargeCenterItems []PaymentRechargeCenterItem `json:"payment_recharge_center_items"`
213+
PaymentRechargeCenterTabEnabled bool `json:"payment_recharge_center_tab_enabled"`
214+
PaymentRechargeTabEnabled bool `json:"payment_recharge_tab_enabled"`
215+
PaymentSubscriptionTabEnabled bool `json:"payment_subscription_tab_enabled"`
213216
PaymentHelpImageURL string `json:"payment_help_image_url"`
214217
PaymentHelpText string `json:"payment_help_text"`
215218

backend/internal/handler/payment_handler.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,9 @@ func (h *PaymentHandler) GetCheckoutInfo(c *gin.Context) {
142142
RechargeFeeRate: cfg.RechargeFeeRate,
143143
AnnouncementText: cfg.AnnouncementText,
144144
RechargeCenterItems: cfg.RechargeCenterItems,
145+
RechargeCenterTabEnabled: cfg.RechargeCenterTabEnabled,
146+
RechargeTabEnabled: cfg.RechargeTabEnabled,
147+
SubscriptionTabEnabled: cfg.SubscriptionTabEnabled,
145148
HelpText: cfg.HelpText,
146149
HelpImageURL: cfg.HelpImageURL,
147150
StripePublishableKey: cfg.StripePublishableKey,
@@ -160,6 +163,9 @@ type checkoutInfoResponse struct {
160163
RechargeFeeRate float64 `json:"recharge_fee_rate"`
161164
AnnouncementText string `json:"announcement_text"`
162165
RechargeCenterItems []service.RechargeCenterItem `json:"recharge_center_items"`
166+
RechargeCenterTabEnabled bool `json:"recharge_center_tab_enabled"`
167+
RechargeTabEnabled bool `json:"recharge_tab_enabled"`
168+
SubscriptionTabEnabled bool `json:"subscription_tab_enabled"`
163169
HelpText string `json:"help_text"`
164170
HelpImageURL string `json:"help_image_url"`
165171
StripePublishableKey string `json:"stripe_publishable_key"`

backend/internal/pkg/apicompat/anthropic_responses_test.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,109 @@ func TestStreamingCachedTokensUseAnthropicInputSemantics(t *testing.T) {
464464
assert.Equal(t, "message_stop", events[1].Type)
465465
}
466466

467+
func TestAnthropicToResponsesResponse_CacheTokensUseResponsesInputSemantics(t *testing.T) {
468+
resp := &AnthropicResponse{
469+
Usage: AnthropicUsage{
470+
InputTokens: 3318,
471+
OutputTokens: 123,
472+
CacheReadInputTokens: 50688,
473+
CacheCreationInputTokens: 200,
474+
},
475+
}
476+
477+
out := AnthropicToResponsesResponse(resp)
478+
require.NotNil(t, out.Usage)
479+
assert.Equal(t, 54206, out.Usage.InputTokens)
480+
assert.Equal(t, 123, out.Usage.OutputTokens)
481+
assert.Equal(t, 54329, out.Usage.TotalTokens)
482+
require.NotNil(t, out.Usage.InputTokensDetails)
483+
assert.Equal(t, 50688, out.Usage.InputTokensDetails.CachedTokens)
484+
}
485+
486+
func TestAnthropicEventToResponses_CacheTokensFromMessageStart(t *testing.T) {
487+
state := NewAnthropicEventToResponsesState()
488+
489+
AnthropicEventToResponsesEvents(&AnthropicStreamEvent{
490+
Type: "message_start",
491+
Message: &AnthropicResponse{
492+
ID: "msg_cached_start",
493+
Model: "claude-sonnet-4-5-20250929",
494+
Usage: AnthropicUsage{
495+
InputTokens: 12,
496+
CacheReadInputTokens: 9,
497+
CacheCreationInputTokens: 3,
498+
},
499+
},
500+
}, state)
501+
502+
AnthropicEventToResponsesEvents(&AnthropicStreamEvent{
503+
Type: "message_delta",
504+
Usage: &AnthropicUsage{
505+
OutputTokens: 7,
506+
},
507+
}, state)
508+
509+
events := AnthropicEventToResponsesEvents(&AnthropicStreamEvent{Type: "message_stop"}, state)
510+
var completed *ResponsesStreamEvent
511+
for i := range events {
512+
if events[i].Type == "response.completed" {
513+
completed = &events[i]
514+
break
515+
}
516+
}
517+
518+
require.NotNil(t, completed)
519+
require.NotNil(t, completed.Response)
520+
require.NotNil(t, completed.Response.Usage)
521+
assert.Equal(t, 24, completed.Response.Usage.InputTokens)
522+
assert.Equal(t, 7, completed.Response.Usage.OutputTokens)
523+
assert.Equal(t, 31, completed.Response.Usage.TotalTokens)
524+
require.NotNil(t, completed.Response.Usage.InputTokensDetails)
525+
assert.Equal(t, 9, completed.Response.Usage.InputTokensDetails.CachedTokens)
526+
}
527+
528+
func TestAnthropicEventToResponses_CacheTokensFromMessageDelta(t *testing.T) {
529+
state := NewAnthropicEventToResponsesState()
530+
531+
AnthropicEventToResponsesEvents(&AnthropicStreamEvent{
532+
Type: "message_start",
533+
Message: &AnthropicResponse{
534+
ID: "msg_cached_delta",
535+
Model: "claude-sonnet-4-5-20250929",
536+
Usage: AnthropicUsage{
537+
InputTokens: 20,
538+
},
539+
},
540+
}, state)
541+
542+
AnthropicEventToResponsesEvents(&AnthropicStreamEvent{
543+
Type: "message_delta",
544+
Usage: &AnthropicUsage{
545+
OutputTokens: 8,
546+
CacheReadInputTokens: 11,
547+
CacheCreationInputTokens: 4,
548+
},
549+
}, state)
550+
551+
events := AnthropicEventToResponsesEvents(&AnthropicStreamEvent{Type: "message_stop"}, state)
552+
var completed *ResponsesStreamEvent
553+
for i := range events {
554+
if events[i].Type == "response.completed" {
555+
completed = &events[i]
556+
break
557+
}
558+
}
559+
560+
require.NotNil(t, completed)
561+
require.NotNil(t, completed.Response)
562+
require.NotNil(t, completed.Response.Usage)
563+
assert.Equal(t, 35, completed.Response.Usage.InputTokens)
564+
assert.Equal(t, 8, completed.Response.Usage.OutputTokens)
565+
assert.Equal(t, 43, completed.Response.Usage.TotalTokens)
566+
require.NotNil(t, completed.Response.Usage.InputTokensDetails)
567+
assert.Equal(t, 11, completed.Response.Usage.InputTokensDetails.CachedTokens)
568+
}
569+
467570
func TestStreamingToolCall(t *testing.T) {
468571
state := NewResponsesEventToAnthropicState()
469572

backend/internal/pkg/apicompat/anthropic_to_responses_response.go

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -94,11 +94,14 @@ func AnthropicToResponsesResponse(resp *AnthropicResponse) *ResponsesResponse {
9494
out.IncompleteDetails = &ResponsesIncompleteDetails{Reason: "max_output_tokens"}
9595
}
9696

97-
// Usage
97+
// Anthropic input_tokens excludes cache read/write tokens; Responses input_tokens includes them.
98+
totalInputTokens := resp.Usage.InputTokens +
99+
resp.Usage.CacheReadInputTokens +
100+
resp.Usage.CacheCreationInputTokens
98101
out.Usage = &ResponsesUsage{
99-
InputTokens: resp.Usage.InputTokens,
102+
InputTokens: totalInputTokens,
100103
OutputTokens: resp.Usage.OutputTokens,
101-
TotalTokens: resp.Usage.InputTokens + resp.Usage.OutputTokens,
104+
TotalTokens: totalInputTokens + resp.Usage.OutputTokens,
102105
}
103106
if resp.Usage.CacheReadInputTokens > 0 {
104107
out.Usage.InputTokensDetails = &ResponsesInputTokensDetails{
@@ -150,10 +153,11 @@ type AnthropicEventToResponsesState struct {
150153
CurrentCallID string
151154
CurrentName string
152155

153-
// Usage from message_delta
154-
InputTokens int
155-
OutputTokens int
156-
CacheReadInputTokens int
156+
// Usage from Anthropic stream events. InputTokens uses Anthropic semantics and excludes cache tokens.
157+
InputTokens int
158+
OutputTokens int
159+
CacheReadInputTokens int
160+
CacheCreationInputTokens int
157161
}
158162

159163
// NewAnthropicEventToResponsesState returns an initialised stream state.
@@ -225,6 +229,12 @@ func anthToResHandleMessageStart(evt *AnthropicStreamEvent, state *AnthropicEven
225229
if evt.Message.Usage.InputTokens > 0 {
226230
state.InputTokens = evt.Message.Usage.InputTokens
227231
}
232+
if evt.Message.Usage.CacheReadInputTokens > 0 {
233+
state.CacheReadInputTokens = evt.Message.Usage.CacheReadInputTokens
234+
}
235+
if evt.Message.Usage.CacheCreationInputTokens > 0 {
236+
state.CacheCreationInputTokens = evt.Message.Usage.CacheCreationInputTokens
237+
}
228238
}
229239

230240
if state.CreatedSent {
@@ -392,9 +402,15 @@ func anthToResHandleMessageDelta(evt *AnthropicStreamEvent, state *AnthropicEven
392402
// Update usage
393403
if evt.Usage != nil {
394404
state.OutputTokens = evt.Usage.OutputTokens
405+
if evt.Usage.InputTokens > 0 {
406+
state.InputTokens = evt.Usage.InputTokens
407+
}
395408
if evt.Usage.CacheReadInputTokens > 0 {
396409
state.CacheReadInputTokens = evt.Usage.CacheReadInputTokens
397410
}
411+
if evt.Usage.CacheCreationInputTokens > 0 {
412+
state.CacheCreationInputTokens = evt.Usage.CacheCreationInputTokens
413+
}
398414
}
399415

400416
return nil
@@ -472,10 +488,11 @@ func makeResponsesCompletedEvent(
472488
seq := state.SequenceNumber
473489
state.SequenceNumber++
474490

491+
totalInputTokens := state.InputTokens + state.CacheReadInputTokens + state.CacheCreationInputTokens
475492
usage := &ResponsesUsage{
476-
InputTokens: state.InputTokens,
493+
InputTokens: totalInputTokens,
477494
OutputTokens: state.OutputTokens,
478-
TotalTokens: state.InputTokens + state.OutputTokens,
495+
TotalTokens: totalInputTokens + state.OutputTokens,
479496
}
480497
if state.CacheReadInputTokens > 0 {
481498
usage.InputTokensDetails = &ResponsesInputTokensDetails{

backend/internal/service/billing_service.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,7 @@ func (s *BillingService) computeTokenBreakdown(
492492
inputPrice := pricing.InputPricePerToken
493493
outputPrice := pricing.OutputPricePerToken
494494
cacheReadPrice := pricing.CacheReadPricePerToken
495+
cacheCreationLongContextMultiplier := 1.0
495496
inputTierMultiplier := 1.0
496497
outputTierMultiplier := 1.0
497498
cacheCreationTierMultiplier := 1.0
@@ -530,6 +531,8 @@ func (s *BillingService) computeTokenBreakdown(
530531
if applyLongCtx && s.shouldApplySessionLongContextPricing(tokens, pricing) {
531532
inputPrice *= pricing.LongContextInputMultiplier
532533
outputPrice *= pricing.LongContextOutputMultiplier
534+
cacheReadPrice *= pricing.LongContextInputMultiplier
535+
cacheCreationLongContextMultiplier = pricing.LongContextInputMultiplier
533536
}
534537

535538
bd := &CostBreakdown{}
@@ -552,7 +555,8 @@ func (s *BillingService) computeTokenBreakdown(
552555
}
553556

554557
// 缓存创建费用
555-
bd.CacheCreationCost = s.computeCacheCreationCost(pricing, tokens) * cacheCreationTierMultiplier
558+
bd.CacheCreationCost = s.computeCacheCreationCost(pricing, tokens) *
559+
cacheCreationLongContextMultiplier * cacheCreationTierMultiplier
556560

557561
bd.CacheReadCost = float64(tokens.CacheReadTokens) * cacheReadPrice * cacheReadTierMultiplier
558562

backend/internal/service/billing_service_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,40 @@ func TestCalculateCost_OpenAIGPT54LongContextAppliesWholeSessionMultipliers(t *t
187187
require.InDelta(t, expectedInput+expectedOutput, cost.ActualCost, 1e-10)
188188
}
189189

190+
func TestCalculateCost_OpenAIGPT54LongContextScalesCacheReadAndCreation(t *testing.T) {
191+
svc := newTestBillingService()
192+
193+
tokens := UsageTokens{
194+
InputTokens: 1000,
195+
OutputTokens: 1000,
196+
CacheCreationTokens: 10000,
197+
CacheReadTokens: 300000,
198+
}
199+
200+
cost, err := svc.CalculateCost("gpt-5.4-2026-03-05", tokens, 1.0)
201+
require.NoError(t, err)
202+
203+
require.InDelta(t, float64(tokens.CacheReadTokens)*6.25e-6*2.0, cost.CacheReadCost, 1e-10)
204+
require.InDelta(t, float64(tokens.CacheCreationTokens)*62.5e-6*2.0, cost.CacheCreationCost, 1e-10)
205+
}
206+
207+
func TestCalculateCost_NoLongContextKeepsCacheReadAndCreationAtBasePrice(t *testing.T) {
208+
svc := newTestBillingService()
209+
210+
tokens := UsageTokens{
211+
InputTokens: 1000,
212+
OutputTokens: 1000,
213+
CacheCreationTokens: 10000,
214+
CacheReadTokens: 100000,
215+
}
216+
217+
cost, err := svc.CalculateCost("gpt-5.4-2026-03-05", tokens, 1.0)
218+
require.NoError(t, err)
219+
220+
require.InDelta(t, float64(tokens.CacheReadTokens)*6.25e-6, cost.CacheReadCost, 1e-10)
221+
require.InDelta(t, float64(tokens.CacheCreationTokens)*62.5e-6, cost.CacheCreationCost, 1e-10)
222+
}
223+
190224
func TestGetFallbackPricing_FamilyMatching(t *testing.T) {
191225
svc := newTestBillingService()
192226

@@ -453,6 +487,40 @@ func TestCalculateCost_SupportsCacheBreakdown(t *testing.T) {
453487
require.InDelta(t, expected5m+expected1h, cost.CacheCreationCost, 1e-10)
454488
}
455489

490+
func TestCalculateCost_LongContextScalesCacheCreation5mAnd1h(t *testing.T) {
491+
svc := &BillingService{
492+
cfg: &config.Config{},
493+
fallbackPrices: map[string]*ModelPricing{
494+
"claude-sonnet-4": {
495+
InputPricePerToken: 3e-6,
496+
OutputPricePerToken: 15e-6,
497+
CacheReadPricePerToken: 0.3e-6,
498+
SupportsCacheBreakdown: true,
499+
CacheCreation5mPrice: 4e-6,
500+
CacheCreation1hPrice: 5e-6,
501+
LongContextInputThreshold: 200000,
502+
LongContextInputMultiplier: 2.0,
503+
LongContextOutputMultiplier: 1.5,
504+
},
505+
},
506+
}
507+
508+
tokens := UsageTokens{
509+
InputTokens: 1000,
510+
OutputTokens: 1000,
511+
CacheReadTokens: 300000,
512+
CacheCreation5mTokens: 8000,
513+
CacheCreation1hTokens: 4000,
514+
}
515+
516+
cost, err := svc.CalculateCost("claude-sonnet-4", tokens, 1.0)
517+
require.NoError(t, err)
518+
519+
expected5m := float64(tokens.CacheCreation5mTokens) * 4e-6 * 2.0
520+
expected1h := float64(tokens.CacheCreation1hTokens) * 5e-6 * 2.0
521+
require.InDelta(t, expected5m+expected1h, cost.CacheCreationCost, 1e-10)
522+
}
523+
456524
func TestCalculateCost_LargeTokenCount(t *testing.T) {
457525
svc := newTestBillingService()
458526

0 commit comments

Comments
 (0)