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:
parent
597fe545b7
commit
6405a21e60
189
controller/payment_alipay.go
Normal file
189
controller/payment_alipay.go
Normal 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")
|
||||
}
|
||||
@ -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
471
payment/provider/alipay.go
Normal 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"`
|
||||
}
|
||||
189
payment/provider/alipay_test.go
Normal file
189
payment/provider/alipay_test.go
Normal 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
15
setting/payment_alipay.go
Normal 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",
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user