Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ tiktoken_cache
plans
.claude
.cursor
.codex
output
skills

electron/node_modules
electron/dist
Expand Down
2 changes: 1 addition & 1 deletion controller/option.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ var completionRatioMetaOptionKeys = []string{

func isVisiblePublicKeyOption(key string) bool {
switch key {
case "WaffoPancakeWebhookPublicKey", "WaffoPancakeWebhookTestKey":
case "AlipayPublicKey", "WaffoPancakeWebhookPublicKey", "WaffoPancakeWebhookTestKey":
return true
default:
return false
Expand Down
12 changes: 12 additions & 0 deletions controller/payment_webhook_availability.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,22 @@ package controller
import (
"strings"

"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
)

func isAlipayConfigured() bool {
return strings.TrimSpace(setting.AlipayAppId) != "" &&
strings.TrimSpace(setting.AlipayPrivateKey) != "" &&
strings.TrimSpace(setting.AlipayPublicKey) != ""
}

func isAlipayTopUpEnabled() bool {
return isAlipayConfigured() &&
operation_setting.ContainsPayMethod(model.PaymentMethodAlipay)
}

func isStripeTopUpEnabled() bool {
return strings.TrimSpace(setting.StripeApiSecret) != "" &&
strings.TrimSpace(setting.StripeWebhookSecret) != "" &&
Expand Down
14 changes: 13 additions & 1 deletion controller/topup.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ import (
func GetTopUpInfo(c *gin.Context) {
// 获取支付方式
payMethods := operation_setting.PayMethods
enableEpay := isEpayTopUpEnabled()
enableAlipay := isAlipayTopUpEnabled()
payMethods = lo.Filter(payMethods, func(method map[string]string, _ int) bool {
if method["type"] == model.PaymentMethodAlipay {
return enableEpay || enableAlipay
}
return enableEpay
})

// 如果启用了 Stripe 支付,添加到支付方法列表
if isStripeTopUpEnabled() {
Expand Down Expand Up @@ -91,7 +99,7 @@ func GetTopUpInfo(c *gin.Context) {
}

data := gin.H{
"enable_online_topup": isEpayTopUpEnabled(),
"enable_online_topup": len(payMethods) > 0 && (enableEpay || enableAlipay),
"enable_stripe_topup": isStripeTopUpEnabled(),
"enable_creem_topup": isCreemTopUpEnabled(),
"enable_waffo_topup": enableWaffo,
Expand Down Expand Up @@ -206,6 +214,10 @@ func RequestEpay(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "支付方式不存在"})
return
}
if req.PaymentMethod == model.PaymentMethodAlipay && isAlipayTopUpEnabled() {
requestAlipayPagePay(c, &req, id, payMoney)
return
}

callBackAddress := service.GetCallbackAddress()
returnUrl, _ := url.Parse(system_setting.ServerAddress + "/console/log")
Expand Down
184 changes: 184 additions & 0 deletions controller/topup_alipay.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
package controller

import (
"fmt"
"net/http"
"strconv"
"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"
alipay "github.com/smartwalle/alipay/v3"
)

func getAlipayClient() (*alipay.Client, error) {
client, err := alipay.New(setting.AlipayAppId, setting.AlipayPrivateKey, !setting.AlipaySandbox)
if err != nil {
return nil, err
}
if err = client.LoadAliPayPublicKey(setting.AlipayPublicKey); err != nil {
return nil, err
}
return client, nil
}

func getAlipayNotifyUrl() string {
if strings.TrimSpace(setting.AlipayNotifyUrl) != "" {
return strings.TrimSpace(setting.AlipayNotifyUrl)
}
return service.GetCallbackAddress() + "/api/user/alipay/notify"
}

func getAlipayReturnUrl() string {
if strings.TrimSpace(setting.AlipayReturnUrl) != "" {
return strings.TrimSpace(setting.AlipayReturnUrl)
}
return system_setting.ServerAddress + "/console/topup?show_history=true"
}

func requestAlipayPagePay(c *gin.Context, req *EpayRequest, userId int, payMoney float64) {
client, err := getAlipayClient()
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("支付宝 client 初始化失败 user_id=%d amount=%d error=%q", userId, req.Amount, err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "当前管理员未配置支付宝支付信息"})
return
}

tradeNo := fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix())
tradeNo = fmt.Sprintf("USR%dNO%s", userId, tradeNo)

payload := alipay.NewPayload("alipay.trade.page.pay")
payload.AddParam("notify_url", getAlipayNotifyUrl())
payload.AddParam("return_url", getAlipayReturnUrl())
payload.AddBizField("out_trade_no", tradeNo)
payload.AddBizField("subject", fmt.Sprintf("TUC%d", req.Amount))
payload.AddBizField("total_amount", strconv.FormatFloat(payMoney, 'f', 2, 64))
payload.AddBizField("product_code", "FAST_INSTANT_TRADE_PAY")

payUrl, err := client.BuildURL(payload)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("支付宝 拉起支付失败 user_id=%d trade_no=%s amount=%d error=%q", userId, tradeNo, req.Amount, err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
return
}

amount := req.Amount
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
dAmount := decimal.NewFromInt(amount)
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
amount = dAmount.Div(dQuotaPerUnit).IntPart()
}

topUp := &model.TopUp{
UserId: userId,
Amount: amount,
Money: payMoney,
TradeNo: tradeNo,
PaymentMethod: model.PaymentMethodAlipay,
PaymentProvider: model.PaymentProviderAlipay,
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", userId, tradeNo, req.Amount, err.Error()))
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
return
}

logger.LogInfo(c.Request.Context(), fmt.Sprintf("支付宝 充值订单创建成功 user_id=%d trade_no=%s amount=%d money=%.2f pay_url=%q", userId, tradeNo, req.Amount, payMoney, payUrl.String()))
c.JSON(http.StatusOK, gin.H{"message": "success", "data": gin.H{}, "url": payUrl.String(), "method": http.MethodGet})
}

func AlipayNotify(c *gin.Context) {
if !isAlipayConfigured() {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("支付宝 webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
_, _ = c.Writer.Write([]byte("fail"))
return
}

if err := c.Request.ParseForm(); err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("支付宝 webhook 表单解析失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
_, _ = c.Writer.Write([]byte("fail"))
return
}

client, err := getAlipayClient()
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("支付宝 client 未初始化 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
_, _ = c.Writer.Write([]byte("fail"))
return
}

if err = client.VerifySign(c.Request.Form); err != nil {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("支付宝 webhook 验签失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
_, _ = c.Writer.Write([]byte("fail"))
return
}

alipay.ACKNotification(c.Writer)

tradeNo := c.Request.Form.Get("out_trade_no")
tradeStatus := c.Request.Form.Get("trade_status")
appId := c.Request.Form.Get("app_id")
totalAmount := c.Request.Form.Get("total_amount")

logger.LogInfo(c.Request.Context(), fmt.Sprintf("支付宝 webhook 验签成功 trade_no=%s trade_status=%s client_ip=%s params=%q", tradeNo, tradeStatus, c.ClientIP(), common.GetJsonString(c.Request.Form)))

if appId != setting.AlipayAppId {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("支付宝 webhook app_id 不匹配 trade_no=%s callback_app_id=%s local_app_id=%s client_ip=%s", tradeNo, appId, setting.AlipayAppId, c.ClientIP()))
return
}

if tradeStatus != "TRADE_SUCCESS" && tradeStatus != "TRADE_FINISHED" {
logger.LogInfo(c.Request.Context(), fmt.Sprintf("支付宝 webhook 忽略事件 trade_no=%s trade_status=%s client_ip=%s", tradeNo, tradeStatus, c.ClientIP()))
return
}

LockOrder(tradeNo)
defer UnlockOrder(tradeNo)

topUp := model.GetTopUpByTradeNo(tradeNo)
if topUp == nil {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("支付宝 回调订单不存在 trade_no=%s client_ip=%s", tradeNo, c.ClientIP()))
return
}
if topUp.PaymentProvider != model.PaymentProviderAlipay {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("支付宝 订单支付网关不匹配 trade_no=%s order_provider=%s client_ip=%s", tradeNo, topUp.PaymentProvider, c.ClientIP()))
return
}
if topUp.Status != common.TopUpStatusPending {
return
}

expectedAmount := strconv.FormatFloat(topUp.Money, 'f', 2, 64)
if totalAmount != expectedAmount {
logger.LogWarn(c.Request.Context(), fmt.Sprintf("支付宝 回调金额不匹配 trade_no=%s callback_total_amount=%s expected_total_amount=%s client_ip=%s", tradeNo, totalAmount, expectedAmount, c.ClientIP()))
return
}

topUp.Status = common.TopUpStatusSuccess
if err = topUp.Update(); err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("支付宝 更新充值订单失败 trade_no=%s user_id=%d client_ip=%s error=%q topup=%q", topUp.TradeNo, topUp.UserId, c.ClientIP(), err.Error(), common.GetJsonString(topUp)))
return
}

