Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,410 @@
|
||||
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), default: alipay.trade.wap.pay — browser redirect into Alipay.
|
||||
// - Mobile with AlipayMobilePrecreate: alipay.trade.precreate — return the
|
||||
// dynamic QR payload so the frontend can open it through the Alipay app.
|
||||
// - 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 {
|
||||
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 {
|
||||
if req.AlipayMobilePrecreate {
|
||||
return a.createPrecreateTrade(ctx, client, req, notifyURL)
|
||||
}
|
||||
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) {
|
||||
// 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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
// 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(),
|
||||
}, 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)
|
||||
)
|
||||
Reference in New Issue
Block a user