feat: add wxpay direct payment flow

Add WxPay settings, APIv3 provider implementation, create/notify handlers, and provider-specific top-up settlement.
This commit is contained in:
zizi 2026-05-20 15:18:17 +08:00
parent 6405a21e60
commit 56a9b1e956
6 changed files with 1181 additions and 0 deletions

195
controller/payment_wxpay.go Normal file
View File

@ -0,0 +1,195 @@
package controller
import (
"fmt"
"io"
"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 WxpayPaymentRequest struct {
Amount int64 `json:"amount"`
PromoCodeId int `json:"promo_code_id"`
TradeType string `json:"trade_type"`
OpenID string `json:"openid"`
}
func CreateWxpayPayment(c *gin.Context) {
if !operation_setting.IsPaymentComplianceConfirmed() {
common.ApiErrorI18n(c, i18n.MsgPaymentComplianceRequired)
return
}
if !setting.Wxpay.Enabled {
common.ApiErrorMsg(c, "微信支付未启用")
return
}
var req WxpayPaymentRequest
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("WXPAY-%d-%d-%s", id, time.Now().UnixMilli(), common.GetRandomString(6))
topUp := &model.TopUp{
UserId: id,
Amount: amount,
Money: payMoney,
TradeNo: tradeNo,
PaymentMethod: model.PaymentMethodWxpay,
PaymentProvider: model.PaymentProviderWxpay,
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
}
wxpayProvider, err := provider.NewWxpay(setting.Wxpay)
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/wxpay"
if setting.Wxpay.NotifyURL != "" {
notifyURL = setting.Wxpay.NotifyURL
}
returnURL := callbackAddr + "/console/topup?show_history=true"
if setting.Wxpay.ReturnURL != "" {
returnURL = setting.Wxpay.ReturnURL
}
payAmount := decimal.NewFromFloat(payMoney).Mul(decimal.NewFromInt(100)).Round(0).IntPart()
resp, err := wxpayProvider.CreatePayment(c.Request.Context(), &payment.PaymentRequest{
OrderID: tradeNo,
Amount: int(payAmount),
Currency: "CNY",
Description: fmt.Sprintf("Top up %d credits", req.Amount),
ReturnURL: returnURL,
NotifyURL: notifyURL,
ClientIP: c.ClientIP(),
TradeType: req.TradeType,
OpenID: req.OpenID,
})
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 WxpayNotify(c *gin.Context) {
wxpayProvider, err := provider.NewWxpay(setting.Wxpay)
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()))
wxpayNotifyFail(c, "支付配置错误")
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()))
wxpayNotifyFail(c, "读取失败")
return
}
status, err := wxpayProvider.VerifyNotification(c.Request.Context(), body, collectWxpayHeaders(c))
if err != nil {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("微信支付 异步通知验签或解密失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
wxpayNotifyFail(c, "验签失败")
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()))
wxpayNotifySuccess(c)
return
}
LockOrder(status.ProviderOrderID)
defer UnlockOrder(status.ProviderOrderID)
if err := model.RechargeWxpay(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()))
wxpayNotifyFail(c, "处理失败")
return
}
logger.LogInfo(c.Request.Context(), fmt.Sprintf("微信支付 充值成功 trade_no=%s client_ip=%s", status.ProviderOrderID, c.ClientIP()))
wxpayNotifySuccess(c)
}
func collectWxpayHeaders(c *gin.Context) map[string]string {
headers := map[string]string{}
for _, key := range []string{
"Wechatpay-Timestamp",
"Wechatpay-Nonce",
"Wechatpay-Signature",
"Wechatpay-Serial",
} {
headers[key] = c.GetHeader(key)
}
return headers
}
func wxpayNotifySuccess(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS", "message": "成功"})
}
func wxpayNotifyFail(c *gin.Context, message string) {
c.JSON(http.StatusOK, gin.H{"code": "FAIL", "message": message})
}

View File

@ -221,6 +221,50 @@ func TestRechargeWaffo_AppliesPromoCodeBonusIdempotently(t *testing.T) {
assert.Equal(t, int64(1), countPromoCodeUsagesForPaymentGuardTest(t, promoCode.Id, 181))
}
func TestRechargeWxpay_AppliesPromoCodeBonusIdempotently(t *testing.T) {
truncateTables(t)
insertUserForPaymentGuardTest(t, 182, 10)
promoCode := insertPromoCodeForPaymentGuardTest(t, "WXPAY_BONUS", 123, 2, 1)
topUp := &TopUp{
UserId: 182,
Amount: 2,
Money: 2,
TradeNo: "wxpay-promo-guard",
PaymentMethod: PaymentMethodWxpay,
PaymentProvider: PaymentProviderWxpay,
PromoCodeId: promoCode.Id,
Status: common.TopUpStatusPending,
CreateTime: time.Now().Unix(),
}
require.NoError(t, topUp.Insert())
require.NoError(t, RechargeWxpay("wxpay-promo-guard", "127.0.0.1"))
baseQuota := int(decimal.NewFromInt(2).Mul(decimal.NewFromFloat(common.QuotaPerUnit)).IntPart())
assert.Equal(t, 10+baseQuota+123, getUserQuotaForPaymentGuardTest(t, 182))
assert.Equal(t, common.TopUpStatusSuccess, getTopUpStatusForPaymentGuardTest(t, "wxpay-promo-guard"))
assert.Equal(t, 1, getPromoCodeUsedCountForPaymentGuardTest(t, promoCode.Id))
assert.Equal(t, int64(1), countPromoCodeUsagesForPaymentGuardTest(t, promoCode.Id, 182))
require.NoError(t, RechargeWxpay("wxpay-promo-guard", "127.0.0.1"))
assert.Equal(t, 10+baseQuota+123, getUserQuotaForPaymentGuardTest(t, 182))
assert.Equal(t, 1, getPromoCodeUsedCountForPaymentGuardTest(t, promoCode.Id))
assert.Equal(t, int64(1), countPromoCodeUsagesForPaymentGuardTest(t, promoCode.Id, 182))
}
func TestRechargeWxpay_RejectsMismatchedPaymentProvider(t *testing.T) {
truncateTables(t)
insertUserForPaymentGuardTest(t, 183, 0)
insertTopUpForPaymentGuardTest(t, "wxpay-provider-guard", 183, PaymentProviderAlipay)
err := RechargeWxpay("wxpay-provider-guard", "127.0.0.1")
require.Error(t, err)
assert.Equal(t, common.TopUpStatusPending, getTopUpStatusForPaymentGuardTest(t, "wxpay-provider-guard"))
assert.Equal(t, 0, getUserQuotaForPaymentGuardTest(t, 183))
}
func TestCompleteSubscriptionOrder_RejectsMismatchedPaymentProvider(t *testing.T) {
truncateTables(t)

View File

@ -34,6 +34,7 @@ const (
PaymentMethodWaffo = "waffo"
PaymentMethodWaffoPancake = "waffo_pancake"
PaymentMethodAlipay = "alipay"
PaymentMethodWxpay = "wxpay"
)
const (
@ -43,6 +44,7 @@ const (
PaymentProviderWaffo = "waffo"
PaymentProviderWaffoPancake = "waffo_pancake"
PaymentProviderAlipay = "alipay"
PaymentProviderWxpay = "wxpay"
)
var (
@ -818,6 +820,73 @@ func RechargeAlipay(tradeNo string, callerIp string) (err error) {
return nil
}
func RechargeWxpay(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 != PaymentProviderWxpay {
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("wxpay 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, PaymentMethodWxpay)
recordPromoCodeBonusLog(promoBonus, callerIp, topUp.PaymentMethod, PaymentMethodWxpay)
}
return nil
}
func RechargeWaffoPancake(tradeNo string) (err error) {
if tradeNo == "" {
return errors.New("未提供支付单号")

585
payment/provider/wxpay.go Normal file
View File

@ -0,0 +1,585 @@
package provider
import (
"bytes"
"context"
"crypto"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/payment"
"github.com/QuantumNous/new-api/setting"
)
const (
wxpayDefaultGateway = "https://api.mch.weixin.qq.com"
wxpayAuthSchema = "WECHATPAY2-SHA256-RSA256"
wxpayCurrency = "CNY"
wxpayTradeSuccess = "SUCCESS"
wxpayTradeClosed = "CLOSED"
wxpayTradeRevoked = "REVOKED"
wxpayTradePayError = "PAYERROR"
wxpayTradeNotPay = "NOTPAY"
wxpayTradeUserPay = "USERPAYING"
wxpayTradeAccept = "ACCEPT"
)
type Wxpay struct {
setting setting.WxpaySetting
privateKey *rsa.PrivateKey
publicKey *rsa.PublicKey
httpClient *http.Client
}
func NewWxpay(cfg setting.WxpaySetting) (*Wxpay, error) {
if !cfg.Enabled {
return nil, errors.New("wxpay is not enabled")
}
cfg.AppID = strings.TrimSpace(cfg.AppID)
cfg.MchID = strings.TrimSpace(cfg.MchID)
cfg.PrivateKey = strings.TrimSpace(cfg.PrivateKey)
cfg.MerchantSerialNo = strings.TrimSpace(cfg.MerchantSerialNo)
cfg.APIv3Key = strings.TrimSpace(cfg.APIv3Key)
cfg.PlatformPublicKey = strings.TrimSpace(cfg.PlatformPublicKey)
cfg.PlatformCertificate = strings.TrimSpace(cfg.PlatformCertificate)
cfg.PlatformSerialNo = strings.TrimSpace(cfg.PlatformSerialNo)
cfg.GatewayURL = strings.TrimRight(strings.TrimSpace(cfg.GatewayURL), "/")
if cfg.GatewayURL == "" {
cfg.GatewayURL = wxpayDefaultGateway
}
if cfg.AppID == "" {
return nil, errors.New("wxpay app_id is required")
}
if cfg.MchID == "" {
return nil, errors.New("wxpay mch_id is required")
}
if cfg.PrivateKey == "" {
return nil, errors.New("wxpay private_key is required")
}
if cfg.MerchantSerialNo == "" {
return nil, errors.New("wxpay merchant_serial_no is required")
}
if len(cfg.APIv3Key) != 32 {
return nil, errors.New("wxpay api_v3_key must be 32 bytes")
}
privateKey, err := parseRSAPrivateKey(cfg.PrivateKey)
if err != nil {
return nil, fmt.Errorf("parse wxpay private_key: %w", err)
}
publicKey, err := parseWxpayPlatformPublicKey(cfg)
if err != nil {
return nil, err
}
return &Wxpay{
setting: cfg,
privateKey: privateKey,
publicKey: publicKey,
httpClient: http.DefaultClient,
}, nil
}
func (w *Wxpay) Name() string {
return "wxpay"
}
func (w *Wxpay) 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")
}
notifyURL := nonEmpty(req.NotifyURL, w.setting.NotifyURL)
if notifyURL == "" {
return nil, errors.New("wxpay notify_url is required")
}
tradeType := strings.ToUpper(strings.TrimSpace(req.TradeType))
if tradeType == "" {
tradeType = "NATIVE"
}
switch tradeType {
case "NATIVE":
return w.prepayNative(ctx, req, notifyURL)
case "H5":
return w.prepayH5(ctx, req, notifyURL)
case "JSAPI":
return w.prepayJSAPI(ctx, req, notifyURL)
default:
return nil, fmt.Errorf("unsupported wxpay trade type: %s", tradeType)
}
}
func (w *Wxpay) prepayNative(ctx context.Context, req *payment.PaymentRequest, notifyURL string) (*payment.PaymentResponse, error) {
body, err := common.Marshal(wxpayPrepayRequest{
AppID: w.setting.AppID,
MchID: w.setting.MchID,
Description: nonEmpty(req.Description, "Top up"),
OutTradeNo: req.OrderID,
NotifyURL: notifyURL,
Amount: wxpayAmount{
Total: req.Amount,
Currency: wxpayCurrency,
},
})
if err != nil {
return nil, fmt.Errorf("marshal wxpay native request: %w", err)
}
respBody, err := w.requestJSON(ctx, http.MethodPost, "/v3/pay/transactions/native", nil, body)
if err != nil {
return nil, err
}
var resp wxpayNativePrepayResponse
if err := common.Unmarshal(respBody, &resp); err != nil {
return nil, fmt.Errorf("decode wxpay native response: %w", err)
}
return &payment.PaymentResponse{
ProviderOrderID: req.OrderID,
QRCode: resp.CodeURL,
RawResponse: string(respBody),
}, nil
}
func (w *Wxpay) prepayH5(ctx context.Context, req *payment.PaymentRequest, notifyURL string) (*payment.PaymentResponse, error) {
if strings.TrimSpace(req.ClientIP) == "" {
return nil, errors.New("wxpay h5 requires client ip")
}
body, err := common.Marshal(wxpayPrepayRequest{
AppID: w.setting.AppID,
MchID: w.setting.MchID,
Description: nonEmpty(req.Description, "Top up"),
OutTradeNo: req.OrderID,
NotifyURL: notifyURL,
Amount: wxpayAmount{
Total: req.Amount,
Currency: wxpayCurrency,
},
SceneInfo: &wxpaySceneInfo{
PayerClientIP: req.ClientIP,
H5Info: &wxpayH5Info{
Type: "Wap",
AppName: w.setting.H5AppName,
AppURL: w.setting.H5AppURL,
},
},
})
if err != nil {
return nil, fmt.Errorf("marshal wxpay h5 request: %w", err)
}
respBody, err := w.requestJSON(ctx, http.MethodPost, "/v3/pay/transactions/h5", nil, body)
if err != nil {
return nil, err
}
var resp wxpayH5PrepayResponse
if err := common.Unmarshal(respBody, &resp); err != nil {
return nil, fmt.Errorf("decode wxpay h5 response: %w", err)
}
return &payment.PaymentResponse{
ProviderOrderID: req.OrderID,
PaymentURL: appendWxpayRedirectURL(resp.H5URL, nonEmpty(req.ReturnURL, w.setting.ReturnURL)),
RawResponse: string(respBody),
}, nil
}
func (w *Wxpay) prepayJSAPI(ctx context.Context, req *payment.PaymentRequest, notifyURL string) (*payment.PaymentResponse, error) {
if strings.TrimSpace(req.OpenID) == "" {
return nil, errors.New("wxpay jsapi requires openid")
}
body, err := common.Marshal(wxpayPrepayRequest{
AppID: w.setting.AppID,
MchID: w.setting.MchID,
Description: nonEmpty(req.Description, "Top up"),
OutTradeNo: req.OrderID,
NotifyURL: notifyURL,
Amount: wxpayAmount{
Total: req.Amount,
Currency: wxpayCurrency,
},
Payer: &wxpayPayer{OpenID: req.OpenID},
})
if err != nil {
return nil, fmt.Errorf("marshal wxpay jsapi request: %w", err)
}
respBody, err := w.requestJSON(ctx, http.MethodPost, "/v3/pay/transactions/jsapi", nil, body)
if err != nil {
return nil, err
}
return &payment.PaymentResponse{
ProviderOrderID: req.OrderID,
RawResponse: string(respBody),
}, nil
}
func (w *Wxpay) QueryOrder(ctx context.Context, providerOrderID string) (*payment.OrderStatus, error) {
providerOrderID = strings.TrimSpace(providerOrderID)
if providerOrderID == "" {
return nil, errors.New("missing provider order id")
}
path := "/v3/pay/transactions/out-trade-no/" + url.PathEscape(providerOrderID)
respBody, err := w.requestJSON(ctx, http.MethodGet, path, url.Values{"mchid": []string{w.setting.MchID}}, nil)
if err != nil {
return nil, err
}
var tx wxpayTransaction
if err := common.Unmarshal(respBody, &tx); err != nil {
return nil, fmt.Errorf("decode wxpay query response: %w", err)
}
return wxpayOrderStatusFromTransaction(&tx, w.setting.MchID)
}
func (w *Wxpay) VerifyNotification(ctx context.Context, body []byte, headers map[string]string) (*payment.OrderStatus, error) {
if len(body) == 0 {
return nil, errors.New("missing wxpay notification body")
}
if err := w.verifyNotificationSignature(body, headers); err != nil {
return nil, err
}
var notification wxpayNotification
if err := common.Unmarshal(body, &notification); err != nil {
return nil, fmt.Errorf("decode wxpay notification: %w", err)
}
plain, err := w.decryptResource(notification.Resource)
if err != nil {
return nil, err
}
var tx wxpayTransaction
if err := common.Unmarshal(plain, &tx); err != nil {
return nil, fmt.Errorf("decode wxpay transaction: %w", err)
}
return wxpayOrderStatusFromTransaction(&tx, w.setting.MchID)
}
func (w *Wxpay) Refund(ctx context.Context, providerOrderID string, amount int) error {
providerOrderID = strings.TrimSpace(providerOrderID)
if providerOrderID == "" {
return errors.New("missing provider order id")
}
if amount <= 0 {
return errors.New("invalid refund amount")
}
status, err := w.QueryOrder(ctx, providerOrderID)
if err != nil {
return err
}
body, err := common.Marshal(wxpayRefundRequest{
OutTradeNo: providerOrderID,
OutRefundNo: fmt.Sprintf("%s-refund-%d", providerOrderID, time.Now().UnixNano()),
Amount: wxpayRefundAmount{
Refund: amount,
Total: status.Amount,
Currency: wxpayCurrency,
},
})
if err != nil {
return fmt.Errorf("marshal wxpay refund request: %w", err)
}
_, err = w.requestJSON(ctx, http.MethodPost, "/v3/refund/domestic/refunds", nil, body)
return err
}
func (w *Wxpay) requestJSON(ctx context.Context, method string, path string, query url.Values, body []byte) ([]byte, error) {
target := w.setting.GatewayURL + path
canonicalURL := path
if len(query) > 0 {
encodedQuery := query.Encode()
target += "?" + encodedQuery
canonicalURL += "?" + encodedQuery
}
req, err := http.NewRequestWithContext(ctx, method, target, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("build wxpay request: %w", err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Accept", "application/json")
auth, err := w.authorizationHeader(method, canonicalURL, body)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", auth)
client := w.httpClient
if client == nil {
client = http.DefaultClient
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request wxpay gateway: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read wxpay response: %w", err)
}
if resp.StatusCode >= http.StatusBadRequest {
return nil, fmt.Errorf("wxpay gateway http status %d: %s", resp.StatusCode, string(respBody))
}
return respBody, nil
}
func (w *Wxpay) authorizationHeader(method string, canonicalURL string, body []byte) (string, error) {
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
nonce := common.GetRandomString(32)
signature, err := w.signMessage(wxpayBuildMessage(method, canonicalURL, timestamp, nonce, string(body)))
if err != nil {
return "", err
}
return fmt.Sprintf(`%s mchid="%s",nonce_str="%s",timestamp="%s",serial_no="%s",signature="%s"`,
wxpayAuthSchema,
w.setting.MchID,
nonce,
timestamp,
w.setting.MerchantSerialNo,
signature,
), nil
}
func (w *Wxpay) signMessage(message string) (string, error) {
digest := sha256.Sum256([]byte(message))
signature, err := rsa.SignPKCS1v15(rand.Reader, w.privateKey, crypto.SHA256, digest[:])
if err != nil {
return "", fmt.Errorf("sign wxpay request: %w", err)
}
return base64.StdEncoding.EncodeToString(signature), nil
}
func (w *Wxpay) verifyNotificationSignature(body []byte, headers map[string]string) error {
timestamp := getWxpayHeader(headers, "Wechatpay-Timestamp")
nonce := getWxpayHeader(headers, "Wechatpay-Nonce")
signature := getWxpayHeader(headers, "Wechatpay-Signature")
serial := getWxpayHeader(headers, "Wechatpay-Serial")
if timestamp == "" || nonce == "" || signature == "" {
return errors.New("missing wxpay notification signature headers")
}
if w.setting.PlatformSerialNo != "" && serial != w.setting.PlatformSerialNo {
return errors.New("wxpay platform serial mismatch")
}
rawSignature, err := base64.StdEncoding.DecodeString(signature)
if err != nil {
return fmt.Errorf("decode wxpay notification signature: %w", err)
}
message := timestamp + "\n" + nonce + "\n" + string(body) + "\n"
digest := sha256.Sum256([]byte(message))
if err := rsa.VerifyPKCS1v15(w.publicKey, crypto.SHA256, digest[:], rawSignature); err != nil {
return fmt.Errorf("verify wxpay notification signature: %w", err)
}
return nil
}
func (w *Wxpay) decryptResource(resource wxpayResource) ([]byte, error) {
if resource.Ciphertext == "" || resource.Nonce == "" {
return nil, errors.New("missing wxpay notification resource")
}
ciphertext, err := base64.StdEncoding.DecodeString(resource.Ciphertext)
if err != nil {
return nil, fmt.Errorf("decode wxpay resource ciphertext: %w", err)
}
block, err := aes.NewCipher([]byte(w.setting.APIv3Key))
if err != nil {
return nil, fmt.Errorf("create wxpay aes cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("create wxpay gcm: %w", err)
}
plain, err := gcm.Open(nil, []byte(resource.Nonce), ciphertext, []byte(resource.AssociatedData))
if err != nil {
return nil, fmt.Errorf("decrypt wxpay notification resource: %w", err)
}
return plain, nil
}
func wxpayBuildMessage(method string, canonicalURL string, timestamp string, nonce string, body string) string {
return strings.ToUpper(method) + "\n" + canonicalURL + "\n" + timestamp + "\n" + nonce + "\n" + body + "\n"
}
func wxpayOrderStatusFromTransaction(tx *wxpayTransaction, expectedMchID string) (*payment.OrderStatus, error) {
if tx == nil {
return nil, errors.New("missing wxpay transaction")
}
if tx.MchID != expectedMchID {
return nil, errors.New("wxpay mchid mismatch")
}
status := &payment.OrderStatus{
ProviderOrderID: nonEmpty(tx.OutTradeNo, tx.TransactionID),
Status: mapWxpayTradeStatus(tx.TradeState),
Amount: tx.Amount.Total,
Currency: nonEmpty(tx.Amount.Currency, wxpayCurrency),
}
if paidAt := parseWxpayTime(tx.SuccessTime); paidAt != nil {
status.PaidAt = paidAt
}
return status, nil
}
func mapWxpayTradeStatus(status string) string {
switch strings.ToUpper(strings.TrimSpace(status)) {
case wxpayTradeSuccess:
return payment.OrderStatusSuccess
case wxpayTradeClosed, wxpayTradeRevoked:
return payment.OrderStatusClosed
case wxpayTradePayError:
return payment.OrderStatusFailed
case wxpayTradeNotPay, wxpayTradeUserPay, wxpayTradeAccept:
return payment.OrderStatusPending
default:
return payment.OrderStatusFailed
}
}
func parseWxpayTime(value string) *time.Time {
if strings.TrimSpace(value) == "" {
return nil
}
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
return nil
}
return &parsed
}
func getWxpayHeader(headers map[string]string, name string) string {
for key, value := range headers {
if strings.EqualFold(key, name) {
return strings.TrimSpace(value)
}
}
return ""
}
func parseWxpayPlatformPublicKey(cfg setting.WxpaySetting) (*rsa.PublicKey, error) {
if cfg.PlatformPublicKey != "" {
publicKey, err := parseRSAPublicKey(cfg.PlatformPublicKey)
if err != nil {
return nil, fmt.Errorf("parse wxpay platform_public_key: %w", err)
}
return publicKey, nil
}
if cfg.PlatformCertificate == "" {
return nil, errors.New("wxpay platform_public_key or platform_certificate is required")
}
block, _ := pem.Decode([]byte(strings.TrimSpace(strings.ReplaceAll(cfg.PlatformCertificate, `\n`, "\n"))))
if block == nil || block.Type != "CERTIFICATE" {
return nil, errors.New("invalid wxpay platform_certificate")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse wxpay platform_certificate: %w", err)
}
publicKey, ok := cert.PublicKey.(*rsa.PublicKey)
if !ok {
return nil, errors.New("wxpay platform_certificate public key is not RSA")
}
return publicKey, nil
}
func appendWxpayRedirectURL(h5URL string, returnURL string) string {
h5URL = strings.TrimSpace(h5URL)
returnURL = strings.TrimSpace(returnURL)
if h5URL == "" || returnURL == "" {
return h5URL
}
sep := "&"
if !strings.Contains(h5URL, "?") {
sep = "?"
}
return h5URL + sep + "redirect_url=" + url.QueryEscape(returnURL)
}
type wxpayPrepayRequest struct {
AppID string `json:"appid"`
MchID string `json:"mchid"`
Description string `json:"description"`
OutTradeNo string `json:"out_trade_no"`
NotifyURL string `json:"notify_url"`
Amount wxpayAmount `json:"amount"`
SceneInfo *wxpaySceneInfo `json:"scene_info,omitempty"`
Payer *wxpayPayer `json:"payer,omitempty"`
}
type wxpayAmount struct {
Total int `json:"total"`
Currency string `json:"currency"`
}
type wxpaySceneInfo struct {
PayerClientIP string `json:"payer_client_ip,omitempty"`
H5Info *wxpayH5Info `json:"h5_info,omitempty"`
}
type wxpayH5Info struct {
Type string `json:"type"`
AppName string `json:"app_name,omitempty"`
AppURL string `json:"app_url,omitempty"`
}
type wxpayPayer struct {
OpenID string `json:"openid"`
}
type wxpayNativePrepayResponse struct {
CodeURL string `json:"code_url"`
}
type wxpayH5PrepayResponse struct {
H5URL string `json:"h5_url"`
}
type wxpayTransaction struct {
AppID string `json:"appid"`
MchID string `json:"mchid"`
OutTradeNo string `json:"out_trade_no"`
TransactionID string `json:"transaction_id"`
TradeState string `json:"trade_state"`
SuccessTime string `json:"success_time"`
Amount wxpayAmount `json:"amount"`
}
type wxpayNotification struct {
ID string `json:"id"`
CreateTime string `json:"create_time"`
EventType string `json:"event_type"`
ResourceType string `json:"resource_type"`
Resource wxpayResource `json:"resource"`
}
type wxpayResource struct {
Algorithm string `json:"algorithm"`
Ciphertext string `json:"ciphertext"`
AssociatedData string `json:"associated_data"`
Nonce string `json:"nonce"`
}
type wxpayRefundRequest struct {
OutTradeNo string `json:"out_trade_no"`
OutRefundNo string `json:"out_refund_no"`
Amount wxpayRefundAmount `json:"amount"`
}
type wxpayRefundAmount struct {
Refund int `json:"refund"`
Total int `json:"total"`
Currency string `json:"currency"`
}
var _ payment.Provider = (*Wxpay)(nil)

View File

@ -0,0 +1,266 @@
package provider
import (
"context"
"crypto"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/payment"
"github.com/QuantumNous/new-api/setting"
)
func testWxpayKeyPair(t *testing.T) (*rsa.PrivateKey, string, string) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate rsa key: %v", err)
}
privateDER, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
t.Fatalf("marshal private key: %v", err)
}
publicDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
if err != nil {
t.Fatalf("marshal public key: %v", err)
}
return key,
string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privateDER})),
string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: publicDER}))
}
func testWxpayProvider(t *testing.T) (*Wxpay, *rsa.PrivateKey) {
t.Helper()
key, privatePEM, publicPEM := testWxpayKeyPair(t)
provider, err := NewWxpay(setting.WxpaySetting{
Enabled: true,
AppID: "wx-app-123",
MchID: "mch-123",
PrivateKey: privatePEM,
MerchantSerialNo: "merchant-serial",
APIv3Key: "12345678901234567890123456789012",
PlatformPublicKey: publicPEM,
PlatformSerialNo: "platform-serial",
GatewayURL: "https://example.test",
})
if err != nil {
t.Fatalf("new wxpay: %v", err)
}
return provider, key
}
func TestWxpayCreatePaymentNativeBuildsSignedRequest(t *testing.T) {
provider, _ := testWxpayProvider(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v3/pay/transactions/native" {
t.Fatalf("path = %q", r.URL.Path)
}
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, wxpayAuthSchema+" ") {
t.Fatalf("missing wxpay authorization header: %q", auth)
}
var req wxpayPrepayRequest
if err := common.DecodeJson(r.Body, &req); err != nil {
t.Fatalf("decode request: %v", err)
}
if req.OutTradeNo != "ORDER-1" {
t.Fatalf("out_trade_no = %q", req.OutTradeNo)
}
if req.Amount.Total != 1234 {
t.Fatalf("amount.total = %d", req.Amount.Total)
}
body, err := common.Marshal(wxpayNativePrepayResponse{CodeURL: "weixin://wxpay/bizpayurl?pr=test"})
if err != nil {
t.Fatalf("marshal response: %v", err)
}
_, _ = w.Write(body)
}))
defer server.Close()
provider.setting.GatewayURL = server.URL
resp, err := provider.CreatePayment(context.Background(), &payment.PaymentRequest{
OrderID: "ORDER-1",
Amount: 1234,
Description: "Recharge",
NotifyURL: "https://merchant.test/api/payment/webhook/wxpay",
TradeType: "NATIVE",
})
if err != nil {
t.Fatalf("create payment: %v", err)
}
if resp.QRCode != "weixin://wxpay/bizpayurl?pr=test" {
t.Fatalf("qrcode = %q", resp.QRCode)
}
}
func TestWxpayCreatePaymentH5ReturnsRedirectURL(t *testing.T) {
provider, _ := testWxpayProvider(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v3/pay/transactions/h5" {
t.Fatalf("path = %q", r.URL.Path)
}
var req wxpayPrepayRequest
if err := common.DecodeJson(r.Body, &req); err != nil {
t.Fatalf("decode request: %v", err)
}
if req.SceneInfo == nil || req.SceneInfo.PayerClientIP != "203.0.113.10" {
t.Fatalf("payer client ip = %#v", req.SceneInfo)
}
body, err := common.Marshal(wxpayH5PrepayResponse{H5URL: "https://wxpay.test/h5"})
if err != nil {
t.Fatalf("marshal response: %v", err)
}
_, _ = w.Write(body)
}))
defer server.Close()
provider.setting.GatewayURL = server.URL
resp, err := provider.CreatePayment(context.Background(), &payment.PaymentRequest{
OrderID: "ORDER-H5",
Amount: 1234,
NotifyURL: "https://merchant.test/api/payment/webhook/wxpay",
ReturnURL: "https://merchant.test/return",
ClientIP: "203.0.113.10",
TradeType: "H5",
})
if err != nil {
t.Fatalf("create h5 payment: %v", err)
}
if !strings.Contains(resp.PaymentURL, "redirect_url=https%3A%2F%2Fmerchant.test%2Freturn") {
t.Fatalf("payment url missing redirect_url: %q", resp.PaymentURL)
}
}
func TestWxpayVerifyNotificationDecryptsAndMapsSuccess(t *testing.T) {
provider, key := testWxpayProvider(t)
body := signedWxpayNotificationBody(t, provider, wxpayTransaction{
AppID: "wx-app-123",
MchID: "mch-123",
OutTradeNo: "ORDER-2",
TransactionID: "4200000001",
TradeState: wxpayTradeSuccess,
SuccessTime: "2026-05-20T12:34:56+08:00",
Amount: wxpayAmount{
Total: 1050,
Currency: wxpayCurrency,
},
})
headers := signWxpayNotificationHeaders(t, key, body)
status, err := provider.VerifyNotification(context.Background(), body, headers)
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 TestWxpayVerifyNotificationRejectsMismatchedMchID(t *testing.T) {
provider, key := testWxpayProvider(t)
body := signedWxpayNotificationBody(t, provider, wxpayTransaction{
MchID: "other-mch",
OutTradeNo: "ORDER-3",
TradeState: wxpayTradeSuccess,
Amount: wxpayAmount{
Total: 100,
Currency: wxpayCurrency,
},
})
headers := signWxpayNotificationHeaders(t, key, body)
if _, err := provider.VerifyNotification(context.Background(), body, headers); err == nil {
t.Fatal("expected mchid mismatch error")
}
}
func TestMapWxpayTradeStatus(t *testing.T) {
tests := []struct {
status string
want string
}{
{wxpayTradeSuccess, payment.OrderStatusSuccess},
{wxpayTradeClosed, payment.OrderStatusClosed},
{wxpayTradeRevoked, payment.OrderStatusClosed},
{wxpayTradePayError, payment.OrderStatusFailed},
{wxpayTradeNotPay, payment.OrderStatusPending},
{"UNKNOWN", payment.OrderStatusFailed},
}
for _, tt := range tests {
if got := mapWxpayTradeStatus(tt.status); got != tt.want {
t.Fatalf("mapWxpayTradeStatus(%q) = %q, want %q", tt.status, got, tt.want)
}
}
}
func signedWxpayNotificationBody(t *testing.T, provider *Wxpay, tx wxpayTransaction) []byte {
t.Helper()
txBody, err := common.Marshal(tx)
if err != nil {
t.Fatalf("marshal tx: %v", err)
}
block, err := aes.NewCipher([]byte(provider.setting.APIv3Key))
if err != nil {
t.Fatalf("aes cipher: %v", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
t.Fatalf("gcm: %v", err)
}
nonce := "nonce-123456"
associatedData := "transaction"
ciphertext := gcm.Seal(nil, []byte(nonce), txBody, []byte(associatedData))
body, err := common.Marshal(wxpayNotification{
ID: "notify-1",
EventType: "TRANSACTION.SUCCESS",
ResourceType: "encrypt-resource",
Resource: wxpayResource{
Algorithm: "AEAD_AES_256_GCM",
Ciphertext: base64.StdEncoding.EncodeToString(ciphertext),
AssociatedData: associatedData,
Nonce: nonce,
},
})
if err != nil {
t.Fatalf("marshal notification: %v", err)
}
return body
}
func signWxpayNotificationHeaders(t *testing.T, key *rsa.PrivateKey, body []byte) map[string]string {
t.Helper()
timestamp := "1779251696"
nonce := "notify-nonce"
message := timestamp + "\n" + nonce + "\n" + string(body) + "\n"
digest := sha256.Sum256([]byte(message))
signature, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
if err != nil {
t.Fatalf("sign notification: %v", err)
}
return map[string]string{
"Wechatpay-Timestamp": timestamp,
"Wechatpay-Nonce": nonce,
"Wechatpay-Signature": base64.StdEncoding.EncodeToString(signature),
"Wechatpay-Serial": "platform-serial",
}
}

22
setting/payment_wxpay.go Normal file
View File

@ -0,0 +1,22 @@
package setting
type WxpaySetting struct {
Enabled bool `json:"enabled"`
AppID string `json:"app_id"`
MchID string `json:"mch_id"`
PrivateKey string `json:"private_key"`
MerchantSerialNo string `json:"merchant_serial_no"`
APIv3Key string `json:"api_v3_key"`
PlatformPublicKey string `json:"platform_public_key"`
PlatformCertificate string `json:"platform_certificate"`
PlatformSerialNo string `json:"platform_serial_no"`
GatewayURL string `json:"gateway_url"`
NotifyURL string `json:"notify_url"`
ReturnURL string `json:"return_url"`
H5AppName string `json:"h5_app_name"`
H5AppURL string `json:"h5_app_url"`
}
var Wxpay = WxpaySetting{
GatewayURL: "https://api.mch.weixin.qq.com",
}