Procházet zdrojové kódy

Merge pull request #1352 from wzxjohn/feature/simple_stripe

Add stripe support and fix wrong top up loading state
IcedTangerine před 5 měsíci
rodič
revize
952b679ca3

+ 6 - 0
common/constants.go

@@ -193,3 +193,9 @@ const (
 	ChannelStatusManuallyDisabled = 2 // also don't use 0
 	ChannelStatusAutoDisabled     = 3
 )
+
+const (
+	TopUpStatusPending = "pending"
+	TopUpStatusSuccess = "success"
+	TopUpStatusExpired = "expired"
+)

+ 34 - 0
common/hash.go

@@ -0,0 +1,34 @@
+package common
+
+import (
+	"crypto/hmac"
+	"crypto/sha1"
+	"crypto/sha256"
+	"encoding/hex"
+)
+
+func Sha256Raw(data []byte) []byte {
+	h := sha256.New()
+	h.Write(data)
+	return h.Sum(nil)
+}
+
+func Sha1Raw(data []byte) []byte {
+	h := sha1.New()
+	h.Write(data)
+	return h.Sum(nil)
+}
+
+func Sha1(data []byte) string {
+	return hex.EncodeToString(Sha1Raw(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)))
+}

+ 3 - 0
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,

+ 275 - 0
controller/topup_stripe.go

@@ -0,0 +1,275 @@
+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"`
+}
+
+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 < getStripeMinTopup() {
+		c.JSON(200, gin.H{"message": fmt.Sprintf("充值数量不能小于 %d", getStripeMinTopup()), "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([]byte(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 len(referenceId) == 0 {
+		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
+	}
+	// Using float64 for monetary calculations is acceptable here due to the small amounts involved
+	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)
+}

+ 3 - 1
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

+ 6 - 0
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=

+ 15 - 0
model/option.go

