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))
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@ -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
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