mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-08-30 17:09:13 +08:00
feat(payment): add mobile Alipay precreate deep link
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
|
||||
+11
-3
@@ -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(银行卡/支付宝/微信等)
|
||||
│
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -69,8 +69,104 @@
|
||||
|
||||
<!-- ═══ Active States: QR or Popup waiting ═══ -->
|
||||
|
||||
<!-- Mobile Alipay app handoff. The QR fallback stays hidden until launch timeout. -->
|
||||
<template v-else-if="isMobileAlipayDeepLink">
|
||||
<template v-if="!deepLinkFallbackVisible">
|
||||
<div class="card p-6">
|
||||
<div class="flex flex-col items-center space-y-4 py-4 text-center">
|
||||
<div
|
||||
v-if="deepLinkState === 'launching'"
|
||||
class="h-10 w-10 animate-spin rounded-full border-4 border-[#00AEEF] border-t-transparent"
|
||||
></div>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-12 w-12 items-center justify-center rounded-full bg-blue-50 dark:bg-blue-950/30"
|
||||
>
|
||||
<Icon name="checkCircle" size="lg" class="text-[#00AEEF]" />
|
||||
</div>
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ deepLinkState === 'backgrounded' ? t('payment.qr.alipayContinueInApp') : t('payment.qr.alipayOpening') }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.alipayWaitingHint') }}</p>
|
||||
<button
|
||||
v-if="deepLinkState === 'backgrounded'"
|
||||
data-test="reopen-alipay"
|
||||
class="btn btn-alipay inline-flex items-center gap-2 text-sm"
|
||||
@click="reopenAlipay"
|
||||
>
|
||||
<Icon name="externalLink" size="sm" />
|
||||
{{ t('payment.qr.reopenAlipay') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card p-4 text-center">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.expiresIn') }}</p>
|
||||
<p class="mt-1 text-2xl font-bold tabular-nums text-gray-900 dark:text-white">{{ countdownDisplay }}</p>
|
||||
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">{{ t('payment.qr.waitingPayment') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div data-test="alipay-qr-fallback" class="card p-6">
|
||||
<div class="flex flex-col items-center space-y-4">
|
||||
<div class="text-center">
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('payment.qr.alipayFallbackTitle') }}</p>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.alipayFallbackHint') }}</p>
|
||||
</div>
|
||||
<div class="w-full space-y-2 border-y border-gray-100 py-3 text-sm dark:border-dark-600">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.payAmount') }}</span>
|
||||
<span class="font-semibold text-gray-900 dark:text-white">{{ displayPaymentAmount }}</span>
|
||||
</div>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderNo') }}</span>
|
||||
<span class="max-w-[70%] break-all text-right font-mono text-xs text-gray-900 dark:text-white">
|
||||
{{ displayOrderNumber }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.qr.expiresIn') }}</span>
|
||||
<span class="font-semibold tabular-nums text-gray-900 dark:text-white">{{ countdownDisplay }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="['relative rounded-lg border-2 p-4', qrBorderClass]">
|
||||
<canvas ref="qrCanvas" class="mx-auto"></canvas>
|
||||
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<span :class="['rounded-full p-2 shadow ring-2 ring-white', qrLogoBgClass]">
|
||||
<img :src="qrLogoIcon" alt="" class="h-5 w-5 brightness-0 invert" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-center text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||
{{ t('payment.qr.alipaySaveAndScanHint') }}
|
||||
</p>
|
||||
<div class="grid w-full gap-2 sm:grid-cols-2">
|
||||
<button
|
||||
data-test="reopen-alipay"
|
||||
class="btn btn-alipay inline-flex items-center justify-center gap-2"
|
||||
@click="reopenAlipay"
|
||||
>
|
||||
<Icon name="externalLink" size="sm" />
|
||||
{{ t('payment.qr.reopenAlipay') }}
|
||||
</button>
|
||||
<button
|
||||
data-test="save-alipay-qr"
|
||||
class="btn btn-secondary inline-flex items-center justify-center gap-2"
|
||||
@click="saveQRCode"
|
||||
>
|
||||
<Icon name="download" size="sm" />
|
||||
{{ t('payment.qr.saveQRCode') }}
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn btn-secondary w-full" @click="handleDone">
|
||||
{{ t('payment.result.backToRecharge') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- QR Code Mode -->
|
||||
<template v-else-if="qrUrl">
|
||||
<template v-else-if="showQRCode">
|
||||
<div class="card p-6">
|
||||
<div class="flex flex-col items-center space-y-4">
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ scanTitle }}</p>
|
||||
@@ -122,7 +218,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
|
||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { usePaymentStore } from '@/stores/payment'
|
||||
import { useAppStore } from '@/stores'
|
||||
@@ -136,15 +232,24 @@ import QRCode from 'qrcode'
|
||||
import alipayIcon from '@/assets/icons/alipay.svg'
|
||||
import wxpayIcon from '@/assets/icons/wxpay.svg'
|
||||
import paymentIcon from '@/assets/icons/payment.svg'
|
||||
import {
|
||||
createAlipayDeepLinkLauncher,
|
||||
type AlipayDeepLinkLauncher,
|
||||
type AlipayDeepLinkState,
|
||||
} from './alipayDeepLink'
|
||||
|
||||
const props = defineProps<{
|
||||
orderId: number
|
||||
amount?: number
|
||||
payAmount?: number
|
||||
qrCode: string
|
||||
expiresAt: string
|
||||
paymentType: string
|
||||
payUrl?: string
|
||||
orderType?: string
|
||||
currency?: string
|
||||
outTradeNo?: string
|
||||
mobileAlipayDeepLink?: boolean
|
||||
}>()
|
||||
|
||||
type PaymentOutcome = 'success' | 'cancelled' | 'expired'
|
||||
@@ -161,6 +266,8 @@ const qrUrl = ref('')
|
||||
const remainingSeconds = ref(0)
|
||||
const cancelling = ref(false)
|
||||
const paidOrder = ref<PaymentOrder | null>(null)
|
||||
const deepLinkState = ref<AlipayDeepLinkState>('idle')
|
||||
const deepLinkFallbackVisible = ref(false)
|
||||
const paymentCurrency = computed(() => normalizePaymentCurrency(props.currency))
|
||||
const creditedAmountSymbol = currencySymbol('USD')
|
||||
const localeCode = computed(() => {
|
||||
@@ -179,12 +286,15 @@ let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||
let verifyAttempts = 0
|
||||
let lastVerifyAt = 0
|
||||
let alipayLauncher: AlipayDeepLinkLauncher | null = null
|
||||
|
||||
const VERIFY_RETRY_INTERVAL_MS = 15000
|
||||
const VERIFY_RETRY_MAX_ATTEMPTS = 6
|
||||
|
||||
const isAlipay = computed(() => isBuiltInAlipayMethod(props.paymentType))
|
||||
const isWxpay = computed(() => isBuiltInWxpayMethod(props.paymentType))
|
||||
const isMobileAlipayDeepLink = computed(() => props.mobileAlipayDeepLink === true && isAlipay.value && !!qrUrl.value)
|
||||
const showQRCode = computed(() => !!qrUrl.value && (!isMobileAlipayDeepLink.value || deepLinkFallbackVisible.value))
|
||||
|
||||
const qrBorderClass = computed(() => {
|
||||
if (isAlipay.value) return 'border-[#00AEEF] bg-blue-50 dark:border-[#00AEEF]/70 dark:bg-blue-950/20'
|
||||
@@ -222,6 +332,9 @@ const countdownDisplay = computed(() => {
|
||||
return m.toString().padStart(2, '0') + ':' + s.toString().padStart(2, '0')
|
||||
})
|
||||
|
||||
const displayPaymentAmount = computed(() => formatGatewayAmount(props.payAmount || props.amount || 0))
|
||||
const displayOrderNumber = computed(() => props.outTradeNo || `#${props.orderId}`)
|
||||
|
||||
function formatGatewayAmount(value: number, currency?: string | null): string {
|
||||
return formatPaymentAmount(value, currency || paymentCurrency.value, localeCode.value)
|
||||
}
|
||||
@@ -247,15 +360,40 @@ function setOutcome(next: PaymentOutcome) {
|
||||
|
||||
async function renderQR() {
|
||||
await nextTick()
|
||||
if (!qrCanvas.value || !qrUrl.value) return
|
||||
if (!showQRCode.value || !qrCanvas.value || !qrUrl.value) return
|
||||
await QRCode.toCanvas(qrCanvas.value, qrUrl.value, {
|
||||
width: 220, margin: 2,
|
||||
errorCorrectionLevel: 'M',
|
||||
})
|
||||
}
|
||||
|
||||
function updateDeepLinkState(state: AlipayDeepLinkState) {
|
||||
deepLinkState.value = state
|
||||
if (state === 'fallback') {
|
||||
deepLinkFallbackVisible.value = true
|
||||
renderQR()
|
||||
} else if (state === 'backgrounded') {
|
||||
deepLinkFallbackVisible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reopenAlipay() {
|
||||
alipayLauncher?.launch()
|
||||
}
|
||||
|
||||
function saveQRCode() {
|
||||
const canvas = qrCanvas.value
|
||||
if (!canvas) return
|
||||
const link = document.createElement('a')
|
||||
link.href = canvas.toDataURL('image/png')
|
||||
link.download = `alipay-${props.outTradeNo || props.orderId}.png`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
}
|
||||
|
||||
async function tryRecoverPendingOrder(order: PaymentOrder): Promise<PaymentOrder> {
|
||||
if (!isWxpay.value) return order
|
||||
if (!isWxpay.value && !isMobileAlipayDeepLink.value) return order
|
||||
const outTradeNo = String(order.out_trade_no || '').trim()
|
||||
if (!outTradeNo) return order
|
||||
const normalizedStatus = String(order.status || '').trim().toUpperCase()
|
||||
@@ -333,6 +471,8 @@ function handleDone() { cleanup(); emit('done') }
|
||||
function cleanup() {
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null }
|
||||
alipayLauncher?.dispose()
|
||||
alipayLauncher = null
|
||||
}
|
||||
|
||||
// Initialize on mount
|
||||
@@ -347,6 +487,18 @@ startCountdown(seconds)
|
||||
pollTimer = setInterval(pollStatus, 3000)
|
||||
renderQR()
|
||||
|
||||
watch(() => qrUrl.value, () => renderQR())
|
||||
watch([() => qrUrl.value, showQRCode], () => renderQR())
|
||||
onMounted(() => {
|
||||
if (!isMobileAlipayDeepLink.value) return
|
||||
alipayLauncher = createAlipayDeepLinkLauncher({
|
||||
qrCode: qrUrl.value,
|
||||
document,
|
||||
lifecycleTarget: window,
|
||||
userAgent: window.navigator.userAgent,
|
||||
assignLocation: (url) => window.location.assign(url),
|
||||
onStateChange: updateDeepLinkState,
|
||||
})
|
||||
alipayLauncher.launch()
|
||||
})
|
||||
onUnmounted(() => cleanup())
|
||||
</script>
|
||||
|
||||
@@ -184,4 +184,132 @@ describe('PaymentStatusPanel', () => {
|
||||
expect(wrapper.text()).toContain('payment.result.success')
|
||||
expect(wrapper.emitted('success')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('actively verifies a pending mobile Alipay precreate order', async () => {
|
||||
const originalLocation = window.location
|
||||
const originalHidden = Object.getOwnPropertyDescriptor(document, 'hidden')
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: { assign: vi.fn() },
|
||||
})
|
||||
Object.defineProperty(document, 'hidden', {
|
||||
configurable: true,
|
||||
get: () => false,
|
||||
})
|
||||
pollOrderStatus.mockResolvedValue(orderFactory('PENDING'))
|
||||
verifyOrder.mockResolvedValue({ data: orderFactory('COMPLETED') })
|
||||
|
||||
const wrapper = mount(PaymentStatusPanel, {
|
||||
props: {
|
||||
orderId: 42,
|
||||
amount: 88,
|
||||
payAmount: 88,
|
||||
qrCode: 'https://qr.alipay.com/dynamic-order-42',
|
||||
expiresAt: '2099-01-01T12:30:00Z',
|
||||
paymentType: 'alipay',
|
||||
orderType: 'balance',
|
||||
outTradeNo: 'sub2_20260420abcd1234',
|
||||
mobileAlipayDeepLink: true,
|
||||
},
|
||||
global: { stubs: { Icon: true } },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
await vi.advanceTimersByTimeAsync(3000)
|
||||
await flushPromises()
|
||||
|
||||
expect(verifyOrder).toHaveBeenCalledWith('sub2_20260420abcd1234')
|
||||
expect(wrapper.emitted('success')).toHaveLength(1)
|
||||
|
||||
wrapper.unmount()
|
||||
Object.defineProperty(window, 'location', { configurable: true, value: originalLocation })
|
||||
if (originalHidden) Object.defineProperty(document, 'hidden', originalHidden)
|
||||
})
|
||||
|
||||
it('keeps the QR fallback hidden until the Alipay app launch times out', async () => {
|
||||
const originalLocation = window.location
|
||||
const originalHidden = Object.getOwnPropertyDescriptor(document, 'hidden')
|
||||
const assign = vi.fn()
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: { assign },
|
||||
})
|
||||
Object.defineProperty(document, 'hidden', {
|
||||
configurable: true,
|
||||
get: () => false,
|
||||
})
|
||||
|
||||
const wrapper = mount(PaymentStatusPanel, {
|
||||
props: {
|
||||
orderId: 42,
|
||||
amount: 88,
|
||||
payAmount: 88,
|
||||
qrCode: 'https://qr.alipay.com/dynamic-order-42',
|
||||
expiresAt: '2099-01-01T12:30:00Z',
|
||||
paymentType: 'alipay',
|
||||
orderType: 'balance',
|
||||
outTradeNo: 'sub2_20260420abcd1234',
|
||||
mobileAlipayDeepLink: true,
|
||||
},
|
||||
global: { stubs: { Icon: true } },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
expect(assign).toHaveBeenCalledWith(expect.stringContaining('alipays://platformapi/startapp?saId=10000007&qrcode='))
|
||||
expect(wrapper.find('[data-test="alipay-qr-fallback"]').exists()).toBe(false)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2200)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-test="alipay-qr-fallback"]').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('payment.qr.saveQRCode')
|
||||
expect(wrapper.text()).toContain('sub2_20260420abcd1234')
|
||||
expect(toCanvas).toHaveBeenCalledWith(expect.any(HTMLCanvasElement), 'https://qr.alipay.com/dynamic-order-42', expect.any(Object))
|
||||
|
||||
wrapper.unmount()
|
||||
Object.defineProperty(window, 'location', { configurable: true, value: originalLocation })
|
||||
if (originalHidden) Object.defineProperty(document, 'hidden', originalHidden)
|
||||
})
|
||||
|
||||
it('does not show the QR fallback after the page enters the background', async () => {
|
||||
const originalLocation = window.location
|
||||
const originalHidden = Object.getOwnPropertyDescriptor(document, 'hidden')
|
||||
let hidden = false
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: { assign: vi.fn() },
|
||||
})
|
||||
Object.defineProperty(document, 'hidden', {
|
||||
configurable: true,
|
||||
get: () => hidden,
|
||||
})
|
||||
|
||||
const wrapper = mount(PaymentStatusPanel, {
|
||||
props: {
|
||||
orderId: 42,
|
||||
amount: 88,
|
||||
payAmount: 88,
|
||||
qrCode: 'https://qr.alipay.com/dynamic-order-42',
|
||||
expiresAt: '2099-01-01T12:30:00Z',
|
||||
paymentType: 'alipay',
|
||||
orderType: 'balance',
|
||||
outTradeNo: 'sub2_20260420abcd1234',
|
||||
mobileAlipayDeepLink: true,
|
||||
},
|
||||
global: { stubs: { Icon: true } },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
hidden = true
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
await vi.advanceTimersByTimeAsync(2200)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-test="alipay-qr-fallback"]').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('payment.qr.alipayContinueInApp')
|
||||
|
||||
wrapper.unmount()
|
||||
Object.defineProperty(window, 'location', { configurable: true, value: originalLocation })
|
||||
if (originalHidden) Object.defineProperty(document, 'hidden', originalHidden)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
ALIPAY_DEEP_LINK_FALLBACK_DELAY_MS,
|
||||
ALIPAY_EMBEDDED_BROWSER_FALLBACK_DELAY_MS,
|
||||
buildAlipayDeepLink,
|
||||
createAlipayDeepLinkLauncher,
|
||||
} from '../alipayDeepLink'
|
||||
|
||||
class FakeEventTarget {
|
||||
private readonly listeners = new Map<string, Set<EventListener>>()
|
||||
|
||||
addEventListener(type: string, listener: EventListener) {
|
||||
const listeners = this.listeners.get(type) ?? new Set<EventListener>()
|
||||
listeners.add(listener)
|
||||
this.listeners.set(type, listeners)
|
||||
}
|
||||
|
||||
removeEventListener(type: string, listener: EventListener) {
|
||||
this.listeners.get(type)?.delete(listener)
|
||||
}
|
||||
|
||||
dispatch(type: string) {
|
||||
for (const listener of this.listeners.get(type) ?? []) {
|
||||
listener(new Event(type))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FakeVisibilityDocument extends FakeEventTarget {
|
||||
hidden = false
|
||||
}
|
||||
|
||||
describe('Alipay deep link', () => {
|
||||
beforeEach(() => vi.useFakeTimers())
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('URL-encodes the dynamic qr_code exactly once', () => {
|
||||
const qrCode = 'https://qr.alipay.com/bax123?subject=A B&return=https%3A%2F%2Fexample.com%2Fpaid'
|
||||
const deepLink = buildAlipayDeepLink(qrCode)
|
||||
|
||||
expect(deepLink).toBe(
|
||||
`alipays://platformapi/startapp?saId=10000007&qrcode=${encodeURIComponent(qrCode)}`,
|
||||
)
|
||||
expect(decodeURIComponent(deepLink.split('&qrcode=')[1])).toBe(qrCode)
|
||||
})
|
||||
|
||||
it('shows fallback after the visible-page timeout', async () => {
|
||||
const visibility = new FakeVisibilityDocument()
|
||||
const lifecycle = new FakeEventTarget()
|
||||
const assignLocation = vi.fn()
|
||||
const onStateChange = vi.fn()
|
||||
const launcher = createAlipayDeepLinkLauncher({
|
||||
qrCode: 'https://qr.alipay.com/dynamic-order-1',
|
||||
document: visibility,
|
||||
lifecycleTarget: lifecycle,
|
||||
userAgent: 'Mozilla/5.0 Mobile Safari',
|
||||
assignLocation,
|
||||
onStateChange,
|
||||
})
|
||||
|
||||
launcher.launch()
|
||||
expect(assignLocation).toHaveBeenCalledWith(buildAlipayDeepLink('https://qr.alipay.com/dynamic-order-1'))
|
||||
expect(onStateChange).toHaveBeenLastCalledWith('launching')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(ALIPAY_DEEP_LINK_FALLBACK_DELAY_MS - 1)
|
||||
expect(onStateChange).not.toHaveBeenCalledWith('fallback')
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(onStateChange).toHaveBeenLastCalledWith('fallback')
|
||||
})
|
||||
|
||||
it('keeps fallback hidden when visibilitychange reports the page in background', async () => {
|
||||
const visibility = new FakeVisibilityDocument()
|
||||
const lifecycle = new FakeEventTarget()
|
||||
const onStateChange = vi.fn()
|
||||
const launcher = createAlipayDeepLinkLauncher({
|
||||
qrCode: 'https://qr.alipay.com/dynamic-order-2',
|
||||
document: visibility,
|
||||
lifecycleTarget: lifecycle,
|
||||
userAgent: 'Mozilla/5.0 iPhone',
|
||||
assignLocation: vi.fn(),
|
||||
onStateChange,
|
||||
})
|
||||
|
||||
launcher.launch()
|
||||
visibility.hidden = true
|
||||
visibility.dispatch('visibilitychange')
|
||||
await vi.advanceTimersByTimeAsync(ALIPAY_DEEP_LINK_FALLBACK_DELAY_MS)
|
||||
|
||||
expect(onStateChange).toHaveBeenLastCalledWith('backgrounded')
|
||||
expect(onStateChange).not.toHaveBeenCalledWith('fallback')
|
||||
})
|
||||
|
||||
it.each([
|
||||
'Mozilla/5.0 MicroMessenger/8.0',
|
||||
'Mozilla/5.0 MQQBrowser/13.7 Mobile',
|
||||
'Mozilla/5.0 Mobile QQ/9.0',
|
||||
])('uses the fast fallback window in restricted browser %s', async (userAgent) => {
|
||||
const visibility = new FakeVisibilityDocument()
|
||||
const onStateChange = vi.fn()
|
||||
const launcher = createAlipayDeepLinkLauncher({
|
||||
qrCode: 'https://qr.alipay.com/dynamic-order-3',
|
||||
document: visibility,
|
||||
lifecycleTarget: new FakeEventTarget(),
|
||||
userAgent,
|
||||
assignLocation: vi.fn(),
|
||||
onStateChange,
|
||||
})
|
||||
|
||||
launcher.launch()
|
||||
await vi.advanceTimersByTimeAsync(ALIPAY_EMBEDDED_BROWSER_FALLBACK_DELAY_MS)
|
||||
expect(onStateChange).toHaveBeenLastCalledWith('fallback')
|
||||
})
|
||||
|
||||
it('treats pagehide as a successful handoff', async () => {
|
||||
const visibility = new FakeVisibilityDocument()
|
||||
const lifecycle = new FakeEventTarget()
|
||||
const onStateChange = vi.fn()
|
||||
const launcher = createAlipayDeepLinkLauncher({
|
||||
qrCode: 'https://qr.alipay.com/dynamic-order-4',
|
||||
document: visibility,
|
||||
lifecycleTarget: lifecycle,
|
||||
userAgent: 'Mozilla/5.0 Android',
|
||||
assignLocation: vi.fn(),
|
||||
onStateChange,
|
||||
})
|
||||
|
||||
launcher.launch()
|
||||
lifecycle.dispatch('pagehide')
|
||||
await vi.advanceTimersByTimeAsync(ALIPAY_DEEP_LINK_FALLBACK_DELAY_MS)
|
||||
|
||||
expect(onStateChange).toHaveBeenLastCalledWith('backgrounded')
|
||||
expect(onStateChange).not.toHaveBeenCalledWith('fallback')
|
||||
})
|
||||
})
|
||||
@@ -248,6 +248,34 @@ describe('decidePaymentLaunch', () => {
|
||||
expect(decision.paymentState.qrCode).toBe('https://pay.example.com/qr/session')
|
||||
})
|
||||
|
||||
it('launches the Alipay app for a mobile precreate order', () => {
|
||||
const decision = decidePaymentLaunch(createOrderResult({
|
||||
qr_code: 'https://qr.alipay.com/dynamic-order-101',
|
||||
alipay_mobile_precreate_deep_link: true,
|
||||
}), {
|
||||
visibleMethod: 'alipay',
|
||||
orderType: 'balance',
|
||||
isMobile: true,
|
||||
})
|
||||
|
||||
expect(decision.kind).toBe('alipay_deep_link')
|
||||
expect(decision.paymentState.qrCode).toBe('https://qr.alipay.com/dynamic-order-101')
|
||||
expect(decision.paymentState.alipayMobilePrecreateDeepLink).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the desktop Alipay QR flow when a precreate marker is present', () => {
|
||||
const decision = decidePaymentLaunch(createOrderResult({
|
||||
qr_code: 'https://qr.alipay.com/dynamic-order-102',
|
||||
alipay_mobile_precreate_deep_link: true,
|
||||
}), {
|
||||
visibleMethod: 'alipay',
|
||||
orderType: 'balance',
|
||||
isMobile: false,
|
||||
})
|
||||
|
||||
expect(decision.kind).toBe('qr_waiting')
|
||||
})
|
||||
|
||||
it('does not affect non-alipay methods when forceQRCode is enabled', () => {
|
||||
const decision = decidePaymentLaunch(createOrderResult({
|
||||
pay_url: 'https://pay.example.com/mobile/session',
|
||||
@@ -317,6 +345,21 @@ describe('buildCreateOrderPayload', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps is_mobile true when mobile precreate takes priority over forceQRCode', () => {
|
||||
expect(buildCreateOrderPayload({
|
||||
amount: 50,
|
||||
paymentType: 'alipay',
|
||||
orderType: 'balance',
|
||||
origin: 'https://app.example.com',
|
||||
isMobile: true,
|
||||
isWechatBrowser: false,
|
||||
forceQRCode: true,
|
||||
mobilePrecreateDeepLink: true,
|
||||
})).toMatchObject({
|
||||
is_mobile: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('still passes is_mobile: true when forceQRCode is enabled for non-alipay methods', () => {
|
||||
expect(buildCreateOrderPayload({
|
||||
amount: 50,
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
export const ALIPAY_DEEP_LINK_FALLBACK_DELAY_MS = 2200
|
||||
export const ALIPAY_EMBEDDED_BROWSER_FALLBACK_DELAY_MS = 300
|
||||
|
||||
export type AlipayDeepLinkState = 'idle' | 'launching' | 'backgrounded' | 'fallback'
|
||||
|
||||
const ALIPAY_DEEP_LINK_PREFIX = 'alipays://platformapi/startapp?saId=10000007&qrcode='
|
||||
|
||||
export function buildAlipayDeepLink(qrCode: string): string {
|
||||
const dynamicQRCode = qrCode.trim()
|
||||
if (!dynamicQRCode) return ''
|
||||
return `${ALIPAY_DEEP_LINK_PREFIX}${encodeURIComponent(dynamicQRCode)}`
|
||||
}
|
||||
|
||||
export function isAlipaySchemeRestrictedBrowser(userAgent: string): boolean {
|
||||
return /MicroMessenger|MQQBrowser|\bQQ\//i.test(userAgent)
|
||||
}
|
||||
|
||||
interface EventTargetLike {
|
||||
addEventListener(type: string, listener: EventListener): void
|
||||
removeEventListener(type: string, listener: EventListener): void
|
||||
}
|
||||
|
||||
interface VisibilityDocumentLike extends EventTargetLike {
|
||||
readonly hidden: boolean
|
||||
}
|
||||
|
||||
export interface AlipayDeepLinkLauncherOptions {
|
||||
qrCode: string
|
||||
document: VisibilityDocumentLike
|
||||
lifecycleTarget: EventTargetLike
|
||||
userAgent: string
|
||||
assignLocation: (url: string) => void
|
||||
onStateChange: (state: AlipayDeepLinkState) => void
|
||||
setTimer?: typeof setTimeout
|
||||
clearTimer?: typeof clearTimeout
|
||||
}
|
||||
|
||||
export interface AlipayDeepLinkLauncher {
|
||||
launch(): void
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
export function createAlipayDeepLinkLauncher(options: AlipayDeepLinkLauncherOptions): AlipayDeepLinkLauncher {
|
||||
const setTimer = options.setTimer ?? setTimeout
|
||||
const clearTimer = options.clearTimer ?? clearTimeout
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let disposed = false
|
||||
|
||||
const setState = (state: AlipayDeepLinkState) => {
|
||||
if (!disposed) options.onStateChange(state)
|
||||
}
|
||||
const clearFallbackTimer = () => {
|
||||
if (timer) {
|
||||
clearTimer(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
const markBackgrounded = () => {
|
||||
clearFallbackTimer()
|
||||
setState('backgrounded')
|
||||
}
|
||||
const handleVisibilityChange: EventListener = () => {
|
||||
if (options.document.hidden) markBackgrounded()
|
||||
}
|
||||
const handlePageHide: EventListener = () => markBackgrounded()
|
||||
|
||||
options.document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
options.lifecycleTarget.addEventListener('pagehide', handlePageHide)
|
||||
|
||||
return {
|
||||
launch() {
|
||||
if (disposed) return
|
||||
clearFallbackTimer()
|
||||
const deepLink = buildAlipayDeepLink(options.qrCode)
|
||||
if (!deepLink) {
|
||||
setState('fallback')
|
||||
return
|
||||
}
|
||||
|
||||
setState('launching')
|
||||
try {
|
||||
options.assignLocation(deepLink)
|
||||
} catch {
|
||||
setState('fallback')
|
||||
return
|
||||
}
|
||||
|
||||
const delay = isAlipaySchemeRestrictedBrowser(options.userAgent)
|
||||
? ALIPAY_EMBEDDED_BROWSER_FALLBACK_DELAY_MS
|
||||
: ALIPAY_DEEP_LINK_FALLBACK_DELAY_MS
|
||||
timer = setTimer(() => {
|
||||
timer = null
|
||||
if (options.document.hidden) {
|
||||
setState('backgrounded')
|
||||
return
|
||||
}
|
||||
setState('fallback')
|
||||
}, delay)
|
||||
},
|
||||
dispose() {
|
||||
clearFallbackTimer()
|
||||
options.document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
options.lifecycleTarget.removeEventListener('pagehide', handlePageHide)
|
||||
disposed = true
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export type VisiblePaymentMethod = 'alipay' | 'wxpay' | 'stripe' | 'airwallex'
|
||||
export type StripeVisibleMethod = 'alipay' | 'wechat_pay'
|
||||
export type PaymentLaunchKind =
|
||||
| 'qr_waiting'
|
||||
| 'alipay_deep_link'
|
||||
| 'redirect_waiting'
|
||||
| 'stripe_popup'
|
||||
| 'stripe_route'
|
||||
@@ -47,6 +48,7 @@ export interface PaymentRecoverySnapshot {
|
||||
orderType: OrderType | ''
|
||||
paymentMode: string
|
||||
resumeToken: string
|
||||
alipayMobilePrecreateDeepLink?: boolean
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
@@ -57,6 +59,8 @@ export interface PaymentLaunchContext {
|
||||
isWechatBrowser?: boolean
|
||||
/** When true, Alipay payments always use QR code regardless of device type */
|
||||
forceQRCode?: boolean
|
||||
/** When true, the new mobile Alipay precreate flow takes priority over forceQRCode */
|
||||
mobilePrecreateDeepLink?: boolean
|
||||
now?: number
|
||||
stripePopupUrl?: string
|
||||
stripeRouteUrl?: string
|
||||
@@ -82,6 +86,8 @@ export interface BuildCreateOrderPayloadInput {
|
||||
isWechatBrowser: boolean
|
||||
/** When true, Alipay payments always use QR code (passes is_mobile: false to backend) */
|
||||
forceQRCode?: boolean
|
||||
/** When true, keep the real mobile signal so the backend can select precreate */
|
||||
mobilePrecreateDeepLink?: boolean
|
||||
}
|
||||
|
||||
type CreateOrderFlowResult = CreateOrderResult & {
|
||||
@@ -117,7 +123,7 @@ export function buildCreateOrderPayload(input: BuildCreateOrderPayloadInput): Cr
|
||||
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')
|
||||
const effectiveMobile = (input.forceQRCode && !input.mobilePrecreateDeepLink && visibleMethod === 'alipay')
|
||||
? false
|
||||
: input.isMobile
|
||||
const payload: CreateOrderRequest = {
|
||||
@@ -162,6 +168,7 @@ export function decidePaymentLaunch(
|
||||
orderType: context.orderType,
|
||||
paymentMode: (result.payment_mode || '').trim(),
|
||||
resumeToken: result.resume_token || '',
|
||||
alipayMobilePrecreateDeepLink: result.alipay_mobile_precreate_deep_link === true,
|
||||
}, context.now)
|
||||
|
||||
if (visibleMethod === 'airwallex' && baseState.clientSecret && baseState.intentId) {
|
||||
@@ -198,10 +205,19 @@ export function decidePaymentLaunch(
|
||||
return { kind: 'wechat_jsapi', paymentState: baseState, recovery: baseState, jsapi: jsapiPayload }
|
||||
}
|
||||
|
||||
if (
|
||||
visibleMethod === 'alipay'
|
||||
&& context.isMobile
|
||||
&& baseState.alipayMobilePrecreateDeepLink
|
||||
&& baseState.qrCode
|
||||
) {
|
||||
return { kind: 'alipay_deep_link', paymentState: baseState, recovery: baseState }
|
||||
}
|
||||
|
||||
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')
|
||||
const effectiveMobile = (context.forceQRCode && !context.mobilePrecreateDeepLink && visibleMethod === 'alipay')
|
||||
? false
|
||||
: context.isMobile
|
||||
const prefersRedirect = normalizedPaymentMode === 'redirect'
|
||||
@@ -279,6 +295,7 @@ export function readPaymentRecoverySnapshot(
|
||||
|| typeof parsed.payAmount !== 'number'
|
||||
|| typeof parsed.paymentMode !== 'string'
|
||||
|| typeof parsed.resumeToken !== 'string'
|
||||
|| (parsed.alipayMobilePrecreateDeepLink != null && typeof parsed.alipayMobilePrecreateDeepLink !== 'boolean')
|
||||
|| typeof parsed.createdAt !== 'number'
|
||||
) {
|
||||
return null
|
||||
@@ -310,6 +327,7 @@ export function readPaymentRecoverySnapshot(
|
||||
orderType: parsed.orderType === 'subscription' ? 'subscription' : 'balance',
|
||||
paymentMode: parsed.paymentMode,
|
||||
resumeToken: parsed.resumeToken,
|
||||
alipayMobilePrecreateDeepLink: parsed.alipayMobilePrecreateDeepLink === true,
|
||||
createdAt: parsed.createdAt,
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -585,6 +585,8 @@ export default {
|
||||
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',
|
||||
alipayMobilePrecreateDeepLink: 'Mobile Alipay Precreate Handoff',
|
||||
alipayMobilePrecreateDeepLinkHint: 'Use official Alipay precreate on mobile, open the Alipay app, and show the dynamic QR only if handoff fails. This takes priority over Force Alipay QR Code',
|
||||
helpText: 'Help Text',
|
||||
helpImageUrl: 'Help Image URL',
|
||||
manageProviders: 'Manage Providers',
|
||||
|
||||
@@ -338,6 +338,14 @@ export default {
|
||||
cancelledDesc: 'You have cancelled this payment.',
|
||||
waitingPayment: 'Waiting for payment...',
|
||||
cancelOrder: 'Cancel Order',
|
||||
alipayOpening: 'Opening Alipay',
|
||||
alipayContinueInApp: 'Complete payment in Alipay',
|
||||
alipayWaitingHint: 'The server will confirm the payment and update this page automatically',
|
||||
alipayFallbackTitle: 'Alipay did not open',
|
||||
alipayFallbackHint: 'Try opening Alipay again, or save the QR code and scan it from your Alipay photo album',
|
||||
reopenAlipay: 'Open Alipay Again',
|
||||
saveQRCode: 'Save QR Code',
|
||||
alipaySaveAndScanHint: 'Save the QR code, open Alipay Scan, then select it from your photo album',
|
||||
},
|
||||
orders: {
|
||||
title: 'My Orders',
|
||||
|
||||
@@ -580,6 +580,8 @@ export default {
|
||||
cancelRateLimitWindowModeFixed: '固定',
|
||||
alipayForceQRCode: '支付宝强制二维码支付',
|
||||
alipayForceQRCodeHint: '启用后,移动端支付宝用户将统一使用二维码扫码支付,不再跳转至手机网站支付',
|
||||
alipayMobilePrecreateDeepLink: '支付宝移动端当面付唤起',
|
||||
alipayMobilePrecreateDeepLinkHint: '启用后,移动端官方支付宝订单调用当面付并尝试打开支付宝;失败时显示动态二维码。该设置优先于强制二维码支付',
|
||||
helpText: '帮助文本',
|
||||
helpImageUrl: '帮助图片链接',
|
||||
manageProviders: '管理服务商',
|
||||
@@ -677,7 +679,7 @@ export default {
|
||||
guideOpenLabel: '开通:',
|
||||
guideCallLabel: '调用:',
|
||||
guideFallbackLabel: '降级:',
|
||||
alipayGuideSummary: '桌面优先扫码单,失败再走收银台;移动优先手机网站支付。',
|
||||
alipayGuideSummary: '桌面优先扫码单,失败再走收银台;移动默认手机网站支付,也可启用当面付唤起。',
|
||||
alipayGuideFaceToFaceTitle: '当面付 / 扫码支付',
|
||||
alipayGuideFaceToFaceOpen: '需开通当面付或扫码支付能力。',
|
||||
alipayGuideFaceToFaceCall: '桌面端下单时优先调用 alipay.trade.precreate,前台直接渲染二维码。',
|
||||
@@ -688,7 +690,7 @@ export default {
|
||||
alipayGuidePagePayFallback: '同时保留打开收银台入口,用户可手动重新拉起支付页。',
|
||||
alipayGuideWapTitle: '手机网站支付',
|
||||
alipayGuideWapOpen: '需开通手机网站支付。',
|
||||
alipayGuideWapCall: '移动端优先调用 alipay.trade.wap.pay,跳转支付宝收银台。',
|
||||
alipayGuideWapCall: '默认调用 alipay.trade.wap.pay;开启移动端当面付唤起后改用 alipay.trade.precreate。',
|
||||
alipayGuideWapFallback: '未开通或返回异常时,前端自动改走扫码支付并提示未开通移动支付。',
|
||||
wxpayGuideSummary: '桌面优先 Native 扫码,移动端按浏览器环境走 JSAPI 或 H5。',
|
||||
wxpayGuideNote: '当前表单默认共用一个 App ID,适合同主体下统一配置网页、移动和公众号场景。',
|
||||
|
||||
@@ -362,6 +362,14 @@ export default {
|
||||
cancelledDesc: '您已取消本次支付',
|
||||
waitingPayment: '等待支付...',
|
||||
cancelOrder: '取消订单',
|
||||
alipayOpening: '正在打开支付宝',
|
||||
alipayContinueInApp: '请在支付宝中完成支付',
|
||||
alipayWaitingHint: '支付结果将由服务端确认,本页面会自动更新',
|
||||
alipayFallbackTitle: '打开支付宝未成功',
|
||||
alipayFallbackHint: '可重新打开支付宝,或保存下方二维码后从支付宝相册识别',
|
||||
reopenAlipay: '重新打开支付宝',
|
||||
saveQRCode: '保存二维码',
|
||||
alipaySaveAndScanHint: '保存二维码后,打开支付宝扫一扫,从相册选择二维码',
|
||||
},
|
||||
orders: {
|
||||
title: '我的订单',
|
||||
|
||||
@@ -76,6 +76,8 @@ export interface CheckoutInfoResponse {
|
||||
stripe_publishable_key: string
|
||||
/** When true, Alipay payments on mobile always show the QR code instead of redirecting */
|
||||
alipay_force_qrcode?: boolean
|
||||
/** When true, official Alipay mobile orders use precreate plus an Alipay app deep link */
|
||||
alipay_mobile_precreate_deep_link?: boolean
|
||||
}
|
||||
|
||||
// ==================== Orders ====================
|
||||
@@ -214,6 +216,7 @@ export interface CreateOrderResult {
|
||||
out_trade_no?: string
|
||||
payment_mode?: string
|
||||
resume_token?: string
|
||||
alipay_mobile_precreate_deep_link?: boolean
|
||||
oauth?: WechatOAuthInfo
|
||||
jsapi?: WechatJSAPIPayload
|
||||
jsapi_payload?: WechatJSAPIPayload
|
||||
|
||||
@@ -7035,6 +7035,38 @@
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{
|
||||
t("admin.settings.payment.alipayMobilePrecreateDeepLink")
|
||||
}}</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
form.payment_alipay_mobile_precreate_deep_link
|
||||
? 'bg-primary-500'
|
||||
: 'bg-gray-300 dark:bg-dark-600',
|
||||
]"
|
||||
@click="
|
||||
form.payment_alipay_mobile_precreate_deep_link =
|
||||
!form.payment_alipay_mobile_precreate_deep_link
|
||||
"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
form.payment_alipay_mobile_precreate_deep_link
|
||||
? 'translate-x-5'
|
||||
: 'translate-x-0',
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">{{
|
||||
t("admin.settings.payment.alipayMobilePrecreateDeepLinkHint")
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Row 4: Enabled payment types (provider badges like sub2apipay) -->
|
||||
<div>
|
||||
@@ -8425,6 +8457,7 @@ const form = reactive<SettingsForm>({
|
||||
payment_cancel_rate_limit_unit: "day",
|
||||
payment_cancel_rate_limit_window_mode: "rolling",
|
||||
payment_alipay_force_qrcode: false,
|
||||
payment_alipay_mobile_precreate_deep_link: false,
|
||||
table_default_page_size: tablePageSizeDefault,
|
||||
table_page_size_options: [10, 20, 50, 100],
|
||||
custom_menu_items: [] as Array<{
|
||||
@@ -10096,6 +10129,8 @@ async function saveSettings() {
|
||||
payment_cancel_rate_limit_window_mode:
|
||||
form.payment_cancel_rate_limit_window_mode,
|
||||
payment_alipay_force_qrcode: form.payment_alipay_force_qrcode,
|
||||
payment_alipay_mobile_precreate_deep_link:
|
||||
form.payment_alipay_mobile_precreate_deep_link,
|
||||
openai_low_upstream_rate_priority_enabled:
|
||||
form.openai_low_upstream_rate_priority_enabled,
|
||||
openai_oauth_scheduling_rate_multiplier:
|
||||
|
||||
@@ -16,12 +16,16 @@
|
||||
<template v-if="paymentPhase === 'paying'">
|
||||
<PaymentStatusPanel
|
||||
:order-id="paymentState.orderId"
|
||||
:amount="paymentState.amount"
|
||||
:pay-amount="paymentState.payAmount"
|
||||
:qr-code="paymentState.qrCode"
|
||||
:expires-at="paymentState.expiresAt"
|
||||
:payment-type="paymentState.paymentType"
|
||||
:pay-url="paymentState.payUrl"
|
||||
:order-type="paymentState.orderType"
|
||||
:currency="paymentState.currency || selectedCurrency"
|
||||
:out-trade-no="paymentState.outTradeNo"
|
||||
:mobile-alipay-deep-link="paymentState.alipayMobilePrecreateDeepLink"
|
||||
@done="onPaymentDone"
|
||||
@success="onPaymentSuccess"
|
||||
@settled="onPaymentSettled"
|
||||
@@ -360,6 +364,7 @@ function emptyPaymentState(): PaymentRecoverySnapshot {
|
||||
orderType: '',
|
||||
paymentMode: '',
|
||||
resumeToken: '',
|
||||
alipayMobilePrecreateDeepLink: false,
|
||||
createdAt: 0,
|
||||
}
|
||||
}
|
||||
@@ -480,12 +485,14 @@ function onPaymentDone() {
|
||||
}
|
||||
}
|
||||
|
||||
function onPaymentSuccess() {
|
||||
async function onPaymentSuccess() {
|
||||
const completedPayment = { ...paymentState.value }
|
||||
removeRecoverySnapshot()
|
||||
authStore.refreshUser()
|
||||
if (paymentState.value.orderType === 'subscription') {
|
||||
subscriptionStore.fetchActiveSubscriptions(true).catch(() => {})
|
||||
}
|
||||
await redirectToPaymentResult(completedPayment)
|
||||
}
|
||||
|
||||
function onPaymentSettled() {
|
||||
@@ -772,6 +779,7 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
|
||||
isMobile: isMobileDevice(),
|
||||
isWechatBrowser: typeof window !== 'undefined' && /MicroMessenger/i.test(window.navigator.userAgent),
|
||||
forceQRCode: !!(checkout.value.alipay_force_qrcode && normalizeVisibleMethod(requestType) === 'alipay'),
|
||||
mobilePrecreateDeepLink: checkout.value.alipay_mobile_precreate_deep_link === true,
|
||||
})
|
||||
if (options.openid) {
|
||||
payload.openid = options.openid
|
||||
@@ -820,6 +828,7 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
|
||||
isMobile: isMobileDevice(),
|
||||
isWechatBrowser: typeof window !== 'undefined' && /MicroMessenger/i.test(window.navigator.userAgent),
|
||||
forceQRCode: !!(checkout.value.alipay_force_qrcode && visibleMethod === 'alipay'),
|
||||
mobilePrecreateDeepLink: checkout.value.alipay_mobile_precreate_deep_link === true,
|
||||
stripePopupUrl: stripeRouteUrl,
|
||||
stripeRouteUrl,
|
||||
airwallexRouteUrl,
|
||||
|
||||
Reference in New Issue
Block a user