feat: add alipay direct payment flow

Add Alipay settings, RSA2 provider implementation, create/return/notify handlers, and provider-specific top-up settlement.
This commit is contained in:
zizi 2026-05-20 15:05:38 +08:00
parent 597fe545b7
commit 6405a21e60
5 changed files with 933 additions and 0 deletions

View File

@ -0,0 +1,189 @@
package controller
import (
"fmt"
"io"
"math"
"net/http"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/payment"
"github.com/QuantumNous/new-api/payment/provider"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin"
"github.com/shopspring/decimal"
)
type AlipayPaymentRequest struct {
Amount int64 `json:"amount"`
PromoCodeId int `json:"promo_code_id"`
TradeType string `json:"trade_type"`
}
func CreateAlipayPayment(c *gin.Context) {
if !operation_setting.IsPaymentComplianceConfirmed() {
common.ApiErrorI18n(c, i18n.MsgPaymentComplianceRequired)
return
}
if !setting.Alipay.Enabled {
common.ApiErrorMsg(c, "支付宝支付未启用")
return
}
var req AlipayPaymentRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiErrorMsg(c, "参数错误")
return
}
if req.Amount < getMinTopup() {
common.ApiErrorMsg(c, fmt.Sprintf("充值数量不能小于 %d", getMinTopup()))
return
}
id := c.GetInt("id")
group, err := model.GetUserGroup(id, true)
if err != nil {
common.ApiErrorMsg(c, "获取用户分组失败")
return
}
payMoney := getPayMoney(req.Amount, group)
if payMoney < 0.01 {
common.ApiErrorMsg(c, "充值金额过低")
return
}
amount := req.Amount
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
dAmount := decimal.NewFromInt(req.Amount)
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
amount = dAmount.Div(dQuotaPerUnit).IntPart()
if amount < 1 {
amount = 1
}
}
if !validateTopUpPromoCodeForOrder(c, id, req.PromoCodeId, amount) {
return
}
tradeNo := fmt.Sprintf("ALIPAY-%d-%d-%s", id, time.Now().UnixMilli(), common.GetRandomString(6))
topUp := &model.TopUp{
UserId: id,
Amount: amount,
Money: payMoney,
TradeNo: tradeNo,
PaymentMethod: model.PaymentMethodAlipay,
PaymentProvider: model.PaymentProviderAlipay,
PromoCodeId: req.PromoCodeId,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
}
if err := topUp.Insert(); err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("支付宝 创建充值订单失败 user_id=%d trade_no=%s amount=%d error=%q", id, tradeNo, req.Amount, err.Error()))
common.ApiErrorMsg(c, "创建订单失败")
return
}
alipayProvider, err := provider.NewAlipay(setting.Alipay)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("支付宝 provider 初始化失败 user_id=%d trade_no=%s error=%q", id, tradeNo, err.Error()))
topUp.Status = common.TopUpStatusFailed
_ = topUp.Update()
common.ApiErrorMsg(c, "支付配置错误")
return
}
callbackAddr := service.GetCallbackAddress()
notifyURL := callbackAddr + "/api/payment/webhook/alipay"
if setting.Alipay.NotifyURL != "" {
notifyURL = setting.Alipay.NotifyURL
}
returnURL := callbackAddr + "/api/payment/alipay/return"
if setting.Alipay.ReturnURL != "" {
returnURL = setting.Alipay.ReturnURL
}
resp, err := alipayProvider.CreatePayment(c.Request.Context(), &payment.PaymentRequest{
OrderID: tradeNo,
Amount: int(math.Round(payMoney * 100)),
Currency: "CNY",
Description: fmt.Sprintf("Top up %d credits", req.Amount),
ReturnURL: returnURL,
NotifyURL: notifyURL,
TradeType: req.TradeType,
})
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("支付宝 拉起支付失败 user_id=%d trade_no=%s error=%q", id, tradeNo, err.Error()))
topUp.Status = common.TopUpStatusFailed
_ = topUp.Update()
common.ApiErrorMsg(c, "拉起支付失败")
return
}
logger.LogInfo(c.Request.Context(), fmt.Sprintf("支付宝 充值订单创建成功 user_id=%d trade_no=%s amount=%d money=%.2f trade_type=%s", id, tradeNo, req.Amount, payMoney, req.TradeType))
common.ApiSuccess(c, gin.H{
"payment_url": resp.PaymentURL,
"qrcode": resp.QRCode,
"order_id": tradeNo,
})
}
func AlipayReturn(c *gin.Context) {
alipayProvider, err := provider.NewAlipay(setting.Alipay)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "error", "data": "支付配置错误"})
return
}
if _, err := alipayProvider.VerifyNotification(c.Request.Context(), []byte(c.Request.URL.Query().Encode()), nil); err != nil {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("支付宝 同步回调验签失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
c.JSON(http.StatusBadRequest, gin.H{"message": "error", "data": "验签失败"})
return
}
redirectURL := setting.Alipay.ReturnURL
if redirectURL == "" {
redirectURL = paymentReturnPath("/console/topup?show_history=true")
}
c.Redirect(http.StatusFound, redirectURL)
}
func AlipayNotify(c *gin.Context) {
alipayProvider, err := provider.NewAlipay(setting.Alipay)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("支付宝 provider 初始化失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
c.String(http.StatusOK, "fail")
return
}
body, err := io.ReadAll(c.Request.Body)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("支付宝 异步通知读取失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
c.String(http.StatusOK, "fail")
return
}
status, err := alipayProvider.VerifyNotification(c.Request.Context(), body, nil)
if err != nil {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("支付宝 异步通知验签失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
c.String(http.StatusOK, "fail")
return
}
if status.Status != payment.OrderStatusSuccess {
logger.LogInfo(c.Request.Context(), fmt.Sprintf("支付宝 异步通知忽略非成功状态 trade_no=%s status=%s client_ip=%s", status.ProviderOrderID, status.Status, c.ClientIP()))
c.String(http.StatusOK, "success")
return
}
LockOrder(status.ProviderOrderID)
defer UnlockOrder(status.ProviderOrderID)
if err := model.RechargeAlipay(status.ProviderOrderID, c.ClientIP()); err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("支付宝 充值处理失败 trade_no=%s client_ip=%s error=%q", status.ProviderOrderID, c.ClientIP(), err.Error()))
c.String(http.StatusOK, "fail")
return
}
logger.LogInfo(c.Request.Context(), fmt.Sprintf("支付宝 充值成功 trade_no=%s client_ip=%s", status.ProviderOrderID, c.ClientIP()))
c.String(http.StatusOK, "success")
}

