diff --git a/backend/internal/payment/provider/alipay.go b/backend/internal/payment/provider/alipay.go index 4a260295..1234b568 100644 --- a/backend/internal/payment/provider/alipay.go +++ b/backend/internal/payment/provider/alipay.go @@ -15,8 +15,9 @@ import ( // Alipay product codes. const ( - alipayProductCodeWapPay = "QUICK_WAP_WAY" - alipayProductCodePagePay = "FAST_INSTANT_TRADE_PAY" + alipayProductCodePreCreate = "FACE_TO_FACE_PAYMENT" + alipayProductCodeWapPay = "QUICK_WAP_WAY" + alipayProductCodePagePay = "FAST_INSTANT_TRADE_PAY" ) // Alipay response constants. @@ -30,6 +31,9 @@ var ( alipayTradeWapPay = func(client *alipay.Client, param alipay.TradeWapPay) (*url.URL, error) { return client.TradeWapPay(param) } + alipayTradePreCreate = func(ctx context.Context, client *alipay.Client, param alipay.TradePreCreate) (*alipay.TradePreCreateRsp, error) { + return client.TradePreCreate(ctx, param) + } alipayTradePagePay = func(client *alipay.Client, param alipay.TradePagePay) (*url.URL, error) { return client.TradePagePay(param) } @@ -99,13 +103,13 @@ func (a *Alipay) MerchantIdentityMetadata() map[string]string { return map[string]string{"app_id": appID} } -// CreatePayment creates an Alipay payment using redirect-only flow: -// - Mobile (H5): alipay.trade.wap.pay — returns a URL the browser jumps to. -// - PC: alipay.trade.page.pay — returns a gateway URL the browser opens in a -// new window; Alipay's own page then shows login/QR. We intentionally do -// NOT encode the URL into a QR on the client (it isn't a scannable payload -// and would produce an invalid scan result). -func (a *Alipay) CreatePayment(_ context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) { +// 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. +func (a *Alipay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) { client, err := a.getClient() if err != nil { return nil, err @@ -123,7 +127,7 @@ func (a *Alipay) CreatePayment(_ context.Context, req payment.CreatePaymentReque if req.IsMobile { return a.createWapTrade(client, req, notifyURL, returnURL) } - return a.createPagePayTrade(client, req, notifyURL, returnURL) + return a.createDesktopTrade(ctx, client, req, notifyURL, returnURL) } func (a *Alipay) createWapTrade(client *alipay.Client, req payment.CreatePaymentRequest, notifyURL, returnURL string) (*payment.CreatePaymentResponse, error) { @@ -145,6 +149,48 @@ func (a *Alipay) createWapTrade(client *alipay.Client, req payment.CreatePayment }, nil } +func (a *Alipay) createDesktopTrade(ctx context.Context, client *alipay.Client, req payment.CreatePaymentRequest, notifyURL, returnURL string) (*payment.CreatePaymentResponse, error) { + resp, precreateErr := a.createPrecreateTrade(ctx, client, req, notifyURL) + if precreateErr == nil { + return resp, nil + } + + resp, pagePayErr := a.createPagePayTrade(client, req, notifyURL, returnURL) + if pagePayErr == nil { + return resp, nil + } + + return nil, fmt.Errorf("alipay desktop payment failed: precreate=%v; pagepay=%w", precreateErr, pagePayErr) +} + +func (a *Alipay) createPrecreateTrade(ctx context.Context, client *alipay.Client, req payment.CreatePaymentRequest, notifyURL string) (*payment.CreatePaymentResponse, error) { + param := alipay.TradePreCreate{} + param.OutTradeNo = req.OrderID + param.TotalAmount = req.Amount + param.Subject = req.Subject + param.ProductCode = alipayProductCodePreCreate + param.NotifyURL = notifyURL + + rsp, err := alipayTradePreCreate(ctx, client, param) + if err != nil { + return nil, fmt.Errorf("alipay TradePreCreate: %w", err) + } + if rsp == nil { + return nil, fmt.Errorf("alipay TradePreCreate: empty response") + } + if rsp.IsFailure() { + return nil, fmt.Errorf("alipay TradePreCreate failed: %s", rsp.Error.Error()) + } + if strings.TrimSpace(rsp.QRCode) == "" { + return nil, fmt.Errorf("alipay TradePreCreate: empty qr_code") + } + + return &payment.CreatePaymentResponse{ + TradeNo: req.OrderID, + QRCode: rsp.QRCode, + }, nil +} + func (a *Alipay) createPagePayTrade(client *alipay.Client, req payment.CreatePaymentRequest, notifyURL, returnURL string) (*payment.CreatePaymentResponse, error) { param := alipay.TradePagePay{} param.OutTradeNo = req.OrderID @@ -161,6 +207,7 @@ func (a *Alipay) createPagePayTrade(client *alipay.Client, req payment.CreatePay return &payment.CreatePaymentResponse{ TradeNo: req.OrderID, PayURL: payURL.String(), + QRCode: payURL.String(), }, nil } @@ -192,7 +239,15 @@ func (a *Alipay) QueryOrder(ctx context.Context, tradeNo string) (*payment.Query amount, err := strconv.ParseFloat(result.TotalAmount, 64) if err != nil { - return nil, fmt.Errorf("alipay parse amount %q: %w", result.TotalAmount, err) + amount, err = parseAlipayAmount( + result.TotalAmount, + result.ReceiptAmount, + result.BuyerPayAmount, + result.InvoiceAmount, + ) + if err != nil { + return nil, fmt.Errorf("alipay parse amount: %w", err) + } } return &payment.QueryOrderResponse{ @@ -228,7 +283,14 @@ func (a *Alipay) VerifyNotification(ctx context.Context, rawBody string, _ map[s amount, err := strconv.ParseFloat(notification.TotalAmount, 64) if err != nil { - return nil, fmt.Errorf("alipay parse notification amount %q: %w", notification.TotalAmount, err) + amount, err = parseAlipayAmount( + notification.TotalAmount, + notification.ReceiptAmount, + notification.BuyerPayAmount, + ) + if err != nil { + return nil, fmt.Errorf("alipay parse notification amount: %w", err) + } } metadata := a.MerchantIdentityMetadata() @@ -306,6 +368,20 @@ func isTradeNotExist(err error) bool { return strings.Contains(err.Error(), alipayErrTradeNotExist) } +func parseAlipayAmount(values ...string) (float64, error) { + for _, raw := range values { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + amount, err := strconv.ParseFloat(raw, 64) + if err == nil { + return amount, nil + } + } + return 0, fmt.Errorf("no valid amount field") +} + // Ensure interface compliance. var ( _ payment.Provider = (*Alipay)(nil) diff --git a/backend/internal/payment/provider/alipay_test.go b/backend/internal/payment/provider/alipay_test.go index 8b3ff8ce..fdc8eec1 100644 --- a/backend/internal/payment/provider/alipay_test.go +++ b/backend/internal/payment/provider/alipay_test.go @@ -3,6 +3,7 @@ package provider import ( + "context" "errors" "net/url" "strings" @@ -136,15 +137,22 @@ func TestNewAlipay(t *testing.T) { } func TestCreateTradeUsesPagePayForDesktop(t *testing.T) { + origPreCreate := alipayTradePreCreate origPagePay := alipayTradePagePay origWapPay := alipayTradeWapPay t.Cleanup(func() { + alipayTradePreCreate = origPreCreate alipayTradePagePay = origPagePay alipayTradeWapPay = origWapPay }) + preCreateCalls := 0 pagePayCalls := 0 wapPayCalls := 0 + alipayTradePreCreate = func(ctx context.Context, client *alipay.Client, param alipay.TradePreCreate) (*alipay.TradePreCreateRsp, error) { + preCreateCalls++ + return nil, errors.New("merchant does not have FACE_TO_FACE_PAYMENT") + } alipayTradePagePay = func(client *alipay.Client, param alipay.TradePagePay) (*url.URL, error) { pagePayCalls++ if param.OutTradeNo != "sub2_100" { @@ -161,7 +169,7 @@ func TestCreateTradeUsesPagePayForDesktop(t *testing.T) { } provider := &Alipay{} - resp, err := provider.createPagePayTrade(&alipay.Client{}, payment.CreatePaymentRequest{ + resp, err := provider.createDesktopTrade(context.Background(), &alipay.Client{}, payment.CreatePaymentRequest{ OrderID: "sub2_100", Amount: "88.00", Subject: "Balance recharge", @@ -169,6 +177,9 @@ func TestCreateTradeUsesPagePayForDesktop(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } + if preCreateCalls != 1 { + t.Fatalf("precreate calls = %d, want 1", preCreateCalls) + } if pagePayCalls != 1 { t.Fatalf("page pay calls = %d, want 1", pagePayCalls) } @@ -178,6 +189,9 @@ 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) + } } func TestCreateTradeUsesWapPayForMobile(t *testing.T) { @@ -213,6 +227,54 @@ func TestCreateTradeUsesWapPayForMobile(t *testing.T) { } } +func TestCreateTradeUsesPrecreateForDesktopWhenAvailable(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++ + 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/precreate-token", + }, nil + } + alipayTradePagePay = func(client *alipay.Client, param alipay.TradePagePay) (*url.URL, error) { + pagePayCalls++ + return url.Parse("https://openapi.alipay.com/gateway.do?page-pay") + } + + provider := &Alipay{} + resp, err := provider.createDesktopTrade(context.Background(), &alipay.Client{}, payment.CreatePaymentRequest{ + OrderID: "sub2_102", + Amount: "66.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 != 1 { + t.Fatalf("precreate calls = %d, want 1", preCreateCalls) + } + if pagePayCalls != 0 { + t.Fatalf("page pay calls = %d, want 0", pagePayCalls) + } + if resp.QRCode != "https://qr.alipay.example.com/precreate-token" { + t.Fatalf("qr_code = %q", resp.QRCode) + } + if resp.PayURL != "" { + t.Fatalf("pay_url = %q, want empty for precreate", resp.PayURL) + } +} + func TestAlipayMerchantIdentityMetadata(t *testing.T) { t.Parallel() @@ -227,3 +289,19 @@ func TestAlipayMerchantIdentityMetadata(t *testing.T) { t.Fatalf("app_id = %q, want %q", metadata["app_id"], "2021001234567890") } } + +func TestParseAlipayAmount(t *testing.T) { + t.Parallel() + + amount, err := parseAlipayAmount("", "88.00", "77.00") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if amount != 88 { + t.Fatalf("amount = %v, want 88", amount) + } + + if _, err := parseAlipayAmount("", "not-a-number"); err == nil { + t.Fatal("expected error when no valid amount field exists") + } +} diff --git a/docs/PAYMENT.md b/docs/PAYMENT.md index 9322f7bf..af93fa7e 100644 --- a/docs/PAYMENT.md +++ b/docs/PAYMENT.md @@ -122,7 +122,7 @@ Compatible with any payment service that implements the EasyPay protocol. ### Alipay (Direct) -Direct integration with Alipay Open Platform. Desktop flows return a QR code for in-page display, while mobile flows return an Alipay WAP/app redirect URL. +Direct integration with Alipay Open Platform. Mobile flows return an Alipay WAP/app redirect URL. Desktop flows prefer Face-to-Face Precreate QR payloads; if the merchant has not enabled that product, the provider falls back to Computer Website Pay and also returns the cashier URL so the frontend can render a QR code or open the hosted checkout page directly. | Parameter | Description | Required | |-----------|-------------|----------| @@ -229,7 +229,7 @@ User selects amount and payment method ▼ User completes payment ├─ EasyPay → QR code / H5 redirect - ├─ Alipay → Desktop QR / mobile Alipay redirect + ├─ Alipay → Desktop QR payload (Face-to-Face preferred, Website Pay fallback) / mobile Alipay redirect ├─ WeChat Pay → Desktop Native QR / non-WeChat H5 / in-WeChat JSAPI └─ Stripe → Payment Element (card/Alipay/WeChat/etc.) │ diff --git a/docs/PAYMENT_CN.md b/docs/PAYMENT_CN.md index 0fbc198a..ae765fb9 100644 --- a/docs/PAYMENT_CN.md +++ b/docs/PAYMENT_CN.md @@ -122,7 +122,7 @@ Sub2API 内置支付系统,支持用户自助充值,无需部署独立的支 ### 支付宝官方 -直接对接支付宝开放平台。桌面端返回二维码供页面内展示和扫码,移动端返回支付宝手机网站支付跳转链接。 +直接对接支付宝开放平台。移动端走支付宝手机网站支付跳转;桌面端优先使用当面付返回扫码串,若商户未开通当面付则回退到电脑网站支付,并将收银台链接同时返回给前端用于渲染二维码或直接打开支付页。 | 参数 | 说明 | 必填 | |------|------|------| @@ -229,7 +229,7 @@ Sub2API 内置支付系统,支持用户自助充值,无需部署独立的支 ▼ 用户完成支付 ├─ EasyPay → 扫码 / H5 跳转 - ├─ 支付宝官方 → 桌面二维码 / 移动端支付宝跳转 + ├─ 支付宝官方 → 桌面扫码单(当面付优先,电脑网站支付回退)/ 移动端支付宝跳转 ├─ 微信官方 → 桌面 Native 扫码 / 非微信 H5 / 微信内 JSAPI └─ Stripe → Payment Element(银行卡/支付宝/微信等) │ diff --git a/frontend/src/components/common/HelpTooltip.vue b/frontend/src/components/common/HelpTooltip.vue index e95052da..d2a2e48f 100644 --- a/frontend/src/components/common/HelpTooltip.vue +++ b/frontend/src/components/common/HelpTooltip.vue @@ -1,23 +1,69 @@