dAmount := decimal.NewFromInt(int64(topUp.Amount))
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
quotaToAdd := int(dAmount.Mul(dQuotaPerUnit).IntPart())
if err = model.IncreaseUserQuota(topUp.UserId, quotaToAdd, true); err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("支付宝 更新用户额度失败 trade_no=%s user_id=%d client_ip=%s quota_to_add=%d error=%q topup=%q", topUp.TradeNo, topUp.UserId, c.ClientIP(), quotaToAdd, err.Error(), common.GetJsonString(topUp)))
return
}

logger.LogInfo(c.Request.Context(), fmt.Sprintf("支付宝 充值成功 trade_no=%s user_id=%d client_ip=%s quota_to_add=%d money=%.2f topup=%q", topUp.TradeNo, topUp.UserId, c.ClientIP(), quotaToAdd, topUp.Money, common.GetJsonString(topUp)))
model.RecordTopupLog(topUp.UserId, fmt.Sprintf("使用支付宝充值成功,充值金额: %v,支付金额:%f", logger.LogQuota(quotaToAdd), topUp.Money), c.ClientIP(), topUp.PaymentMethod, model.PaymentProviderAlipay)
}
7 changes: 7 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ require (
gorm.io/gorm v1.25.2
)

require (
github.com/smartwalle/alipay/v3 v3.2.29 // indirect
github.com/smartwalle/ncrypto v1.0.4 // indirect
github.com/smartwalle/ngx v1.1.0 // indirect
github.com/smartwalle/nsign v1.0.9 // indirect
)

