diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index eaaae471..9907d441 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -278,6 +278,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { PaymentCancelRateLimitWindow: paymentCfg.CancelRateLimitWindow, PaymentCancelRateLimitUnit: paymentCfg.CancelRateLimitUnit, PaymentCancelRateLimitMode: paymentCfg.CancelRateLimitMode, + PaymentAlipayForceQRCode: paymentCfg.AlipayForceQRCode, ChannelMonitorEnabled: settings.ChannelMonitorEnabled, ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds, @@ -603,6 +604,9 @@ type UpdateSettingsRequest struct { PaymentCancelRateLimitUnit *string `json:"payment_cancel_rate_limit_unit"` PaymentCancelRateLimitMode *string `json:"payment_cancel_rate_limit_window_mode"` + // Force Alipay mobile clients to use QR code payment instead of mobile redirect + PaymentAlipayForceQRCode *bool `json:"payment_alipay_force_qrcode"` + // Channel Monitor feature switch ChannelMonitorEnabled *bool `json:"channel_monitor_enabled"` ChannelMonitorDefaultIntervalSeconds *int `json:"channel_monitor_default_interval_seconds"` @@ -1774,6 +1778,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { CancelRateLimitWindow: req.PaymentCancelRateLimitWindow, CancelRateLimitUnit: req.PaymentCancelRateLimitUnit, CancelRateLimitMode: req.PaymentCancelRateLimitMode, + AlipayForceQRCode: req.PaymentAlipayForceQRCode, } if err := h.paymentConfigService.UpdatePaymentConfig(c.Request.Context(), paymentReq); err != nil { response.ErrorFrom(c, err) @@ -1981,6 +1986,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { PaymentCancelRateLimitWindow: updatedPaymentCfg.CancelRateLimitWindow, PaymentCancelRateLimitUnit: updatedPaymentCfg.CancelRateLimitUnit, PaymentCancelRateLimitMode: updatedPaymentCfg.CancelRateLimitMode, + PaymentAlipayForceQRCode: updatedPaymentCfg.AlipayForceQRCode, ChannelMonitorEnabled: updatedSettings.ChannelMonitorEnabled, ChannelMonitorDefaultIntervalSeconds: updatedSettings.ChannelMonitorDefaultIntervalSeconds, @@ -2022,7 +2028,8 @@ func hasPaymentFields(req UpdateSettingsRequest) bool { req.PaymentProductNameSuffix != nil || req.PaymentHelpImageURL != nil || req.PaymentHelpText != nil || req.PaymentCancelRateLimitEnabled != nil || req.PaymentCancelRateLimitMax != nil || req.PaymentCancelRateLimitWindow != nil || - req.PaymentCancelRateLimitUnit != nil || req.PaymentCancelRateLimitMode != nil + req.PaymentCancelRateLimitUnit != nil || req.PaymentCancelRateLimitMode != nil || + req.PaymentAlipayForceQRCode != nil } func (h *SettingHandler) auditSettingsUpdate(c *gin.Context, before *service.SystemSettings, after *service.SystemSettings, beforeAuthSourceDefaults *service.AuthSourceDefaultSettings, afterAuthSourceDefaults *service.AuthSourceDefaultSettings, req UpdateSettingsRequest) { diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index fb09faf7..45ad7a70 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -218,6 +218,9 @@ type SystemSettings struct { PaymentCancelRateLimitUnit string `json:"payment_cancel_rate_limit_unit"` PaymentCancelRateLimitMode string `json:"payment_cancel_rate_limit_window_mode"` + // Force Alipay mobile clients to use QR code payment instead of mobile redirect + PaymentAlipayForceQRCode bool `json:"payment_alipay_force_qrcode"` + // Balance low notification BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"` BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"` diff --git a/backend/internal/handler/payment_handler.go b/backend/internal/handler/payment_handler.go index f293c2f2..1bb81190 100644 --- a/backend/internal/handler/payment_handler.go +++ b/backend/internal/handler/payment_handler.go @@ -141,6 +141,7 @@ func (h *PaymentHandler) GetCheckoutInfo(c *gin.Context) { HelpText: cfg.HelpText, HelpImageURL: cfg.HelpImageURL, StripePublishableKey: cfg.StripePublishableKey, + AlipayForceQRCode: cfg.AlipayForceQRCode, }) } @@ -155,6 +156,7 @@ type checkoutInfoResponse struct { HelpText string `json:"help_text"` HelpImageURL string `json:"help_image_url"` StripePublishableKey string `json:"stripe_publishable_key"` + AlipayForceQRCode bool `json:"alipay_force_qrcode"` } type checkoutPlan struct { diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 0d60ac9d..380a9b4d 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -858,6 +858,7 @@ func TestAPIContracts(t *testing.T) { "payment_cancel_rate_limit_window": 0, "payment_cancel_rate_limit_unit": "", "payment_cancel_rate_limit_window_mode": "", + "payment_alipay_force_qrcode": false, "balance_low_notify_enabled": false, "account_quota_notify_enabled": false, "balance_low_notify_threshold": 0, @@ -1080,6 +1081,7 @@ func TestAPIContracts(t *testing.T) { "payment_cancel_rate_limit_window": 0, "payment_cancel_rate_limit_unit": "", "payment_cancel_rate_limit_window_mode": "", + "payment_alipay_force_qrcode": false, "balance_low_notify_enabled": false, "account_quota_notify_enabled": false, "balance_low_notify_threshold": 0, diff --git a/backend/internal/service/payment_config_service.go b/backend/internal/service/payment_config_service.go index f57ac614..022b1b01 100644 --- a/backend/internal/service/payment_config_service.go +++ b/backend/internal/service/payment_config_service.go @@ -34,6 +34,7 @@ const ( SettingCancelWindowSize = "CANCEL_RATE_LIMIT_WINDOW" SettingCancelWindowUnit = "CANCEL_RATE_LIMIT_UNIT" SettingCancelWindowMode = "CANCEL_RATE_LIMIT_WINDOW_MODE" + SettingAlipayForceQRCode = "ALIPAY_FORCE_QRCODE" ) // Default values for payment configuration settings. @@ -67,6 +68,9 @@ type PaymentConfig struct { CancelRateLimitWindow int `json:"cancel_rate_limit_window"` CancelRateLimitUnit string `json:"cancel_rate_limit_unit"` CancelRateLimitMode string `json:"cancel_rate_limit_window_mode"` + + // Force Alipay mobile users to use QR code instead of mobile redirect + AlipayForceQRCode bool `json:"alipay_force_qrcode"` } // UpdatePaymentConfigRequest contains fields to update payment configuration. @@ -94,6 +98,9 @@ type UpdatePaymentConfigRequest struct { CancelRateLimitUnit *string `json:"cancel_rate_limit_unit"` CancelRateLimitMode *string `json:"cancel_rate_limit_window_mode"` + // Force Alipay mobile users to use QR code instead of mobile redirect + AlipayForceQRCode *bool `json:"alipay_force_qrcode"` + VisibleMethodAlipaySource *string `json:"payment_visible_method_alipay_source"` VisibleMethodWxpaySource *string `json:"payment_visible_method_wxpay_source"` VisibleMethodAlipayEnabled *bool `json:"payment_visible_method_alipay_enabled"` @@ -202,6 +209,7 @@ func (s *PaymentConfigService) GetPaymentConfig(ctx context.Context) (*PaymentCo SettingHelpImageURL, SettingHelpText, SettingCancelRateLimitOn, SettingCancelRateLimitMax, SettingCancelWindowSize, SettingCancelWindowUnit, SettingCancelWindowMode, + SettingAlipayForceQRCode, SettingPaymentVisibleMethodAlipayEnabled, SettingPaymentVisibleMethodAlipaySource, SettingPaymentVisibleMethodWxpayEnabled, SettingPaymentVisibleMethodWxpaySource, } @@ -237,6 +245,8 @@ func (s *PaymentConfigService) parsePaymentConfig(vals map[string]string) *Payme CancelRateLimitWindow: pcParseInt(vals[SettingCancelWindowSize], 1), CancelRateLimitUnit: vals[SettingCancelWindowUnit], CancelRateLimitMode: vals[SettingCancelWindowMode], + + AlipayForceQRCode: vals[SettingAlipayForceQRCode] == "true", } if cfg.LoadBalanceStrategy == "" { cfg.LoadBalanceStrategy = payment.DefaultLoadBalanceStrategy @@ -314,6 +324,7 @@ func (s *PaymentConfigService) UpdatePaymentConfig(ctx context.Context, req Upda SettingCancelWindowSize: formatPositiveInt(req.CancelRateLimitWindow), SettingCancelWindowUnit: derefStr(req.CancelRateLimitUnit), SettingCancelWindowMode: derefStr(req.CancelRateLimitMode), + SettingAlipayForceQRCode: formatBoolOrEmpty(req.AlipayForceQRCode), SettingPaymentVisibleMethodAlipaySource: derefStr(req.VisibleMethodAlipaySource), SettingPaymentVisibleMethodWxpaySource: derefStr(req.VisibleMethodWxpaySource), SettingPaymentVisibleMethodAlipayEnabled: formatBoolOrEmpty(req.VisibleMethodAlipayEnabled), diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 4fc49de6..dfef451d 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -528,6 +528,7 @@ export interface SystemSettings { payment_cancel_rate_limit_window: number; payment_cancel_rate_limit_unit: string; payment_cancel_rate_limit_window_mode: string; + payment_alipay_force_qrcode?: boolean; payment_visible_method_alipay_source?: string; payment_visible_method_wxpay_source?: string; payment_visible_method_alipay_enabled?: boolean; @@ -745,6 +746,7 @@ export interface UpdateSettingsRequest { payment_cancel_rate_limit_window?: number; payment_cancel_rate_limit_unit?: string; payment_cancel_rate_limit_window_mode?: string; + payment_alipay_force_qrcode?: boolean; payment_visible_method_alipay_source?: string; payment_visible_method_wxpay_source?: string; payment_visible_method_alipay_enabled?: boolean; diff --git a/frontend/src/components/payment/__tests__/paymentFlow.spec.ts b/frontend/src/components/payment/__tests__/paymentFlow.spec.ts index e9530ff2..7eda7a0d 100644 --- a/frontend/src/components/payment/__tests__/paymentFlow.spec.ts +++ b/frontend/src/components/payment/__tests__/paymentFlow.spec.ts @@ -220,6 +220,36 @@ describe('decidePaymentLaunch', () => { expect(decision.jsapi?.appId).toBe('wx123') expect(decision.paymentState.orderType).toBe('subscription') }) + + it('forces qr_waiting for mobile alipay when forceQRCode is enabled', () => { + const decision = decidePaymentLaunch(createOrderResult({ + pay_url: 'https://pay.example.com/mobile/session', + qr_code: 'https://pay.example.com/qr/session', + }), { + visibleMethod: 'alipay', + orderType: 'balance', + isMobile: true, + forceQRCode: true, + }) + + expect(decision.kind).toBe('qr_waiting') + expect(decision.paymentState.qrCode).toBe('https://pay.example.com/qr/session') + }) + + it('does not affect non-alipay methods when forceQRCode is enabled', () => { + const decision = decidePaymentLaunch(createOrderResult({ + pay_url: 'https://pay.example.com/mobile/session', + qr_code: 'https://pay.example.com/qr/session', + }), { + visibleMethod: 'wxpay', + orderType: 'balance', + isMobile: true, + forceQRCode: true, + }) + + // wxpay mobile with pay_url still redirects + expect(decision.kind).toBe('redirect_waiting') + }) }) describe('buildCreateOrderPayload', () => { @@ -260,6 +290,34 @@ describe('buildCreateOrderPayload', () => { payment_source: 'wechat_in_app_resume', }) }) + + it('passes is_mobile: false when forceQRCode is enabled for alipay', () => { + expect(buildCreateOrderPayload({ + amount: 50, + paymentType: 'alipay', + orderType: 'balance', + origin: 'https://app.example.com', + isMobile: true, + isWechatBrowser: false, + forceQRCode: true, + })).toMatchObject({ + is_mobile: false, + }) + }) + + it('still passes is_mobile: true when forceQRCode is enabled for non-alipay methods', () => { + expect(buildCreateOrderPayload({ + amount: 50, + paymentType: 'wxpay', + orderType: 'balance', + origin: 'https://app.example.com', + isMobile: true, + isWechatBrowser: false, + forceQRCode: true, + })).toMatchObject({ + is_mobile: true, + }) + }) }) describe('readPaymentRecoverySnapshot', () => { diff --git a/frontend/src/components/payment/paymentFlow.ts b/frontend/src/components/payment/paymentFlow.ts index e66ef8e3..ab5acf26 100644 --- a/frontend/src/components/payment/paymentFlow.ts +++ b/frontend/src/components/payment/paymentFlow.ts @@ -55,6 +55,8 @@ export interface PaymentLaunchContext { orderType: OrderType isMobile: boolean isWechatBrowser?: boolean + /** When true, Alipay payments always use QR code regardless of device type */ + forceQRCode?: boolean now?: number stripePopupUrl?: string stripeRouteUrl?: string @@ -78,6 +80,8 @@ export interface BuildCreateOrderPayloadInput { origin?: string isMobile: boolean isWechatBrowser: boolean + /** When true, Alipay payments always use QR code (passes is_mobile: false to backend) */ + forceQRCode?: boolean } type CreateOrderFlowResult = CreateOrderResult & { @@ -111,11 +115,16 @@ export function getVisibleMethods(methods: Record): Record< export function buildCreateOrderPayload(input: BuildCreateOrderPayloadInput): CreateOrderRequest { const visibleMethod = normalizeVisibleMethod(input.paymentType) || input.paymentType.trim() const normalizedOrigin = (input.origin || '').trim().replace(/\/+$/, '') + // When forceQRCode is enabled for alipay, always tell the backend this is not a mobile + // request so it generates a QR code instead of a mobile-redirect URL. + const effectiveMobile = (input.forceQRCode && visibleMethod === 'alipay') + ? false + : input.isMobile const payload: CreateOrderRequest = { amount: input.amount, payment_type: visibleMethod, order_type: input.orderType, - is_mobile: input.isMobile, + is_mobile: effectiveMobile, payment_source: visibleMethod === 'wxpay' && input.isWechatBrowser ? 'wechat_in_app_resume' : 'hosted_redirect', @@ -190,9 +199,14 @@ export function decidePaymentLaunch( } const normalizedPaymentMode = baseState.paymentMode.trim().toLowerCase() + // When forceQRCode is on for alipay, treat the device as desktop so the mobile-redirect + // branch is bypassed and we fall through to qr_waiting. + const effectiveMobile = (context.forceQRCode && visibleMethod === 'alipay') + ? false + : context.isMobile const prefersRedirect = normalizedPaymentMode === 'redirect' || normalizedPaymentMode === 'popup' - || (context.isMobile && !!baseState.payUrl) + || (effectiveMobile && !!baseState.payUrl) const prefersQr = normalizedPaymentMode === 'qrcode' || normalizedPaymentMode === 'native' || (!prefersRedirect && !!baseState.qrCode) diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 01c8b82e..00ba3e5b 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -5635,6 +5635,8 @@ export default { cancelRateLimitWindowMode: 'Window Mode', cancelRateLimitWindowModeRolling: 'Rolling', cancelRateLimitWindowModeFixed: 'Fixed', + alipayForceQRCode: 'Force Alipay QR Code', + alipayForceQRCodeHint: 'When enabled, mobile Alipay users always see a QR code instead of being redirected to the mobile payment page', helpText: 'Help Text', helpImageUrl: 'Help Image URL', manageProviders: 'Manage Providers', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 0c62f18d..a7ff4841 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -5795,6 +5795,8 @@ export default { cancelRateLimitWindowMode: '窗口模式', cancelRateLimitWindowModeRolling: '滚动', cancelRateLimitWindowModeFixed: '固定', + alipayForceQRCode: '支付宝强制二维码支付', + alipayForceQRCodeHint: '启用后,移动端支付宝用户将统一使用二维码扫码支付,不再跳转至手机网站支付', helpText: '帮助文本', helpImageUrl: '帮助图片链接', manageProviders: '管理服务商', diff --git a/frontend/src/types/payment.ts b/frontend/src/types/payment.ts index 816ae902..d4c4fdda 100644 --- a/frontend/src/types/payment.ts +++ b/frontend/src/types/payment.ts @@ -69,6 +69,8 @@ export interface CheckoutInfoResponse { help_text: string help_image_url: string stripe_publishable_key: string + /** When true, Alipay payments on mobile always show the QR code instead of redirecting */ + alipay_force_qrcode?: boolean } // ==================== Orders ==================== diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 3f44a474..e0c3e1d4 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -5843,6 +5843,38 @@ > +
+ +
+ + {{ + t("admin.settings.payment.alipayForceQRCodeHint") + }} +
+
@@ -6772,6 +6804,7 @@ const form = reactive({ payment_cancel_rate_limit_window: 1, payment_cancel_rate_limit_unit: "day", payment_cancel_rate_limit_window_mode: "rolling", + payment_alipay_force_qrcode: false, table_default_page_size: tablePageSizeDefault, table_page_size_options: [10, 20, 50, 100], custom_menu_items: [] as Array<{ @@ -8036,6 +8069,7 @@ async function saveSettings() { payment_cancel_rate_limit_unit: form.payment_cancel_rate_limit_unit, payment_cancel_rate_limit_window_mode: form.payment_cancel_rate_limit_window_mode, + payment_alipay_force_qrcode: form.payment_alipay_force_qrcode, openai_advanced_scheduler_enabled: form.openai_advanced_scheduler_enabled, // Balance & quota notification balance_low_notify_enabled: form.balance_low_notify_enabled, diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue index 3c7a85fc..b7037b57 100644 --- a/frontend/src/views/user/PaymentView.vue +++ b/frontend/src/views/user/PaymentView.vue @@ -698,6 +698,7 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n origin: typeof window !== 'undefined' ? window.location.origin : '', isMobile: isMobileDevice(), isWechatBrowser: typeof window !== 'undefined' && /MicroMessenger/i.test(window.navigator.userAgent), + forceQRCode: !!(checkout.value.alipay_force_qrcode && normalizeVisibleMethod(requestType) === 'alipay'), }) if (options.openid) { payload.openid = options.openid @@ -745,6 +746,7 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n orderType, isMobile: isMobileDevice(), isWechatBrowser: typeof window !== 'undefined' && /MicroMessenger/i.test(window.navigator.userAgent), + forceQRCode: !!(checkout.value.alipay_force_qrcode && visibleMethod === 'alipay'), stripePopupUrl: stripeRouteUrl, stripeRouteUrl, airwallexRouteUrl,