Two issues fixed: 1. Alipay.SupportedTypes() returned ["alipay_direct"] and Wxpay returned ["wxpay_direct"], but the frontend sends payment_type="alipay"/"wxpay". The registry lookup failed with "payment method (alipay) is not configured". Fix: return the base types ["alipay"]/["wxpay"]. 2. When multiple providers support the same payment type (e.g. EasyPay and Alipay direct both handle "alipay"), only the last-registered provider's instances were reachable — the registry mapped one type to one provider key, and SelectInstance queried by that single key. Fix: bypass the registry in invokeProvider and let SelectInstance query across all providers when providerKey is empty. The selected instance's own ProviderKey (now included in InstanceSelection) is used to create the correct provider, enabling true cross-provider load balancing. Closes #1592
280 lines
7.3 KiB
Go
280 lines
7.3 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 (
|
|
alipayProductCodePagePay = "FAST_INSTANT_TRADE_PAY"
|
|
alipayProductCodeWapPay = "QUICK_WAP_WAY"
|
|
)
|
|
|
|
// Alipay response constants.
|
|
const (
|
|
alipayFundChangeYes = "Y"
|
|
alipayErrTradeNotExist = "ACQ.TRADE_NOT_EXIST"
|
|
alipayRefundSuffix = "-refund"
|
|
)
|
|
|
|
// 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}
|
|
}
|
|
|
|
// CreatePayment creates an Alipay payment page URL.
|
|
func (a *Alipay) CreatePayment(_ 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.createTrade(client, req, notifyURL, returnURL, true)
|
|
}
|
|
return a.createTrade(client, req, notifyURL, returnURL, false)
|
|
}
|
|
|
|
func (a *Alipay) createTrade(client *alipay.Client, req payment.CreatePaymentRequest, notifyURL, returnURL string, isMobile bool) (*payment.CreatePaymentResponse, error) {
|
|
if isMobile {
|
|
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 := client.TradeWapPay(param)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("alipay TradeWapPay: %w", err)
|
|
}
|
|
return &payment.CreatePaymentResponse{
|
|
TradeNo: req.OrderID,
|
|
PayURL: payURL.String(),
|
|
}, nil
|
|
}
|
|
|
|
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 := client.TradePagePay(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 {
|
|
return nil, fmt.Errorf("alipay parse amount %q: %w", result.TotalAmount, err)
|
|
}
|
|
|
|
return &payment.QueryOrderResponse{
|
|
TradeNo: result.TradeNo,
|
|
Status: status,
|
|
Amount: amount,
|
|
PaidAt: result.SendPayDate,
|
|
}, 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 {
|
|
return nil, fmt.Errorf("alipay parse notification amount %q: %w", notification.TotalAmount, err)
|
|
}
|
|
|
|
return &payment.PaymentNotification{
|
|
TradeNo: notification.TradeNo,
|
|
OrderID: notification.OutTradeNo,
|
|
Amount: amount,
|
|
Status: status,
|
|
RawData: rawBody,
|
|
}, 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)
|
|
}
|
|
|
|
// Ensure interface compliance.
|
|
var (
|
|
_ payment.Provider = (*Alipay)(nil)
|
|
_ payment.CancelableProvider = (*Alipay)(nil)
|
|
)
|