From 7443129e18a534d632340fe9dca19e17f40b9047 Mon Sep 17 00:00:00 2001 From: wzxjohn Date: Thu, 10 Jul 2025 16:29:38 +0800 Subject: [PATCH 1/7] feat: add stripe pay api support --- common/constants.go | 6 + common/hash.go | 34 +++++ controller/misc.go | 3 + controller/topup_stripe.go | 276 +++++++++++++++++++++++++++++++++++++ go.mod | 4 +- go.sum | 6 + model/option.go | 15 ++ model/topup.go | 70 +++++++++- model/user.go | 1 + router/api-router.go | 8 +- setting/payment_stripe.go | 7 + 11 files changed, 420 insertions(+), 10 deletions(-) create mode 100644 common/hash.go create mode 100644 controller/topup_stripe.go create mode 100644 setting/payment_stripe.go diff --git a/common/constants.go b/common/constants.go index e4f5f047..30522411 100644 --- a/common/constants.go +++ b/common/constants.go @@ -193,3 +193,9 @@ const ( ChannelStatusManuallyDisabled = 2 // also don't use 0 ChannelStatusAutoDisabled = 3 ) + +const ( + TopUpStatusPending = "pending" + TopUpStatusSuccess = "success" + TopUpStatusExpired = "expired" +) diff --git a/common/hash.go b/common/hash.go new file mode 100644 index 00000000..4d298192 --- /dev/null +++ b/common/hash.go @@ -0,0 +1,34 @@ +package common + +import ( + "crypto/hmac" + "crypto/sha1" + "crypto/sha256" + "encoding/hex" +) + +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))) +} diff --git a/controller/misc.go b/controller/misc.go index 4ffe86f4..fa5be140 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -57,7 +57,9 @@ func GetStatus(c *gin.Context) { "wechat_login": common.WeChatAuthEnabled, "server_address": setting.ServerAddress, "price": setting.Price, + "stripe_unit_price": setting.StripeUnitPrice, "min_topup": setting.MinTopUp, + "stripe_min_topup": setting.StripeMinTopUp, "turnstile_check": common.TurnstileCheckEnabled, "turnstile_site_key": common.TurnstileSiteKey, "top_up_link": common.TopUpLink, @@ -71,6 +73,7 @@ func GetStatus(c *gin.Context) { "data_export_default_time": common.DataExportDefaultTime, "default_collapse_sidebar": common.DefaultCollapseSidebar, "enable_online_topup": setting.PayAddress != "" && setting.EpayId != "" && setting.EpayKey != "", + "enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "", "mj_notify_enabled": setting.MjNotifyEnabled, "chats": setting.Chats, "demo_site_enabled": operation_setting.DemoSiteEnabled, diff --git a/controller/topup_stripe.go b/controller/topup_stripe.go new file mode 100644 index 00000000..12ca2e4c --- /dev/null +++ b/controller/topup_stripe.go @@ -0,0 +1,276 @@ +package controller + +import ( + "fmt" + "io" + "log" + "net/http" + "one-api/common" + "one-api/model" + "one-api/setting" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/stripe/stripe-go/v81" + "github.com/stripe/stripe-go/v81/checkout/session" + "github.com/stripe/stripe-go/v81/webhook" + "github.com/thanhpk/randstr" +) + +const ( + PaymentMethodStripe = "stripe" +) + +var stripeAdaptor = &StripeAdaptor{} + +type StripePayRequest struct { + Amount int64 `json:"amount"` + PaymentMethod string `json:"payment_method"` + TopUpCode string `json:"top_up_code"` +} + +type StripeAdaptor struct { +} + +func (*StripeAdaptor) RequestAmount(c *gin.Context, req *StripePayRequest) { + if req.Amount < getStripeMinTopup() { + c.JSON(200, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getStripeMinTopup())}) + return + } + id := c.GetInt("id") + group, err := model.GetUserGroup(id, true) + if err != nil { + c.JSON(200, gin.H{"message": "error", "data": "获取用户分组失败"}) + return + } + payMoney := getStripePayMoney(float64(req.Amount), group) + if payMoney <= 0.01 { + c.JSON(200, gin.H{"message": "error", "data": "充值金额过低"}) + return + } + c.JSON(200, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)}) +} + +func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) { + if req.PaymentMethod != PaymentMethodStripe { + c.JSON(200, gin.H{"message": "error", "data": "不支持的支付渠道"}) + return + } + if req.Amount < int64(setting.StripeMinTopUp) { + c.JSON(200, gin.H{"message": fmt.Sprintf("充值数量不能小于 %d", setting.StripeMinTopUp), "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) + chargedMoney := GetChargedAmount(float64(req.Amount), *user) + + reference := fmt.Sprintf("new-api-ref-%d-%d-%s", user.Id, time.Now().UnixMilli(), randstr.String(4)) + referenceId := "ref_" + common.Sha1(reference) + + payLink, err := genStripeLink(referenceId, user.StripeCustomer, user.Email, 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: chargedMoney, + TradeNo: referenceId, + CreateTime: time.Now().Unix(), + 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": gin.H{ + "pay_link": payLink, + }, + }) +} + +func RequestStripeAmount(c *gin.Context) { + var req StripePayRequest + err := c.ShouldBindJSON(&req) + if err != nil { + c.JSON(200, gin.H{"message": "error", "data": "参数错误"}) + return + } + stripeAdaptor.RequestAmount(c, &req) +} + +func RequestStripePay(c *gin.Context) { + var req StripePayRequest + err := c.ShouldBindJSON(&req) + if err != nil { + c.JSON(200, gin.H{"message": "error", "data": "参数错误"}) + return + } + stripeAdaptor.RequestPay(c, &req) +} + +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 := setting.StripeWebhookSecret + event, err := webhook.ConstructEventWithOptions(payload, signature, endpointSecret, webhook.ConstructEventOptions{ + IgnoreAPIVersionMismatch: true, + }) + + 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) { + customerId := event.GetObjectValue("customer") + referenceId := event.GetObjectValue("client_reference_id") + status := event.GetObjectValue("status") + if "complete" != status { + log.Println("错误的Stripe Checkout完成状态:", status, ",", referenceId) + return + } + + err := model.Recharge(referenceId, customerId) + 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) +} + +func genStripeLink(referenceId string, customerId string, email string, amount int64) (string, error) { + if !strings.HasPrefix(setting.StripeApiSecret, "sk_") && !strings.HasPrefix(setting.StripeApiSecret, "rk_") { + return "", fmt.Errorf("无效的Stripe API密钥") + } + + stripe.Key = setting.StripeApiSecret + + params := &stripe.CheckoutSessionParams{ + ClientReferenceID: stripe.String(referenceId), + SuccessURL: stripe.String(setting.ServerAddress + "/log"), + CancelURL: stripe.String(setting.ServerAddress + "/topup"), + LineItems: []*stripe.CheckoutSessionLineItemParams{ + { + Price: stripe.String(setting.StripePriceId), + Quantity: stripe.Int64(amount), + }, + }, + Mode: stripe.String(string(stripe.CheckoutSessionModePayment)), + } + + if "" == customerId { + if "" != email { + params.CustomerEmail = stripe.String(email) + } + + params.CustomerCreation = stripe.String(string(stripe.CheckoutSessionCustomerCreationAlways)) + } else { + params.Customer = stripe.String(customerId) + } + + result, err := session.New(params) + if err != nil { + return "", err + } + + return result.URL, nil +} + +func GetChargedAmount(count float64, user model.User) float64 { + topUpGroupRatio := common.GetTopupGroupRatio(user.Group) + if topUpGroupRatio == 0 { + topUpGroupRatio = 1 + } + + return count * topUpGroupRatio +} + +func getStripePayMoney(amount float64, group string) float64 { + if !common.DisplayInCurrencyEnabled { + amount = amount / common.QuotaPerUnit + } + // 别问为什么用float64,问就是这么点钱没必要 + topupGroupRatio := common.GetTopupGroupRatio(group) + if topupGroupRatio == 0 { + topupGroupRatio = 1 + } + payMoney := amount * setting.StripeUnitPrice * topupGroupRatio + return payMoney +} + +func getStripeMinTopup() int64 { + minTopup := setting.StripeMinTopUp + if !common.DisplayInCurrencyEnabled { + minTopup = minTopup * int(common.QuotaPerUnit) + } + return int64(minTopup) +} diff --git a/go.mod b/go.mod index 9479ba55..94873c88 100644 --- a/go.mod +++ b/go.mod @@ -27,10 +27,13 @@ require ( github.com/samber/lo v1.39.0 github.com/shirou/gopsutil v3.21.11+incompatible github.com/shopspring/decimal v1.4.0 + github.com/stripe/stripe-go/v81 v81.4.0 + github.com/thanhpk/randstr v1.0.6 github.com/tiktoken-go/tokenizer v0.6.2 golang.org/x/crypto v0.35.0 golang.org/x/image v0.23.0 golang.org/x/net v0.35.0 + golang.org/x/sync v0.11.0 gorm.io/driver/mysql v1.4.3 gorm.io/driver/postgres v1.5.2 gorm.io/gorm v1.25.2 @@ -84,7 +87,6 @@ require ( github.com/yusufpapurcu/wmi v1.2.3 // indirect golang.org/x/arch v0.12.0 // indirect golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect - golang.org/x/sync v0.11.0 // indirect golang.org/x/sys v0.30.0 // indirect golang.org/x/text v0.22.0 // indirect google.golang.org/protobuf v1.34.2 // indirect diff --git a/go.sum b/go.sum index 71dd83c2..74eecd4c 100644 --- a/go.sum +++ b/go.sum @@ -195,6 +195,10 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stripe/stripe-go/v81 v81.4.0 h1:AuD9XzdAvl193qUCSaLocf8H+nRopOouXhxqJUzCLbw= +github.com/stripe/stripe-go/v81 v81.4.0/go.mod h1:C/F4jlmnGNacvYtBp/LUHCvVUJEZffFQCobkzwY1WOo= +github.com/thanhpk/randstr v1.0.6 h1:psAOktJFD4vV9NEVb3qkhRSMvYh4ORRaj1+w/hn4B+o= +github.com/thanhpk/randstr v1.0.6/go.mod h1:M/H2P1eNLZzlDwAzpkkkUvoyNNMbzRGhESZuEQk3r0U= github.com/tiktoken-go/tokenizer v0.6.2 h1:t0GN2DvcUZSFWT/62YOgoqb10y7gSXBGs0A+4VCQK+g= github.com/tiktoken-go/tokenizer v0.6.2/go.mod h1:6UCYI/DtOallbmL7sSy30p6YQv60qNyU/4aVigPOx6w= github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= @@ -224,6 +228,7 @@ golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0/go.mod h1:/lliqkxwWAhPjf5oSO golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= 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.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -232,6 +237,7 @@ golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 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 ea72e5ee..46117d8a 100644 --- a/model/option.go +++ b/model/option.go @@ -75,6 +75,11 @@ func InitOptionMap() { common.OptionMap["EpayKey"] = "" common.OptionMap["Price"] = strconv.FormatFloat(setting.Price, 'f', -1, 64) common.OptionMap["MinTopUp"] = strconv.Itoa(setting.MinTopUp) + common.OptionMap["StripeMinTopUp"] = strconv.Itoa(setting.StripeMinTopUp) + common.OptionMap["StripeApiSecret"] = setting.StripeApiSecret + common.OptionMap["StripeWebhookSecret"] = setting.StripeWebhookSecret + common.OptionMap["StripePriceId"] = setting.StripePriceId + common.OptionMap["StripeUnitPrice"] = strconv.FormatFloat(setting.StripeUnitPrice, 'f', -1, 64) common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString() common.OptionMap["Chats"] = setting.Chats2JsonString() common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString() @@ -308,6 +313,16 @@ func updateOptionMap(key string, value string) (err error) { setting.Price, _ = strconv.ParseFloat(value, 64) case "MinTopUp": setting.MinTopUp, _ = strconv.Atoi(value) + case "StripeApiSecret": + setting.StripeApiSecret = value + case "StripeWebhookSecret": + setting.StripeWebhookSecret = value + case "StripePriceId": + setting.StripePriceId = value + case "StripeUnitPrice": + setting.StripeUnitPrice, _ = strconv.ParseFloat(value, 64) + case "StripeMinTopUp": + setting.StripeMinTopUp, _ = strconv.Atoi(value) case "TopupGroupRatio": err = common.UpdateTopupGroupRatioByJSONString(value) case "GitHubClientId": diff --git a/model/topup.go b/model/topup.go index 507b8518..39d96721 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 int64 `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 int64 `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, customerId 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).Updates(map[string]interface{}{"stripe_customer": customerId, "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.FormatQuota(int(quota)), topUp.Amount)) + + return nil +} diff --git a/model/user.go b/model/user.go index 6bb5a867..bde2b8a9 100644 --- a/model/user.go +++ b/model/user.go @@ -43,6 +43,7 @@ type User struct { LinuxDOId string `json:"linux_do_id" gorm:"column:linux_do_id;index"` Setting string `json:"setting" gorm:"type:text;column:setting"` Remark string `json:"remark,omitempty" gorm:"type:varchar(255)" validate:"max=255"` + StripeCustomer string `json:"stripe_customer" gorm:"column:stripe_customer;index"` } func (user *User) ToBaseUser() *UserBase { diff --git a/router/api-router.go b/router/api-router.go index db4c3898..9f66e246 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -38,6 +38,8 @@ func SetApiRouter(router *gin.Engine) { apiRouter.GET("/oauth/telegram/bind", middleware.CriticalRateLimit(), controller.TelegramBind) apiRouter.GET("/ratio_config", middleware.CriticalRateLimit(), controller.GetRatioConfig) + apiRouter.POST("/stripe/webhook", controller.StripeWebhook) + userRoute := apiRouter.Group("/user") { userRoute.POST("/register", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register) @@ -57,9 +59,11 @@ 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.RequestEpay) selfRoute.POST("/amount", controller.RequestAmount) + selfRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.RequestStripePay) + selfRoute.POST("/stripe/amount", controller.RequestStripeAmount) selfRoute.POST("/aff_transfer", controller.TransferAffQuota) selfRoute.PUT("/setting", controller.UpdateUserSetting) } diff --git a/setting/payment_stripe.go b/setting/payment_stripe.go new file mode 100644 index 00000000..80d877df --- /dev/null +++ b/setting/payment_stripe.go @@ -0,0 +1,7 @@ +package setting + +var StripeApiSecret = "" +var StripeWebhookSecret = "" +var StripePriceId = "" +var StripeUnitPrice = 8.0 +var StripeMinTopUp = 1 From 3568042cd90a5f5cacc39bee1db35cb86e292bf2 Mon Sep 17 00:00:00 2001 From: wzxjohn Date: Thu, 10 Jul 2025 16:59:30 +0800 Subject: [PATCH 2/7] feat: add stripe setting page --- web/src/components/settings/PaymentSetting.js | 12 ++ .../Payment/SettingsPaymentGatewayStripe.js | 196 ++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 web/src/pages/Setting/Payment/SettingsPaymentGatewayStripe.js diff --git a/web/src/components/settings/PaymentSetting.js b/web/src/components/settings/PaymentSetting.js index 91a40a2b..4d9e1ccd 100644 --- a/web/src/components/settings/PaymentSetting.js +++ b/web/src/components/settings/PaymentSetting.js @@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'; import { Card, Spin } from '@douyinfe/semi-ui'; import SettingsGeneralPayment from '../../pages/Setting/Payment/SettingsGeneralPayment.js'; import SettingsPaymentGateway from '../../pages/Setting/Payment/SettingsPaymentGateway.js'; +import SettingsPaymentGatewayStripe from '../../pages/Setting/Payment/SettingsPaymentGatewayStripe.js'; import { API, showError } from '../../helpers'; import { useTranslation } from 'react-i18next'; @@ -17,6 +18,12 @@ const PaymentSetting = () => { TopupGroupRatio: '', CustomCallbackAddress: '', PayMethods: '', + + StripeApiSecret: '', + StripeWebhookSecret: '', + StripePriceId: '', + StripeUnitPrice: 8.0, + StripeMinTopUp: 1, }); let [loading, setLoading] = useState(false); @@ -38,6 +45,8 @@ const PaymentSetting = () => { break; case 'Price': case 'MinTopUp': + case 'StripeUnitPrice': + case 'StripeMinTopUp': newInputs[item.key] = parseFloat(item.value); break; default: @@ -80,6 +89,9 @@ const PaymentSetting = () => { + + + ); diff --git a/web/src/pages/Setting/Payment/SettingsPaymentGatewayStripe.js b/web/src/pages/Setting/Payment/SettingsPaymentGatewayStripe.js new file mode 100644 index 00000000..c8cf3667 --- /dev/null +++ b/web/src/pages/Setting/Payment/SettingsPaymentGatewayStripe.js @@ -0,0 +1,196 @@ +import React, { useEffect, useState, useRef } from 'react'; +import { + Banner, + Button, + Form, + Row, + Col, + Typography, + Spin, +} from '@douyinfe/semi-ui'; +const { Text } = Typography; +import { + API, + removeTrailingSlash, + showError, + showSuccess, + verifyJSON, +} from '../../../helpers'; +import { useTranslation } from 'react-i18next'; + +export default function SettingsPaymentGateway(props) { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + const [inputs, setInputs] = useState({ + StripeApiSecret: '', + StripeWebhookSecret: '', + StripePriceId: '', + StripeUnitPrice: 8.0, + StripeMinTopUp: 1, + }); + const [originInputs, setOriginInputs] = useState({}); + const formApiRef = useRef(null); + + useEffect(() => { + if (props.options && formApiRef.current) { + const currentInputs = { + StripeApiSecret: props.options.StripeApiSecret || '', + StripeWebhookSecret: props.options.StripeWebhookSecret || '', + StripePriceId: props.options.StripePriceId || '', + StripeUnitPrice: props.options.StripeUnitPrice !== undefined ? parseFloat(props.options.StripeUnitPrice) : 8.0, + StripeMinTopUp: props.options.StripeMinTopUp !== undefined ? parseFloat(props.options.StripeMinTopUp) : 1, + }; + setInputs(currentInputs); + setOriginInputs({ ...currentInputs }); + formApiRef.current.setValues(currentInputs); + } + }, [props.options]); + + const handleFormChange = (values) => { + setInputs(values); + }; + + const submitStripeSetting = async () => { + if (props.options.ServerAddress === '') { + showError(t('请先填写服务器地址')); + return; + } + + setLoading(true); + try { + const options = [] + + if (inputs.StripeApiSecret !== undefined && inputs.StripeApiSecret !== '') { + options.push({ key: 'StripeApiSecret', value: inputs.StripeApiSecret }); + } + if (inputs.StripeWebhookSecret !== undefined && inputs.StripeWebhookSecret !== '') { + options.push({ key: 'StripeWebhookSecret', value: inputs.StripeWebhookSecret }); + } + if (inputs.StripePriceId !== '') { + options.push({key: 'StripePriceId', value: inputs.StripePriceId,}); + } + if (inputs.StripeUnitPrice !== '') { + options.push({ key: 'StripeUnitPrice', value: inputs.StripeUnitPrice.toString() }); + } + if (inputs.StripeMinTopUp !== '') { + options.push({ key: 'StripeMinTopUp', value: inputs.StripeMinTopUp.toString() }); + } + + // 发送请求 + const requestQueue = options.map(opt => + API.put('/api/option/', { + key: opt.key, + value: opt.value, + }) + ); + + const results = await Promise.all(requestQueue); + + // 检查所有请求是否成功 + const errorResults = results.filter(res => !res.data.success); + if (errorResults.length > 0) { + errorResults.forEach(res => { + showError(res.data.message); + }); + } else { + showSuccess(t('更新成功')); + // 更新本地存储的原始值 + setOriginInputs({ ...inputs }); + props.refresh && props.refresh(); + } + } catch (error) { + showError(t('更新失败')); + } + setLoading(false); + }; + + return ( + +
(formApiRef.current = api)} + > + + + Stripe 密钥、Webhook 等设置请 + + 点击此处 + + 进行设置,最好先在 + + 测试环境 + + 进行测试。 + +
+
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+ ); +} \ No newline at end of file From e8ae9801048536f9181c712614419ce93ec04157 Mon Sep 17 00:00:00 2001 From: wzxjohn Date: Thu, 10 Jul 2025 20:28:29 +0800 Subject: [PATCH 3/7] feat: add stripe topup page --- controller/topup_stripe.go | 1 - web/src/pages/TopUp/index.js | 221 ++++++++++++++++++++++++++++++++++- 2 files changed, 220 insertions(+), 2 deletions(-) diff --git a/controller/topup_stripe.go b/controller/topup_stripe.go index 12ca2e4c..c7aa3875 100644 --- a/controller/topup_stripe.go +++ b/controller/topup_stripe.go @@ -28,7 +28,6 @@ var stripeAdaptor = &StripeAdaptor{} type StripePayRequest struct { Amount int64 `json:"amount"` PaymentMethod string `json:"payment_method"` - TopUpCode string `json:"top_up_code"` } type StripeAdaptor struct { diff --git a/web/src/pages/TopUp/index.js b/web/src/pages/TopUp/index.js index dc986077..929f82ca 100644 --- a/web/src/pages/TopUp/index.js +++ b/web/src/pages/TopUp/index.js @@ -59,6 +59,13 @@ const TopUp = () => { statusState?.status?.enable_online_topup || false, ); const [priceRatio, setPriceRatio] = useState(statusState?.status?.price || 1); + + const [stripeAmount, setStripeAmount] = useState(0.0); + const [stripeMinTopUp, setStripeMinTopUp] = useState(statusState?.status?.stripe_min_topup || 1); + const [stripeTopUpCount, setStripeTopUpCount] = useState(statusState?.status?.stripe_min_topup || 1); + const [enableStripeTopUp, setEnableStripeTopUp] = useState(statusState?.status?.enable_stripe_topup || false); + const [stripeOpen, setStripeOpen] = useState(false); + const [userQuota, setUserQuota] = useState(0); const [isSubmitting, setIsSubmitting] = useState(false); const [open, setOpen] = useState(false); @@ -231,6 +238,65 @@ const TopUp = () => { } }; + const stripePreTopUp = async () => { + if (!enableStripeTopUp) { + showError(t('管理员未开启在线充值!')); + return; + } + setPayWay('stripe') + setPaymentLoading(true); + try { + await getStripeAmount(); + if (stripeTopUpCount < stripeMinTopUp) { + showError(t('充值数量不能小于') + stripeMinTopUp); + return; + } + setStripeOpen(true); + } catch (error) { + showError(t('获取金额失败')); + } finally { + setPayWay('') + setPaymentLoading(false); + } + }; + + const onlineStripeTopUp = async () => { + if (stripeAmount === 0) { + await getStripeAmount(); + } + if (stripeTopUpCount < stripeMinTopUp) { + showError(t('充值数量不能小于') + stripeMinTopUp); + return; + } + setConfirmLoading(true); + try { + const res = await API.post('/api/user/stripe/pay', { + amount: parseInt(stripeTopUpCount), + payment_method: 'stripe', + }); + if (res !== undefined) { + const { message, data } = res.data; + if (message === 'success') { + processStripeCallback(data) + } else { + showError(data); + } + } else { + showError(res); + } + } catch (err) { + console.log(err); + showError(t('支付请求失败')); + } finally { + setStripeOpen(false); + setConfirmLoading(false); + } + } + + const processStripeCallback = (data) => { + location.href = data.pay_link; + }; + const getUserQuota = async () => { setUserDataLoading(true); let res = await API.get(`/api/user/self`); @@ -327,6 +393,10 @@ const TopUp = () => { setTopUpLink(statusState.status.top_up_link || ''); setEnableOnlineTopUp(statusState.status.enable_online_topup || false); setPriceRatio(statusState.status.price || 1); + + setStripeMinTopUp(statusState.status.stripe_min_topup || 1); + setStripeTopUpCount(statusState.status.stripe_min_topup || 1); + setEnableStripeTopUp(statusState.status.enable_stripe_topup || false); } }, [statusState?.status]); @@ -334,6 +404,10 @@ const TopUp = () => { return amount + ' ' + t('元'); }; + const renderStripeAmount = () => { + return stripeAmount + ' ' + t('元'); + }; + const getAmount = async (value) => { if (value === undefined) { value = topUpCount; @@ -361,10 +435,40 @@ const TopUp = () => { setAmountLoading(false); }; + const getStripeAmount = async (value) => { + if (value === undefined) { + value = stripeTopUpCount + } + try { + const res = await API.post('/api/user/stripe/amount', { + amount: parseFloat(value), + }); + if (res !== undefined) { + const { message, data } = res.data; + // showInfo(message); + if (message === 'success') { + setStripeAmount(parseFloat(data)) + } else { + setStripeAmount( 0) + Toast.error({ content: '错误:' + data, id: 'getAmount' }); + } + } else { + showError(res); + } + } catch (err) { + console.log(err); + } finally { + } + } + const handleCancel = () => { setOpen(false); }; + const handleStripeCancel = () => { + setStripeOpen(false); + }; + const handleTransferCancel = () => { setOpenTransfer(false); }; @@ -374,6 +478,9 @@ const TopUp = () => { setTopUpCount(preset.value); setSelectedPreset(preset.value); setAmount(preset.value * priceRatio); + + setStripeTopUpCount(preset.value); + setStripeAmount(preset.value); }; // 格式化大数字显示 @@ -496,6 +603,25 @@ const TopUp = () => { + +

+ {t('充值数量')}:{stripeTopUpCount} +

+

+ {t('实付金额')}:{renderStripeAmount()} +

+

{t('是否确认充值?')}

+
+
{/* 左侧充值区域 */}
@@ -798,7 +924,7 @@ const TopUp = () => { )} - {!enableOnlineTopUp && ( + {!enableOnlineTopUp && !enableStripeTopUp && ( { /> )} + {enableStripeTopUp && ( + <> + {/* 桌面端显示的自定义金额和支付按钮 */} +
+ {!enableOnlineTopUp?( + + + {t('或输入自定义金额')} + + + ) : ( + + + {t('Stripe')} + + + ) + } + + +
+
+ {t('充值数量')} + {amountLoading ? ( + + ) : ( + + {t('实付金额:') + renderStripeAmount()} + + )} +
+ { + if (value && value >= 1) { + setStripeTopUpCount(value); + setSelectedPreset(null); + await getStripeAmount(value); + } + }} + onBlur={(e) => { + const value = parseInt(e.target.value); + if (!value || value < 1) { + setStripeTopUpCount(1); + getStripeAmount(1); + } + }} + size='large' + className='w-full' + formatter={(value) => (value ? `${value}` : '')} + parser={(value) => + value ? parseInt(value.replace(/[^\d]/g, '')) : 0 + } + /> +
+ +
+ + {t('选择支付方式')} + +
+ +
+
+
+ + )} + {t('兑换码充值')} From d6ed2ab3e0cc3d103771265918ed3cd37346857a Mon Sep 17 00:00:00 2001 From: wzxjohn Date: Thu, 10 Jul 2025 20:31:40 +0800 Subject: [PATCH 4/7] fix: wrong loading state while top up --- web/src/pages/TopUp/index.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/web/src/pages/TopUp/index.js b/web/src/pages/TopUp/index.js index 929f82ca..22ac8fe7 100644 --- a/web/src/pages/TopUp/index.js +++ b/web/src/pages/TopUp/index.js @@ -168,6 +168,7 @@ const TopUp = () => { showError(t('管理员未开启在线充值!')); return; } + setPayWay(payment); setPaymentLoading(true); try { await getAmount(); @@ -175,7 +176,6 @@ const TopUp = () => { showError(t('充值数量不能小于') + minTopUp); return; } - setPayWay(payment); setOpen(true); } catch (error) { showError(t('获取金额失败')); @@ -193,7 +193,6 @@ const TopUp = () => { return; } setConfirmLoading(true); - setOpen(false); try { const res = await API.post('/api/user/pay', { amount: parseInt(topUpCount), @@ -234,6 +233,7 @@ const TopUp = () => { console.log(err); showError(t('支付请求失败')); } finally { + setOpen(false); setConfirmLoading(false); } }; @@ -255,7 +255,6 @@ const TopUp = () => { } catch (error) { showError(t('获取金额失败')); } finally { - setPayWay('') setPaymentLoading(false); } }; From 8cc747ef225ba9025759d526e7db997a54b74298 Mon Sep 17 00:00:00 2001 From: wzxjohn Date: Thu, 10 Jul 2025 20:55:43 +0800 Subject: [PATCH 5/7] fix: for AI review problems --- common/hash.go | 10 ++++---- controller/topup_stripe.go | 10 ++++---- model/user.go | 2 +- .../Payment/SettingsPaymentGatewayStripe.js | 13 +++++----- web/src/pages/TopUp/index.js | 24 ++++++------------- 5 files changed, 24 insertions(+), 35 deletions(-) diff --git a/common/hash.go b/common/hash.go index 4d298192..50191938 100644 --- a/common/hash.go +++ b/common/hash.go @@ -7,20 +7,20 @@ import ( "encoding/hex" ) -func Sha256Raw(data string) []byte { +func Sha256Raw(data []byte) []byte { h := sha256.New() - h.Write([]byte(data)) + h.Write(data) return h.Sum(nil) } func Sha1Raw(data []byte) []byte { h := sha1.New() - h.Write([]byte(data)) + h.Write(data) return h.Sum(nil) } -func Sha1(data string) string { - return hex.EncodeToString(Sha1Raw([]byte(data))) +func Sha1(data []byte) string { + return hex.EncodeToString(Sha1Raw(data)) } func HmacSha256Raw(message, key []byte) []byte { diff --git a/controller/topup_stripe.go b/controller/topup_stripe.go index c7aa3875..eb320809 100644 --- a/controller/topup_stripe.go +++ b/controller/topup_stripe.go @@ -57,8 +57,8 @@ func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) { c.JSON(200, gin.H{"message": "error", "data": "不支持的支付渠道"}) return } - if req.Amount < int64(setting.StripeMinTopUp) { - c.JSON(200, gin.H{"message": fmt.Sprintf("充值数量不能小于 %d", setting.StripeMinTopUp), "data": 10}) + if req.Amount < getStripeMinTopup() { + c.JSON(200, gin.H{"message": fmt.Sprintf("充值数量不能小于 %d", getStripeMinTopup()), "data": 10}) return } if req.Amount > 10000 { @@ -71,7 +71,7 @@ func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) { chargedMoney := GetChargedAmount(float64(req.Amount), *user) reference := fmt.Sprintf("new-api-ref-%d-%d-%s", user.Id, time.Now().UnixMilli(), randstr.String(4)) - referenceId := "ref_" + common.Sha1(reference) + referenceId := "ref_" + common.Sha1([]byte(reference)) payLink, err := genStripeLink(referenceId, user.StripeCustomer, user.Email, req.Amount) if err != nil { @@ -181,7 +181,7 @@ func sessionExpired(event stripe.Event) { return } - if "" == referenceId { + if len(referenceId) == 0 { log.Println("未提供支付单号") return } @@ -257,7 +257,7 @@ func getStripePayMoney(amount float64, group string) float64 { if !common.DisplayInCurrencyEnabled { amount = amount / common.QuotaPerUnit } - // 别问为什么用float64,问就是这么点钱没必要 + // Using float64 for monetary calculations is acceptable here due to the small amounts involved topupGroupRatio := common.GetTopupGroupRatio(group) if topupGroupRatio == 0 { topupGroupRatio = 1 diff --git a/model/user.go b/model/user.go index bde2b8a9..6021f495 100644 --- a/model/user.go +++ b/model/user.go @@ -43,7 +43,7 @@ type User struct { LinuxDOId string `json:"linux_do_id" gorm:"column:linux_do_id;index"` Setting string `json:"setting" gorm:"type:text;column:setting"` Remark string `json:"remark,omitempty" gorm:"type:varchar(255)" validate:"max=255"` - StripeCustomer string `json:"stripe_customer" gorm:"column:stripe_customer;index"` + StripeCustomer string `json:"stripe_customer" gorm:"type:varchar(64);column:stripe_customer;index"` } func (user *User) ToBaseUser() *UserBase { diff --git a/web/src/pages/Setting/Payment/SettingsPaymentGatewayStripe.js b/web/src/pages/Setting/Payment/SettingsPaymentGatewayStripe.js index c8cf3667..4c4a1af6 100644 --- a/web/src/pages/Setting/Payment/SettingsPaymentGatewayStripe.js +++ b/web/src/pages/Setting/Payment/SettingsPaymentGatewayStripe.js @@ -14,7 +14,6 @@ import { removeTrailingSlash, showError, showSuccess, - verifyJSON, } from '../../../helpers'; import { useTranslation } from 'react-i18next'; @@ -60,19 +59,19 @@ export default function SettingsPaymentGateway(props) { try { const options = [] - if (inputs.StripeApiSecret !== undefined && inputs.StripeApiSecret !== '') { + if (inputs.StripeApiSecret && inputs.StripeApiSecret !== '') { options.push({ key: 'StripeApiSecret', value: inputs.StripeApiSecret }); } - if (inputs.StripeWebhookSecret !== undefined && inputs.StripeWebhookSecret !== '') { + if (inputs.StripeWebhookSecret && inputs.StripeWebhookSecret !== '') { options.push({ key: 'StripeWebhookSecret', value: inputs.StripeWebhookSecret }); } if (inputs.StripePriceId !== '') { options.push({key: 'StripePriceId', value: inputs.StripePriceId,}); } - if (inputs.StripeUnitPrice !== '') { + if (inputs.StripeUnitPrice !== undefined && inputs.StripeUnitPrice !== null) { options.push({ key: 'StripeUnitPrice', value: inputs.StripeUnitPrice.toString() }); } - if (inputs.StripeMinTopUp !== '') { + if (inputs.StripeMinTopUp !== undefined && inputs.StripeMinTopUp !== null) { options.push({ key: 'StripeMinTopUp', value: inputs.StripeMinTopUp.toString() }); } @@ -96,7 +95,7 @@ export default function SettingsPaymentGateway(props) { showSuccess(t('更新成功')); // 更新本地存储的原始值 setOriginInputs({ ...inputs }); - props.refresh && props.refresh(); + props.refresh?.(); } } catch (error) { showError(t('更新失败')); @@ -135,7 +134,7 @@ export default function SettingsPaymentGateway(props) { { } const processStripeCallback = (data) => { - location.href = data.pay_link; + window.open(data.pay_link, '_blank'); }; const getUserQuota = async () => { @@ -938,21 +938,11 @@ const TopUp = () => { <> {/* 桌面端显示的自定义金额和支付按钮 */}
- {!enableOnlineTopUp?( - - - {t('或输入自定义金额')} - - - ) : ( - - - {t('Stripe')} - - - ) - } - + + + {t(!enableOnlineTopUp ? '或输入自定义金额' : 'Stripe')} + +
@@ -968,7 +958,7 @@ const TopUp = () => { )}
Date: Thu, 10 Jul 2025 21:00:43 +0800 Subject: [PATCH 6/7] fix: for AI review problems --- web/src/pages/TopUp/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/pages/TopUp/index.js b/web/src/pages/TopUp/index.js index 7fcc716d..e070c043 100644 --- a/web/src/pages/TopUp/index.js +++ b/web/src/pages/TopUp/index.js @@ -243,7 +243,7 @@ const TopUp = () => { showError(t('管理员未开启在线充值!')); return; } - setPayWay('stripe') + setPayWay('stripe'); setPaymentLoading(true); try { await getStripeAmount(); @@ -276,7 +276,7 @@ const TopUp = () => { if (res !== undefined) { const { message, data } = res.data; if (message === 'success') { - processStripeCallback(data) + processStripeCallback(data); } else { showError(data); } From 4740293640892681f1cd17f35cb9f6766e82237f Mon Sep 17 00:00:00 2001 From: wzxjohn Date: Thu, 10 Jul 2025 21:03:24 +0800 Subject: [PATCH 7/7] fix: for AI review problems --- web/src/pages/TopUp/index.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/web/src/pages/TopUp/index.js b/web/src/pages/TopUp/index.js index e070c043..a7ac6ba6 100644 --- a/web/src/pages/TopUp/index.js +++ b/web/src/pages/TopUp/index.js @@ -438,6 +438,7 @@ const TopUp = () => { if (value === undefined) { value = stripeTopUpCount } + setAmountLoading(true); try { const res = await API.post('/api/user/stripe/amount', { amount: parseFloat(value), @@ -446,9 +447,9 @@ const TopUp = () => { const { message, data } = res.data; // showInfo(message); if (message === 'success') { - setStripeAmount(parseFloat(data)) + setStripeAmount(parseFloat(data)); } else { - setStripeAmount( 0) + setStripeAmount(0); Toast.error({ content: '错误:' + data, id: 'getAmount' }); } } else { @@ -457,6 +458,7 @@ const TopUp = () => { } catch (err) { console.log(err); } finally { + setAmountLoading(false); } }