View File

@ -33,6 +33,7 @@ const (
PaymentMethodCreem = "creem"
PaymentMethodWaffo = "waffo"
PaymentMethodWaffoPancake = "waffo_pancake"
PaymentMethodAlipay = "alipay"
)
const (
@ -41,6 +42,7 @@ const (
PaymentProviderCreem = "creem"
PaymentProviderWaffo = "waffo"
PaymentProviderWaffoPancake = "waffo_pancake"
PaymentProviderAlipay = "alipay"
)
var (
@ -749,6 +751,73 @@ func RechargeWaffo(tradeNo string, callerIp string) (err error) {
return nil
}
func RechargeAlipay(tradeNo string, callerIp string) (err error) {
if tradeNo == "" {
return errors.New("未提供支付单号")
}
var quotaToAdd int
topUp := &TopUp{}
var promoBonus *promoCodeBonusInfo
refCol := "`trade_no`"
if common.UsingPostgreSQL {
refCol = `"trade_no"`
}
err = DB.Transaction(func(tx *gorm.DB) error {
err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(topUp).Error
if err != nil {
return errors.New("充值订单不存在")
}
if topUp.PaymentProvider != PaymentProviderAlipay {
return ErrPaymentMethodMismatch
}
if topUp.Status == common.TopUpStatusSuccess {
return nil
}
if topUp.Status != common.TopUpStatusPending {
return errors.New("充值订单状态错误")
}
dAmount := decimal.NewFromInt(topUp.Amount)
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
quotaToAdd = int(dAmount.Mul(dQuotaPerUnit).IntPart())
if quotaToAdd <= 0 {
return errors.New("无效的充值额度")
}
topUp.CompleteTime = common.GetTimestamp()
topUp.Status = common.TopUpStatusSuccess
if err := tx.Save(topUp).Error; err != nil {
return err
}
if err := tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)).Error; err != nil {
return err
}
bonus, bonusErr := applyPromoCodeBonusTx(tx, topUp)
promoBonus = bonus
return bonusErr
})
if err != nil {
common.SysError("alipay topup failed: " + err.Error())
return errors.New("充值失败,请稍后重试")
}
if quotaToAdd > 0 {
RecordTopupLog(topUp.UserId, fmt.Sprintf("支付宝充值成功,充值额度: %v支付金额: %.2f", logger.FormatQuota(quotaToAdd), topUp.Money), callerIp, topUp.PaymentMethod, PaymentMethodAlipay)
recordPromoCodeBonusLog(promoBonus, callerIp, topUp.PaymentMethod, PaymentMethodAlipay)
}
return nil
}
func RechargeWaffoPancake(tradeNo string) (err error) {
if tradeNo == "" {
return errors.New("未提供支付单号")

471
payment/provider/alipay.go Normal file
View File

@ -0,0 +1,471 @@
package provider
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/payment"
"github.com/QuantumNous/new-api/setting"
)
const (
alipayDefaultGateway = "https://openapi.alipay.com/gateway.do"
alipaySignType = "RSA2"
alipayTradeClosed = "TRADE_CLOSED"
alipayTradeSuccess = "TRADE_SUCCESS"
alipayTradeFinished = "TRADE_FINISHED"
alipayTradePending = "WAIT_BUYER_PAY"
)
type Alipay struct {
setting setting.AlipaySetting
privateKey *rsa.PrivateKey
publicKey *rsa.PublicKey
httpClient *http.Client
}
func NewAlipay(cfg setting.AlipaySetting) (*Alipay, error) {
if !cfg.Enabled {
return nil, errors.New("alipay is not enabled")
}
cfg.AppID = strings.TrimSpace(cfg.AppID)
cfg.PrivateKey = strings.TrimSpace(cfg.PrivateKey)
cfg.PublicKey = strings.TrimSpace(cfg.PublicKey)
cfg.GatewayUrl = strings.TrimSpace(cfg.GatewayUrl)
if cfg.GatewayUrl == "" {
cfg.GatewayUrl = alipayDefaultGateway
}
if cfg.AppID == "" {
return nil, errors.New("alipay app_id is required")
}
if cfg.PrivateKey == "" {
return nil, errors.New("alipay private_key is required")
}
if cfg.PublicKey == "" {
return nil, errors.New("alipay public_key is required")
}
privateKey, err := parseRSAPrivateKey(cfg.PrivateKey)
if err != nil {
return nil, fmt.Errorf("parse alipay private_key: %w", err)
}
publicKey, err := parseRSAPublicKey(cfg.PublicKey)
if err != nil {
return nil, fmt.Errorf("parse alipay public_key: %w", err)
}
return &Alipay{
setting: cfg,
privateKey: privateKey,
publicKey: publicKey,
httpClient: http.DefaultClient,
}, nil
}
func (a *Alipay) Name() string {
return "alipay"
}
func (a *Alipay) CreatePayment(ctx context.Context, req *payment.PaymentRequest) (*payment.PaymentResponse, error) {
if req == nil {
return nil, errors.New("missing payment request")
}
if strings.TrimSpace(req.OrderID) == "" {
return nil, errors.New("missing order id")
}
if req.Amount <= 0 {
return nil, errors.New("invalid payment amount")
}
method := "alipay.trade.page.pay"
productCode := "FAST_INSTANT_TRADE_PAY"
if strings.EqualFold(req.TradeType, "H5") {
method = "alipay.trade.wap.pay"
productCode = "QUICK_WAP_WAY"
}
bizContent, err := common.Marshal(map[string]string{
"out_trade_no": req.OrderID,
"total_amount": amountCentsToDecimal(req.Amount),
"subject": nonEmpty(req.Description, "Top up"),
"product_code": productCode,
})
if err != nil {
return nil, fmt.Errorf("marshal alipay biz_content: %w", err)
}
params := a.baseParams(method)
params.Set("biz_content", string(bizContent))
if req.NotifyURL != "" {
params.Set("notify_url", req.NotifyURL)
} else if a.setting.NotifyURL != "" {
params.Set("notify_url", a.setting.NotifyURL)
}
if req.ReturnURL != "" {
params.Set("return_url", req.ReturnURL)
} else if a.setting.ReturnURL != "" {
params.Set("return_url", a.setting.ReturnURL)
}
if err := a.signParams(params); err != nil {
return nil, err
}
paymentURL := a.setting.GatewayUrl + "?" + params.Encode()
return &payment.PaymentResponse{
ProviderOrderID: req.OrderID,
PaymentURL: paymentURL,
QRCode: paymentURL,
RawResponse: paymentURL,
}, nil
}
func (a *Alipay) QueryOrder(ctx context.Context, providerOrderID string) (*payment.OrderStatus, error) {
if strings.TrimSpace(providerOrderID) == "" {
return nil, errors.New("missing provider order id")
}
body, err := common.Marshal(map[string]string{"out_trade_no": providerOrderID})
if err != nil {
return nil, fmt.Errorf("marshal alipay query biz_content: %w", err)
}
respBody, err := a.requestGateway(ctx, "alipay.trade.query", string(body))
if err != nil {
return nil, err
}
var envelope alipayQueryEnvelope
if err := common.Unmarshal(respBody, &envelope); err != nil {
return nil, fmt.Errorf("decode alipay query response: %w", err)
}
resp := envelope.Response
if resp.Code != "10000" {
return nil, fmt.Errorf("alipay query failed code=%s sub_code=%s msg=%s sub_msg=%s", resp.Code, resp.SubCode, resp.Msg, resp.SubMsg)
}
return &payment.OrderStatus{
ProviderOrderID: nonEmpty(resp.OutTradeNo, resp.TradeNo),
Status: mapAlipayTradeStatus(resp.TradeStatus),
Amount: decimalAmountToCents(resp.TotalAmount),
Currency: "CNY",
}, nil
}
func (a *Alipay) VerifyNotification(ctx context.Context, body []byte, headers map[string]string) (*payment.OrderStatus, error) {
values, err := url.ParseQuery(string(body))
if err != nil {
return nil, fmt.Errorf("parse alipay notification form: %w", err)
}
params := make(map[string]string, len(values))
for key := range values {
params[key] = values.Get(key)
}
signature := params["sign"]
if signature == "" {
return nil, errors.New("missing alipay notification sign")
}
if err := a.verifyParams(params, signature); err != nil {
return nil, err
}
if params["app_id"] != a.setting.AppID {
return nil, errors.New("alipay notification app_id mismatch")
}
status := mapAlipayTradeStatus(params["trade_status"])
orderStatus := &payment.OrderStatus{
ProviderOrderID: nonEmpty(params["out_trade_no"], params["trade_no"]),
Status: status,
Amount: decimalAmountToCents(params["total_amount"]),
Currency: "CNY",
}
if paidAt := parseAlipayTime(params["gmt_payment"]); paidAt != nil {
orderStatus.PaidAt = paidAt
}
return orderStatus, nil
}
func (a *Alipay) Refund(ctx context.Context, providerOrderID string, amount int) error {
if strings.TrimSpace(providerOrderID) == "" {
return errors.New("missing provider order id")
}
if amount <= 0 {
return errors.New("invalid refund amount")
}
body, err := common.Marshal(map[string]string{
"out_trade_no": providerOrderID,
"refund_amount": amountCentsToDecimal(amount),
"out_request_no": fmt.Sprintf("%s-refund-%d", providerOrderID, time.Now().UnixNano()),
})
if err != nil {
return fmt.Errorf("marshal alipay refund biz_content: %w", err)
}
respBody, err := a.requestGateway(ctx, "alipay.trade.refund", string(body))
if err != nil {
return err
}
var envelope alipayRefundEnvelope
if err := common.Unmarshal(respBody, &envelope); err != nil {
return fmt.Errorf("decode alipay refund response: %w", err)
}
resp := envelope.Response
if resp.Code != "10000" {
return fmt.Errorf("alipay refund failed code=%s sub_code=%s msg=%s sub_msg=%s", resp.Code, resp.SubCode, resp.Msg, resp.SubMsg)
}
return nil
}
func (a *Alipay) baseParams(method string) url.Values {
params := url.Values{}
params.Set("app_id", a.setting.AppID)
params.Set("method", method)
params.Set("format", "JSON")
params.Set("charset", "utf-8")
params.Set("sign_type", alipaySignType)
params.Set("timestamp", time.Now().Format("2006-01-02 15:04:05"))
params.Set("version", "1.0")
return params
}
func (a *Alipay) requestGateway(ctx context.Context, method string, bizContent string) ([]byte, error) {
params := a.baseParams(method)
params.Set("biz_content", bizContent)
if err := a.signParams(params); err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.setting.GatewayUrl, strings.NewReader(params.Encode()))
if err != nil {
return nil, fmt.Errorf("build alipay request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := a.httpClient
if client == nil {
client = http.DefaultClient
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request alipay gateway: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read alipay response: %w", err)
}
if resp.StatusCode >= http.StatusBadRequest {
return nil, fmt.Errorf("alipay gateway http status %d: %s", resp.StatusCode, string(respBody))
}
return respBody, nil
}
func (a *Alipay) signParams(params url.Values) error {
canonical := canonicalAlipayParams(params)
if canonical == "" {
return errors.New("empty alipay signing payload")
}
digest := sha256.Sum256([]byte(canonical))
signature, err := rsa.SignPKCS1v15(rand.Reader, a.privateKey, crypto.SHA256, digest[:])
if err != nil {
return fmt.Errorf("sign alipay params: %w", err)
}
params.Set("sign", base64.StdEncoding.EncodeToString(signature))
return nil
}
func (a *Alipay) verifyParams(params map[string]string, signature string) error {
canonical := canonicalAlipayMap(params)
if canonical == "" {
return errors.New("empty alipay verification payload")
}
rawSignature, err := base64.StdEncoding.DecodeString(signature)
if err != nil {
return fmt.Errorf("decode alipay sign: %w", err)
}
digest := sha256.Sum256([]byte(canonical))
if err := rsa.VerifyPKCS1v15(a.publicKey, crypto.SHA256, digest[:], rawSignature); err != nil {
return fmt.Errorf("verify alipay sign: %w", err)
}
return nil
}
func canonicalAlipayParams(params url.Values) string {
items := make(map[string]string, len(params))
for key := range params {
items[key] = params.Get(key)
}
return canonicalAlipayMap(items)
}
func canonicalAlipayMap(params map[string]string) string {
keys := make([]string, 0, len(params))
for key, value := range params {
if key == "sign" || key == "sign_type" || value == "" {
continue
}
keys = append(keys, key)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, key := range keys {
parts = append(parts, key+"="+params[key])
}
return strings.Join(parts, "&")
}
func parseRSAPrivateKey(raw string) (*rsa.PrivateKey, error) {
block, err := normalizePEMBlock(raw, "PRIVATE KEY", "RSA PRIVATE KEY")
if err != nil {
return nil, err
}
switch block.Type {
case "PRIVATE KEY":
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, err
}
privateKey, ok := key.(*rsa.PrivateKey)
if !ok {
return nil, errors.New("private key is not RSA")
}
return privateKey, nil
case "RSA PRIVATE KEY":
return x509.ParsePKCS1PrivateKey(block.Bytes)
default:
return nil, fmt.Errorf("unsupported private key type: %s", block.Type)
}
}
func parseRSAPublicKey(raw string) (*rsa.PublicKey, error) {
block, err := normalizePEMBlock(raw, "PUBLIC KEY", "RSA PUBLIC KEY")
if err != nil {
return nil, err
}
switch block.Type {
case "PUBLIC KEY":
key, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, err
}
publicKey, ok := key.(*rsa.PublicKey)
if !ok {
return nil, errors.New("public key is not RSA")
}
return publicKey, nil
case "RSA PUBLIC KEY":
return x509.ParsePKCS1PublicKey(block.Bytes)
default:
return nil, fmt.Errorf("unsupported public key type: %s", block.Type)
}
}
func normalizePEMBlock(raw string, pkcs8Type string, pkcs1Type string) (*pem.Block, error) {
normalized := strings.TrimSpace(strings.ReplaceAll(raw, `\n`, "\n"))
if normalized == "" {
return nil, errors.New("empty RSA key")
}
if strings.Contains(normalized, "BEGIN ") {
block, _ := pem.Decode([]byte(normalized))
if block == nil {
return nil, errors.New("invalid PEM encoded RSA key")
}
return block, nil
}
der, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(normalized, "\n", ""))
if err != nil {
return nil, fmt.Errorf("invalid base64 encoded RSA key: %w", err)
}
pemType := pkcs8Type
if pkcs8Type == "PRIVATE KEY" {
if _, err := x509.ParsePKCS8PrivateKey(der); err != nil {
if _, err := x509.ParsePKCS1PrivateKey(der); err == nil {
pemType = pkcs1Type
} else {
return nil, errors.New("invalid RSA private key")
}
}
} else if _, err := x509.ParsePKIXPublicKey(der); err != nil {
if _, err := x509.ParsePKCS1PublicKey(der); err == nil {
pemType = pkcs1Type
} else {
return nil, errors.New("invalid RSA public key")
}
}
return &pem.Block{Type: pemType, Bytes: der}, nil
}
func amountCentsToDecimal(amount int) string {
return fmt.Sprintf("%d.%02d", amount/100, amount%100)
}
func decimalAmountToCents(amount string) int {
value, err := strconv.ParseFloat(strings.TrimSpace(amount), 64)
if err != nil {
return 0
}
return int(value*100 + 0.5)
}
func mapAlipayTradeStatus(status string) string {
switch strings.ToUpper(strings.TrimSpace(status)) {
case alipayTradeSuccess, alipayTradeFinished:
return payment.OrderStatusSuccess
case alipayTradeClosed:
return payment.OrderStatusClosed
case alipayTradePending:
return payment.OrderStatusPending
default:
return payment.OrderStatusFailed
}
}
func parseAlipayTime(value string) *time.Time {
if strings.TrimSpace(value) == "" {
return nil
}
parsed, err := time.ParseInLocation("2006-01-02 15:04:05", value, time.Local)
if err != nil {
return nil
}
return &parsed
}
func nonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
type alipayGatewayResponse struct {
Code string `json:"code"`
Msg string `json:"msg"`
SubCode string `json:"sub_code"`
SubMsg string `json:"sub_msg"`
}
type alipayQueryEnvelope struct {
Response struct {
alipayGatewayResponse
TradeNo string `json:"trade_no"`
OutTradeNo string `json:"out_trade_no"`
TradeStatus string `json:"trade_status"`
TotalAmount string `json:"total_amount"`
} `json:"alipay_trade_query_response"`
}
type alipayRefundEnvelope struct {
Response alipayGatewayResponse `json:"alipay_trade_refund_response"`
}

