From 7914433011cacb32ce0d7fb4ade4bdb529934cb9 Mon Sep 17 00:00:00 2001 From: wjx2951874 Date: Wed, 22 Jul 2026 16:57:49 +0800 Subject: [PATCH] feat(payment): add mobile Alipay precreate deep link --- .../internal/handler/admin/setting_handler.go | 1 + .../handler/admin/setting_handler_update.go | 50 +++--- backend/internal/handler/dto/settings.go | 2 + backend/internal/handler/payment_handler.go | 58 ++++--- backend/internal/payment/provider/alipay.go | 7 +- .../internal/payment/provider/alipay_test.go | 84 +++++++++ backend/internal/payment/types.go | 23 +-- backend/internal/server/api_contract_test.go | 2 + .../service/payment_config_service.go | 52 ++++-- .../service/payment_config_service_test.go | 43 +++-- .../service/payment_fulfillment_test.go | 66 +++++++ backend/internal/service/payment_order.go | 10 ++ .../service/payment_order_result_test.go | 53 ++++++ backend/internal/service/payment_service.go | 43 ++--- .../payment_visible_method_instances.go | 14 ++ .../186_alipay_mobile_precreate_deep_link.sql | 4 + deploy/.env.example | 4 + deploy/docker-compose.yml | 1 + docs/PAYMENT_CN.md | 14 +- frontend/src/api/admin/settings.ts | 2 + .../components/payment/PaymentStatusPanel.vue | 162 +++++++++++++++++- .../__tests__/PaymentStatusPanel.spec.ts | 128 ++++++++++++++ .../payment/__tests__/alipayDeepLink.spec.ts | 134 +++++++++++++++ .../payment/__tests__/paymentFlow.spec.ts | 43 +++++ .../src/components/payment/alipayDeepLink.ts | 107 ++++++++++++ .../src/components/payment/paymentFlow.ts | 22 ++- .../src/i18n/locales/en/admin/settings.ts | 2 + frontend/src/i18n/locales/en/misc.ts | 8 + .../src/i18n/locales/zh/admin/settings.ts | 6 +- frontend/src/i18n/locales/zh/misc.ts | 8 + frontend/src/types/payment.ts | 3 + frontend/src/views/admin/SettingsView.vue | 35 ++++ frontend/src/views/user/PaymentView.vue | 11 +- 33 files changed, 1085 insertions(+), 117 deletions(-) create mode 100644 backend/migrations/186_alipay_mobile_precreate_deep_link.sql create mode 100644 frontend/src/components/payment/__tests__/alipayDeepLink.spec.ts create mode 100644 frontend/src/components/payment/alipayDeepLink.ts diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 6399c8eaf1b..8c33b783b1c 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -339,6 +339,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { PaymentCancelRateLimitUnit: paymentCfg.CancelRateLimitUnit, PaymentCancelRateLimitMode: paymentCfg.CancelRateLimitMode, PaymentAlipayForceQRCode: paymentCfg.AlipayForceQRCode, + PaymentAlipayMobilePrecreateDeepLink: paymentCfg.AlipayMobilePrecreateDeepLink, ChannelMonitorEnabled: settings.ChannelMonitorEnabled, ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds, diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go index 2f5792e2135..f852bb7e6fe 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -300,6 +300,8 @@ type UpdateSettingsRequest struct { // Force Alipay mobile clients to use QR code payment instead of mobile redirect PaymentAlipayForceQRCode *bool `json:"payment_alipay_force_qrcode"` + // Use Alipay face-to-face precreate and an app deep link on mobile clients. + PaymentAlipayMobilePrecreateDeepLink *bool `json:"payment_alipay_mobile_precreate_deep_link"` // Channel Monitor feature switch ChannelMonitorEnabled *bool `json:"channel_monitor_enabled"` @@ -1710,28 +1712,29 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { // Skip if no payment fields were provided (prevents accidental wipe). if h.paymentConfigService != nil && hasPaymentFields(req) { paymentReq := service.UpdatePaymentConfigRequest{ - Enabled: req.PaymentEnabled, - MinAmount: req.PaymentMinAmount, - MaxAmount: req.PaymentMaxAmount, - DailyLimit: req.PaymentDailyLimit, - OrderTimeoutMin: req.PaymentOrderTimeoutMin, - MaxPendingOrders: req.PaymentMaxPendingOrders, - EnabledTypes: req.PaymentEnabledTypes, - BalanceDisabled: req.PaymentBalanceDisabled, - BalanceRechargeMultiplier: req.PaymentBalanceRechargeMultiplier, - SubscriptionUSDToCNYRate: req.PaymentSubscriptionUSDToCNYRate, - RechargeFeeRate: req.PaymentRechargeFeeRate, - LoadBalanceStrategy: req.PaymentLoadBalanceStrat, - ProductNamePrefix: req.PaymentProductNamePrefix, - ProductNameSuffix: req.PaymentProductNameSuffix, - HelpImageURL: req.PaymentHelpImageURL, - HelpText: req.PaymentHelpText, - CancelRateLimitEnabled: req.PaymentCancelRateLimitEnabled, - CancelRateLimitMax: req.PaymentCancelRateLimitMax, - CancelRateLimitWindow: req.PaymentCancelRateLimitWindow, - CancelRateLimitUnit: req.PaymentCancelRateLimitUnit, - CancelRateLimitMode: req.PaymentCancelRateLimitMode, - AlipayForceQRCode: req.PaymentAlipayForceQRCode, + Enabled: req.PaymentEnabled, + MinAmount: req.PaymentMinAmount, + MaxAmount: req.PaymentMaxAmount, + DailyLimit: req.PaymentDailyLimit, + OrderTimeoutMin: req.PaymentOrderTimeoutMin, + MaxPendingOrders: req.PaymentMaxPendingOrders, + EnabledTypes: req.PaymentEnabledTypes, + BalanceDisabled: req.PaymentBalanceDisabled, + BalanceRechargeMultiplier: req.PaymentBalanceRechargeMultiplier, + SubscriptionUSDToCNYRate: req.PaymentSubscriptionUSDToCNYRate, + RechargeFeeRate: req.PaymentRechargeFeeRate, + LoadBalanceStrategy: req.PaymentLoadBalanceStrat, + ProductNamePrefix: req.PaymentProductNamePrefix, + ProductNameSuffix: req.PaymentProductNameSuffix, + HelpImageURL: req.PaymentHelpImageURL, + HelpText: req.PaymentHelpText, + CancelRateLimitEnabled: req.PaymentCancelRateLimitEnabled, + CancelRateLimitMax: req.PaymentCancelRateLimitMax, + CancelRateLimitWindow: req.PaymentCancelRateLimitWindow, + CancelRateLimitUnit: req.PaymentCancelRateLimitUnit, + CancelRateLimitMode: req.PaymentCancelRateLimitMode, + AlipayForceQRCode: req.PaymentAlipayForceQRCode, + AlipayMobilePrecreateDeepLink: req.PaymentAlipayMobilePrecreateDeepLink, } if err := h.paymentConfigService.UpdatePaymentConfig(c.Request.Context(), paymentReq); err != nil { response.ErrorFrom(c, err) @@ -1985,6 +1988,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { PaymentCancelRateLimitUnit: updatedPaymentCfg.CancelRateLimitUnit, PaymentCancelRateLimitMode: updatedPaymentCfg.CancelRateLimitMode, PaymentAlipayForceQRCode: updatedPaymentCfg.AlipayForceQRCode, + PaymentAlipayMobilePrecreateDeepLink: updatedPaymentCfg.AlipayMobilePrecreateDeepLink, ChannelMonitorEnabled: updatedSettings.ChannelMonitorEnabled, ChannelMonitorDefaultIntervalSeconds: updatedSettings.ChannelMonitorDefaultIntervalSeconds, @@ -2038,7 +2042,7 @@ func hasPaymentFields(req UpdateSettingsRequest) bool { req.PaymentHelpText != nil || req.PaymentCancelRateLimitEnabled != nil || req.PaymentCancelRateLimitMax != nil || req.PaymentCancelRateLimitWindow != nil || req.PaymentCancelRateLimitUnit != nil || req.PaymentCancelRateLimitMode != nil || - req.PaymentAlipayForceQRCode != nil + req.PaymentAlipayForceQRCode != nil || req.PaymentAlipayMobilePrecreateDeepLink != nil } // ensureDingTalkSyncAttributes 在保存 settings 后,按 admin 配置的 (attr key, attr name) diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index a62b9b07a7a..181aa07a01f 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -268,6 +268,8 @@ type SystemSettings struct { // Force Alipay mobile clients to use QR code payment instead of mobile redirect PaymentAlipayForceQRCode bool `json:"payment_alipay_force_qrcode"` + // Use Alipay face-to-face precreate and an app deep link on mobile clients. + PaymentAlipayMobilePrecreateDeepLink bool `json:"payment_alipay_mobile_precreate_deep_link"` // 余额、订阅到期与账号限额通知 BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"` diff --git a/backend/internal/handler/payment_handler.go b/backend/internal/handler/payment_handler.go index 672f8c37588..9aab3255044 100644 --- a/backend/internal/handler/payment_handler.go +++ b/backend/internal/handler/payment_handler.go @@ -109,6 +109,14 @@ func (h *PaymentHandler) GetCheckoutInfo(c *gin.Context) { response.ErrorFrom(c, err) return } + alipayMobilePrecreateDeepLink := false + if cfg.AlipayMobilePrecreateDeepLink { + alipayMobilePrecreateDeepLink, err = h.configService.UsesOfficialAlipayVisibleMethod(ctx) + if err != nil { + response.ErrorFrom(c, err) + return + } + } // Fetch plans with group info plans, _ := h.configService.ListPlansForSale(ctx) @@ -133,34 +141,36 @@ func (h *PaymentHandler) GetCheckoutInfo(c *gin.Context) { } response.Success(c, checkoutInfoResponse{ - Methods: limitsResp.Methods, - GlobalMin: limitsResp.GlobalMin, - GlobalMax: limitsResp.GlobalMax, - Plans: planList, - BalanceDisabled: cfg.BalanceDisabled, - BalanceRechargeMultiplier: cfg.BalanceRechargeMultiplier, - SubscriptionUSDToCNYRate: cfg.SubscriptionUSDToCNYRate, - RechargeFeeRate: cfg.RechargeFeeRate, - HelpText: cfg.HelpText, - HelpImageURL: cfg.HelpImageURL, - StripePublishableKey: cfg.StripePublishableKey, - AlipayForceQRCode: cfg.AlipayForceQRCode, + Methods: limitsResp.Methods, + GlobalMin: limitsResp.GlobalMin, + GlobalMax: limitsResp.GlobalMax, + Plans: planList, + BalanceDisabled: cfg.BalanceDisabled, + BalanceRechargeMultiplier: cfg.BalanceRechargeMultiplier, + SubscriptionUSDToCNYRate: cfg.SubscriptionUSDToCNYRate, + RechargeFeeRate: cfg.RechargeFeeRate, + HelpText: cfg.HelpText, + HelpImageURL: cfg.HelpImageURL, + StripePublishableKey: cfg.StripePublishableKey, + AlipayForceQRCode: cfg.AlipayForceQRCode, + AlipayMobilePrecreateDeepLink: alipayMobilePrecreateDeepLink, }) } type checkoutInfoResponse struct { - Methods map[string]service.MethodLimits `json:"methods"` - GlobalMin float64 `json:"global_min"` - GlobalMax float64 `json:"global_max"` - Plans []checkoutPlan `json:"plans"` - BalanceDisabled bool `json:"balance_disabled"` - BalanceRechargeMultiplier float64 `json:"balance_recharge_multiplier"` - SubscriptionUSDToCNYRate float64 `json:"subscription_usd_to_cny_rate"` - RechargeFeeRate float64 `json:"recharge_fee_rate"` - HelpText string `json:"help_text"` - HelpImageURL string `json:"help_image_url"` - StripePublishableKey string `json:"stripe_publishable_key"` - AlipayForceQRCode bool `json:"alipay_force_qrcode"` + Methods map[string]service.MethodLimits `json:"methods"` + GlobalMin float64 `json:"global_min"` + GlobalMax float64 `json:"global_max"` + Plans []checkoutPlan `json:"plans"` + BalanceDisabled bool `json:"balance_disabled"` + BalanceRechargeMultiplier float64 `json:"balance_recharge_multiplier"` + SubscriptionUSDToCNYRate float64 `json:"subscription_usd_to_cny_rate"` + RechargeFeeRate float64 `json:"recharge_fee_rate"` + HelpText string `json:"help_text"` + HelpImageURL string `json:"help_image_url"` + StripePublishableKey string `json:"stripe_publishable_key"` + AlipayForceQRCode bool `json:"alipay_force_qrcode"` + AlipayMobilePrecreateDeepLink bool `json:"alipay_mobile_precreate_deep_link"` } type checkoutPlan struct { diff --git a/backend/internal/payment/provider/alipay.go b/backend/internal/payment/provider/alipay.go index c4c6e634e5a..42a93871717 100644 --- a/backend/internal/payment/provider/alipay.go +++ b/backend/internal/payment/provider/alipay.go @@ -104,7 +104,9 @@ func (a *Alipay) MerchantIdentityMetadata() map[string]string { } // CreatePayment creates an Alipay payment using the following routing: -// - Mobile (H5): alipay.trade.wap.pay — browser redirect into Alipay. +// - Mobile (H5), default: alipay.trade.wap.pay — browser redirect into Alipay. +// - Mobile with AlipayMobilePrecreate: alipay.trade.precreate — return the +// dynamic QR payload so the frontend can open it through the Alipay app. // - Desktop, default: prefer alipay.trade.precreate (FACE_TO_FACE_PAYMENT) to // get a scannable QR payload. If precreate is unavailable for the merchant, // fall back to alipay.trade.page.pay and expose pay_url only — the frontend @@ -131,6 +133,9 @@ func (a *Alipay) CreatePayment(ctx context.Context, req payment.CreatePaymentReq } if req.IsMobile { + if req.AlipayMobilePrecreate { + return a.createPrecreateTrade(ctx, client, req, notifyURL) + } return a.createWapTrade(client, req, notifyURL, returnURL) } return a.createDesktopTrade(ctx, client, req, notifyURL, returnURL) diff --git a/backend/internal/payment/provider/alipay_test.go b/backend/internal/payment/provider/alipay_test.go index 9f8aec53173..bb725f8c2f1 100644 --- a/backend/internal/payment/provider/alipay_test.go +++ b/backend/internal/payment/provider/alipay_test.go @@ -282,6 +282,90 @@ func TestCreateTradeUsesWapPayForMobile(t *testing.T) { } } +func TestCreatePaymentUsesPrecreateForMobileWhenEnabled(t *testing.T) { + origPreCreate := alipayTradePreCreate + origWapPay := alipayTradeWapPay + t.Cleanup(func() { + alipayTradePreCreate = origPreCreate + alipayTradeWapPay = origWapPay + }) + + precreateCalls := 0 + wapPayCalls := 0 + alipayTradePreCreate = func(_ context.Context, _ *alipay.Client, param alipay.TradePreCreate) (*alipay.TradePreCreateRsp, error) { + precreateCalls++ + if param.OutTradeNo != "sub2_mobile_precreate" { + t.Fatalf("out_trade_no = %q", param.OutTradeNo) + } + if param.ProductCode != alipayProductCodePreCreate { + t.Fatalf("product_code = %q, want %q", param.ProductCode, alipayProductCodePreCreate) + } + return &alipay.TradePreCreateRsp{ + Error: alipay.Error{Code: alipay.CodeSuccess}, + QRCode: "https://qr.alipay.example.com/mobile-dynamic-token", + }, nil + } + alipayTradeWapPay = func(_ *alipay.Client, _ alipay.TradeWapPay) (*url.URL, error) { + wapPayCalls++ + return url.Parse("https://openapi.alipay.com/gateway.do?wap-pay") + } + + provider := &Alipay{client: &alipay.Client{}, config: map[string]string{}} + resp, err := provider.CreatePayment(context.Background(), payment.CreatePaymentRequest{ + OrderID: "sub2_mobile_precreate", + Amount: "28.00", + Subject: "Balance recharge", + IsMobile: true, + AlipayMobilePrecreate: true, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if precreateCalls != 1 || wapPayCalls != 0 { + t.Fatalf("precreate calls = %d, wap calls = %d; want 1, 0", precreateCalls, wapPayCalls) + } + if resp.QRCode != "https://qr.alipay.example.com/mobile-dynamic-token" || resp.PayURL != "" { + t.Fatalf("unexpected response: qr_code=%q pay_url=%q", resp.QRCode, resp.PayURL) + } +} + +func TestCreatePaymentKeepsWapPayForMobileWhenPrecreateDisabled(t *testing.T) { + origPreCreate := alipayTradePreCreate + origWapPay := alipayTradeWapPay + t.Cleanup(func() { + alipayTradePreCreate = origPreCreate + alipayTradeWapPay = origWapPay + }) + + precreateCalls := 0 + wapPayCalls := 0 + alipayTradePreCreate = func(_ context.Context, _ *alipay.Client, _ alipay.TradePreCreate) (*alipay.TradePreCreateRsp, error) { + precreateCalls++ + return nil, errors.New("unexpected precreate call") + } + alipayTradeWapPay = func(_ *alipay.Client, _ alipay.TradeWapPay) (*url.URL, error) { + wapPayCalls++ + return url.Parse("https://openapi.alipay.com/gateway.do?wap-pay") + } + + provider := &Alipay{client: &alipay.Client{}, config: map[string]string{}} + resp, err := provider.CreatePayment(context.Background(), payment.CreatePaymentRequest{ + OrderID: "sub2_mobile_wap", + Amount: "18.00", + Subject: "Balance recharge", + IsMobile: true, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if precreateCalls != 0 || wapPayCalls != 1 { + t.Fatalf("precreate calls = %d, wap calls = %d; want 0, 1", precreateCalls, wapPayCalls) + } + if resp.PayURL == "" || resp.QRCode != "" { + t.Fatalf("unexpected response: qr_code=%q pay_url=%q", resp.QRCode, resp.PayURL) + } +} + func TestCreateTradeUsesPrecreateForDesktopWhenAvailable(t *testing.T) { origPreCreate := alipayTradePreCreate origPagePay := alipayTradePagePay diff --git a/backend/internal/payment/types.go b/backend/internal/payment/types.go index 0fc161c11da..6421c4afed0 100644 --- a/backend/internal/payment/types.go +++ b/backend/internal/payment/types.go @@ -99,16 +99,19 @@ func GetBasePaymentType(t string) string { // CreatePaymentRequest holds the parameters for creating a new payment. type CreatePaymentRequest struct { - OrderID string // Internal order ID - Amount string // 支付金额,按服务商实例配置的币种解释 - PaymentType string // e.g. "alipay", "wxpay", "stripe" - Subject string // Product description - NotifyURL string // Webhook callback URL - ReturnURL string // Browser redirect URL after payment - OpenID string // WeChat JSAPI payer OpenID when available - ClientIP string // Payer's IP address - IsMobile bool // Whether the request comes from a mobile device - InstanceSubMethods string // Comma-separated sub-methods from instance supported_types (for Stripe) + OrderID string // Internal order ID + Amount string // 支付金额,按服务商实例配置的币种解释 + PaymentType string // e.g. "alipay", "wxpay", "stripe" + Subject string // Product description + NotifyURL string // Webhook callback URL + ReturnURL string // Browser redirect URL after payment + OpenID string // WeChat JSAPI payer OpenID when available + ClientIP string // Payer's IP address + IsMobile bool // Whether the request comes from a mobile device + // AlipayMobilePrecreate routes a mobile Alipay request through + // alipay.trade.precreate instead of alipay.trade.wap.pay. + AlipayMobilePrecreate bool + InstanceSubMethods string // Comma-separated sub-methods from instance supported_types (for Stripe) } // CreatePaymentResultType describes the shape of the create-payment result. diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 99c7c423282..955ea9b7a5a 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -943,6 +943,7 @@ func TestAPIContracts(t *testing.T) { "payment_cancel_rate_limit_unit": "", "payment_cancel_rate_limit_window_mode": "", "payment_alipay_force_qrcode": false, + "payment_alipay_mobile_precreate_deep_link": false, "balance_low_notify_enabled": false, "account_quota_notify_enabled": false, "subscription_expiry_notify_enabled": true, @@ -1222,6 +1223,7 @@ func TestAPIContracts(t *testing.T) { "payment_cancel_rate_limit_unit": "", "payment_cancel_rate_limit_window_mode": "", "payment_alipay_force_qrcode": false, + "payment_alipay_mobile_precreate_deep_link": false, "balance_low_notify_enabled": false, "account_quota_notify_enabled": false, "subscription_expiry_notify_enabled": true, diff --git a/backend/internal/service/payment_config_service.go b/backend/internal/service/payment_config_service.go index edeff8b56b6..d79a46ad6be 100644 --- a/backend/internal/service/payment_config_service.go +++ b/backend/internal/service/payment_config_service.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "math" + "os" "strconv" "strings" @@ -26,18 +27,19 @@ const ( SettingBalanceRechargeMult = "BALANCE_RECHARGE_MULTIPLIER" // SettingSubscriptionUSDToCNYRate 是订阅 CNY 换算汇率(1 USD = X CNY)。 // 0/未配置 = 关闭换算(订阅按 price 数值直付),显式配置后 CNY 通道订阅按 price × rate 收款。 - SettingSubscriptionUSDToCNYRate = "SUBSCRIPTION_USD_TO_CNY_RATE" - SettingRechargeFeeRate = "RECHARGE_FEE_RATE" - SettingProductNamePrefix = "PRODUCT_NAME_PREFIX" - SettingProductNameSuffix = "PRODUCT_NAME_SUFFIX" - SettingHelpImageURL = "PAYMENT_HELP_IMAGE_URL" - SettingHelpText = "PAYMENT_HELP_TEXT" - SettingCancelRateLimitOn = "CANCEL_RATE_LIMIT_ENABLED" - SettingCancelRateLimitMax = "CANCEL_RATE_LIMIT_MAX" - SettingCancelWindowSize = "CANCEL_RATE_LIMIT_WINDOW" - SettingCancelWindowUnit = "CANCEL_RATE_LIMIT_UNIT" - SettingCancelWindowMode = "CANCEL_RATE_LIMIT_WINDOW_MODE" - SettingAlipayForceQRCode = "ALIPAY_FORCE_QRCODE" + SettingSubscriptionUSDToCNYRate = "SUBSCRIPTION_USD_TO_CNY_RATE" + SettingRechargeFeeRate = "RECHARGE_FEE_RATE" + SettingProductNamePrefix = "PRODUCT_NAME_PREFIX" + SettingProductNameSuffix = "PRODUCT_NAME_SUFFIX" + SettingHelpImageURL = "PAYMENT_HELP_IMAGE_URL" + SettingHelpText = "PAYMENT_HELP_TEXT" + SettingCancelRateLimitOn = "CANCEL_RATE_LIMIT_ENABLED" + SettingCancelRateLimitMax = "CANCEL_RATE_LIMIT_MAX" + SettingCancelWindowSize = "CANCEL_RATE_LIMIT_WINDOW" + SettingCancelWindowUnit = "CANCEL_RATE_LIMIT_UNIT" + SettingCancelWindowMode = "CANCEL_RATE_LIMIT_WINDOW_MODE" + SettingAlipayForceQRCode = "ALIPAY_FORCE_QRCODE" + SettingAlipayMobilePrecreateDeepLink = "ALIPAY_MOBILE_PRECREATE_DEEP_LINK" ) // Default values for payment configuration settings. @@ -76,6 +78,8 @@ type PaymentConfig struct { // Force Alipay mobile users to use QR code instead of mobile redirect AlipayForceQRCode bool `json:"alipay_force_qrcode"` + // Use Alipay face-to-face precreate and an app deep link on mobile clients. + AlipayMobilePrecreateDeepLink bool `json:"alipay_mobile_precreate_deep_link"` } // UpdatePaymentConfigRequest contains fields to update payment configuration. @@ -106,6 +110,8 @@ type UpdatePaymentConfigRequest struct { // Force Alipay mobile users to use QR code instead of mobile redirect AlipayForceQRCode *bool `json:"alipay_force_qrcode"` + // Use Alipay face-to-face precreate and an app deep link on mobile clients. + AlipayMobilePrecreateDeepLink *bool `json:"alipay_mobile_precreate_deep_link"` VisibleMethodAlipaySource *string `json:"payment_visible_method_alipay_source"` VisibleMethodWxpaySource *string `json:"payment_visible_method_wxpay_source"` @@ -218,7 +224,7 @@ func (s *PaymentConfigService) GetPaymentConfig(ctx context.Context) (*PaymentCo SettingHelpImageURL, SettingHelpText, SettingCancelRateLimitOn, SettingCancelRateLimitMax, SettingCancelWindowSize, SettingCancelWindowUnit, SettingCancelWindowMode, - SettingAlipayForceQRCode, + SettingAlipayForceQRCode, SettingAlipayMobilePrecreateDeepLink, SettingPaymentVisibleMethodAlipayEnabled, SettingPaymentVisibleMethodAlipaySource, SettingPaymentVisibleMethodWxpayEnabled, SettingPaymentVisibleMethodWxpaySource, } @@ -256,8 +262,13 @@ func (s *PaymentConfigService) parsePaymentConfig(vals map[string]string) *Payme CancelRateLimitUnit: vals[SettingCancelWindowUnit], CancelRateLimitMode: vals[SettingCancelWindowMode], - AlipayForceQRCode: vals[SettingAlipayForceQRCode] == "true", + AlipayForceQRCode: vals[SettingAlipayForceQRCode] == "true", + AlipayMobilePrecreateDeepLink: vals[SettingAlipayMobilePrecreateDeepLink] == "true", } + cfg.AlipayMobilePrecreateDeepLink = pcEnvBoolOverride( + SettingAlipayMobilePrecreateDeepLink, + cfg.AlipayMobilePrecreateDeepLink, + ) if cfg.LoadBalanceStrategy == "" { cfg.LoadBalanceStrategy = payment.DefaultLoadBalanceStrategy } @@ -274,6 +285,18 @@ func (s *PaymentConfigService) parsePaymentConfig(vals map[string]string) *Payme return cfg } +func pcEnvBoolOverride(key string, fallback bool) bool { + raw, ok := os.LookupEnv(key) + if !ok || strings.TrimSpace(raw) == "" { + return fallback + } + value, err := strconv.ParseBool(strings.TrimSpace(raw)) + if err != nil { + return fallback + } + return value +} + // getStripePublishableKey finds the publishable key from the first enabled Stripe provider instance. func (s *PaymentConfigService) getStripePublishableKey(ctx context.Context) string { if s.entClient == nil { @@ -342,6 +365,7 @@ func (s *PaymentConfigService) UpdatePaymentConfig(ctx context.Context, req Upda SettingCancelWindowUnit: derefStr(req.CancelRateLimitUnit), SettingCancelWindowMode: derefStr(req.CancelRateLimitMode), SettingAlipayForceQRCode: formatBoolOrEmpty(req.AlipayForceQRCode), + SettingAlipayMobilePrecreateDeepLink: formatBoolOrEmpty(req.AlipayMobilePrecreateDeepLink), SettingPaymentVisibleMethodAlipaySource: derefStr(req.VisibleMethodAlipaySource), SettingPaymentVisibleMethodWxpaySource: derefStr(req.VisibleMethodWxpaySource), SettingPaymentVisibleMethodAlipayEnabled: formatBoolOrEmpty(req.VisibleMethodAlipayEnabled), diff --git a/backend/internal/service/payment_config_service_test.go b/backend/internal/service/payment_config_service_test.go index bfc69d1705d..87b17943818 100644 --- a/backend/internal/service/payment_config_service_test.go +++ b/backend/internal/service/payment_config_service_test.go @@ -73,6 +73,20 @@ func TestPcParseInt(t *testing.T) { } } +func TestAlipayMobilePrecreateEnvironmentOverride(t *testing.T) { + svc := &PaymentConfigService{} + + t.Setenv(SettingAlipayMobilePrecreateDeepLink, "true") + if !svc.parsePaymentConfig(map[string]string{SettingAlipayMobilePrecreateDeepLink: "false"}).AlipayMobilePrecreateDeepLink { + t.Fatal("expected environment variable to enable mobile Alipay precreate") + } + + t.Setenv(SettingAlipayMobilePrecreateDeepLink, "false") + if svc.parsePaymentConfig(map[string]string{SettingAlipayMobilePrecreateDeepLink: "true"}).AlipayMobilePrecreateDeepLink { + t.Fatal("expected environment variable to disable mobile Alipay precreate") + } +} + func TestParsePaymentConfig(t *testing.T) { t.Parallel() @@ -102,22 +116,26 @@ func TestParsePaymentConfig(t *testing.T) { if len(cfg.EnabledTypes) != 0 { t.Fatalf("expected empty EnabledTypes, got %v", cfg.EnabledTypes) } + if cfg.AlipayMobilePrecreateDeepLink { + t.Fatal("expected AlipayMobilePrecreateDeepLink=false by default") + } }) t.Run("all values populated", func(t *testing.T) { t.Parallel() vals := map[string]string{ - SettingPaymentEnabled: "true", - SettingMinRechargeAmount: "5.00", - SettingMaxRechargeAmount: "1000.00", - SettingDailyRechargeLimit: "5000.00", - SettingOrderTimeoutMinutes: "15", - SettingMaxPendingOrders: "5", - SettingEnabledPaymentTypes: "alipay,wxpay,stripe", - SettingBalancePayDisabled: "true", - SettingLoadBalanceStrategy: "least_amount", - SettingProductNamePrefix: "PRE", - SettingProductNameSuffix: "SUF", + SettingPaymentEnabled: "true", + SettingMinRechargeAmount: "5.00", + SettingMaxRechargeAmount: "1000.00", + SettingDailyRechargeLimit: "5000.00", + SettingOrderTimeoutMinutes: "15", + SettingMaxPendingOrders: "5", + SettingEnabledPaymentTypes: "alipay,wxpay,stripe", + SettingBalancePayDisabled: "true", + SettingLoadBalanceStrategy: "least_amount", + SettingProductNamePrefix: "PRE", + SettingProductNameSuffix: "SUF", + SettingAlipayMobilePrecreateDeepLink: "true", } cfg := svc.parsePaymentConfig(vals) @@ -157,6 +175,9 @@ func TestParsePaymentConfig(t *testing.T) { if cfg.ProductNameSuffix != "SUF" { t.Fatalf("ProductNameSuffix = %q, want %q", cfg.ProductNameSuffix, "SUF") } + if !cfg.AlipayMobilePrecreateDeepLink { + t.Fatal("expected AlipayMobilePrecreateDeepLink=true") + } }) t.Run("enabled types with spaces are trimmed", func(t *testing.T) { diff --git a/backend/internal/service/payment_fulfillment_test.go b/backend/internal/service/payment_fulfillment_test.go index d040095d63a..50ccd485ed3 100644 --- a/backend/internal/service/payment_fulfillment_test.go +++ b/backend/internal/service/payment_fulfillment_test.go @@ -704,6 +704,72 @@ func TestExecuteBalanceFulfillmentRecoversAfterRedeemWithoutCreditingAgain(t *te require.Equal(t, OrderStatusCompleted, reloaded.Status) } +func TestDuplicatePaymentNotificationDoesNotReprocessCompletedBalanceOrder(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + order := createPaymentFulfillmentSubscriptionOrder(t, ctx, client, OrderStatusCompleted, time.Now()) + order, err := client.PaymentOrder.UpdateOneID(order.ID). + SetOrderType(payment.OrderTypeBalance). + ClearPlanID(). + ClearSubscriptionGroupID(). + ClearSubscriptionDays(). + Save(ctx) + require.NoError(t, err) + + redeemRepo := &redeemCodeRepoStub{codesByCode: map[string]*RedeemCode{ + order.RechargeCode: { + ID: 102, + Code: order.RechargeCode, + Type: RedeemTypeBalance, + Value: order.Amount, + Status: StatusUnused, + }, + }} + svc := &PaymentService{ + entClient: client, + redeemService: &RedeemService{redeemRepo: redeemRepo}, + } + notification := &payment.PaymentNotification{ + TradeNo: "alipay-trade-replayed", + OrderID: order.OutTradeNo, + Amount: order.PayAmount, + Status: payment.NotificationStatusSuccess, + } + require.NoError(t, svc.HandlePaymentNotification(ctx, notification, payment.TypeAlipay)) + require.NoError(t, svc.HandlePaymentNotification(ctx, notification, payment.TypeAlipay)) + + reloaded, err := client.PaymentOrder.Get(ctx, order.ID) + require.NoError(t, err) + require.Equal(t, OrderStatusCompleted, reloaded.Status) + require.Empty(t, redeemRepo.useCalls, "a duplicate notification must not redeem the balance code again") +} + +func TestPaymentNotificationRejectsAmountMismatchBeforeFulfillment(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + order := createPaymentFulfillmentSubscriptionOrder(t, ctx, client, OrderStatusPending, time.Now()) + order, err := client.PaymentOrder.UpdateOneID(order.ID). + SetOrderType(payment.OrderTypeBalance). + ClearPlanID(). + ClearSubscriptionGroupID(). + ClearSubscriptionDays(). + Save(ctx) + require.NoError(t, err) + + svc := &PaymentService{entClient: client} + err = svc.HandlePaymentNotification(ctx, &payment.PaymentNotification{ + TradeNo: "alipay-trade-wrong-amount", + OrderID: order.OutTradeNo, + Amount: order.PayAmount - 1, + Status: payment.NotificationStatusSuccess, + }, payment.TypeAlipay) + require.ErrorContains(t, err, "amount mismatch") + + reloaded, err := client.PaymentOrder.Get(ctx, order.ID) + require.NoError(t, err) + require.Equal(t, OrderStatusPending, reloaded.Status) +} + func TestExecuteSubscriptionFulfillmentRecoversCommittedAssignmentWithoutExtendingAgain(t *testing.T) { ctx := context.Background() client := newPaymentConfigServiceTestClient(t) diff --git a/backend/internal/service/payment_order.go b/backend/internal/service/payment_order.go index 9b7ee08990a..da7178dd7fe 100644 --- a/backend/internal/service/payment_order.go +++ b/backend/internal/service/payment_order.go @@ -446,6 +446,7 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen IsMobile: req.IsMobile, ReturnURL: providerReturnURL, }, sel, outTradeNo, payAmountStr, subject) + providerReq.AlipayMobilePrecreate = shouldUseAlipayMobilePrecreate(req, cfg, sel) finishProviderCall := servertiming.ObserveDependency(ctx, "payment") pr, err := prov.CreatePayment(ctx, providerReq) finishProviderCall() @@ -481,9 +482,18 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen } resp := buildCreateOrderResponse(order, req, payAmount, sel, pr, resultType) resp.ResumeToken = resumeToken + resp.AlipayMobilePrecreateDeepLink = providerReq.AlipayMobilePrecreate && strings.TrimSpace(pr.QRCode) != "" return resp, nil } +func shouldUseAlipayMobilePrecreate(req CreateOrderRequest, cfg *PaymentConfig, sel *payment.InstanceSelection) bool { + return cfg != nil && + cfg.AlipayMobilePrecreateDeepLink && + req.IsMobile && + sel != nil && + strings.EqualFold(strings.TrimSpace(sel.ProviderKey), payment.TypeAlipay) +} + func sanitizeCreatePaymentResponseDetails(pr *payment.CreatePaymentResponse) { if pr == nil { return diff --git a/backend/internal/service/payment_order_result_test.go b/backend/internal/service/payment_order_result_test.go index ac439ee6f24..e77fbce4822 100644 --- a/backend/internal/service/payment_order_result_test.go +++ b/backend/internal/service/payment_order_result_test.go @@ -11,6 +11,59 @@ import ( infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" ) +func TestShouldUseAlipayMobilePrecreate(t *testing.T) { + t.Parallel() + + enabled := &PaymentConfig{AlipayMobilePrecreateDeepLink: true} + officialAlipay := &payment.InstanceSelection{ProviderKey: payment.TypeAlipay} + + tests := []struct { + name string + req CreateOrderRequest + cfg *PaymentConfig + sel *payment.InstanceSelection + want bool + }{ + {name: "mobile official alipay with switch", req: CreateOrderRequest{IsMobile: true}, cfg: enabled, sel: officialAlipay, want: true}, + {name: "desktop remains unchanged", req: CreateOrderRequest{IsMobile: false}, cfg: enabled, sel: officialAlipay, want: false}, + {name: "switch disabled keeps wap", req: CreateOrderRequest{IsMobile: true}, cfg: &PaymentConfig{}, sel: officialAlipay, want: false}, + {name: "other provider remains unchanged", req: CreateOrderRequest{IsMobile: true}, cfg: enabled, sel: &payment.InstanceSelection{ProviderKey: payment.TypeEasyPay}, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := shouldUseAlipayMobilePrecreate(tt.req, tt.cfg, tt.sel); got != tt.want { + t.Fatalf("shouldUseAlipayMobilePrecreate() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestIsOfficialAlipayProviderInstance(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + instance *dbent.PaymentProviderInstance + want bool + }{ + {name: "nil instance", instance: nil, want: false}, + {name: "official alipay", instance: &dbent.PaymentProviderInstance{ProviderKey: payment.TypeAlipay}, want: true}, + {name: "normalized official alipay", instance: &dbent.PaymentProviderInstance{ProviderKey: " ALIPAY "}, want: true}, + {name: "easypay alipay route", instance: &dbent.PaymentProviderInstance{ProviderKey: payment.TypeEasyPay}, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := isOfficialAlipayProviderInstance(tt.instance); got != tt.want { + t.Fatalf("isOfficialAlipayProviderInstance() = %v, want %v", got, tt.want) + } + }) + } +} + func TestBuildCreateOrderResponseDefaultsToOrderCreated(t *testing.T) { t.Parallel() diff --git a/backend/internal/service/payment_service.go b/backend/internal/service/payment_service.go index 3698a59c0b3..3518fbe2e98 100644 --- a/backend/internal/service/payment_service.go +++ b/backend/internal/service/payment_service.go @@ -88,27 +88,28 @@ type CreateOrderRequest struct { } type CreateOrderResponse struct { - OrderID int64 `json:"order_id"` - Amount float64 `json:"amount"` - PayAmount float64 `json:"pay_amount"` - FeeRate float64 `json:"fee_rate"` - Status string `json:"status"` - ResultType payment.CreatePaymentResultType `json:"result_type,omitempty"` - PaymentType string `json:"payment_type"` - OutTradeNo string `json:"out_trade_no,omitempty"` - PayURL string `json:"pay_url,omitempty"` - QRCode string `json:"qr_code,omitempty"` - ClientSecret string `json:"client_secret,omitempty"` - IntentID string `json:"intent_id,omitempty"` - Currency string `json:"currency,omitempty"` - CountryCode string `json:"country_code,omitempty"` - PaymentEnv string `json:"payment_env,omitempty"` - OAuth *payment.WechatOAuthInfo `json:"oauth,omitempty"` - JSAPI *payment.WechatJSAPIPayload `json:"jsapi,omitempty"` - JSAPIPayload *payment.WechatJSAPIPayload `json:"jsapi_payload,omitempty"` - ExpiresAt time.Time `json:"expires_at"` - PaymentMode string `json:"payment_mode,omitempty"` - ResumeToken string `json:"resume_token,omitempty"` + OrderID int64 `json:"order_id"` + Amount float64 `json:"amount"` + PayAmount float64 `json:"pay_amount"` + FeeRate float64 `json:"fee_rate"` + Status string `json:"status"` + ResultType payment.CreatePaymentResultType `json:"result_type,omitempty"` + PaymentType string `json:"payment_type"` + OutTradeNo string `json:"out_trade_no,omitempty"` + PayURL string `json:"pay_url,omitempty"` + QRCode string `json:"qr_code,omitempty"` + ClientSecret string `json:"client_secret,omitempty"` + IntentID string `json:"intent_id,omitempty"` + Currency string `json:"currency,omitempty"` + CountryCode string `json:"country_code,omitempty"` + PaymentEnv string `json:"payment_env,omitempty"` + OAuth *payment.WechatOAuthInfo `json:"oauth,omitempty"` + JSAPI *payment.WechatJSAPIPayload `json:"jsapi,omitempty"` + JSAPIPayload *payment.WechatJSAPIPayload `json:"jsapi_payload,omitempty"` + ExpiresAt time.Time `json:"expires_at"` + PaymentMode string `json:"payment_mode,omitempty"` + ResumeToken string `json:"resume_token,omitempty"` + AlipayMobilePrecreateDeepLink bool `json:"alipay_mobile_precreate_deep_link,omitempty"` } type OrderListParams struct { diff --git a/backend/internal/service/payment_visible_method_instances.go b/backend/internal/service/payment_visible_method_instances.go index 97b3b1ef66a..1d9be38b7ce 100644 --- a/backend/internal/service/payment_visible_method_instances.go +++ b/backend/internal/service/payment_visible_method_instances.go @@ -247,3 +247,17 @@ func (s *PaymentConfigService) resolveEnabledVisibleMethodInstance( } return selectVisibleMethodInstanceByProviderKey(matching, providerKey), nil } + +// UsesOfficialAlipayVisibleMethod reports whether the user-facing Alipay method +// currently resolves to an enabled official Alipay provider instance. +func (s *PaymentConfigService) UsesOfficialAlipayVisibleMethod(ctx context.Context) (bool, error) { + instance, err := s.resolveEnabledVisibleMethodInstance(ctx, payment.TypeAlipay) + if err != nil { + return false, err + } + return isOfficialAlipayProviderInstance(instance), nil +} + +func isOfficialAlipayProviderInstance(instance *dbent.PaymentProviderInstance) bool { + return instance != nil && strings.EqualFold(strings.TrimSpace(instance.ProviderKey), payment.TypeAlipay) +} diff --git a/backend/migrations/186_alipay_mobile_precreate_deep_link.sql b/backend/migrations/186_alipay_mobile_precreate_deep_link.sql new file mode 100644 index 00000000000..7a30bcaf7e8 --- /dev/null +++ b/backend/migrations/186_alipay_mobile_precreate_deep_link.sql @@ -0,0 +1,4 @@ +-- Mobile Alipay keeps the legacy WAP flow unless this opt-in is enabled. +INSERT INTO settings (key, value, updated_at) +VALUES ('ALIPAY_MOBILE_PRECREATE_DEEP_LINK', 'false', NOW()) +ON CONFLICT (key) DO NOTHING; diff --git a/deploy/.env.example b/deploy/.env.example index 4ddac781e80..a6d5e36db9f 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -118,6 +118,10 @@ RUN_MODE=standard # Timezone TZ=Asia/Shanghai +# Optional mobile Alipay flow. Unset uses the value saved in Admin Settings. +# Enable only for official Alipay instances with face-to-face payment enabled. +# ALIPAY_MOBILE_PRECREATE_DEEP_LINK=true + # ----------------------------------------------------------------------------- # PostgreSQL Configuration (REQUIRED) # ----------------------------------------------------------------------------- diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 16cc3109863..b8c08befaff 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -50,6 +50,7 @@ services: - ENABLE_SERVER_TIMING=${ENABLE_SERVER_TIMING:-false} - RUN_MODE=${RUN_MODE:-standard} - UPDATE_GITHUB_TOKEN=${UPDATE_GITHUB_TOKEN:-} + - ALIPAY_MOBILE_PRECREATE_DEEP_LINK=${ALIPAY_MOBILE_PRECREATE_DEEP_LINK:-} # ======================================================================= # Database Configuration (PostgreSQL) diff --git a/docs/PAYMENT_CN.md b/docs/PAYMENT_CN.md index 1fd50d1f67a..935176958ef 100644 --- a/docs/PAYMENT_CN.md +++ b/docs/PAYMENT_CN.md @@ -22,7 +22,7 @@ Sub2API 内置支付系统,支持用户自助充值,无需部署独立的支 | 服务商 | 支付方式 | 说明 | |--------|---------|------| | **EasyPay(易支付)** | 支付宝、微信支付 | 兼容易支付协议的第三方聚合支付 | -| **支付宝官方** | 桌面二维码扫码、移动端支付宝跳转 | 直接对接支付宝开放平台,桌面端返回二维码,移动端返回 WAP/唤起链接 | +| **支付宝官方** | 桌面二维码扫码、移动端支付宝跳转或当面付唤起 | 直接对接支付宝开放平台;移动端默认 WAP,也可选择当面付二维码唤起支付宝 | | **微信官方** | Native 扫码、H5、公众号/JSAPI 支付 | 直接对接微信支付 APIv3,按终端环境自动分流 | | **Stripe** | 银行卡、支付宝、微信支付、Link 等 | 国际支付,支持多币种 | @@ -65,6 +65,14 @@ Sub2API 内置支付系统,支持用户自助充值,无需部署独立的支 | **最大待支付订单数** | 同一用户最大并行待支付订单数 | 3 | | **负载均衡策略** | 多服务商实例时的选择策略 | 轮询 | +### 支付宝移动端当面付唤起 + +`支付宝移动端当面付唤起` 默认关闭,仅对前台路由到 **支付宝官方** 的移动端订单生效。开启后,服务端调用 `alipay.trade.precreate` 获取动态二维码,前端立即使用支付宝 Scheme 尝试唤起 App;页面未进入后台时会自动展示动态二维码备用页,继续轮询订单状态。桌面端仍保持“当面付二维码优先,电脑网站支付回退”的既有行为。 + +- 需要为该支付宝应用开通 **当面付 / 扫码支付**;未开通时保持关闭。 +- 管理后台开关保存为 `ALIPAY_MOBILE_PRECREATE_DEEP_LINK`;部署环境变量 `ALIPAY_MOBILE_PRECREATE_DEEP_LINK=true` 可强制开启,未设置时使用后台值。 +- 与“支付宝强制二维码支付”同时开启时,当面付唤起优先。关闭本开关即可恢复移动端手机网站支付。 + ### 前台可见支付方式路由 当前版本对用户统一展示支付方式,不区分官方渠道还是易支付: @@ -122,7 +130,7 @@ Sub2API 内置支付系统,支持用户自助充值,无需部署独立的支 ### 支付宝官方 -直接对接支付宝开放平台。移动端走支付宝手机网站支付跳转;桌面端优先使用当面付返回扫码串,若商户未开通当面付则回退到电脑网站支付,并将收银台链接同时返回给前端用于渲染二维码或直接打开支付页。 +直接对接支付宝开放平台。移动端默认走支付宝手机网站支付跳转;开启“支付宝移动端当面付唤起”后改为调用当面付,前端尝试打开支付宝 App,失败时显示动态二维码备用页。桌面端优先使用当面付返回扫码串,若商户未开通当面付则回退到电脑网站支付,并将收银台链接同时返回给前端用于渲染二维码或直接打开支付页。 | 参数 | 说明 | 必填 | |------|------|------| @@ -229,7 +237,7 @@ Sub2API 内置支付系统,支持用户自助充值,无需部署独立的支 ▼ 用户完成支付 ├─ EasyPay → 扫码 / H5 跳转 - ├─ 支付宝官方 → 桌面扫码单(当面付优先,电脑网站支付回退)/ 移动端支付宝跳转 + ├─ 支付宝官方 → 桌面扫码单(当面付优先,电脑网站支付回退)/ 移动端 WAP 或当面付唤起 + 动态二维码备用页 ├─ 微信官方 → 桌面 Native 扫码 / 非微信 H5 / 微信内 JSAPI └─ Stripe → Payment Element(银行卡/支付宝/微信等) │ diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 2779c2db625..8861ce56bd4 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -607,6 +607,7 @@ export interface SystemSettings { payment_cancel_rate_limit_unit: string; payment_cancel_rate_limit_window_mode: string; payment_alipay_force_qrcode?: boolean; + payment_alipay_mobile_precreate_deep_link?: boolean; payment_visible_method_alipay_source?: string; payment_visible_method_wxpay_source?: string; payment_visible_method_alipay_enabled?: boolean; @@ -888,6 +889,7 @@ export interface UpdateSettingsRequest { payment_cancel_rate_limit_unit?: string; payment_cancel_rate_limit_window_mode?: string; payment_alipay_force_qrcode?: boolean; + payment_alipay_mobile_precreate_deep_link?: 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/PaymentStatusPanel.vue b/frontend/src/components/payment/PaymentStatusPanel.vue index 75c4164777f..9b2b78e800c 100644 --- a/frontend/src/components/payment/PaymentStatusPanel.vue +++ b/frontend/src/components/payment/PaymentStatusPanel.vue @@ -69,8 +69,104 @@ + + + -