From 247ae0988fd6d1aeb1a736cc8d3db36d5e37e9ce Mon Sep 17 00:00:00 2001 From: wozulong <> Date: Fri, 22 Mar 2024 18:00:20 +0800 Subject: [PATCH] replace epay with stripe Signed-off-by: wozulong <> --- common/constants.go | 18 ++- common/hash.go | 84 +++++++++++ common/logger.go | 8 + controller/misc.go | 4 +- controller/stripe.go | 96 ++++++++++++ controller/topup.go | 226 ++++++++++------------------ go.mod | 1 + go.sum | 4 + model/option.go | 30 ++-- model/topup.go | 70 ++++++++- router/api-router.go | 7 +- service/epay.go | 10 -- web/src/components/SystemSetting.js | 148 +++++++++++------- web/src/helpers/utils.js | 4 + web/src/pages/TopUp/index.js | 152 +++++++++---------- 15 files changed, 533 insertions(+), 329 deletions(-) create mode 100644 common/hash.go create mode 100644 controller/stripe.go delete mode 100644 service/epay.go diff --git a/common/constants.go b/common/constants.go index a702126..de992d4 100644 --- a/common/constants.go +++ b/common/constants.go @@ -11,12 +11,12 @@ import ( // Pay Settings -var PayAddress = "" -var CustomCallbackAddress = "" -var EpayId = "" -var EpayKey = "" -var Price = 7.3 -var MinTopUp = 1 +var StripeApiSecret = "" +var StripeWebhookSecret = "" +var StripePriceId = "" +var PaymentEnabled = false +var StripeUnitPrice = 8.0 +var MinTopUp = 5 var StartTime = time.Now().Unix() // unit: second var Version = "v0.0.0" // this hard coding will be replaced automatically when building, no need to manually change @@ -188,6 +188,12 @@ const ( ChannelStatusAutoDisabled = 3 ) +const ( + TopUpStatusPending = "pending" + TopUpStatusSuccess = "success" + TopUpStatusExpired = "expired" +) + const ( ChannelTypeUnknown = 0 ChannelTypeOpenAI = 1 diff --git a/common/hash.go b/common/hash.go new file mode 100644 index 0000000..4ba16e1 --- /dev/null +++ b/common/hash.go @@ -0,0 +1,84 @@ +package common + +import ( + "crypto/hmac" + "crypto/sha1" + "crypto/sha256" + "encoding/hex" + "math/rand" + "time" +) + +func Sha256Raw(data string) []byte { + h := sha256.New() + h.Write([]byte(data)) + return h.Sum(nil) +} + +func Sha1Raw(data []byte) []byte { + h := sha1.New() + h.Write([]byte(data)) + return h.Sum(nil) +} + +func Sha1(data string) string { + return hex.EncodeToString(Sha1Raw([]byte(data))) +} + +func HmacSha256Raw(message, key []byte) []byte { + h := hmac.New(sha256.New, key) + h.Write(message) + return h.Sum(nil) +} + +func HmacSha256(message, key string) string { + return hex.EncodeToString(HmacSha256Raw([]byte(message), []byte(key))) +} + +func RandomBytes(length int) []byte { + rand.Seed(time.Now().UnixNano()) + b := make([]byte, length) + if _, err := rand.Read(b); err != nil { + panic(err) + } + + return b +} + +func RandomString(length int) string { + const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + result := make([]byte, length) + randomBytes := RandomBytes(length) + for i := 0; i < length; i++ { + result[i] = chars[randomBytes[i]%byte(len(chars))] + } + + return string(result) +} + +func RandomHex(length int) string { + const chars = "abcdef0123456789" + result := make([]byte, length) + randomBytes := RandomBytes(length) + for i := 0; i < length; i++ { + result[i] = chars[randomBytes[i]%byte(len(chars))] + } + + return string(result) +} + +func RandomNumber(length int) string { + const chars = "0123456789" + result := make([]byte, length) + randomBytes := RandomBytes(length) + for i := 0; i < length; i++ { + result[i] = chars[randomBytes[i]%byte(len(chars))] + } + + return string(result) +} + +func RandomUUID() string { + all := RandomHex(32) + return all[:8] + "-" + all[8:12] + "-" + all[12:16] + "-" + all[16:20] + "-" + all[20:] +} diff --git a/common/logger.go b/common/logger.go index 6162721..ac03bd1 100644 --- a/common/logger.go +++ b/common/logger.go @@ -98,3 +98,11 @@ func LogQuota(quota int) string { return fmt.Sprintf("%d 点额度", quota) } } + +func LogQuotaF(quota float64) string { + if DisplayInCurrencyEnabled { + return fmt.Sprintf("$%.6f 额度", quota/QuotaPerUnit) + } else { + return fmt.Sprintf("%d 点额度", int64(quota)) + } +} diff --git a/controller/misc.go b/controller/misc.go index bf72a36..04b2c5b 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -47,7 +47,7 @@ func GetStatus(c *gin.Context) { "wechat_qrcode": common.WeChatAccountQRCodeImageURL, "wechat_login": common.WeChatAuthEnabled, "server_address": common.ServerAddress, - "price": common.Price, + "stripe_unit_price": common.StripeUnitPrice, "min_topup": common.MinTopUp, "turnstile_check": common.TurnstileCheckEnabled, "turnstile_site_key": common.TurnstileSiteKey, @@ -61,7 +61,7 @@ func GetStatus(c *gin.Context) { "enable_data_export": common.DataExportEnabled, "data_export_default_time": common.DataExportDefaultTime, "default_collapse_sidebar": common.DefaultCollapseSidebar, - "enable_online_topup": common.PayAddress != "" && common.EpayId != "" && common.EpayKey != "", + "payment_enabled": common.PaymentEnabled, "mj_notify_enabled": constant.MjNotifyEnabled, "version": common.Version, }, diff --git a/controller/stripe.go b/controller/stripe.go new file mode 100644 index 0000000..30a03b6 --- /dev/null +++ b/controller/stripe.go @@ -0,0 +1,96 @@ +package controller + +import ( + "github.com/gin-gonic/gin" + "github.com/stripe/stripe-go/v76" + "github.com/stripe/stripe-go/v76/webhook" + "io" + "log" + "net/http" + "one-api/common" + "one-api/model" + "strconv" + "strings" +) + +func StripeWebhook(c *gin.Context) { + payload, err := io.ReadAll(c.Request.Body) + if err != nil { + log.Printf("解析Stripe Webhook参数失败: %v\n", err) + c.AbortWithStatus(http.StatusServiceUnavailable) + return + } + + signature := c.GetHeader("Stripe-Signature") + endpointSecret := common.StripeWebhookSecret + event, err := webhook.ConstructEvent(payload, signature, endpointSecret) + + if err != nil { + log.Printf("Stripe Webhook验签失败: %v\n", err) + c.AbortWithStatus(http.StatusBadRequest) + return + } + + switch event.Type { + case stripe.EventTypeCheckoutSessionCompleted: + sessionCompleted(event) + case stripe.EventTypeCheckoutSessionExpired: + sessionExpired(event) + default: + log.Printf("不支持的Stripe Webhook事件类型: %s\n", event.Type) + } + + c.Status(http.StatusOK) +} + +func sessionCompleted(event stripe.Event) { + referenceId := event.GetObjectValue("client_reference_id") + status := event.GetObjectValue("status") + if "complete" != status { + log.Println("错误的Stripe Checkout完成状态:", status, ",", referenceId) + return + } + + err := model.Recharge(referenceId) + if err != nil { + log.Println(err.Error(), referenceId) + return + } + + total, _ := strconv.ParseFloat(event.GetObjectValue("amount_total"), 64) + currency := strings.ToUpper(event.GetObjectValue("currency")) + log.Printf("收到款项:%s, %.2f(%s)", referenceId, total/100, currency) +} + +func sessionExpired(event stripe.Event) { + referenceId := event.GetObjectValue("client_reference_id") + status := event.GetObjectValue("status") + if "expired" != status { + log.Println("错误的Stripe Checkout过期状态:", status, ",", referenceId) + return + } + + if "" == referenceId { + log.Println("未提供支付单号") + return + } + + topUp := model.GetTopUpByTradeNo(referenceId) + if topUp == nil { + log.Println("充值订单不存在", referenceId) + return + } + + if topUp.Status != common.TopUpStatusPending { + log.Println("充值订单状态错误", referenceId) + } + + topUp.Status = common.TopUpStatusExpired + err := topUp.Update() + if err != nil { + log.Println("过期充值订单失败", referenceId, ", err:", err.Error()) + return + } + + log.Println("充值订单已过期", referenceId) +} diff --git a/controller/topup.go b/controller/topup.go index 039c409..f786904 100644 --- a/controller/topup.go +++ b/controller/topup.go @@ -1,21 +1,20 @@ package controller +import "C" import ( "fmt" "github.com/gin-gonic/gin" - "github.com/samber/lo" - epay "github.com/star-horizon/go-epay" + "github.com/stripe/stripe-go/v76" + "github.com/stripe/stripe-go/v76/checkout/session" "log" - "net/url" "one-api/common" "one-api/model" - "one-api/service" "strconv" - "sync" + "strings" "time" ) -type EpayRequest struct { +type PayRequest struct { Amount int `json:"amount"` PaymentMethod string `json:"payment_method"` TopUpCode string `json:"top_up_code"` @@ -26,177 +25,103 @@ type AmountRequest struct { TopUpCode string `json:"top_up_code"` } -func GetEpayClient() *epay.Client { - if common.PayAddress == "" || common.EpayId == "" || common.EpayKey == "" { - return nil +func genStripeLink(referenceId string, amount int64) (string, error) { + if !strings.HasPrefix(common.StripeApiSecret, "sk_") { + return "", fmt.Errorf("无效的Stripe API密钥") } - withUrl, err := epay.NewClientWithUrl(&epay.Config{ - PartnerID: common.EpayId, - Key: common.EpayKey, - }, common.PayAddress) + + stripe.Key = common.StripeApiSecret + + params := &stripe.CheckoutSessionParams{ + ClientReferenceID: stripe.String(referenceId), + SuccessURL: stripe.String(common.ServerAddress + "/log"), + CancelURL: stripe.String(common.ServerAddress + "/topup"), + LineItems: []*stripe.CheckoutSessionLineItemParams{ + { + Price: stripe.String(common.StripePriceId), + Quantity: stripe.Int64(amount), + }, + }, + Mode: stripe.String(string(stripe.CheckoutSessionModePayment)), + } + result, err := session.New(params) if err != nil { - return nil + return "", err } - return withUrl + + return result.URL, nil } -func GetAmount(count float64, user model.User) float64 { - // 别问为什么用float64,问就是这么点钱没必要 - topupGroupRatio := common.GetTopupGroupRatio(user.Group) - if topupGroupRatio == 0 { - topupGroupRatio = 1 - } - amount := count * common.Price * topupGroupRatio - return amount +func GetPayAmount(count float64) float64 { + return count * common.StripeUnitPrice } -func RequestEpay(c *gin.Context) { - var req EpayRequest +func GetChargedAmount(count float64, user model.User) float64 { + topUpGroupRatio := common.GetTopupGroupRatio(user.Group) + if topUpGroupRatio == 0 { + topUpGroupRatio = 1 + } + + return count * topUpGroupRatio +} + +func RequestPayLink(c *gin.Context) { + var req PayRequest err := c.ShouldBindJSON(&req) if err != nil { c.JSON(200, gin.H{"message": err.Error(), "data": 10}) return } + if !common.PaymentEnabled { + c.JSON(200, gin.H{"message": "error", "data": "管理员未开启在线支付"}) + return + } + if req.PaymentMethod != "stripe" { + c.JSON(200, gin.H{"message": "error", "data": "不支持的支付渠道"}) + return + } if req.Amount < common.MinTopUp { c.JSON(200, gin.H{"message": fmt.Sprintf("充值数量不能小于 %d", common.MinTopUp), "data": 10}) return } + if req.Amount > 10000 { + c.JSON(200, gin.H{"message": "充值数量不能大于 10000", "data": 10}) + return + } id := c.GetInt("id") user, _ := model.GetUserById(id, false) - payMoney := GetAmount(float64(req.Amount), *user) + chargedMoney := GetChargedAmount(float64(req.Amount), *user) - var payType epay.PurchaseType - if req.PaymentMethod == "zfb" { - payType = epay.Alipay - } - if req.PaymentMethod == "wx" { - req.PaymentMethod = "wxpay" - payType = epay.WechatPay - } - callBackAddress := service.GetCallbackAddress() - returnUrl, _ := url.Parse(common.ServerAddress + "/log") - notifyUrl, _ := url.Parse(callBackAddress + "/api/user/epay/notify") - tradeNo := strconv.FormatInt(time.Now().Unix(), 10) - client := GetEpayClient() - if client == nil { - c.JSON(200, gin.H{"message": "error", "data": "当前管理员未配置支付信息"}) - return - } - uri, params, err := client.Purchase(&epay.PurchaseArgs{ - Type: payType, - ServiceTradeNo: "A" + tradeNo, - Name: "B" + tradeNo, - Money: strconv.FormatFloat(payMoney, 'f', 2, 64), - Device: epay.PC, - NotifyUrl: notifyUrl, - ReturnUrl: returnUrl, - }) + reference := fmt.Sprintf("new-api-ref-%d-%d-%s", user.Id, time.Now().UnixMilli(), common.RandomString(4)) + referenceId := "ref_" + common.Sha1(reference) + + payLink, err := genStripeLink(referenceId, int64(req.Amount)) if err != nil { + log.Println("获取Stripe Checkout支付链接失败", err) c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"}) return } + topUp := &model.TopUp{ UserId: id, Amount: req.Amount, - Money: payMoney, - TradeNo: "A" + tradeNo, + Money: chargedMoney, + TradeNo: referenceId, CreateTime: time.Now().Unix(), - Status: "pending", + Status: common.TopUpStatusPending, } err = topUp.Insert() if err != nil { c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"}) return } - c.JSON(200, gin.H{"message": "success", "data": params, "url": uri}) -} - -// tradeNo lock -var orderLocks sync.Map -var createLock sync.Mutex - -// LockOrder 尝试对给定订单号加锁 -func LockOrder(tradeNo string) { - lock, ok := orderLocks.Load(tradeNo) - if !ok { - createLock.Lock() - defer createLock.Unlock() - lock, ok = orderLocks.Load(tradeNo) - if !ok { - lock = new(sync.Mutex) - orderLocks.Store(tradeNo, lock) - } - } - lock.(*sync.Mutex).Lock() -} - -// UnlockOrder 释放给定订单号的锁 -func UnlockOrder(tradeNo string) { - lock, ok := orderLocks.Load(tradeNo) - if ok { - lock.(*sync.Mutex).Unlock() - } -} - -func EpayNotify(c *gin.Context) { - params := lo.Reduce(lo.Keys(c.Request.URL.Query()), func(r map[string]string, t string, i int) map[string]string { - r[t] = c.Request.URL.Query().Get(t) - return r - }, map[string]string{}) - client := GetEpayClient() - if client == nil { - log.Println("易支付回调失败 未找到配置信息") - _, err := c.Writer.Write([]byte("fail")) - if err != nil { - log.Println("易支付回调写入失败") - return - } - } - verifyInfo, err := client.Verify(params) - if err == nil && verifyInfo.VerifyStatus { - _, err := c.Writer.Write([]byte("success")) - if err != nil { - log.Println("易支付回调写入失败") - } - } else { - _, err := c.Writer.Write([]byte("fail")) - if err != nil { - log.Println("易支付回调写入失败") - } - log.Println("易支付回调签名验证失败") - return - } - - if verifyInfo.TradeStatus == epay.StatusTradeSuccess { - log.Println(verifyInfo) - LockOrder(verifyInfo.ServiceTradeNo) - defer UnlockOrder(verifyInfo.ServiceTradeNo) - topUp := model.GetTopUpByTradeNo(verifyInfo.ServiceTradeNo) - if topUp == nil { - log.Printf("易支付回调未找到订单: %v", verifyInfo) - return - } - if topUp.Status == "pending" { - topUp.Status = "success" - err := topUp.Update() - if err != nil { - log.Printf("易支付回调更新订单失败: %v", topUp) - return - } - //user, _ := model.GetUserById(topUp.UserId, false) - //user.Quota += topUp.Amount * 500000 - err = model.IncreaseUserQuota(topUp.UserId, topUp.Amount*500000) - if err != nil { - log.Printf("易支付回调更新用户失败: %v", topUp) - return - } - log.Printf("易支付回调更新用户成功 %v", topUp) - model.RecordLog(topUp.UserId, model.LogTypeTopup, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%f", common.LogQuota(topUp.Amount*500000), topUp.Money)) - } - } else { - log.Printf("易支付异常回调: %v", verifyInfo) - } + c.JSON(200, gin.H{ + "message": "success", + "data": gin.H{ + "payLink": payLink, + }, + }) } func RequestAmount(c *gin.Context) { @@ -206,12 +131,23 @@ func RequestAmount(c *gin.Context) { c.JSON(200, gin.H{"message": "error", "data": "参数错误"}) return } + if !common.PaymentEnabled { + c.JSON(200, gin.H{"message": "error", "data": "管理员未开启在线支付"}) + return + } if req.Amount < common.MinTopUp { c.JSON(200, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", common.MinTopUp)}) return } id := c.GetInt("id") user, _ := model.GetUserById(id, false) - payMoney := GetAmount(float64(req.Amount), *user) - c.JSON(200, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)}) + payMoney := GetPayAmount(float64(req.Amount)) + chargedMoney := GetChargedAmount(float64(req.Amount), *user) + c.JSON(200, gin.H{ + "message": "success", + "data": gin.H{ + "payAmount": strconv.FormatFloat(payMoney, 'f', 2, 64), + "chargedAmount": strconv.FormatFloat(chargedMoney, 'f', 2, 64), + }, + }) } diff --git a/go.mod b/go.mod index b0c7220..2ecbc52 100644 --- a/go.mod +++ b/go.mod @@ -59,6 +59,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.0.8 // indirect + github.com/stripe/stripe-go/v76 v76.21.0 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect diff --git a/go.sum b/go.sum index 5b17b48..d498440 100644 --- a/go.sum +++ b/go.sum @@ -153,6 +153,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stripe/stripe-go/v76 v76.21.0 h1:O3GHImHS4oUI3qWMOClHN3zAQF5/oswS/NB7leV1fsU= +github.com/stripe/stripe-go/v76 v76.21.0/go.mod h1:rw1MxjlAKKcZ+3FOXgTHgwiOa2ya6CPq6ykpJ0Q6Po4= github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= @@ -178,6 +180,7 @@ golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17/go.mod h1:lgLbSvA5ygNOMpwM/9 golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8= golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= @@ -185,6 +188,7 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/model/option.go b/model/option.go index f66f94b..f8f10e1 100644 --- a/model/option.go +++ b/model/option.go @@ -58,11 +58,11 @@ func InitOptionMap() { common.OptionMap["SystemName"] = common.SystemName common.OptionMap["Logo"] = common.Logo common.OptionMap["ServerAddress"] = "" - common.OptionMap["PayAddress"] = "" - common.OptionMap["CustomCallbackAddress"] = "" - common.OptionMap["EpayId"] = "" - common.OptionMap["EpayKey"] = "" - common.OptionMap["Price"] = strconv.FormatFloat(common.Price, 'f', -1, 64) + common.OptionMap["StripeApiSecret"] = common.StripeApiSecret + common.OptionMap["StripeWebhookSecret"] = common.StripeWebhookSecret + common.OptionMap["StripePriceId"] = common.StripePriceId + common.OptionMap["PaymentEnabled"] = strconv.FormatBool(common.PaymentEnabled) + common.OptionMap["StripeUnitPrice"] = strconv.FormatFloat(common.StripeUnitPrice, 'f', -1, 64) common.OptionMap["MinTopUp"] = strconv.Itoa(common.MinTopUp) common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString() common.OptionMap["GitHubClientId"] = "" @@ -223,16 +223,16 @@ func updateOptionMap(key string, value string) (err error) { common.SMTPToken = value case "ServerAddress": common.ServerAddress = value - case "PayAddress": - common.PayAddress = value - case "CustomCallbackAddress": - common.CustomCallbackAddress = value - case "EpayId": - common.EpayId = value - case "EpayKey": - common.EpayKey = value - case "Price": - common.Price, _ = strconv.ParseFloat(value, 64) + case "StripeApiSecret": + common.StripeApiSecret = value + case "StripeWebhookSecret": + common.StripeWebhookSecret = value + case "StripePriceId": + common.StripePriceId = value + case "PaymentEnabled": + common.PaymentEnabled, _ = strconv.ParseBool(value) + case "StripeUnitPrice": + common.StripeUnitPrice, _ = strconv.ParseFloat(value, 64) case "MinTopUp": common.MinTopUp, _ = strconv.Atoi(value) case "TopupGroupRatio": diff --git a/model/topup.go b/model/topup.go index dc1cbc5..ceed01f 100644 --- a/model/topup.go +++ b/model/topup.go @@ -1,13 +1,21 @@ package model +import ( + "errors" + "fmt" + "gorm.io/gorm" + "one-api/common" +) + type TopUp struct { - Id int `json:"id"` - UserId int `json:"user_id" gorm:"index"` - Amount int `json:"amount"` - Money float64 `json:"money"` - TradeNo string `json:"trade_no"` - CreateTime int64 `json:"create_time"` - Status string `json:"status"` + Id int `json:"id"` + UserId int `json:"user_id" gorm:"index"` + Amount int `json:"amount"` + Money float64 `json:"money"` + TradeNo string `json:"trade_no" gorm:"unique"` + CreateTime int64 `json:"create_time"` + CompleteTime int64 `json:"complete_time"` + Status string `json:"status"` } func (topUp *TopUp) Insert() error { @@ -41,3 +49,51 @@ func GetTopUpByTradeNo(tradeNo string) *TopUp { } return topUp } + +func Recharge(referenceId string) (err error) { + if referenceId == "" { + return errors.New("未提供支付单号") + } + + var quota float64 + topUp := &TopUp{} + + 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+" = ?", referenceId).First(topUp).Error + if err != nil { + return errors.New("充值订单不存在") + } + + if topUp.Status != common.TopUpStatusPending { + return errors.New("充值订单状态错误") + } + + topUp.CompleteTime = common.GetTimestamp() + topUp.Status = common.TopUpStatusSuccess + err = tx.Save(topUp).Error + if err != nil { + return err + } + + quota = topUp.Money * common.QuotaPerUnit + err = tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quota)).Error + if err != nil { + return err + } + + return nil + }) + + if err != nil { + return errors.New("充值失败," + err.Error()) + } + + RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%d", common.LogQuotaF(quota), topUp.Amount)) + + return nil +} diff --git a/router/api-router.go b/router/api-router.go index 89d93e1..734e3c0 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -31,13 +31,14 @@ func SetApiRouter(router *gin.Engine) { apiRouter.GET("/oauth/telegram/login", middleware.CriticalRateLimit(), controller.TelegramLogin) apiRouter.GET("/oauth/telegram/bind", middleware.CriticalRateLimit(), middleware.UserAuth(), controller.TelegramBind) + apiRouter.POST("/stripe/webhook", controller.StripeWebhook) + userRoute := apiRouter.Group("/user") { userRoute.POST("/register", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register) userRoute.POST("/login", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Login) //userRoute.POST("/tokenlog", middleware.CriticalRateLimit(), controller.TokenLog) userRoute.GET("/logout", controller.Logout) - userRoute.GET("/epay/notify", controller.EpayNotify) selfRoute := userRoute.Group("/") selfRoute.Use(middleware.UserAuth()) @@ -48,8 +49,8 @@ func SetApiRouter(router *gin.Engine) { selfRoute.DELETE("/self", controller.DeleteSelf) selfRoute.GET("/token", controller.GenerateAccessToken) selfRoute.GET("/aff", controller.GetAffCode) - selfRoute.POST("/topup", controller.TopUp) - selfRoute.POST("/pay", controller.RequestEpay) + selfRoute.POST("/topup", middleware.CriticalRateLimit(), controller.TopUp) + selfRoute.POST("/pay", middleware.CriticalRateLimit(), controller.RequestPayLink) selfRoute.POST("/amount", controller.RequestAmount) selfRoute.POST("/aff_transfer", controller.TransferAffQuota) } diff --git a/service/epay.go b/service/epay.go deleted file mode 100644 index 7ce4aad..0000000 --- a/service/epay.go +++ /dev/null @@ -1,10 +0,0 @@ -package service - -import "one-api/common" - -func GetCallbackAddress() string { - if common.CustomCallbackAddress == "" { - return common.ServerAddress - } - return common.CustomCallbackAddress -} diff --git a/web/src/components/SystemSetting.js b/web/src/components/SystemSetting.js index ba8af09..aa9057c 100644 --- a/web/src/components/SystemSetting.js +++ b/web/src/components/SystemSetting.js @@ -21,13 +21,13 @@ const SystemSetting = () => { SMTPFrom: '', SMTPToken: '', ServerAddress: '', - EpayId: '', - EpayKey: '', - Price: 7.3, - MinTopUp: 1, + StripeApiSecret: '', + StripeWebhookSecret: '', + StripePriceId: '', + PaymentEnabled: false, + StripeUnitPrice: 8.0, + MinTopUp: 5, TopupGroupRatio: '', - PayAddress: '', - CustomCallbackAddress: '', Footer: '', WeChatAuthEnabled: '', WeChatServerAddress: '', @@ -92,6 +92,7 @@ const SystemSetting = () => { case 'TurnstileCheckEnabled': case 'EmailDomainRestrictionEnabled': case 'RegisterEnabled': + case 'PaymentEnabled': value = inputs[key] === 'true' ? 'false' : 'true'; break; default: @@ -106,9 +107,6 @@ const SystemSetting = () => { if (key === 'EmailDomainWhitelist') { value = value.split(','); } - if (key === 'Price') { - value = parseFloat(value); - } setInputs((inputs) => ({ ...inputs, [key]: value })); @@ -128,10 +126,11 @@ const SystemSetting = () => { name === 'Notice' || name.startsWith('SMTP') || name === 'ServerAddress' || - name === 'EpayId' || - name === 'EpayKey' || - name === 'Price' || - name === 'PayAddress' || + name === 'StripeApiSecret' || + name === 'StripeWebhookSecret' || + name === 'StripePriceId' || + name === 'StripeUnitPrice' || + name === 'MinTopUp' || name === 'GitHubClientId' || name === 'GitHubClientSecret' || name === 'LinuxDoClientId' || @@ -158,7 +157,7 @@ const SystemSetting = () => { await updateOption('ServerAddress', ServerAddress); }; - const submitPayAddress = async () => { + const submitPaymentConfig = async () => { if (inputs.ServerAddress === '') { showError('请先填写服务器地址'); return; @@ -170,15 +169,30 @@ const SystemSetting = () => { } await updateOption('TopupGroupRatio', inputs.TopupGroupRatio); } - let PayAddress = removeTrailingSlash(inputs.PayAddress); - await updateOption('PayAddress', PayAddress); - if (inputs.EpayId !== '') { - await updateOption('EpayId', inputs.EpayId); + let stripeApiSecret = removeTrailingSlash(inputs.StripeApiSecret); + if (stripeApiSecret && !stripeApiSecret.startsWith("sk_")) { + showError('输入了无效的Stripe API密钥'); + return; } - if (inputs.EpayKey !== '') { - await updateOption('EpayKey', inputs.EpayKey); + stripeApiSecret && await updateOption('StripeApiSecret', stripeApiSecret); + + let stripeWebhookSecret = removeTrailingSlash(inputs.StripeWebhookSecret); + if (stripeWebhookSecret && !stripeWebhookSecret.startsWith("whsec_")) { + showError('输入了无效的Stripe Webhook签名密钥'); + return; } - await updateOption('Price', '' + inputs.Price); + stripeWebhookSecret && await updateOption('StripeWebhookSecret', stripeWebhookSecret); + + let stripePriceId = removeTrailingSlash(inputs.StripePriceId); + if (stripePriceId && !stripePriceId.startsWith("price_")) { + showError('输入了无效的Stripe 物品价格ID'); + return; + } + await updateOption('StripePriceId', stripePriceId); + + await updateOption('PaymentEnable', inputs.PaymentEnabled); + await updateOption('StripeUnitPrice', inputs.StripeUnitPrice); + await updateOption('MinTopUp', inputs.MinTopUp); }; const submitSMTP = async () => { @@ -318,54 +332,66 @@ const SystemSetting = () => { 更新服务器地址 -
支付设置(当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)
+
+ 支付设置(当前仅支持Stripe Checkout) + + 密钥、Webhook 等设置请 + + 点击此处 + + 进行设置,最好先在 + + 测试环境 + + 进行测试 + +
+ + Webhook 填: + {`${inputs.ServerAddress}/api/stripe/webhook`} + ,需要包含事件:checkout.session.completedcheckout.session.expired + - - @@ -379,9 +405,17 @@ const SystemSetting = () => { placeholder="为一个 JSON 文本,键为组名称,值为倍率" /> - - 更新支付设置 - + + + 更新支付设置 + + +
配置登录注册
diff --git a/web/src/helpers/utils.js b/web/src/helpers/utils.js index 3e1fdb2..05db55f 100644 --- a/web/src/helpers/utils.js +++ b/web/src/helpers/utils.js @@ -126,6 +126,10 @@ export function openPage(url) { } export function removeTrailingSlash(url) { + if (!url) { + return ""; + } + if (url.endsWith('/')) { return url.slice(0, -1); } else { diff --git a/web/src/pages/TopUp/index.js b/web/src/pages/TopUp/index.js index aec9c44..a8cf735 100644 --- a/web/src/pages/TopUp/index.js +++ b/web/src/pages/TopUp/index.js @@ -11,18 +11,20 @@ const TopUp = () => { const [topUpCode, setTopUpCode] = useState(''); const [topUpCount, setTopUpCount] = useState(10); const [minTopupCount, setMinTopUpCount] = useState(1); - const [amount, setAmount] = useState(0.0); + const [payAmount, setPayAmount] = useState(0.0); + const [chargedAmount, setChargedAmount] = useState(0.0); const [minTopUp, setMinTopUp] = useState(1); const [topUpLink, setTopUpLink] = useState(''); - const [enableOnlineTopUp, setEnableOnlineTopUp] = useState(false); + const [paymentEnabled, setPaymentEnabled] = useState(false); const [userQuota, setUserQuota] = useState(0); const [isSubmitting, setIsSubmitting] = useState(false); + const [isPaying, setIsPaying] = useState(false); const [open, setOpen] = useState(false); const [payWay, setPayWay] = useState(''); const topUp = async () => { if (redemptionCode === '') { - showInfo('请输入兑换码!') + showError('请输入兑换码!') return; } setIsSubmitting(true); @@ -57,15 +59,19 @@ const TopUp = () => { }; const preTopUp = async (payment) => { - if (!enableOnlineTopUp) { + if (!paymentEnabled) { showError('管理员未开启在线充值!'); return; } - if (amount === 0) { + if (!Number.isInteger(Number(topUpCount))) { + showError('充值数量必须是整数!'); + return; + } + if (payAmount === 0) { await getAmount(); } if (topUpCount < minTopUp) { - showInfo('充值数量不能小于' + minTopUp); + showError('充值数量不能小于' + minTopUp); return; } setPayWay(payment) @@ -73,15 +79,16 @@ const TopUp = () => { } const onlineTopUp = async () => { - if (amount === 0) { + if (payAmount === 0) { await getAmount(); } if (topUpCount < minTopUp) { - showInfo('充值数量不能小于' + minTopUp); + showError('充值数量不能小于' + minTopUp); return; } setOpen(false); try { + setIsPaying(true) const res = await API.post('/api/user/pay', { amount: parseInt(topUpCount), top_up_code: topUpCode, @@ -91,33 +98,13 @@ const TopUp = () => { const {message, data} = res.data; // showInfo(message); if (message === 'success') { - - let params = data - let url = res.data.url - let form = document.createElement('form') - form.action = url - form.method = 'POST' - // 判断是否为safari浏览器 - let isSafari = navigator.userAgent.indexOf("Safari") > -1 && navigator.userAgent.indexOf("Chrome") < 1; - if (!isSafari) { - form.target = '_blank' - } - for (let key in params) { - let input = document.createElement('input') - input.type = 'hidden' - input.name = key - input.value = params[key] - form.appendChild(input) - } - document.body.appendChild(form) - form.submit() - document.body.removeChild(form) + location.href = data.payLink } else { + setIsPaying(false) showError(data); - // setTopUpCount(parseInt(res.data.count)); - // setAmount(parseInt(data)); } } else { + setIsPaying(false) showError(res); } } catch (err) { @@ -146,8 +133,8 @@ const TopUp = () => { if (status.min_topup) { setMinTopUp(status.min_topup); } - if (status.enable_online_topup) { - setEnableOnlineTopUp(status.enable_online_topup); + if (status.payment_enabled) { + setPaymentEnabled(status.payment_enabled); } } getUserQuota().then(); @@ -155,7 +142,7 @@ const TopUp = () => { const renderAmount = () => { // console.log(amount); - return amount + '元'; + return payAmount + '元'; } const getAmount = async (value) => { @@ -171,7 +158,8 @@ const TopUp = () => { const {message, data} = res.data; // showInfo(message); if (message === 'success') { - setAmount(parseFloat(data)); + setPayAmount(parseFloat(data.payAmount)); + setChargedAmount(parseFloat(data.chargedAmount)); } else { showError(data); // setTopUpCount(parseInt(res.data.count)); @@ -206,7 +194,7 @@ const TopUp = () => { size={'small'} centered={true} > -

充值数量:{topUpCount}$

+

充值数量:{topUpCount}$(实到:{chargedAmount}$)

实付金额:{renderAmount()}

是否确认充值?

@@ -244,54 +232,50 @@ const TopUp = () => { -
- - 在线充值 - -
- { - if (value < 1) { - value = 1; - } - if (value > 100000) { - value = 100000; - } - setTopUpCount(value); - await getAmount(value); - }} - /> - - - - - -
+ {paymentEnabled ? +
+ + 在线充值 + +
+ { + if (value < 1) { + value = 1; + } + if (value > 100000) { + value = 100000; + } + setTopUpCount(value); + await getAmount(value); + }} + /> + + + + +
: <> + } {/*
*/} {/* */} {/*