From b67ca74e65557645b0e1650b28295670eecb4abb Mon Sep 17 00:00:00 2001 From: kouzhenqi <826563886@qq.com> Date: Tue, 2 Jun 2026 04:36:13 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat(payment):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=94=AF=E4=BB=98=E9=85=8D=E7=BD=AE=E4=B8=8E=20Anthropic=20?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增支付配置字段在后台设置接口、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 --- backend/cmd/server/VERSION | 2 +- .../internal/handler/admin/setting_handler.go | 14 +++ backend/internal/handler/dto/settings.go | 3 + backend/internal/handler/payment_handler.go | 6 + .../pkg/apicompat/anthropic_responses_test.go | 103 ++++++++++++++++++ .../anthropic_to_responses_response.go | 35 ++++-- backend/internal/service/billing_service.go | 6 +- .../internal/service/billing_service_test.go | 68 ++++++++++++ .../service/payment_config_service.go | 19 +++- .../service/payment_config_service_test.go | 34 ++++++ frontend/src/api/admin/payment.ts | 6 + frontend/src/api/admin/settings.ts | 6 + frontend/src/i18n/locales/en.ts | 3 + frontend/src/i18n/locales/zh.ts | 3 + frontend/src/types/payment.ts | 6 + frontend/src/views/admin/SettingsView.vue | 46 ++++++++ .../admin/__tests__/SettingsView.spec.ts | 3 + frontend/src/views/user/PaymentView.vue | 39 +++++-- .../views/user/__tests__/PaymentView.spec.ts | 3 + 19 files changed, 383 insertions(+), 22 deletions(-) diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index 8955a0173..adb7b04cb 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -1.0.26 +1.0.27 diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 2d2af2403..a629bb723 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -289,6 +289,9 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { PaymentProductNameSuffix: paymentCfg.ProductNameSuffix, PaymentAnnouncementText: paymentCfg.AnnouncementText, PaymentRechargeCenterItems: rechargeCenterItemsToDTO(paymentCfg.RechargeCenterItems), + PaymentRechargeCenterTabEnabled: paymentCfg.RechargeCenterTabEnabled, + PaymentRechargeTabEnabled: paymentCfg.RechargeTabEnabled, + PaymentSubscriptionTabEnabled: paymentCfg.SubscriptionTabEnabled, PaymentHelpImageURL: paymentCfg.HelpImageURL, PaymentHelpText: paymentCfg.HelpText, PaymentReceiptCodeOSSEnabled: paymentCfg.ReceiptCodeOSS.Enabled, @@ -608,6 +611,9 @@ type UpdateSettingsRequest struct { PaymentProductNameSuffix *string `json:"payment_product_name_suffix"` PaymentAnnouncementText *string `json:"payment_announcement_text"` PaymentRechargeCenterItems []dto.PaymentRechargeCenterItem `json:"payment_recharge_center_items"` + PaymentRechargeCenterTabEnabled *bool `json:"payment_recharge_center_tab_enabled"` + PaymentRechargeTabEnabled *bool `json:"payment_recharge_tab_enabled"` + PaymentSubscriptionTabEnabled *bool `json:"payment_subscription_tab_enabled"` PaymentHelpImageURL *string `json:"payment_help_image_url"` PaymentHelpText *string `json:"payment_help_text"` @@ -1722,6 +1728,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { ProductNameSuffix: req.PaymentProductNameSuffix, AnnouncementText: req.PaymentAnnouncementText, RechargeCenterItems: rechargeCenterItemsFromDTO(req.PaymentRechargeCenterItems), + RechargeCenterTabEnabled: req.PaymentRechargeCenterTabEnabled, + RechargeTabEnabled: req.PaymentRechargeTabEnabled, + SubscriptionTabEnabled: req.PaymentSubscriptionTabEnabled, HelpImageURL: req.PaymentHelpImageURL, HelpText: req.PaymentHelpText, ReceiptCodeOSSEnabled: req.PaymentReceiptCodeOSSEnabled, @@ -1934,6 +1943,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { PaymentProductNameSuffix: updatedPaymentCfg.ProductNameSuffix, PaymentAnnouncementText: updatedPaymentCfg.AnnouncementText, PaymentRechargeCenterItems: rechargeCenterItemsToDTO(updatedPaymentCfg.RechargeCenterItems), + PaymentRechargeCenterTabEnabled: updatedPaymentCfg.RechargeCenterTabEnabled, + PaymentRechargeTabEnabled: updatedPaymentCfg.RechargeTabEnabled, + PaymentSubscriptionTabEnabled: updatedPaymentCfg.SubscriptionTabEnabled, PaymentHelpImageURL: updatedPaymentCfg.HelpImageURL, PaymentHelpText: updatedPaymentCfg.HelpText, PaymentReceiptCodeOSSEnabled: updatedPaymentCfg.ReceiptCodeOSS.Enabled, @@ -1978,6 +1990,8 @@ func hasPaymentFields(req UpdateSettingsRequest) bool { req.PaymentLoadBalanceStrat != nil || req.PaymentProductNamePrefix != nil || req.PaymentProductNameSuffix != nil || req.PaymentHelpImageURL != nil || req.PaymentAnnouncementText != nil || req.PaymentRechargeCenterItems != nil || + req.PaymentRechargeCenterTabEnabled != nil || req.PaymentRechargeTabEnabled != nil || + req.PaymentSubscriptionTabEnabled != nil || req.PaymentHelpText != nil || req.PaymentReceiptCodeOSSEnabled != nil || req.PaymentReceiptCodeOSSEndpoint != nil || req.PaymentReceiptCodeOSSRegion != nil || req.PaymentReceiptCodeOSSBucket != nil || req.PaymentReceiptCodeOSSAccessKeyID != nil || diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index ccb2980c6..fb3034851 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -210,6 +210,9 @@ type SystemSettings struct { PaymentProductNameSuffix string `json:"payment_product_name_suffix"` PaymentAnnouncementText string `json:"payment_announcement_text"` PaymentRechargeCenterItems []PaymentRechargeCenterItem `json:"payment_recharge_center_items"` + PaymentRechargeCenterTabEnabled bool `json:"payment_recharge_center_tab_enabled"` + PaymentRechargeTabEnabled bool `json:"payment_recharge_tab_enabled"` + PaymentSubscriptionTabEnabled bool `json:"payment_subscription_tab_enabled"` PaymentHelpImageURL string `json:"payment_help_image_url"` PaymentHelpText string `json:"payment_help_text"` diff --git a/backend/internal/handler/payment_handler.go b/backend/internal/handler/payment_handler.go index 7a702ac63..0ec5af806 100644 --- a/backend/internal/handler/payment_handler.go +++ b/backend/internal/handler/payment_handler.go @@ -142,6 +142,9 @@ func (h *PaymentHandler) GetCheckoutInfo(c *gin.Context) { RechargeFeeRate: cfg.RechargeFeeRate, AnnouncementText: cfg.AnnouncementText, RechargeCenterItems: cfg.RechargeCenterItems, + RechargeCenterTabEnabled: cfg.RechargeCenterTabEnabled, + RechargeTabEnabled: cfg.RechargeTabEnabled, + SubscriptionTabEnabled: cfg.SubscriptionTabEnabled, HelpText: cfg.HelpText, HelpImageURL: cfg.HelpImageURL, StripePublishableKey: cfg.StripePublishableKey, @@ -160,6 +163,9 @@ type checkoutInfoResponse struct { RechargeFeeRate float64 `json:"recharge_fee_rate"` AnnouncementText string `json:"announcement_text"` RechargeCenterItems []service.RechargeCenterItem `json:"recharge_center_items"` + RechargeCenterTabEnabled bool `json:"recharge_center_tab_enabled"` + RechargeTabEnabled bool `json:"recharge_tab_enabled"` + SubscriptionTabEnabled bool `json:"subscription_tab_enabled"` HelpText string `json:"help_text"` HelpImageURL string `json:"help_image_url"` StripePublishableKey string `json:"stripe_publishable_key"` diff --git a/backend/internal/pkg/apicompat/anthropic_responses_test.go b/backend/internal/pkg/apicompat/anthropic_responses_test.go index e8b25c2b2..e3e3b4467 100644 --- a/backend/internal/pkg/apicompat/anthropic_responses_test.go +++ b/backend/internal/pkg/apicompat/anthropic_responses_test.go @@ -464,6 +464,109 @@ func TestStreamingCachedTokensUseAnthropicInputSemantics(t *testing.T) { assert.Equal(t, "message_stop", events[1].Type) } +func TestAnthropicToResponsesResponse_CacheTokensUseResponsesInputSemantics(t *testing.T) { + resp := &AnthropicResponse{ + Usage: AnthropicUsage{ + InputTokens: 3318, + OutputTokens: 123, + CacheReadInputTokens: 50688, + CacheCreationInputTokens: 200, + }, + } + + out := AnthropicToResponsesResponse(resp) + require.NotNil(t, out.Usage) + assert.Equal(t, 54206, out.Usage.InputTokens) + assert.Equal(t, 123, out.Usage.OutputTokens) + assert.Equal(t, 54329, out.Usage.TotalTokens) + require.NotNil(t, out.Usage.InputTokensDetails) + assert.Equal(t, 50688, out.Usage.InputTokensDetails.CachedTokens) +} + +func TestAnthropicEventToResponses_CacheTokensFromMessageStart(t *testing.T) { + state := NewAnthropicEventToResponsesState() + + AnthropicEventToResponsesEvents(&AnthropicStreamEvent{ + Type: "message_start", + Message: &AnthropicResponse{ + ID: "msg_cached_start", + Model: "claude-sonnet-4-5-20250929", + Usage: AnthropicUsage{ + InputTokens: 12, + CacheReadInputTokens: 9, + CacheCreationInputTokens: 3, + }, + }, + }, state) + + AnthropicEventToResponsesEvents(&AnthropicStreamEvent{ + Type: "message_delta", + Usage: &AnthropicUsage{ + OutputTokens: 7, + }, + }, state) + + events := AnthropicEventToResponsesEvents(&AnthropicStreamEvent{Type: "message_stop"}, state) + var completed *ResponsesStreamEvent + for i := range events { + if events[i].Type == "response.completed" { + completed = &events[i] + break + } + } + + require.NotNil(t, completed) + require.NotNil(t, completed.Response) + require.NotNil(t, completed.Response.Usage) + assert.Equal(t, 24, completed.Response.Usage.InputTokens) + assert.Equal(t, 7, completed.Response.Usage.OutputTokens) + assert.Equal(t, 31, completed.Response.Usage.TotalTokens) + require.NotNil(t, completed.Response.Usage.InputTokensDetails) + assert.Equal(t, 9, completed.Response.Usage.InputTokensDetails.CachedTokens) +} + +func TestAnthropicEventToResponses_CacheTokensFromMessageDelta(t *testing.T) { + state := NewAnthropicEventToResponsesState() + + AnthropicEventToResponsesEvents(&AnthropicStreamEvent{ + Type: "message_start", + Message: &AnthropicResponse{ + ID: "msg_cached_delta", + Model: "claude-sonnet-4-5-20250929", + Usage: AnthropicUsage{ + InputTokens: 20, + }, + }, + }, state) + + AnthropicEventToResponsesEvents(&AnthropicStreamEvent{ + Type: "message_delta", + Usage: &AnthropicUsage{ + OutputTokens: 8, + CacheReadInputTokens: 11, + CacheCreationInputTokens: 4, + }, + }, state) + + events := AnthropicEventToResponsesEvents(&AnthropicStreamEvent{Type: "message_stop"}, state) + var completed *ResponsesStreamEvent + for i := range events { + if events[i].Type == "response.completed" { + completed = &events[i] + break + } + } + + require.NotNil(t, completed) + require.NotNil(t, completed.Response) + require.NotNil(t, completed.Response.Usage) + assert.Equal(t, 35, completed.Response.Usage.InputTokens) + assert.Equal(t, 8, completed.Response.Usage.OutputTokens) + assert.Equal(t, 43, completed.Response.Usage.TotalTokens) + require.NotNil(t, completed.Response.Usage.InputTokensDetails) + assert.Equal(t, 11, completed.Response.Usage.InputTokensDetails.CachedTokens) +} + func TestStreamingToolCall(t *testing.T) { state := NewResponsesEventToAnthropicState() diff --git a/backend/internal/pkg/apicompat/anthropic_to_responses_response.go b/backend/internal/pkg/apicompat/anthropic_to_responses_response.go index 9290e3995..821ad426f 100644 --- a/backend/internal/pkg/apicompat/anthropic_to_responses_response.go +++ b/backend/internal/pkg/apicompat/anthropic_to_responses_response.go @@ -94,11 +94,14 @@ func AnthropicToResponsesResponse(resp *AnthropicResponse) *ResponsesResponse { out.IncompleteDetails = &ResponsesIncompleteDetails{Reason: "max_output_tokens"} } - // Usage + // Anthropic input_tokens excludes cache read/write tokens; Responses input_tokens includes them. + totalInputTokens := resp.Usage.InputTokens + + resp.Usage.CacheReadInputTokens + + resp.Usage.CacheCreationInputTokens out.Usage = &ResponsesUsage{ - InputTokens: resp.Usage.InputTokens, + InputTokens: totalInputTokens, OutputTokens: resp.Usage.OutputTokens, - TotalTokens: resp.Usage.InputTokens + resp.Usage.OutputTokens, + TotalTokens: totalInputTokens + resp.Usage.OutputTokens, } if resp.Usage.CacheReadInputTokens > 0 { out.Usage.InputTokensDetails = &ResponsesInputTokensDetails{ @@ -150,10 +153,11 @@ type AnthropicEventToResponsesState struct { CurrentCallID string CurrentName string - // Usage from message_delta - InputTokens int - OutputTokens int - CacheReadInputTokens int + // Usage from Anthropic stream events. InputTokens uses Anthropic semantics and excludes cache tokens. + InputTokens int + OutputTokens int + CacheReadInputTokens int + CacheCreationInputTokens int } // NewAnthropicEventToResponsesState returns an initialised stream state. @@ -225,6 +229,12 @@ func anthToResHandleMessageStart(evt *AnthropicStreamEvent, state *AnthropicEven if evt.Message.Usage.InputTokens > 0 { state.InputTokens = evt.Message.Usage.InputTokens } + if evt.Message.Usage.CacheReadInputTokens > 0 { + state.CacheReadInputTokens = evt.Message.Usage.CacheReadInputTokens + } + if evt.Message.Usage.CacheCreationInputTokens > 0 { + state.CacheCreationInputTokens = evt.Message.Usage.CacheCreationInputTokens + } } if state.CreatedSent { @@ -392,9 +402,15 @@ func anthToResHandleMessageDelta(evt *AnthropicStreamEvent, state *AnthropicEven // Update usage if evt.Usage != nil { state.OutputTokens = evt.Usage.OutputTokens + if evt.Usage.InputTokens > 0 { + state.InputTokens = evt.Usage.InputTokens + } if evt.Usage.CacheReadInputTokens > 0 { state.CacheReadInputTokens = evt.Usage.CacheReadInputTokens } + if evt.Usage.CacheCreationInputTokens > 0 { + state.CacheCreationInputTokens = evt.Usage.CacheCreationInputTokens + } } return nil @@ -472,10 +488,11 @@ func makeResponsesCompletedEvent( seq := state.SequenceNumber state.SequenceNumber++ + totalInputTokens := state.InputTokens + state.CacheReadInputTokens + state.CacheCreationInputTokens usage := &ResponsesUsage{ - InputTokens: state.InputTokens, + InputTokens: totalInputTokens, OutputTokens: state.OutputTokens, - TotalTokens: state.InputTokens + state.OutputTokens, + TotalTokens: totalInputTokens + state.OutputTokens, } if state.CacheReadInputTokens > 0 { usage.InputTokensDetails = &ResponsesInputTokensDetails{ diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index fbd4b861f..2ef531eb4 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -492,6 +492,7 @@ func (s *BillingService) computeTokenBreakdown( inputPrice := pricing.InputPricePerToken outputPrice := pricing.OutputPricePerToken cacheReadPrice := pricing.CacheReadPricePerToken + cacheCreationLongContextMultiplier := 1.0 inputTierMultiplier := 1.0 outputTierMultiplier := 1.0 cacheCreationTierMultiplier := 1.0 @@ -530,6 +531,8 @@ func (s *BillingService) computeTokenBreakdown( if applyLongCtx && s.shouldApplySessionLongContextPricing(tokens, pricing) { inputPrice *= pricing.LongContextInputMultiplier outputPrice *= pricing.LongContextOutputMultiplier + cacheReadPrice *= pricing.LongContextInputMultiplier + cacheCreationLongContextMultiplier = pricing.LongContextInputMultiplier } bd := &CostBreakdown{} @@ -552,7 +555,8 @@ func (s *BillingService) computeTokenBreakdown( } // 缓存创建费用 - bd.CacheCreationCost = s.computeCacheCreationCost(pricing, tokens) * cacheCreationTierMultiplier + bd.CacheCreationCost = s.computeCacheCreationCost(pricing, tokens) * + cacheCreationLongContextMultiplier * cacheCreationTierMultiplier bd.CacheReadCost = float64(tokens.CacheReadTokens) * cacheReadPrice * cacheReadTierMultiplier diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index 19a83193c..74aa4af2b 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -187,6 +187,40 @@ func TestCalculateCost_OpenAIGPT54LongContextAppliesWholeSessionMultipliers(t *t require.InDelta(t, expectedInput+expectedOutput, cost.ActualCost, 1e-10) } +func TestCalculateCost_OpenAIGPT54LongContextScalesCacheReadAndCreation(t *testing.T) { + svc := newTestBillingService() + + tokens := UsageTokens{ + InputTokens: 1000, + OutputTokens: 1000, + CacheCreationTokens: 10000, + CacheReadTokens: 300000, + } + + cost, err := svc.CalculateCost("gpt-5.4-2026-03-05", tokens, 1.0) + require.NoError(t, err) + + require.InDelta(t, float64(tokens.CacheReadTokens)*6.25e-6*2.0, cost.CacheReadCost, 1e-10) + require.InDelta(t, float64(tokens.CacheCreationTokens)*62.5e-6*2.0, cost.CacheCreationCost, 1e-10) +} + +func TestCalculateCost_NoLongContextKeepsCacheReadAndCreationAtBasePrice(t *testing.T) { + svc := newTestBillingService() + + tokens := UsageTokens{ + InputTokens: 1000, + OutputTokens: 1000, + CacheCreationTokens: 10000, + CacheReadTokens: 100000, + } + + cost, err := svc.CalculateCost("gpt-5.4-2026-03-05", tokens, 1.0) + require.NoError(t, err) + + require.InDelta(t, float64(tokens.CacheReadTokens)*6.25e-6, cost.CacheReadCost, 1e-10) + require.InDelta(t, float64(tokens.CacheCreationTokens)*62.5e-6, cost.CacheCreationCost, 1e-10) +} + func TestGetFallbackPricing_FamilyMatching(t *testing.T) { svc := newTestBillingService() @@ -453,6 +487,40 @@ func TestCalculateCost_SupportsCacheBreakdown(t *testing.T) { require.InDelta(t, expected5m+expected1h, cost.CacheCreationCost, 1e-10) } +func TestCalculateCost_LongContextScalesCacheCreation5mAnd1h(t *testing.T) { + svc := &BillingService{ + cfg: &config.Config{}, + fallbackPrices: map[string]*ModelPricing{ + "claude-sonnet-4": { + InputPricePerToken: 3e-6, + OutputPricePerToken: 15e-6, + CacheReadPricePerToken: 0.3e-6, + SupportsCacheBreakdown: true, + CacheCreation5mPrice: 4e-6, + CacheCreation1hPrice: 5e-6, + LongContextInputThreshold: 200000, + LongContextInputMultiplier: 2.0, + LongContextOutputMultiplier: 1.5, + }, + }, + } + + tokens := UsageTokens{ + InputTokens: 1000, + OutputTokens: 1000, + CacheReadTokens: 300000, + CacheCreation5mTokens: 8000, + CacheCreation1hTokens: 4000, + } + + cost, err := svc.CalculateCost("claude-sonnet-4", tokens, 1.0) + require.NoError(t, err) + + expected5m := float64(tokens.CacheCreation5mTokens) * 4e-6 * 2.0 + expected1h := float64(tokens.CacheCreation1hTokens) * 5e-6 * 2.0 + require.InDelta(t, expected5m+expected1h, cost.CacheCreationCost, 1e-10) +} + func TestCalculateCost_LargeTokenCount(t *testing.T) { svc := newTestBillingService() diff --git a/backend/internal/service/payment_config_service.go b/backend/internal/service/payment_config_service.go index c59428496..98559c4a1 100644 --- a/backend/internal/service/payment_config_service.go +++ b/backend/internal/service/payment_config_service.go @@ -32,6 +32,9 @@ const ( SettingProductNameSuffix = "PRODUCT_NAME_SUFFIX" SettingPaymentAnnouncement = "PAYMENT_ANNOUNCEMENT_TEXT" SettingRechargeCenterItems = "PAYMENT_RECHARGE_CENTER_ITEMS" + SettingRechargeCenterTabOn = "PAYMENT_RECHARGE_CENTER_TAB_ENABLED" + SettingRechargeTabOn = "PAYMENT_RECHARGE_TAB_ENABLED" + SettingSubscriptionTabOn = "PAYMENT_SUBSCRIPTION_TAB_ENABLED" SettingHelpImageURL = "PAYMENT_HELP_IMAGE_URL" SettingHelpText = "PAYMENT_HELP_TEXT" SettingCancelRateLimitOn = "CANCEL_RATE_LIMIT_ENABLED" @@ -83,6 +86,9 @@ type PaymentConfig struct { ProductNameSuffix string `json:"product_name_suffix"` AnnouncementText string `json:"announcement_text"` RechargeCenterItems []RechargeCenterItem `json:"recharge_center_items"` + RechargeCenterTabEnabled bool `json:"recharge_center_tab_enabled"` + RechargeTabEnabled bool `json:"recharge_tab_enabled"` + SubscriptionTabEnabled bool `json:"subscription_tab_enabled"` HelpImageURL string `json:"help_image_url"` HelpText string `json:"help_text"` StripePublishableKey string `json:"stripe_publishable_key,omitempty"` @@ -135,6 +141,9 @@ type UpdatePaymentConfigRequest struct { ProductNameSuffix *string `json:"product_name_suffix"` AnnouncementText *string `json:"announcement_text"` RechargeCenterItems []RechargeCenterItem `json:"recharge_center_items"` + RechargeCenterTabEnabled *bool `json:"recharge_center_tab_enabled"` + RechargeTabEnabled *bool `json:"recharge_tab_enabled"` + SubscriptionTabEnabled *bool `json:"subscription_tab_enabled"` HelpImageURL *string `json:"help_image_url"` HelpText *string `json:"help_text"` @@ -263,7 +272,9 @@ func (s *PaymentConfigService) GetPaymentConfig(ctx context.Context) (*PaymentCo SettingDailyRechargeLimit, SettingOrderTimeoutMinutes, SettingMaxPendingOrders, SettingEnabledPaymentTypes, SettingBalancePayDisabled, SettingBalanceRechargeMult, SettingRechargeFeeRate, SettingLoadBalanceStrategy, SettingProductNamePrefix, SettingProductNameSuffix, - SettingPaymentAnnouncement, SettingRechargeCenterItems, SettingHelpImageURL, SettingHelpText, + SettingPaymentAnnouncement, SettingRechargeCenterItems, + SettingRechargeCenterTabOn, SettingRechargeTabOn, SettingSubscriptionTabOn, + SettingHelpImageURL, SettingHelpText, SettingCancelRateLimitOn, SettingCancelRateLimitMax, SettingCancelWindowSize, SettingCancelWindowUnit, SettingCancelWindowMode, SettingPaymentVisibleMethodAlipayEnabled, SettingPaymentVisibleMethodAlipaySource, @@ -301,6 +312,9 @@ func (s *PaymentConfigService) parsePaymentConfig(vals map[string]string) *Payme ProductNameSuffix: vals[SettingProductNameSuffix], AnnouncementText: vals[SettingPaymentAnnouncement], RechargeCenterItems: parseRechargeCenterItems(vals[SettingRechargeCenterItems]), + RechargeCenterTabEnabled: parseBoolWithDefault(vals[SettingRechargeCenterTabOn], true), + RechargeTabEnabled: parseBoolWithDefault(vals[SettingRechargeTabOn], true), + SubscriptionTabEnabled: parseBoolWithDefault(vals[SettingSubscriptionTabOn], true), HelpImageURL: vals[SettingHelpImageURL], HelpText: vals[SettingHelpText], @@ -435,6 +449,9 @@ func (s *PaymentConfigService) UpdatePaymentConfig(ctx context.Context, req Upda SettingProductNamePrefix: derefStr(req.ProductNamePrefix), SettingProductNameSuffix: derefStr(req.ProductNameSuffix), SettingPaymentAnnouncement: derefStr(req.AnnouncementText), + SettingRechargeCenterTabOn: formatBoolOrEmpty(req.RechargeCenterTabEnabled), + SettingRechargeTabOn: formatBoolOrEmpty(req.RechargeTabEnabled), + SettingSubscriptionTabOn: formatBoolOrEmpty(req.SubscriptionTabEnabled), SettingHelpImageURL: derefStr(req.HelpImageURL), SettingHelpText: derefStr(req.HelpText), SettingCancelRateLimitOn: formatBoolOrEmpty(req.CancelRateLimitEnabled), diff --git a/backend/internal/service/payment_config_service_test.go b/backend/internal/service/payment_config_service_test.go index bb4a102bb..7969f1b35 100644 --- a/backend/internal/service/payment_config_service_test.go +++ b/backend/internal/service/payment_config_service_test.go @@ -102,6 +102,9 @@ func TestParsePaymentConfig(t *testing.T) { if len(cfg.EnabledTypes) != 0 { t.Fatalf("expected empty EnabledTypes, got %v", cfg.EnabledTypes) } + if !cfg.RechargeCenterTabEnabled || !cfg.RechargeTabEnabled || !cfg.SubscriptionTabEnabled { + t.Fatalf("expected payment page tabs enabled by default, got center=%v recharge=%v subscription=%v", cfg.RechargeCenterTabEnabled, cfg.RechargeTabEnabled, cfg.SubscriptionTabEnabled) + } }) t.Run("all values populated", func(t *testing.T) { @@ -120,6 +123,9 @@ func TestParsePaymentConfig(t *testing.T) { SettingProductNameSuffix: "SUF", SettingPaymentAnnouncement: "Please read before paying", SettingRechargeCenterItems: `[{"name":"FastPay","description":"Instant balance top-up","url":"https://pay.example.com/fast"}]`, + SettingRechargeCenterTabOn: "false", + SettingRechargeTabOn: "false", + SettingSubscriptionTabOn: "false", SettingHelpText: "Contact support after payment", } cfg := svc.parsePaymentConfig(vals) @@ -172,6 +178,9 @@ func TestParsePaymentConfig(t *testing.T) { if cfg.HelpText != "Contact support after payment" { t.Fatalf("HelpText = %q, want %q", cfg.HelpText, "Contact support after payment") } + if cfg.RechargeCenterTabEnabled || cfg.RechargeTabEnabled || cfg.SubscriptionTabEnabled { + t.Fatalf("expected payment page tabs disabled, got center=%v recharge=%v subscription=%v", cfg.RechargeCenterTabEnabled, cfg.RechargeTabEnabled, cfg.SubscriptionTabEnabled) + } }) t.Run("enabled types with spaces are trimmed", func(t *testing.T) { @@ -491,6 +500,31 @@ func TestUpdatePaymentConfig_PersistsAnnouncementText(t *testing.T) { } } +func TestUpdatePaymentConfig_PersistsPaymentPageTabVisibility(t *testing.T) { + repo := &paymentConfigSettingRepoStub{values: map[string]string{}} + svc := &PaymentConfigService{settingRepo: repo} + + disabled := false + err := svc.UpdatePaymentConfig(context.Background(), UpdatePaymentConfigRequest{ + RechargeCenterTabEnabled: &disabled, + RechargeTabEnabled: &disabled, + SubscriptionTabEnabled: &disabled, + }) + if err != nil { + t.Fatalf("UpdatePaymentConfig returned error: %v", err) + } + + if repo.values[SettingRechargeCenterTabOn] != "false" { + t.Fatalf("recharge center tab = %q, want false", repo.values[SettingRechargeCenterTabOn]) + } + if repo.values[SettingRechargeTabOn] != "false" { + t.Fatalf("recharge tab = %q, want false", repo.values[SettingRechargeTabOn]) + } + if repo.values[SettingSubscriptionTabOn] != "false" { + t.Fatalf("subscription tab = %q, want false", repo.values[SettingSubscriptionTabOn]) + } +} + func TestUpdatePaymentConfig_PersistsRechargeCenterItems(t *testing.T) { repo := &paymentConfigSettingRepoStub{values: map[string]string{}} svc := &PaymentConfigService{settingRepo: repo} diff --git a/frontend/src/api/admin/payment.ts b/frontend/src/api/admin/payment.ts index ae74258f4..89a03333e 100644 --- a/frontend/src/api/admin/payment.ts +++ b/frontend/src/api/admin/payment.ts @@ -33,6 +33,9 @@ export interface AdminPaymentConfig { product_name_suffix: string announcement_text: string recharge_center_items: RechargeCenterItem[] + recharge_center_tab_enabled: boolean + recharge_tab_enabled: boolean + subscription_tab_enabled: boolean help_image_url: string help_text: string } @@ -54,6 +57,9 @@ export interface UpdatePaymentConfigRequest { product_name_suffix?: string announcement_text?: string recharge_center_items?: RechargeCenterItem[] + recharge_center_tab_enabled?: boolean + recharge_tab_enabled?: boolean + subscription_tab_enabled?: boolean help_image_url?: string help_text?: string } diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 58ac70307..db65a3260 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -498,6 +498,9 @@ export interface SystemSettings { payment_product_name_suffix: string; payment_announcement_text: string; payment_recharge_center_items: PaymentRechargeCenterItem[]; + payment_recharge_center_tab_enabled: boolean; + payment_recharge_tab_enabled: boolean; + payment_subscription_tab_enabled: boolean; payment_help_image_url: string; payment_help_text: string; payment_receipt_code_oss_enabled: boolean; @@ -718,6 +721,9 @@ export interface UpdateSettingsRequest { payment_product_name_suffix?: string; payment_announcement_text?: string; payment_recharge_center_items?: PaymentRechargeCenterItem[]; + payment_recharge_center_tab_enabled?: boolean; + payment_recharge_tab_enabled?: boolean; + payment_subscription_tab_enabled?: boolean; payment_help_image_url?: string; payment_help_text?: string; payment_receipt_code_oss_enabled?: boolean; diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 2695793d7..cf927eaf5 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -6043,6 +6043,8 @@ export default { configGuide: 'Configuration Guide', enabled: 'Enable Payment', enabledHint: 'Enable or disable the payment system', + tabVisibilityTitle: 'Recharge / Subscription Page', + tabVisibilityHint: 'When disabled, the corresponding tab and content are hidden from the user payment page.', enabledPaymentTypes: 'Enabled Providers', enabledPaymentTypesHint: 'Disabling a provider will also disable its instances.', findProvider: 'Looking for a suitable EasyPay provider?', @@ -7161,6 +7163,7 @@ export default { noActiveSubscription: 'No active subscription', tabTopUp: 'Top Up', tabSubscribe: 'Subscribe', + noVisibleTabs: 'Recharge / subscription entries are currently unavailable', noPlans: 'No subscription plans available', notAvailable: 'Top-up is currently unavailable', confirmSubscription: 'Confirm Subscription', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 43fad6817..cf6e652b7 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -6203,6 +6203,8 @@ export default { configGuide: '支付配置指南', enabled: '启用支付', enabledHint: '启用或禁用支付系统', + tabVisibilityTitle: '充值/订阅页显示', + tabVisibilityHint: '关闭后,用户侧充值/订阅页将不显示对应的顶部 Tab 和内容区域。', enabledPaymentTypes: '启用的服务商', enabledPaymentTypesHint: '禁用服务商将同时禁用对应的实例。', findProvider: '正在寻找合适的易支付服务商?', @@ -7344,6 +7346,7 @@ export default { noActiveSubscription: '暂无有效订阅', tabTopUp: '充值', tabSubscribe: '订阅', + noVisibleTabs: '充值/订阅入口暂未开放', noPlans: '暂无可用订阅套餐', notAvailable: '充值功能暂未开放', confirmSubscription: '确认订阅', diff --git a/frontend/src/types/payment.ts b/frontend/src/types/payment.ts index 7bbdf8caa..32983badb 100644 --- a/frontend/src/types/payment.ts +++ b/frontend/src/types/payment.ts @@ -44,6 +44,9 @@ export interface PaymentConfig { enabled_payment_types: PaymentType[] announcement_text: string recharge_center_items: RechargeCenterItem[] + recharge_center_tab_enabled: boolean + recharge_tab_enabled: boolean + subscription_tab_enabled: boolean help_image_url: string help_text: string stripe_publishable_key: string @@ -80,6 +83,9 @@ export interface CheckoutInfoResponse { recharge_fee_rate: number announcement_text: string recharge_center_items: RechargeCenterItem[] + recharge_center_tab_enabled: boolean + recharge_tab_enabled: boolean + subscription_tab_enabled: boolean help_text: string help_image_url: string stripe_publishable_key: string diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 06cd02999..719fbb56d 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -5373,6 +5373,44 @@