feat(payment): add mobile Alipay precreate deep link

This commit is contained in:
wjx2951874
2026-07-22 16:57:49 +08:00
parent 60013c5f10
commit 7914433011
33 changed files with 1085 additions and 117 deletions
@@ -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)
+2
View File
@@ -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"`
+34 -24
View File
@@ -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 {
+6 -1
View File
@@ -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
+13 -10
View File
@@ -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)
+10
View File
@@ -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()
+22 -21
View File
@@ -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;
+4
View File
@@ -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)
# -----------------------------------------------------------------------------
+1
View File
@@ -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
View File
@@ -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(银行卡/支付宝/微信等)
+2
View File
@@ -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
},
}
}
+20 -2
View File
@@ -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',
+8
View File
@@ -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,适合同主体下统一配置网页、移动和公众号场景。',
+8
View File
@@ -362,6 +362,14 @@ export default {
cancelledDesc: '您已取消本次支付',
waitingPayment: '等待支付...',
cancelOrder: '取消订单',
alipayOpening: '正在打开支付宝',
alipayContinueInApp: '请在支付宝中完成支付',
alipayWaitingHint: '支付结果将由服务端确认,本页面会自动更新',
alipayFallbackTitle: '打开支付宝未成功',
alipayFallbackHint: '可重新打开支付宝,或保存下方二维码后从支付宝相册识别',
reopenAlipay: '重新打开支付宝',
saveQRCode: '保存二维码',
alipaySaveAndScanHint: '保存二维码后,打开支付宝扫一扫,从相册选择二维码',
},
orders: {
title: '我的订单',
+3
View File
@@ -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
+35
View File
@@ -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:
+10 -1
View File
@@ -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,