tokenFactory/controller/topup_wechat.go

516 lines
15 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package controller
import (
"crypto"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"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"
)
const (
PaymentMethodWechatPay = "wxpay"
)
var wechatPayAdaptor = &WechatPayAdaptor{}
// WechatPayRequest represents a payment request for WeChat Pay.
type WechatPayRequest struct {
Amount int64 `json:"amount"`
PaymentMethod string `json:"payment_method"`
}
type WechatPayAdaptor struct{}
func getWechatPayAppId() string {
return setting.WechatPayAppId
}
func getWechatPayMchId() string {
if setting.WechatPaySandbox && setting.WechatPaySandboxMchId != "" {
return setting.WechatPaySandboxMchId
}
return setting.WechatPayMchId
}
func getWechatPayApiV3Key() string {
if setting.WechatPaySandbox && setting.WechatPaySandboxApiV3Key != "" {
return setting.WechatPaySandboxApiV3Key
}
return setting.WechatPayApiV3Key
}
func getWechatPayPrivateKey() string {
if setting.WechatPaySandbox && setting.WechatPaySandboxPrivateKey != "" {
return setting.WechatPaySandboxPrivateKey
}
return setting.WechatPayPrivateKey
}
func getWechatPayAPIBaseURL() string {
if setting.WechatPaySandbox {
return "https://apihk.mch.weixin.qq.com/sandboxnew"
}
return "https://api.mch.weixin.qq.com"
}
func getWechatPayMinTopUp() int64 {
minTopup := setting.WechatPayMinTopUp
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
minTopup = minTopup * int(common.QuotaPerUnit)
}
return int64(minTopup)
}
func getWechatPayPayMoney(amount float64, group string) float64 {
originalAmount := amount
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
amount = amount / common.QuotaPerUnit
}
topupGroupRatio := common.GetTopupGroupRatio(group)
if topupGroupRatio == 0 {
topupGroupRatio = 1
}
discount := 1.0
if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(originalAmount)]; ok {
if ds > 0 {
discount = ds
}
}
return amount * setting.WechatPayUnitPrice * topupGroupRatio * discount
}
// wechatPaySign generates the WeChat Pay v3 signature
func wechatPaySign(method string, urlPath string, body string, timestamp string, nonce string, privateKeyStr string) (string, error) {
message := fmt.Sprintf("%s\n%s\n%s\n%s\n", method, urlPath, timestamp, nonce)
if body != "" && body != "{}" {
message = fmt.Sprintf("%s\n%s\n%s\n%s\n", method, urlPath, timestamp, nonce) + body
} else {
message = fmt.Sprintf("%s\n%s\n%s\n%s\n", method, urlPath, timestamp, nonce)
}
block, _ := pem.Decode([]byte(privateKeyStr))
if block == nil {
return "", fmt.Errorf("failed to decode private key PEM")
}
priv, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
priv, err = x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return "", fmt.Errorf("failed to parse private key: %v", err)
}
}
rsaKey, ok := priv.(*rsa.PrivateKey)
if !ok {
return "", fmt.Errorf("private key is not RSA key")
}
hashed := sha256.Sum256([]byte(message))
signature, err := rsa.SignPKCS1v15(rand.Reader, rsaKey, crypto.SHA256, hashed[:])
if err != nil {
return "", fmt.Errorf("sign failed: %v", err)
}
return base64.StdEncoding.EncodeToString(signature), nil
}
// wechatPayBuildAuthHeader builds the WeChat Pay v3 Authorization header
func wechatPayBuildAuthHeader(method string, urlPath string, body string) (string, error) {
mchId := getWechatPayMchId()
privateKey := getWechatPayPrivateKey()
certSerial := setting.WechatPayCertSerial
if mchId == "" || privateKey == "" || certSerial == "" {
return "", fmt.Errorf("WeChat Pay not fully configured")
}
timestamp := fmt.Sprintf("%d", time.Now().Unix())
nonce := common.GetRandomString(32)
sign, err := wechatPaySign(method, urlPath, body, timestamp, nonce, privateKey)
if err != nil {
return "", err
}
auth := fmt.Sprintf(`WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",timestamp="%s",serial_no="%s",signature="%s"`,
mchId, nonce, timestamp, certSerial, sign)
return auth, nil
}
// wechatPayPost sends a POST request to WeChat Pay API v3
func wechatPayPost(path string, requestBody interface{}) (map[string]interface{}, error) {
jsonBytes, err := common.Marshal(requestBody)
if err != nil {
return nil, fmt.Errorf("marshal request body failed: %v", err)
}
bodyStr := string(jsonBytes)
authHeader, err := wechatPayBuildAuthHeader("POST", path, bodyStr)
if err != nil {
return nil, err
}
apiBaseURL := getWechatPayAPIBaseURL()
req, err := http.NewRequest("POST", apiBaseURL+path, strings.NewReader(bodyStr))
if err != nil {
return nil, fmt.Errorf("create request failed: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", authHeader)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "TokenFactory")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %v", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response failed: %v", err)
}
if resp.StatusCode != 200 && resp.StatusCode != 201 && resp.StatusCode != 204 {
return nil, fmt.Errorf("WeChat Pay API error: HTTP %d, body: %s", resp.StatusCode, string(respBody))
}
var result map[string]interface{}
if len(respBody) > 0 {
if err := common.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("parse response failed: %v", err)
}
}
return result, nil
}
// wechatPayDecryptResource decrypts WeChat Pay notification resource
func wechatPayDecryptResource(ciphertext, associatedData, nonce, apiV3Key string) ([]byte, error) {
aesKey, err := base64.StdEncoding.DecodeString(apiV3Key)
if err != nil {
return nil, fmt.Errorf("decode api v3 key failed: %v", err)
}
cipherData, err := base64.StdEncoding.DecodeString(ciphertext)
if err != nil {
return nil, fmt.Errorf("decode ciphertext failed: %v", err)
}
nonceBytes := []byte(nonce)
adBytes := []byte(associatedData)
block, err := aes.NewCipher(aesKey)
if err != nil {
return nil, fmt.Errorf("create cipher failed: %v", err)
}
aesGCM, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("create GCM failed: %v", err)
}
plaintext, err := aesGCM.Open(nil, nonceBytes, cipherData, adBytes)
if err != nil {
return nil, fmt.Errorf("decrypt failed: %v", err)
}
return plaintext, nil
}
func (a *WechatPayAdaptor) RequestPay(c *gin.Context, req *WechatPayRequest) {
if req.PaymentMethod != PaymentMethodWechatPay {
c.JSON(200, gin.H{"message": "error", "data": "不支持的支付渠道"})
return
}
minTopup := getWechatPayMinTopUp()
if req.Amount < minTopup {
c.JSON(200, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", minTopup)})
return
}
if req.Amount > 100000 {
c.JSON(200, gin.H{"message": "error", "data": "充值数量不能大于 100000"})
return
}
id := c.GetInt("id")
user, err := model.GetUserById(id, false)
if err != nil || user == nil {
c.JSON(200, gin.H{"message": "error", "data": "用户不存在"})
return
}
group, err := model.GetUserGroup(id, true)
if err != nil {
c.JSON(200, gin.H{"message": "error", "data": "获取用户分组失败"})
return
}
payMoney := getWechatPayPayMoney(float64(req.Amount), group)
if payMoney <= 0.01 {
c.JSON(200, gin.H{"message": "error", "data": "充值金额过低"})
return
}
tradeNo := fmt.Sprintf("WX%d%s%d", id, common.GetRandomString(12), time.Now().Unix())
topUp := &model.TopUp{
UserId: id,
Amount: req.Amount,
Money: payMoney,
TradeNo: tradeNo,
PaymentMethod: PaymentMethodWechatPay,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
}
err = topUp.Insert()
if err != nil {
log.Printf("创建WeChat Pay订单失败: %v", err)
c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"})
return
}
// Create WeChat Pay order via API v3
callBackAddress := service.GetCallbackAddress()
notifyUrl := strings.TrimRight(callBackAddress, "/") + "/api/wechatpay/notify"
if setting.WechatPayNotifyUrl != "" {
notifyUrl = setting.WechatPayNotifyUrl
}
amountFen := int64(payMoney * 100) // Convert yuan to fen
log.Printf("WeChat Pay创建订单 - 用户: %d, 订单: %s, 金额: %.2f元 (%d分)", id, tradeNo, payMoney, amountFen)
payParams := map[string]interface{}{
"mchid": getWechatPayMchId(),
"out_trade_no": tradeNo,
"appid": getWechatPayAppId(),
"description": "TokenFactory TopUp",
"notify_url": notifyUrl,
"amount": map[string]interface{}{
"total": amountFen,
"currency": "CNY",
},
"payer": map[string]interface{}{
"openid": "", // Will be obtained from WeChat OAuth
},
"scene_info": map[string]interface{}{
"payer_client_ip": c.ClientIP(),
},
}
// For H5 payment
payParams["scene_info"].(map[string]interface{})["h5_info"] = map[string]interface{}{
"type": "Wap",
}
result, err := wechatPayPost("/v3/pay/transactions/h5", payParams)
if err != nil {
log.Printf("WeChat Pay创建订单失败: %v", err)
c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"})
return
}
h5Url := ""
if result != nil {
if urlStr, ok := result["h5_url"].(string); ok {
h5Url = urlStr
}
}
log.Printf("WeChat Pay订单创建成功 - 用户ID: %d, 订单号: %s, 金额: %.2f", id, tradeNo, payMoney)
c.JSON(200, gin.H{
"message": "success",
"data": gin.H{
"pay_url": h5Url,
"trade_no": tradeNo,
},
})
}
// WechatPayNotify handles the WeChat Pay payment notification
func WechatPayNotify(c *gin.Context) {
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
log.Printf("读取WeChat Pay通知失败: %v", err)
c.JSON(http.StatusOK, gin.H{"code": "FAIL", "message": "read body failed"})
return
}
defer c.Request.Body.Close()
log.Printf("收到WeChat Pay通知: %s", string(bodyBytes))
var notifyData struct {
Id string `json:"id"`
CreateTime string `json:"create_time"`
EventType string `json:"event_type"`
ResourceType string `json:"resource_type"`
Resource struct {
Algorithm string `json:"algorithm"`
Ciphertext string `json:"ciphertext"`
AssociatedData string `json:"associated_data"`
Nonce string `json:"nonce"`
OriginalType string `json:"original_type"`
} `json:"resource"`
Summary string `json:"summary"`
}
if err := common.Unmarshal(bodyBytes, &notifyData); err != nil {
log.Printf("解析WeChat Pay通知失败: %v", err)
c.JSON(http.StatusOK, gin.H{"code": "FAIL", "message": "parse failed"})
return
}
apiV3Key := getWechatPayApiV3Key()
if apiV3Key == "" {
log.Printf("WeChat Pay API v3 key not configured")
c.JSON(http.StatusOK, gin.H{"code": "FAIL", "message": "not configured"})
return
}
// Decrypt the resource
plaintext, err := wechatPayDecryptResource(
notifyData.Resource.Ciphertext,
notifyData.Resource.AssociatedData,
notifyData.Resource.Nonce,
apiV3Key,
)
if err != nil {
log.Printf("WeChat Pay通知解密失败: %v", err)
c.JSON(http.StatusOK, gin.H{"code": "FAIL", "message": "decrypt failed"})
return
}
log.Printf("WeChat Pay通知解密成功: %s", string(plaintext))
var paymentResult struct {
AppId string `json:"appid"`
MchId string `json:"mchid"`
OutTradeNo string `json:"out_trade_no"`
TransactionId string `json:"transaction_id"`
TradeType string `json:"trade_type"`
TradeState string `json:"trade_state"`
TradeStateDesc string `json:"trade_state_desc"`
BankType string `json:"bank_type"`
Attach string `json:"attach"`
SuccessTime string `json:"success_time"`
Payer struct {
OpenId string `json:"openid"`
} `json:"payer"`
Amount struct {
Total int `json:"total"`
PayerTotal int `json:"payer_total"`
Currency string `json:"currency"`
PayerCurrency string `json:"payer_currency"`
} `json:"amount"`
}
if err := common.Unmarshal(plaintext, &paymentResult); err != nil {
log.Printf("解析WeChat Pay支付结果失败: %v", err)
c.JSON(http.StatusOK, gin.H{"code": "FAIL", "message": "parse result failed"})
return
}
tradeNo := paymentResult.OutTradeNo
log.Printf("WeChat Pay支付结果: out_trade_no=%s, transaction_id=%s, trade_state=%s",
tradeNo, paymentResult.TransactionId, paymentResult.TradeState)
if paymentResult.TradeState != "SUCCESS" {
log.Printf("WeChat Pay支付未成功: %s, trade_state=%s", tradeNo, paymentResult.TradeState)
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS", "message": "ok"})
return
}
LockOrder(tradeNo)
defer UnlockOrder(tradeNo)
topUp := model.GetTopUpByTradeNo(tradeNo)
if topUp == nil {
log.Printf("WeChat Pay通知未找到订单: %s", tradeNo)
c.JSON(http.StatusOK, gin.H{"code": "FAIL", "message": "order not found"})
return
}
if topUp.Status != common.TopUpStatusPending {
log.Printf("WeChat Pay订单状态错误: %s, 当前状态: %s", tradeNo, topUp.Status)
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS", "message": "ok"})
return
}
dAmount := decimal.NewFromInt(topUp.Amount)
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
quotaToAdd := int(dAmount.Mul(dQuotaPerUnit).IntPart())
if quotaToAdd <= 0 {
log.Printf("WeChat Pay无效的充值额度: %s", tradeNo)
c.JSON(http.StatusOK, gin.H{"code": "FAIL", "message": "invalid quota"})
return
}
topUp.CompleteTime = common.GetTimestamp()
topUp.Status = common.TopUpStatusSuccess
if err := topUp.Update(); err != nil {
log.Printf("WeChat Pay更新订单失败: %v", err)
c.JSON(http.StatusOK, gin.H{"code": "FAIL", "message": "update failed"})
return
}
if err := model.IncreaseUserQuota(topUp.UserId, quotaToAdd, true); err != nil {
log.Printf("WeChat Pay更新用户额度失败: %v", err)
c.JSON(http.StatusOK, gin.H{"code": "FAIL", "message": "quota update failed"})
return
}
model.RecordLog(topUp.UserId, model.LogTypeTopup,
fmt.Sprintf("使用微信支付充值成功,充值金额: %v支付金额%.2f",
logger.LogQuota(quotaToAdd), topUp.Money))
model.ApplyAffiliateTopupReward(topUp.UserId, quotaToAdd)
log.Printf("WeChat Pay充值成功 - 订单: %s, 额度: %d", tradeNo, quotaToAdd)
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS", "message": "ok"})
}
// RequestWechatPay handles the WeChat Pay payment request from frontend
func RequestWechatPay(c *gin.Context) {
if !setting.WechatPayEnabled {
c.JSON(200, gin.H{"message": "error", "data": "微信支付未启用"})
return
}
var req WechatPayRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
return
}
wechatPayAdaptor.RequestPay(c, &req)
}