464 lines
12 KiB
Go
464 lines
12 KiB
Go
package controller
|
||
|
||
import (
|
||
"crypto"
|
||
"crypto/rand"
|
||
"crypto/rsa"
|
||
"crypto/sha256"
|
||
"crypto/x509"
|
||
"encoding/base64"
|
||
"encoding/pem"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"net/http"
|
||
"net/url"
|
||
"sort"
|
||
"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/QuantumNous/new-api/setting/system_setting"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/shopspring/decimal"
|
||
)
|
||
|
||
const (
|
||
PaymentMethodAlipay = "alipay"
|
||
)
|
||
|
||
var alipayAdaptor = &AlipayAdaptor{}
|
||
|
||
// AlipayPayRequest represents a payment request for Alipay checkout.
|
||
type AlipayPayRequest struct {
|
||
Amount int64 `json:"amount"`
|
||
PaymentMethod string `json:"payment_method"`
|
||
}
|
||
|
||
type AlipayAdaptor struct{}
|
||
|
||
func getAlipayAppId() string {
|
||
if setting.AlipaySandbox && setting.AlipaySandboxAppId != "" {
|
||
return setting.AlipaySandboxAppId
|
||
}
|
||
return setting.AlipayAppId
|
||
}
|
||
|
||
func getAlipayPrivateKey() string {
|
||
if setting.AlipaySandbox && setting.AlipaySandboxPrivateKey != "" {
|
||
return setting.AlipaySandboxPrivateKey
|
||
}
|
||
return setting.AlipayPrivateKey
|
||
}
|
||
|
||
func getAlipayPublicKey() string {
|
||
return setting.AlipayPublicKey
|
||
}
|
||
|
||
func getAlipayGatewayURL() string {
|
||
if setting.AlipaySandbox {
|
||
return "https://openapi-sandbox.dl.alipaydev.com/gateway.do"
|
||
}
|
||
return "https://openapi.alipay.com/gateway.do"
|
||
}
|
||
|
||
func getAlipayMinTopUp() int64 {
|
||
minTopup := setting.AlipayMinTopUp
|
||
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
|
||
minTopup = minTopup * int(common.QuotaPerUnit)
|
||
}
|
||
return int64(minTopup)
|
||
}
|
||
|
||
func getAlipayPayMoney(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.AlipayUnitPrice * topupGroupRatio * discount
|
||
}
|
||
|
||
// alipaySign generates RSA2 signature for Alipay params
|
||
func alipaySign(params map[string]string, privateKey string) (string, error) {
|
||
keys := make([]string, 0, len(params))
|
||
for k := range params {
|
||
keys = append(keys, k)
|
||
}
|
||
sort.Strings(keys)
|
||
|
||
var buf strings.Builder
|
||
for i, k := range keys {
|
||
v := params[k]
|
||
if v == "" {
|
||
continue
|
||
}
|
||
if i > 0 {
|
||
buf.WriteString("&")
|
||
}
|
||
buf.WriteString(k)
|
||
buf.WriteString("=")
|
||
buf.WriteString(v)
|
||
}
|
||
|
||
signStr := buf.String()
|
||
|
||
block, _ := pem.Decode([]byte(privateKey))
|
||
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(signStr))
|
||
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
|
||
}
|
||
|
||
// alipayVerify verifies Alipay notification signature
|
||
func alipayVerify(params map[string]string, publicKey string) bool {
|
||
sign, ok := params["sign"]
|
||
if !ok || sign == "" {
|
||
return false
|
||
}
|
||
|
||
keys := make([]string, 0, len(params))
|
||
for k := range params {
|
||
if k == "sign" || k == "sign_type" {
|
||
continue
|
||
}
|
||
keys = append(keys, k)
|
||
}
|
||
sort.Strings(keys)
|
||
|
||
var buf strings.Builder
|
||
for i, k := range keys {
|
||
v := params[k]
|
||
if v == "" {
|
||
continue
|
||
}
|
||
if i > 0 {
|
||
buf.WriteString("&")
|
||
}
|
||
buf.WriteString(k)
|
||
buf.WriteString("=")
|
||
buf.WriteString(v)
|
||
}
|
||
|
||
signStr := buf.String()
|
||
|
||
sigBytes, err := base64.StdEncoding.DecodeString(sign)
|
||
if err != nil {
|
||
log.Printf("Alipay verify: base64 decode sign failed: %v", err)
|
||
return false
|
||
}
|
||
|
||
block, _ := pem.Decode([]byte(publicKey))
|
||
if block == nil {
|
||
log.Printf("Alipay verify: failed to decode public key PEM")
|
||
return false
|
||
}
|
||
|
||
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||
if err != nil {
|
||
log.Printf("Alipay verify: failed to parse public key: %v", err)
|
||
return false
|
||
}
|
||
|
||
rsaPub, ok := pub.(*rsa.PublicKey)
|
||
if !ok {
|
||
log.Printf("Alipay verify: public key is not RSA key")
|
||
return false
|
||
}
|
||
|
||
hashed := sha256.Sum256([]byte(signStr))
|
||
err = rsa.VerifyPKCS1v15(rsaPub, crypto.SHA256, hashed[:], sigBytes)
|
||
return err == nil
|
||
}
|
||
|
||
func (a *AlipayAdaptor) RequestPay(c *gin.Context, req *AlipayPayRequest) {
|
||
if req.PaymentMethod != PaymentMethodAlipay {
|
||
c.JSON(200, gin.H{"message": "error", "data": "不支持的支付渠道"})
|
||
return
|
||
}
|
||
|
||
minTopup := getAlipayMinTopUp()
|
||
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 := getAlipayPayMoney(float64(req.Amount), group)
|
||
if payMoney <= 0.01 {
|
||
c.JSON(200, gin.H{"message": "error", "data": "充值金额过低"})
|
||
return
|
||
}
|
||
|
||
tradeNo := fmt.Sprintf("ALI%d%s%d", id, common.GetRandomString(12), time.Now().Unix())
|
||
|
||
topUp := &model.TopUp{
|
||
UserId: id,
|
||
Amount: req.Amount,
|
||
Money: payMoney,
|
||
TradeNo: tradeNo,
|
||
PaymentMethod: PaymentMethodAlipay,
|
||
CreateTime: time.Now().Unix(),
|
||
Status: common.TopUpStatusPending,
|
||
}
|
||
err = topUp.Insert()
|
||
if err != nil {
|
||
log.Printf("创建Alipay订单失败: %v", err)
|
||
c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"})
|
||
return
|
||
}
|
||
|
||
payUrl, err := genAlipayPagePayUrl(tradeNo, payMoney, user.Email)
|
||
if err != nil {
|
||
log.Printf("生成Alipay支付链接失败: %v", err)
|
||
c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"})
|
||
return
|
||
}
|
||
|
||
log.Printf("Alipay订单创建成功 - 用户ID: %d, 订单号: %s, 金额: %.2f", id, tradeNo, payMoney)
|
||
|
||
c.JSON(200, gin.H{
|
||
"message": "success",
|
||
"data": gin.H{
|
||
"pay_url": payUrl,
|
||
"trade_no": tradeNo,
|
||
},
|
||
})
|
||
}
|
||
|
||
// genAlipayPagePayUrl generates an Alipay page payment URL
|
||
func genAlipayPagePayUrl(tradeNo string, payMoney float64, email string) (string, error) {
|
||
appId := getAlipayAppId()
|
||
privateKey := getAlipayPrivateKey()
|
||
|
||
if appId == "" || privateKey == "" {
|
||
return "", fmt.Errorf("Alipay not configured")
|
||
}
|
||
|
||
callBackAddress := service.GetCallbackAddress()
|
||
notifyUrl := strings.TrimRight(callBackAddress, "/") + "/api/alipay/notify"
|
||
if setting.AlipayNotifyUrl != "" {
|
||
notifyUrl = setting.AlipayNotifyUrl
|
||
}
|
||
|
||
returnUrl := system_setting.ServerAddress + "/console/log"
|
||
if setting.AlipayReturnUrl != "" {
|
||
returnUrl = setting.AlipayReturnUrl
|
||
}
|
||
|
||
amountStr := fmt.Sprintf("%.2f", payMoney)
|
||
|
||
params := map[string]string{
|
||
"app_id": appId,
|
||
"method": "alipay.trade.page.pay",
|
||
"charset": "utf-8",
|
||
"sign_type": "RSA2",
|
||
"timestamp": time.Now().Format("2006-01-02 15:04:05"),
|
||
"version": "1.0",
|
||
"notify_url": notifyUrl,
|
||
"return_url": returnUrl,
|
||
"biz_content": fmt.Sprintf(`{"out_trade_no":"%s","product_code":"FAST_INSTANT_TRADE_PAY","total_amount":"%s","subject":"TokenFactory TopUp"}`, tradeNo, amountStr),
|
||
}
|
||
|
||
sign, err := alipaySign(params, privateKey)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
params["sign"] = sign
|
||
|
||
gatewayUrl := getAlipayGatewayURL()
|
||
queryParams := url.Values{}
|
||
for k, v := range params {
|
||
queryParams.Set(k, v)
|
||
}
|
||
|
||
return gatewayUrl + "?" + queryParams.Encode(), nil
|
||
}
|
||
|
||
// AlipayNotify handles the Alipay async notification
|
||
func AlipayNotify(c *gin.Context) {
|
||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||
if err != nil {
|
||
log.Printf("读取Alipay通知失败: %v", err)
|
||
c.String(http.StatusOK, "failure")
|
||
return
|
||
}
|
||
defer c.Request.Body.Close()
|
||
|
||
bodyStr := string(bodyBytes)
|
||
formValues, err := url.ParseQuery(bodyStr)
|
||
if err != nil {
|
||
log.Printf("解析Alipay通知参数失败: %v", err)
|
||
c.String(http.StatusOK, "failure")
|
||
return
|
||
}
|
||
|
||
params := make(map[string]string)
|
||
for k, v := range formValues {
|
||
if len(v) > 0 {
|
||
params[k] = v[0]
|
||
}
|
||
}
|
||
|
||
log.Printf("收到Alipay异步通知: trade_no=%s, out_trade_no=%s, trade_status=%s",
|
||
params["trade_no"], params["out_trade_no"], params["trade_status"])
|
||
|
||
publicKey := getAlipayPublicKey()
|
||
if publicKey != "" && !alipayVerify(params, publicKey) {
|
||
log.Printf("Alipay通知签名验证失败: %v", params)
|
||
c.String(http.StatusOK, "failure")
|
||
return
|
||
}
|
||
|
||
tradeStatus := params["trade_status"]
|
||
outTradeNo := params["out_trade_no"]
|
||
|
||
if tradeStatus == "TRADE_SUCCESS" || tradeStatus == "TRADE_FINISHED" {
|
||
LockOrder(outTradeNo)
|
||
defer UnlockOrder(outTradeNo)
|
||
|
||
topUp := model.GetTopUpByTradeNo(outTradeNo)
|
||
if topUp == nil {
|
||
log.Printf("Alipay通知未找到订单: %s", outTradeNo)
|
||
c.String(http.StatusOK, "failure")
|
||
return
|
||
}
|
||
|
||
if topUp.Status != common.TopUpStatusPending {
|
||
log.Printf("Alipay订单状态错误: %s, 当前状态: %s", outTradeNo, topUp.Status)
|
||
c.String(http.StatusOK, "success")
|
||
return
|
||
}
|
||
|
||
dAmount := decimal.NewFromInt(topUp.Amount)
|
||
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
|
||
quotaToAdd := int(dAmount.Mul(dQuotaPerUnit).IntPart())
|
||
|
||
if quotaToAdd <= 0 {
|
||
log.Printf("Alipay无效的充值额度: %s", outTradeNo)
|
||
c.String(http.StatusOK, "failure")
|
||
return
|
||
}
|
||
|
||
topUp.CompleteTime = common.GetTimestamp()
|
||
topUp.Status = common.TopUpStatusSuccess
|
||
if err := topUp.Update(); err != nil {
|
||
log.Printf("Alipay更新订单失败: %v", err)
|
||
c.String(http.StatusOK, "failure")
|
||
return
|
||
}
|
||
|
||
if err := model.IncreaseUserQuota(topUp.UserId, quotaToAdd, true); err != nil {
|
||
log.Printf("Alipay更新用户额度失败: %v", err)
|
||
c.String(http.StatusOK, "failure")
|
||
return
|
||
}
|
||
|
||
model.RecordLog(topUp.UserId, model.LogTypeTopup,
|
||
fmt.Sprintf("使用支付宝充值成功,充值金额: %v,支付金额:%.2f",
|
||
logger.LogQuota(quotaToAdd), topUp.Money))
|
||
model.ApplyAffiliateTopupReward(topUp.UserId, quotaToAdd)
|
||
|
||
log.Printf("Alipay充值成功 - 订单: %s, 额度: %d", outTradeNo, quotaToAdd)
|
||
}
|
||
|
||
c.String(http.StatusOK, "success")
|
||
}
|
||
|
||
// AlipayReturn handles the Alipay synchronous return (page redirect)
|
||
func AlipayReturn(c *gin.Context) {
|
||
params := make(map[string]string)
|
||
for k, v := range c.Request.URL.Query() {
|
||
if len(v) > 0 {
|
||
params[k] = v[0]
|
||
}
|
||
}
|
||
|
||
log.Printf("Alipay同步返回: out_trade_no=%s, trade_no=%s", params["out_trade_no"], params["trade_no"])
|
||
|
||
publicKey := getAlipayPublicKey()
|
||
if publicKey != "" && !alipayVerify(params, publicKey) {
|
||
log.Printf("Alipay返回签名验证失败")
|
||
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup")
|
||
return
|
||
}
|
||
|
||
outTradeNo := params["out_trade_no"]
|
||
if outTradeNo != "" {
|
||
topUp := model.GetTopUpByTradeNo(outTradeNo)
|
||
if topUp != nil && topUp.Status == common.TopUpStatusSuccess {
|
||
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/log")
|
||
return
|
||
}
|
||
}
|
||
|
||
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup")
|
||
}
|
||
|
||
// RequestAlipayPay handles the Alipay payment request from frontend
|
||
func RequestAlipayPay(c *gin.Context) {
|
||
if !setting.AlipayEnabled {
|
||
c.JSON(200, gin.H{"message": "error", "data": "支付宝支付未启用"})
|
||
return
|
||
}
|
||
|
||
var req AlipayPayRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
|
||
return
|
||
}
|
||
|
||
alipayAdaptor.RequestPay(c, &req)
|
||
}
|