@@ -76,6 +76,11 @@ func InitOptionMap() {
 	common.OptionMap["Price"] = strconv.FormatFloat(setting.Price, 'f', -1, 64)
 	common.OptionMap["USDExchangeRate"] = strconv.FormatFloat(setting.USDExchangeRate, '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()
@@ -311,6 +316,16 @@ func updateOptionMap(key string, value string) (err error) {
 		setting.USDExchangeRate, _ = 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":

+ 63 - 7
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
+}

+ 1 - 0
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:"type:varchar(64);column:stripe_customer;index"`
 }
 
 func (user *User) ToBaseUser() *UserBase {

+ 6 - 2
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)
 			}

+ 7 - 0
setting/payment_stripe.go

@@ -0,0 +1,7 @@
+package setting
+
+var StripeApiSecret = ""
+var StripeWebhookSecret = ""
+var StripePriceId = ""
+var StripeUnitPrice = 8.0
+var StripeMinTopUp = 1

+ 12 - 0
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, toBoolean } 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 = () => {
         <Card style={{ marginTop: '10px' }}>
           <SettingsPaymentGateway options={inputs} refresh={onRefresh} />
         </Card>
+        <Card style={{ marginTop: '10px' }}>
+          <SettingsPaymentGatewayStripe options={inputs} refresh={onRefresh} />
+        </Card>
       </Spin>
     </>
   );

+ 195 - 0
web/src/pages/Setting/Payment/SettingsPaymentGatewayStripe.js

@@ -0,0 +1,195 @@
+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,
+} 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 && inputs.StripeApiSecret !== '') {
+        options.push({ key: 'StripeApiSecret', value: inputs.StripeApiSecret });
+      }
+      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 !== undefined && inputs.StripeUnitPrice !== null) {
+        options.push({ key: 'StripeUnitPrice', value: inputs.StripeUnitPrice.toString() });
+      }
+      if (inputs.StripeMinTopUp !== undefined && inputs.StripeMinTopUp !== null) {
+        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?.();
+      }
+    } catch (error) {
+      showError(t('更新失败'));
+    }
+    setLoading(false);
+  };
+
+  return (
+    <Spin spinning={loading}>
+      <Form
+        initValues={inputs}
+        onValueChange={handleFormChange}
+        getFormApi={(api) => (formApiRef.current = api)}
+      >
+        <Form.Section text={t('Stripe 设置')}>
+          <Text>
+            Stripe 密钥、Webhook 等设置请
+            <a
+                href='https://dashboard.stripe.com/developers'
+                target='_blank'
+                rel='noreferrer'
+            >
+              点击此处
+            </a>
+            进行设置,最好先在
+            <a
+                href='https://dashboard.stripe.com/test/developers'
+                target='_blank'
+                rel='noreferrer'
+            >
+              测试环境
+            </a>
+            进行测试。
+
+            <br />
+          </Text>
+          <Banner
+              type='info'
+              description={`Webhook 填:${props.options.ServerAddress ? removeTrailingSlash(props.options.ServerAddress) : t('网站地址')}/api/stripe/webhook`}
+          />
+          <Banner
+              type='warning'
+              description={`需要包含事件:checkout.session.completed 和 checkout.session.expired`}
+          />
+          <Row
+            gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
+          >
+            <Col xs={24} sm={24} md={8} lg={8} xl={8}>
+              <Form.Input
+                field='StripeApiSecret'
+                label={t('API 密钥')}
+                placeholder={t('sk_xxx 或 rk_xxx 的 Stripe 密钥,敏感信息不显示')}
+                type='password'
+              />
+            </Col>
+            <Col xs={24} sm={24} md={8} lg={8} xl={8}>
+              <Form.Input
+                field='StripeWebhookSecret'
+                label={t('Webhook 签名密钥')}
+                placeholder={t('whsec_xxx 的 Webhook 签名密钥,敏感信息不显示')}
+                type='password'
+              />
+            </Col>
+            <Col xs={24} sm={24} md={8} lg={8} xl={8}>
+              <Form.Input
+                field='StripePriceId'
+                label={t('商品价格 ID')}
+                placeholder={t('price_xxx 的商品价格 ID,新建产品后可获得')}
+              />
+            </Col>
+          </Row>
+          <Row
+            gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
+            style={{ marginTop: 16 }}
+          >
+            <Col xs={24} sm={24} md={8} lg={8} xl={8}>
+              <Form.InputNumber
+                field='StripeUnitPrice'
+                precision={2}
+                label={t('充值价格(x元/美金)')}
+                placeholder={t('例如:7,就是7元/美金')}
+              />
+            </Col>
+            <Col xs={24} sm={24} md={8} lg={8} xl={8}>
+              <Form.InputNumber
+                field='StripeMinTopUp'
+                label={t('最低充值美元数量')}
+                placeholder={t('例如:2,就是最低充值2$')}
+              />
+            </Col>
+          </Row>
+          <Button onClick={submitStripeSetting}>{t('更新 Stripe 设置')}</Button>
+        </Form.Section>
+      </Form>
+    </Spin>
+  );
+} 

+ 213 - 3
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);
@@ -161,6 +168,7 @@ const TopUp = () => {
       showError(t('管理员未开启在线充值!'));
       return;
     }
+    setPayWay(payment);
     setPaymentLoading(true);
     try {
       await getAmount();
@@ -168,7 +176,6 @@ const TopUp = () => {
         showError(t('充值数量不能小于') + minTopUp);
         return;
       }
-      setPayWay(payment);
       setOpen(true);
     } catch (error) {
       showError(t('获取金额失败'));
@@ -186,7 +193,6 @@ const TopUp = () => {
       return;
     }
     setConfirmLoading(true);
-    setOpen(false);
     try {
       const res = await API.post('/api/user/pay', {
         amount: parseInt(topUpCount),
@@ -227,8 +233,67 @@ const TopUp = () => {
       console.log(err);
       showError(t('支付请求失败'));
     } finally {
+      setOpen(false);
+      setConfirmLoading(false);
+    }
+  };
+
+  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 {
+      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) => {
+    window.open(data.pay_link, '_blank');
   };
 
   const getUserQuota = async () => {
@@ -327,6 +392,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 +403,10 @@ const TopUp = () => {
     return amount + ' ' + t('元');
   };
 
+  const renderStripeAmount = () => {
+    return stripeAmount + ' ' + t('元');
+  };
+
   const getAmount = async (value) => {
     if (value === undefined) {
       value = topUpCount;
@@ -361,10 +434,42 @@ const TopUp = () => {
     setAmountLoading(false);
   };
 
+  const getStripeAmount = async (value) => {
+    if (value === undefined) {
+      value = stripeTopUpCount
+    }
+    setAmountLoading(true);
+    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 {
+      setAmountLoading(false);
+    }
+  }
+
   const handleCancel = () => {
     setOpen(false);
   };
 
+  const handleStripeCancel = () => {
+    setStripeOpen(false);
+  };
+
   const handleTransferCancel = () => {
     setOpenTransfer(false);
   };
@@ -374,6 +479,9 @@ const TopUp = () => {
     setTopUpCount(preset.value);
     setSelectedPreset(preset.value);
     setAmount(preset.value * priceRatio);
+
+    setStripeTopUpCount(preset.value);
+    setStripeAmount(preset.value);
   };
 
   // 格式化大数字显示
@@ -496,6 +604,25 @@ const TopUp = () => {
         </div>
       </Modal>
 
+      <Modal
+          title={t('确定要充值吗')}
+          visible={stripeOpen}
+          onOk={onlineStripeTopUp}
+          onCancel={handleStripeCancel}
+          maskClosable={false}
+          size='small'
+          centered
+          confirmLoading={confirmLoading}
+      >
+        <p>
+          {t('充值数量')}:{stripeTopUpCount}
+        </p>
+        <p>
+          {t('实付金额')}:{renderStripeAmount()}
+        </p>
+        <p>{t('是否确认充值?')}</p>
+      </Modal>
+
       <div className='grid grid-cols-1 lg:grid-cols-12 gap-6'>
         {/* 左侧充值区域 */}
         <div className='lg:col-span-7 space-y-6 w-full'>
@@ -798,7 +925,7 @@ const TopUp = () => {
                 </>
               )}
 
-              {!enableOnlineTopUp && (
+              {!enableOnlineTopUp && !enableStripeTopUp && (
                 <Banner
                   type='warning'
                   description={t(
@@ -809,6 +936,89 @@ const TopUp = () => {
                 />
               )}
 
+              {enableStripeTopUp && (
+                  <>
+                    {/* 桌面端显示的自定义金额和支付按钮 */}
+                    <div className='hidden md:block space-y-4'>
+                      <Divider style={{ margin: '24px 0' }}>
+                        <Text className='text-sm font-medium'>
+                          {t(!enableOnlineTopUp ? '或输入自定义金额' : 'Stripe')}
+                        </Text>
+                      </Divider>
+
+                      <div>
+                        <div className='flex justify-between mb-2'>
+                          <Text strong>{t('充值数量')}</Text>
+                          {amountLoading ? (
+                              <Skeleton.Title
+                                  style={{ width: '80px', height: '16px' }}
+                              />
+                          ) : (
+                              <Text type='tertiary'>
+                                {t('实付金额:') + renderStripeAmount()}
+                              </Text>
+                          )}
+                        </div>
+                        <InputNumber
+                            disabled={!enableStripeTopUp}
+                            placeholder={
+                                t('充值数量,最低 ') + renderQuotaWithAmount(stripeMinTopUp)
+                            }
+                            value={stripeTopUpCount}
+                            min={stripeMinTopUp}
+                            max={999999999}
+                            step={1}
+                            precision={0}
+                            onChange={async (value) => {
+                              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
+                            }
+                        />
+                      </div>
+
+                      <div>
+                        <Text strong className='block mb-3'>
+                          {t('选择支付方式')}
+                        </Text>
+                          <div className='grid grid-cols-1 gap-3'>
+                            <Button
+                                key='stripe'
+                                type='primary'
+                                onClick={() => stripePreTopUp()}
+                                size='large'
+                                disabled={!enableStripeTopUp}
+                                loading={paymentLoading && payWay === 'stripe'}
+                                icon={<CreditCard size={16} />}
+                                style={{
+                                  height: '40px',
+                                  color: '#b161fe',
+                                }}
+                                className='transition-all hover:shadow-md w-full'
+                            >
+                              <span className='ml-1'>Stripe</span>
+                            </Button>
+                          </div>
+                      </div>
+                    </div>
+                  </>
+              )}
+
               <Divider style={{ margin: '24px 0' }}>
                 <Text className='text-sm font-medium'>{t('兑换码充值')}</Text>
               </Divider>