diff --git a/backend/internal/payment/provider/alipay.go b/backend/internal/payment/provider/alipay.go
index 1234b568..c4c6e634 100644
--- a/backend/internal/payment/provider/alipay.go
+++ b/backend/internal/payment/provider/alipay.go
@@ -105,10 +105,16 @@ 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.
-// - Desktop: prefer alipay.trade.precreate to get a scan payload directly.
-// - Desktop fallback: if precreate is unavailable for the merchant, fall back
-// to alipay.trade.page.pay and expose both pay_url and qr_code so the
-// frontend can render a QR while still allowing direct page open.
+// - 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
+// opens the Alipay checkout in a new tab.
+// - Desktop, paymentMode == "redirect": skip precreate and go straight to
+// alipay.trade.page.pay so the frontend always opens the Alipay checkout
+// in a new tab. Use this when the merchant has not enabled FACE_TO_FACE_PAYMENT.
+//
+// Note: alipay.trade.page.pay returns a checkout page URL, not a scannable
+// payment QR. Never expose it via the QRCode field.
func (a *Alipay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
client, err := a.getClient()
if err != nil {
@@ -150,6 +156,13 @@ func (a *Alipay) createWapTrade(client *alipay.Client, req payment.CreatePayment
}
func (a *Alipay) createDesktopTrade(ctx context.Context, client *alipay.Client, req payment.CreatePaymentRequest, notifyURL, returnURL string) (*payment.CreatePaymentResponse, error) {
+ // Explicit redirect mode: merchant opted into "always open the Alipay
+ // checkout page in a new tab" via the provider instance's payment_mode.
+ // Skip precreate to avoid a wasted API call.
+ if strings.EqualFold(strings.TrimSpace(a.config["paymentMode"]), "redirect") {
+ return a.createPagePayTrade(client, req, notifyURL, returnURL)
+ }
+
resp, precreateErr := a.createPrecreateTrade(ctx, client, req, notifyURL)
if precreateErr == nil {
return resp, nil
@@ -204,10 +217,12 @@ func (a *Alipay) createPagePayTrade(client *alipay.Client, req payment.CreatePay
if err != nil {
return nil, fmt.Errorf("alipay TradePagePay: %w", err)
}
+ // Only PayURL is exposed: alipay.trade.page.pay returns a checkout page URL
+ // that must be opened in a browser, not a scannable payment QR. Setting it
+ // as QRCode would let the frontend render an unscannable image.
return &payment.CreatePaymentResponse{
TradeNo: req.OrderID,
PayURL: payURL.String(),
- QRCode: payURL.String(),
}, nil
}
diff --git a/backend/internal/payment/provider/alipay_test.go b/backend/internal/payment/provider/alipay_test.go
index fdc8eec1..9f8aec53 100644
--- a/backend/internal/payment/provider/alipay_test.go
+++ b/backend/internal/payment/provider/alipay_test.go
@@ -189,8 +189,63 @@ func TestCreateTradeUsesPagePayForDesktop(t *testing.T) {
if resp.PayURL == "" {
t.Fatal("expected pay_url for desktop page pay")
}
- if resp.QRCode != resp.PayURL {
- t.Fatalf("qr_code = %q, want same as pay_url %q", resp.QRCode, resp.PayURL)
+ // page.pay returns a checkout page URL, not a scannable QR payload —
+ // it must never be exposed via QRCode (the frontend would render an
+ // unscannable image from it).
+ if resp.QRCode != "" {
+ t.Fatalf("qr_code = %q, want empty for page pay", resp.QRCode)
+ }
+}
+
+// When the provider instance is configured with paymentMode == "redirect",
+// the desktop flow must skip precreate and go straight to page.pay.
+func TestCreateTradeRedirectModeSkipsPrecreate(t *testing.T) {
+ origPreCreate := alipayTradePreCreate
+ origPagePay := alipayTradePagePay
+ t.Cleanup(func() {
+ alipayTradePreCreate = origPreCreate
+ alipayTradePagePay = origPagePay
+ })
+
+ preCreateCalls := 0
+ pagePayCalls := 0
+ alipayTradePreCreate = func(ctx context.Context, client *alipay.Client, param alipay.TradePreCreate) (*alipay.TradePreCreateRsp, error) {
+ preCreateCalls++
+ return &alipay.TradePreCreateRsp{
+ Error: alipay.Error{Code: alipay.CodeSuccess},
+ QRCode: "https://qr.alipay.example.com/precreate-token",
+ }, nil
+ }
+ alipayTradePagePay = func(client *alipay.Client, param alipay.TradePagePay) (*url.URL, error) {
+ pagePayCalls++
+ if param.ProductCode != alipayProductCodePagePay {
+ t.Fatalf("product_code = %q, want %q", param.ProductCode, alipayProductCodePagePay)
+ }
+ return url.Parse("https://openapi.alipay.com/gateway.do?page-pay")
+ }
+
+ provider := &Alipay{
+ config: map[string]string{"paymentMode": "redirect"},
+ }
+ resp, err := provider.createDesktopTrade(context.Background(), &alipay.Client{}, payment.CreatePaymentRequest{
+ OrderID: "sub2_103",
+ Amount: "12.00",
+ Subject: "Balance recharge",
+ }, "https://merchant.example.com/api/v1/payment/webhook/alipay", "https://merchant.example.com/payment/result")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if preCreateCalls != 0 {
+ t.Fatalf("precreate calls = %d, want 0 (redirect mode must skip precreate)", preCreateCalls)
+ }
+ if pagePayCalls != 1 {
+ t.Fatalf("page pay calls = %d, want 1", pagePayCalls)
+ }
+ if resp.PayURL == "" {
+ t.Fatal("expected pay_url for redirect mode")
+ }
+ if resp.QRCode != "" {
+ t.Fatalf("qr_code = %q, want empty for redirect mode", resp.QRCode)
}
}
diff --git a/frontend/src/components/payment/PaymentProviderDialog.vue b/frontend/src/components/payment/PaymentProviderDialog.vue
index 86304cf6..b6085ed0 100644
--- a/frontend/src/components/payment/PaymentProviderDialog.vue
+++ b/frontend/src/components/payment/PaymentProviderDialog.vue
@@ -34,7 +34,7 @@