Files
sub2api/backend/internal/payment/provider/alipay.go
2026-04-22 07:33:14 -07:00

391 lines
11 KiB
Go

package provider
import (
"context"
"fmt"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/Wei-Shaw/sub2api/internal/payment"
"github.com/smartwalle/alipay/v3"
)
// Alipay product codes.
const (
alipayProductCodePreCreate = "FACE_TO_FACE_PAYMENT"
alipayProductCodeWapPay = "QUICK_WAP_WAY"
alipayProductCodePagePay = "FAST_INSTANT_TRADE_PAY"
)
// Alipay response constants.
const (
alipayFundChangeYes = "Y"
alipayErrTradeNotExist = "ACQ.TRADE_NOT_EXIST"
alipayRefundSuffix = "-refund"
)
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)
}
)
// Alipay implements payment.Provider and payment.CancelableProvider using the smartwalle/alipay SDK.
type Alipay struct {
instanceID string
config map[string]string // appId, privateKey, publicKey (or alipayPublicKey), notifyUrl, returnUrl
mu sync.Mutex
client *alipay.Client
}
// NewAlipay creates a new Alipay provider instance.
func NewAlipay(instanceID string, config map[string]string) (*Alipay, error) {
required := []string{"appId", "privateKey"}
for _, k := range required {
if config[k] == "" {
return nil, fmt.Errorf("alipay config missing required key: %s", k)
}
}
return &Alipay{
instanceID: instanceID,
config: config,
}, nil
}
func (a *Alipay) getClient() (*alipay.Client, error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.client != nil {
return a.client, nil
}
client, err := alipay.New(a.config["appId"], a.config["privateKey"], true)
if err != nil {
return nil, fmt.Errorf("alipay init client: %w", err)
}
pubKey := a.config["publicKey"]
if pubKey == "" {
pubKey = a.config["alipayPublicKey"]
}
if pubKey == "" {
return nil, fmt.Errorf("alipay config missing required key: publicKey (or alipayPublicKey)")
}
if err := client.LoadAliPayPublicKey(pubKey); err != nil {
return nil, fmt.Errorf("alipay load public key: %w", err)
}
a.client = client
return a.client, nil
}
func (a *Alipay) Name() string { return "Alipay" }
func (a *Alipay) ProviderKey() string { return payment.TypeAlipay }
func (a *Alipay) SupportedTypes() []payment.PaymentType {
return []payment.PaymentType{payment.TypeAlipay}
}
func (a *Alipay) MerchantIdentityMetadata() map[string]string {
if a == nil {
return nil
}
appID := strings.TrimSpace(a.config["appId"])
if appID == "" {
return nil
}
return map[string]string{"app_id": appID}
}
// 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
}
notifyURL := a.config["notifyUrl"]
if req.NotifyURL != "" {
notifyURL = req.NotifyURL
}
returnURL := a.config["returnUrl"]
if req.ReturnURL != "" {
returnURL = req.ReturnURL
}
if req.IsMobile {
return a.createWapTrade(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) {
param := alipay.TradeWapPay{}
param.OutTradeNo = req.OrderID
param.TotalAmount = req.Amount
param.Subject = req.Subject
param.ProductCode = alipayProductCodeWapPay
param.NotifyURL = notifyURL
param.ReturnURL = returnURL
payURL, err := alipayTradeWapPay(client, param)
if err != nil {
return nil, fmt.Errorf("alipay TradeWapPay: %w", err)
}
return &payment.CreatePaymentResponse{
TradeNo: req.OrderID,
PayURL: payURL.String(),
}, 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
param.TotalAmount = req.Amount
param.Subject = req.Subject
param.ProductCode = alipayProductCodePagePay
param.NotifyURL = notifyURL
param.ReturnURL = returnURL
payURL, err := alipayTradePagePay(client, param)
if err != nil {
return nil, fmt.Errorf("alipay TradePagePay: %w", err)
}
return &payment.CreatePaymentResponse{
TradeNo: req.OrderID,
PayURL: payURL.String(),
QRCode: payURL.String(),
}, nil
}
// QueryOrder queries the trade status via Alipay.
func (a *Alipay) QueryOrder(ctx context.Context, tradeNo string) (*payment.QueryOrderResponse, error) {
client, err := a.getClient()
if err != nil {
return nil, err
}
result, err := client.TradeQuery(ctx, alipay.TradeQuery{OutTradeNo: tradeNo})
if err != nil {
if isTradeNotExist(err) {
return &payment.QueryOrderResponse{
TradeNo: tradeNo,
Status: payment.ProviderStatusPending,
}, nil
}
return nil, fmt.Errorf("alipay TradeQuery: %w", err)
}
status := payment.ProviderStatusPending
switch result.TradeStatus {
case alipay.TradeStatusSuccess, alipay.TradeStatusFinished:
status = payment.ProviderStatusPaid
case alipay.TradeStatusClosed:
status = payment.ProviderStatusFailed
}
amount, err := strconv.ParseFloat(result.TotalAmount, 64)
if err != nil {
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{
TradeNo: result.TradeNo,
Status: status,
Amount: amount,
PaidAt: result.SendPayDate,
Metadata: a.MerchantIdentityMetadata(),
}, nil
}
// VerifyNotification decodes and verifies an Alipay async notification.
func (a *Alipay) VerifyNotification(ctx context.Context, rawBody string, _ map[string]string) (*payment.PaymentNotification, error) {
client, err := a.getClient()
if err != nil {
return nil, err
}
values, err := url.ParseQuery(rawBody)
if err != nil {
return nil, fmt.Errorf("alipay parse notification: %w", err)
}
notification, err := client.DecodeNotification(ctx, values)
if err != nil {
return nil, fmt.Errorf("alipay verify notification: %w", err)
}
status := payment.ProviderStatusFailed
if notification.TradeStatus == alipay.TradeStatusSuccess || notification.TradeStatus == alipay.TradeStatusFinished {
status = payment.ProviderStatusSuccess
}
amount, err := strconv.ParseFloat(notification.TotalAmount, 64)
if err != nil {
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()
if appID := strings.TrimSpace(notification.AppId); appID != "" {
if metadata == nil {
metadata = map[string]string{}
}
metadata["app_id"] = appID
}
return &payment.PaymentNotification{
TradeNo: notification.TradeNo,
OrderID: notification.OutTradeNo,
Amount: amount,
Status: status,
RawData: rawBody,
Metadata: metadata,
}, nil
}
// Refund requests a refund through Alipay.
func (a *Alipay) Refund(ctx context.Context, req payment.RefundRequest) (*payment.RefundResponse, error) {
client, err := a.getClient()
if err != nil {
return nil, err
}
result, err := client.TradeRefund(ctx, alipay.TradeRefund{
OutTradeNo: req.OrderID,
RefundAmount: req.Amount,
RefundReason: req.Reason,
OutRequestNo: fmt.Sprintf("%s-refund-%d", req.OrderID, time.Now().UnixNano()),
})
if err != nil {
return nil, fmt.Errorf("alipay TradeRefund: %w", err)
}
refundStatus := payment.ProviderStatusPending
if result.FundChange == alipayFundChangeYes {
refundStatus = payment.ProviderStatusSuccess
}
refundID := result.TradeNo
if refundID == "" {
refundID = req.OrderID + alipayRefundSuffix
}
return &payment.RefundResponse{
RefundID: refundID,
Status: refundStatus,
}, nil
}
// CancelPayment closes a pending trade on Alipay.
func (a *Alipay) CancelPayment(ctx context.Context, tradeNo string) error {
client, err := a.getClient()
if err != nil {
return err
}
_, err = client.TradeClose(ctx, alipay.TradeClose{OutTradeNo: tradeNo})
if err != nil {
if isTradeNotExist(err) {
return nil
}
return fmt.Errorf("alipay TradeClose: %w", err)
}
return nil
}
func isTradeNotExist(err error) bool {
if err == nil {
return false
}
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)
_ payment.CancelableProvider = (*Alipay)(nil)
_ payment.MerchantIdentityProvider = (*Alipay)(nil)
)