View File

@ -0,0 +1,189 @@
package provider
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/payment"
"github.com/QuantumNous/new-api/setting"
)
func testAlipayProvider(t *testing.T) *Alipay {
t.Helper()
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate private key: %v", err)
}
privateDER, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
t.Fatalf("marshal private key: %v", err)
}
publicDER, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
if err != nil {
t.Fatalf("marshal public key: %v", err)
}
provider, err := NewAlipay(setting.AlipaySetting{
Enabled: true,
AppID: "app-123",
PrivateKey: base64.StdEncoding.EncodeToString(privateDER),
PublicKey: base64.StdEncoding.EncodeToString(publicDER),
GatewayUrl: "https://example.test/gateway.do",
})
if err != nil {
t.Fatalf("new alipay: %v", err)
}
return provider
}
func TestAlipayCreatePaymentBuildsSignedWapURL(t *testing.T) {
provider := testAlipayProvider(t)
resp, err := provider.CreatePayment(context.Background(), &payment.PaymentRequest{
OrderID: "ORDER-1",
Amount: 1234,
Description: "Recharge",
ReturnURL: "https://merchant.test/return",
NotifyURL: "https://merchant.test/notify",
TradeType: "H5",
})
if err != nil {
t.Fatalf("create payment: %v", err)
}
parsed, err := url.Parse(resp.PaymentURL)
if err != nil {
t.Fatalf("parse payment url: %v", err)
}
params := parsed.Query()
if got := params.Get("method"); got != "alipay.trade.wap.pay" {
t.Fatalf("method = %q", got)
}
if got := params.Get("return_url"); got != "https://merchant.test/return" {
t.Fatalf("return_url = %q", got)
}
var biz map[string]string
if err := common.Unmarshal([]byte(params.Get("biz_content")), &biz); err != nil {
t.Fatalf("decode biz_content: %v", err)
}
if got := biz["total_amount"]; got != "12.34" {
t.Fatalf("total_amount = %q", got)
}
if got := biz["product_code"]; got != "QUICK_WAP_WAY" {
t.Fatalf("product_code = %q", got)
}
items := map[string]string{}
for key := range params {
items[key] = params.Get(key)
}
if err := provider.verifyParams(items, params.Get("sign")); err != nil {
t.Fatalf("verify generated sign: %v", err)
}
}
func TestAlipayVerifyNotificationMapsSuccess(t *testing.T) {
provider := testAlipayProvider(t)
params := url.Values{}
params.Set("app_id", "app-123")
params.Set("out_trade_no", "ORDER-2")
params.Set("trade_no", "202605202200")
params.Set("trade_status", alipayTradeSuccess)
params.Set("total_amount", "10.50")
params.Set("gmt_payment", "2026-05-20 12:34:56")
params.Set("sign_type", alipaySignType)
if err := provider.signParams(params); err != nil {
t.Fatalf("sign params: %v", err)
}
status, err := provider.VerifyNotification(context.Background(), []byte(params.Encode()), nil)
if err != nil {
t.Fatalf("verify notification: %v", err)
}
if status.ProviderOrderID != "ORDER-2" {
t.Fatalf("provider order id = %q", status.ProviderOrderID)
}
if status.Status != payment.OrderStatusSuccess {
t.Fatalf("status = %q", status.Status)
}
if status.Amount != 1050 {
t.Fatalf("amount = %d", status.Amount)
}
if status.PaidAt == nil {
t.Fatal("paid_at is nil")
}
}
func TestAlipayVerifyNotificationRejectsMismatchedAppID(t *testing.T) {
provider := testAlipayProvider(t)
params := url.Values{}
params.Set("app_id", "other-app")
params.Set("out_trade_no", "ORDER-2")
params.Set("trade_no", "202605202200")
params.Set("trade_status", alipayTradeSuccess)
params.Set("total_amount", "10.50")
params.Set("sign_type", alipaySignType)
if err := provider.signParams(params); err != nil {
t.Fatalf("sign params: %v", err)
}
if _, err := provider.VerifyNotification(context.Background(), []byte(params.Encode()), nil); err == nil {
t.Fatal("expected app_id mismatch error")
}
}
func TestAlipayQueryOrderMapsClosedStatus(t *testing.T) {
provider := testAlipayProvider(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
t.Fatalf("parse form: %v", err)
}
if got := r.Form.Get("method"); got != "alipay.trade.query" {
t.Fatalf("method = %q", got)
}
if !strings.Contains(r.Form.Get("biz_content"), "ORDER-3") {
t.Fatalf("biz_content missing order id: %q", r.Form.Get("biz_content"))
}
body, err := common.Marshal(map[string]any{
"alipay_trade_query_response": map[string]string{
"code": "10000",
"msg": "Success",
"out_trade_no": "ORDER-3",
"trade_no": "202605202201",
"trade_status": alipayTradeClosed,
"total_amount": "9.99",
},
})
if err != nil {
t.Fatalf("marshal response: %v", err)
}
_, _ = w.Write(body)
}))
defer server.Close()
provider.setting.GatewayUrl = server.URL
status, err := provider.QueryOrder(context.Background(), "ORDER-3")
if err != nil {
t.Fatalf("query order: %v", err)
}
if status.ProviderOrderID != "ORDER-3" {
t.Fatalf("provider order id = %q", status.ProviderOrderID)
}
if status.Status != payment.OrderStatusClosed {
t.Fatalf("status = %q", status.Status)
}
if status.Amount != 999 {
t.Fatalf("amount = %d", status.Amount)
}
}

15
setting/payment_alipay.go Normal file
View File

@ -0,0 +1,15 @@
package setting
type AlipaySetting struct {
Enabled bool `json:"enabled"`
AppID string `json:"app_id"`
PrivateKey string `json:"private_key"`
PublicKey string `json:"public_key"`
GatewayUrl string `json:"gateway_url"`
NotifyURL string `json:"notify_url"`
ReturnURL string `json:"return_url"`
}
var Alipay = AlipaySetting{
GatewayUrl: "https://openapi.alipay.com/gateway.do",
}