From 1b03240515a465f878f421cd04f00550ed70e0ac Mon Sep 17 00:00:00 2001 From: Yuhao Jiang Date: Sun, 17 May 2026 14:58:42 -0500 Subject: [PATCH] =?UTF-8?q?fix(payment):=20=E4=BF=AE=E5=A4=8D=E6=94=AF?= =?UTF-8?q?=E4=BB=98=E5=AE=9D=E5=AE=98=E6=96=B9=E6=89=AB=E7=A0=81=E4=BA=8C?= =?UTF-8?q?=E7=BB=B4=E7=A0=81=E7=94=9F=E6=88=90=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 支付宝官方服务商在 precreate(当面付)不可用回退到 page.pay 时, 错误地把网页跳转 URL 当作可扫码二维码内容返回。前端用 QRCode 库 把这段 URL 渲染成二维码后,支付宝 APP 无法识别(扫到的只是个 HTTP URL,不是支付二维码),用户必须点"重新打开支付页面"跳转到支付宝 收银台才能扫到真正可用的二维码。 - 后端 alipay.go:createPagePayTrade 不再把 PayURL 塞给 QRCode; createDesktopTrade 在 paymentMode == "redirect" 时跳过 precreate 直接走 page.pay,避免没开通"当面付"的商户走一次无用的 API 调用 - 前端管理端 PaymentProviderDialog:让支付宝官方实例可在"支付模式" 中选择"跳转",开启后始终在新标签页打开支付宝收银台 - ProviderCard 的 modeLabel 增加 redirect 分支 - 补充 TestCreateTradeRedirectModeSkipsPrecreate 单元测试 Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/internal/payment/provider/alipay.go | 25 ++++++-- .../internal/payment/provider/alipay_test.go | 59 ++++++++++++++++++- .../payment/PaymentProviderDialog.vue | 50 ++++++++++++++-- .../src/components/payment/ProviderCard.vue | 3 +- .../src/components/payment/providerConfig.ts | 5 ++ 5 files changed, 130 insertions(+), 12 deletions(-) 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 @@ -
+
{{ t('admin.settings.payment.paymentMode') }}