require (
github.com/DmitriyVTitov/size v1.5.0 // indirect
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 // indirect
Expand Down
8 changes: 8 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,14 @@ github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKl
github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/smartwalle/alipay/v3 v3.2.29 h1:roGFqlml8hDa//0TpFmlyxZhndTYs7rbYLu/HlNFNJo=
github.com/smartwalle/alipay/v3 v3.2.29/go.mod h1:XarBLuAkwK3ah7mYjVtghRu+ysxzlex9sRkgqNMzMRU=
github.com/smartwalle/ncrypto v1.0.4 h1:P2rqQxDepJwgeO5ShoC+wGcK2wNJDmcdBOWAksuIgx8=
github.com/smartwalle/ncrypto v1.0.4/go.mod h1:Dwlp6sfeNaPMnOxMNayMTacvC5JGEVln3CVdiVDgbBk=
github.com/smartwalle/ngx v1.1.0 h1:q8nANgWSPRGeI/u+ixBoA4mf68DrUq6vZ+n9L5UKv9I=
github.com/smartwalle/ngx v1.1.0/go.mod h1:mx/nz2Pk5j+RBs7t6u6k22MPiBG/8CtOMpCnALIG8Y0=
github.com/smartwalle/nsign v1.0.9 h1:8poAgG7zBd8HkZy9RQDwasC6XZvJpDGQWSjzL2FZL6E=
github.com/smartwalle/nsign v1.0.9/go.mod h1:eY6I4CJlyNdVMP+t6z1H6Jpd4m5/V+8xi44ufSTxXgc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
Expand Down
18 changes: 18 additions & 0 deletions model/option.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ func InitOptionMap() {
common.OptionMap["CustomCallbackAddress"] = ""
common.OptionMap["EpayId"] = ""
common.OptionMap["EpayKey"] = ""
common.OptionMap["AlipayAppId"] = setting.AlipayAppId
common.OptionMap["AlipayPrivateKey"] = setting.AlipayPrivateKey
common.OptionMap["AlipayPublicKey"] = setting.AlipayPublicKey
common.OptionMap["AlipaySandbox"] = strconv.FormatBool(setting.AlipaySandbox)
common.OptionMap["AlipayNotifyUrl"] = setting.AlipayNotifyUrl
common.OptionMap["AlipayReturnUrl"] = setting.AlipayReturnUrl
common.OptionMap["Price"] = strconv.FormatFloat(operation_setting.Price, 'f', -1, 64)
common.OptionMap["USDExchangeRate"] = strconv.FormatFloat(operation_setting.USDExchangeRate, 'f', -1, 64)
common.OptionMap["MinTopUp"] = strconv.Itoa(operation_setting.MinTopUp)
Expand Down Expand Up @@ -327,6 +333,8 @@ func updateOptionMap(key string, value string) (err error) {
system_setting.WorkerAllowHttpImageRequestEnabled = boolValue
case "DefaultUseAutoGroup":
setting.DefaultUseAutoGroup = boolValue
case "AlipaySandbox":
setting.AlipaySandbox = boolValue
case "ExposeRatioEnabled":
ratio_setting.SetExposeRatioEnabled(boolValue)
}
Expand Down Expand Up @@ -363,6 +371,16 @@ func updateOptionMap(key string, value string) (err error) {
operation_setting.EpayId = value
case "EpayKey":
operation_setting.EpayKey = value
case "AlipayAppId":
setting.AlipayAppId = value
case "AlipayPrivateKey":
setting.AlipayPrivateKey = value
case "AlipayPublicKey":
setting.AlipayPublicKey = value
case "AlipayNotifyUrl":
setting.AlipayNotifyUrl = value
case "AlipayReturnUrl":
setting.AlipayReturnUrl = value
case "Price":
operation_setting.Price, _ = strconv.ParseFloat(value, 64)
case "USDExchangeRate":
Expand Down
2 changes: 2 additions & 0 deletions model/topup.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@ type TopUp struct {
}

const (
PaymentMethodAlipay = "alipay"
PaymentMethodStripe = "stripe"
PaymentMethodCreem = "creem"
PaymentMethodWaffo = "waffo"
PaymentMethodWaffoPancake = "waffo_pancake"
)

const (
PaymentProviderAlipay = "alipay"
PaymentProviderEpay = "epay"
PaymentProviderStripe = "stripe"
PaymentProviderCreem = "creem"
Expand Down
1 change: 1 addition & 0 deletions router/api-router.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ func SetApiRouter(router *gin.Engine) {
userRoute.GET("/logout", controller.Logout)
userRoute.POST("/epay/notify", controller.EpayNotify)
userRoute.GET("/epay/notify", controller.EpayNotify)
userRoute.POST("/alipay/notify", controller.AlipayNotify)
userRoute.GET("/groups", controller.GetUserGroups)

selfRoute := userRoute.Group("/")
Expand Down
10 changes: 10 additions & 0 deletions setting/payment_alipay.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package setting

var (
AlipayAppId = ""
AlipayPrivateKey = ""
AlipayPublicKey = ""
AlipaySandbox = false
AlipayNotifyUrl = ""
AlipayReturnUrl = ""
)
6 changes: 6 additions & 0 deletions web/default/src/features/system-settings/billing/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ const defaultBillingSettings: BillingSettings = {
PayAddress: '',
EpayId: '',
EpayKey: '',
AlipayAppId: '',
AlipayPrivateKey: '',
AlipayPublicKey: '',
AlipaySandbox: false,
AlipayNotifyUrl: '',
AlipayReturnUrl: '',
Price: 7.3,
MinTopUp: 1,
CustomCallbackAddress: '',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,12 @@ const BILLING_SECTIONS = [
PayAddress: settings.PayAddress,
EpayId: settings.EpayId,
EpayKey: settings.EpayKey,
AlipayAppId: settings.AlipayAppId,
AlipayPrivateKey: settings.AlipayPrivateKey,
AlipayPublicKey: settings.AlipayPublicKey,
AlipaySandbox: settings.AlipaySandbox,
AlipayNotifyUrl: settings.AlipayNotifyUrl,
AlipayReturnUrl: settings.AlipayReturnUrl,
Price: settings.Price,
MinTopUp: settings.MinTopUp,
CustomCallbackAddress: settings.CustomCallbackAddress,
Expand Down
Loading