diff --git a/controller/payment_wxpay.go b/controller/payment_wxpay.go new file mode 100644 index 00000000..23d3a183 --- /dev/null +++ b/controller/payment_wxpay.go @@ -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}) +} diff --git a/model/payment_method_guard_test.go b/model/payment_method_guard_test.go index 26518713..2af8b812 100644 --- a/model/payment_method_guard_test.go +++ b/model/payment_method_guard_test.go @@ -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) diff --git a/model/topup.go b/model/topup.go index ca8e2062..2677188e 100644 --- a/model/topup.go +++ b/model/topup.go @@ -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("未提供支付单号") diff --git a/payment/provider/wxpay.go b/payment/provider/wxpay.go new file mode 100644 index 00000000..000db2b0 --- /dev/null +++ b/payment/provider/wxpay.go @@ -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) diff --git a/payment/provider/wxpay_test.go b/payment/provider/wxpay_test.go new file mode 100644 index 00000000..2de43c29 --- /dev/null +++ b/payment/provider/wxpay_test.go @@ -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", + } +} diff --git a/setting/payment_wxpay.go b/setting/payment_wxpay.go new file mode 100644 index 00000000..fbebc436 --- /dev/null +++ b/setting/payment_wxpay.go @@ -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", +}