Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/internal/handler/admin/setting_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
50 changes: 27 additions & 23 deletions backend/internal/handler/admin/setting_handler_update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions backend/internal/handler/dto/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
58 changes: 34 additions & 24 deletions backend/internal/handler/payment_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand Down
7 changes: 6 additions & 1 deletion backend/internal/payment/provider/alipay.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
84 changes: 84 additions & 0 deletions backend/internal/payment/provider/alipay_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 13 additions & 10 deletions backend/internal/payment/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions backend/internal/server/api_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading