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:
parent
6405a21e60
commit
56a9b1e956
195
controller/payment_wxpay.go
Normal file
195
controller/payment_wxpay.go
Normal 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})
|
||||||
|
}
|
||||||
@ -221,6 +221,50 @@ func TestRechargeWaffo_AppliesPromoCodeBonusIdempotently(t *testing.T) {
|
|||||||
assert.Equal(t, int64(1), countPromoCodeUsagesForPaymentGuardTest(t, promoCode.Id, 181))
|
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) {
|
func TestCompleteSubscriptionOrder_RejectsMismatchedPaymentProvider(t *testing.T) {
|
||||||
truncateTables(t)
|
truncateTables(t)
|
||||||
|
|
||||||
|
|||||||
@ -34,6 +34,7 @@ const (
|
|||||||
PaymentMethodWaffo = "waffo"
|
PaymentMethodWaffo = "waffo"
|
||||||
PaymentMethodWaffoPancake = "waffo_pancake"
|
PaymentMethodWaffoPancake = "waffo_pancake"
|
||||||
PaymentMethodAlipay = "alipay"
|
PaymentMethodAlipay = "alipay"
|
||||||
|
PaymentMethodWxpay = "wxpay"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -43,6 +44,7 @@ const (
|
|||||||
PaymentProviderWaffo = "waffo"
|
PaymentProviderWaffo = "waffo"
|
||||||
PaymentProviderWaffoPancake = "waffo_pancake"
|
PaymentProviderWaffoPancake = "waffo_pancake"
|
||||||
PaymentProviderAlipay = "alipay"
|
PaymentProviderAlipay = "alipay"
|
||||||
|
PaymentProviderWxpay = "wxpay"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@ -818,6 +820,73 @@ func RechargeAlipay(tradeNo string, callerIp string) (err error) {
|
|||||||
return nil
|
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) {
|
func RechargeWaffoPancake(tradeNo string) (err error) {
|
||||||
if tradeNo == "" {
|
if tradeNo == "" {
|
||||||
return errors.New("未提供支付单号")
|
return errors.New("未提供支付单号")
|
||||||
|
|||||||
585
payment/provider/wxpay.go
Normal file
585
payment/provider/wxpay.go
Normal 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, ¬ification); 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)
|
||||||
266
payment/provider/wxpay_test.go
Normal file
266
payment/provider/wxpay_test.go
Normal 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
22
setting/payment_wxpay.go
Normal 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",
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user