Merge pull request #1352 from wzxjohn/feature/simple_stripe
Add stripe support and fix wrong top up loading state
This commit is contained in:
@@ -193,3 +193,9 @@ const (
|
|||||||
ChannelStatusManuallyDisabled = 2 // also don't use 0
|
ChannelStatusManuallyDisabled = 2 // also don't use 0
|
||||||
ChannelStatusAutoDisabled = 3
|
ChannelStatusAutoDisabled = 3
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
TopUpStatusPending = "pending"
|
||||||
|
TopUpStatusSuccess = "success"
|
||||||
|
TopUpStatusExpired = "expired"
|
||||||
|
)
|
||||||
|
|||||||
34
common/hash.go
Normal file
34
common/hash.go
Normal file
@@ -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)))
|
||||||
|
}
|
||||||
@@ -57,7 +57,9 @@ func GetStatus(c *gin.Context) {
|
|||||||
"wechat_login": common.WeChatAuthEnabled,
|
"wechat_login": common.WeChatAuthEnabled,
|
||||||
"server_address": setting.ServerAddress,
|
"server_address": setting.ServerAddress,
|
||||||
"price": setting.Price,
|
"price": setting.Price,
|
||||||
|
"stripe_unit_price": setting.StripeUnitPrice,
|
||||||
"min_topup": setting.MinTopUp,
|
"min_topup": setting.MinTopUp,
|
||||||
|
"stripe_min_topup": setting.StripeMinTopUp,
|
||||||
"turnstile_check": common.TurnstileCheckEnabled,
|
"turnstile_check": common.TurnstileCheckEnabled,
|
||||||
"turnstile_site_key": common.TurnstileSiteKey,
|
"turnstile_site_key": common.TurnstileSiteKey,
|
||||||
"top_up_link": common.TopUpLink,
|
"top_up_link": common.TopUpLink,
|
||||||
@@ -71,6 +73,7 @@ func GetStatus(c *gin.Context) {
|
|||||||
"data_export_default_time": common.DataExportDefaultTime,
|
"data_export_default_time": common.DataExportDefaultTime,
|
||||||
"default_collapse_sidebar": common.DefaultCollapseSidebar,
|
"default_collapse_sidebar": common.DefaultCollapseSidebar,
|
||||||
"enable_online_topup": setting.PayAddress != "" && setting.EpayId != "" && setting.EpayKey != "",
|
"enable_online_topup": setting.PayAddress != "" && setting.EpayId != "" && setting.EpayKey != "",
|
||||||
|
"enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "",
|
||||||
"mj_notify_enabled": setting.MjNotifyEnabled,
|
"mj_notify_enabled": setting.MjNotifyEnabled,
|
||||||
"chats": setting.Chats,
|
"chats": setting.Chats,
|
||||||
"demo_site_enabled": operation_setting.DemoSiteEnabled,
|
"demo_site_enabled": operation_setting.DemoSiteEnabled,
|
||||||
|
|||||||
275
controller/topup_stripe.go
Normal file
275
controller/topup_stripe.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
4
go.mod
4
go.mod
@@ -27,10 +27,13 @@ require (
|
|||||||
github.com/samber/lo v1.39.0
|
github.com/samber/lo v1.39.0
|
||||||
github.com/shirou/gopsutil v3.21.11+incompatible
|
github.com/shirou/gopsutil v3.21.11+incompatible
|
||||||
github.com/shopspring/decimal v1.4.0
|
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
|
github.com/tiktoken-go/tokenizer v0.6.2
|
||||||
golang.org/x/crypto v0.35.0
|
golang.org/x/crypto v0.35.0
|
||||||
golang.org/x/image v0.23.0
|
golang.org/x/image v0.23.0
|
||||||
golang.org/x/net v0.35.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/mysql v1.4.3
|
||||||
gorm.io/driver/postgres v1.5.2
|
gorm.io/driver/postgres v1.5.2
|
||||||
gorm.io/gorm v1.25.2
|
gorm.io/gorm v1.25.2
|
||||||
@@ -84,7 +87,6 @@ require (
|
|||||||
github.com/yusufpapurcu/wmi v1.2.3 // indirect
|
github.com/yusufpapurcu/wmi v1.2.3 // indirect
|
||||||
golang.org/x/arch v0.12.0 // indirect
|
golang.org/x/arch v0.12.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // 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/sys v0.30.0 // indirect
|
||||||
golang.org/x/text v0.22.0 // indirect
|
golang.org/x/text v0.22.0 // indirect
|
||||||
google.golang.org/protobuf v1.34.2 // indirect
|
google.golang.org/protobuf v1.34.2 // indirect
|
||||||
|
|||||||
6
go.sum
6
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.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 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
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 h1:t0GN2DvcUZSFWT/62YOgoqb10y7gSXBGs0A+4VCQK+g=
|
||||||
github.com/tiktoken-go/tokenizer v0.6.2/go.mod h1:6UCYI/DtOallbmL7sSy30p6YQv60qNyU/4aVigPOx6w=
|
github.com/tiktoken-go/tokenizer v0.6.2/go.mod h1:6UCYI/DtOallbmL7sSy30p6YQv60qNyU/4aVigPOx6w=
|
||||||
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
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 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
|
||||||
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
|
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-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 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
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=
|
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-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-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-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-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-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
|||||||
@@ -76,6 +76,11 @@ func InitOptionMap() {
|
|||||||
common.OptionMap["Price"] = strconv.FormatFloat(setting.Price, 'f', -1, 64)
|
common.OptionMap["Price"] = strconv.FormatFloat(setting.Price, 'f', -1, 64)
|
||||||
common.OptionMap["USDExchangeRate"] = strconv.FormatFloat(setting.USDExchangeRate, 'f', -1, 64)
|
common.OptionMap["USDExchangeRate"] = strconv.FormatFloat(setting.USDExchangeRate, 'f', -1, 64)
|
||||||
common.OptionMap["MinTopUp"] = strconv.Itoa(setting.MinTopUp)
|
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["TopupGroupRatio"] = common.TopupGroupRatio2JSONString()
|
||||||
common.OptionMap["Chats"] = setting.Chats2JsonString()
|
common.OptionMap["Chats"] = setting.Chats2JsonString()
|
||||||
common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString()
|
common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString()
|
||||||
@@ -311,6 +316,16 @@ func updateOptionMap(key string, value string) (err error) {
|
|||||||
setting.USDExchangeRate, _ = strconv.ParseFloat(value, 64)
|
setting.USDExchangeRate, _ = strconv.ParseFloat(value, 64)
|
||||||
case "MinTopUp":
|
case "MinTopUp":
|
||||||
setting.MinTopUp, _ = strconv.Atoi(value)
|
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":
|
case "TopupGroupRatio":
|
||||||
err = common.UpdateTopupGroupRatioByJSONString(value)
|
err = common.UpdateTopupGroupRatioByJSONString(value)
|
||||||
case "GitHubClientId":
|
case "GitHubClientId":
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"one-api/common"
|
||||||
|
)
|
||||||
|
|
||||||
type TopUp struct {
|
type TopUp struct {
|
||||||
Id int `json:"id"`
|
Id int `json:"id"`
|
||||||
UserId int `json:"user_id" gorm:"index"`
|
UserId int `json:"user_id" gorm:"index"`
|
||||||
Amount int64 `json:"amount"`
|
Amount int64 `json:"amount"`
|
||||||
Money float64 `json:"money"`
|
Money float64 `json:"money"`
|
||||||
TradeNo string `json:"trade_no"`
|
TradeNo string `json:"trade_no" gorm:"unique"`
|
||||||
CreateTime int64 `json:"create_time"`
|
CreateTime int64 `json:"create_time"`
|
||||||
Status string `json:"status"`
|
CompleteTime int64 `json:"complete_time"`
|
||||||
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (topUp *TopUp) Insert() error {
|
func (topUp *TopUp) Insert() error {
|
||||||
@@ -41,3 +49,51 @@ func GetTopUpByTradeNo(tradeNo string) *TopUp {
|
|||||||
}
|
}
|
||||||
return 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ type User struct {
|
|||||||
LinuxDOId string `json:"linux_do_id" gorm:"column:linux_do_id;index"`
|
LinuxDOId string `json:"linux_do_id" gorm:"column:linux_do_id;index"`
|
||||||
Setting string `json:"setting" gorm:"type:text;column:setting"`
|
Setting string `json:"setting" gorm:"type:text;column:setting"`
|
||||||
Remark string `json:"remark,omitempty" gorm:"type:varchar(255)" validate:"max=255"`
|
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 {
|
func (user *User) ToBaseUser() *UserBase {
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
apiRouter.GET("/oauth/telegram/bind", middleware.CriticalRateLimit(), controller.TelegramBind)
|
apiRouter.GET("/oauth/telegram/bind", middleware.CriticalRateLimit(), controller.TelegramBind)
|
||||||
apiRouter.GET("/ratio_config", middleware.CriticalRateLimit(), controller.GetRatioConfig)
|
apiRouter.GET("/ratio_config", middleware.CriticalRateLimit(), controller.GetRatioConfig)
|
||||||
|
|
||||||
|
apiRouter.POST("/stripe/webhook", controller.StripeWebhook)
|
||||||
|
|
||||||
userRoute := apiRouter.Group("/user")
|
userRoute := apiRouter.Group("/user")
|
||||||
{
|
{
|
||||||
userRoute.POST("/register", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register)
|
userRoute.POST("/register", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register)
|
||||||
@@ -57,9 +59,11 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
selfRoute.DELETE("/self", controller.DeleteSelf)
|
selfRoute.DELETE("/self", controller.DeleteSelf)
|
||||||
selfRoute.GET("/token", controller.GenerateAccessToken)
|
selfRoute.GET("/token", controller.GenerateAccessToken)
|
||||||
selfRoute.GET("/aff", controller.GetAffCode)
|
selfRoute.GET("/aff", controller.GetAffCode)
|
||||||
selfRoute.POST("/topup", controller.TopUp)
|
selfRoute.POST("/topup", middleware.CriticalRateLimit(), controller.TopUp)
|
||||||
selfRoute.POST("/pay", controller.RequestEpay)
|
selfRoute.POST("/pay", middleware.CriticalRateLimit(), controller.RequestEpay)
|
||||||
selfRoute.POST("/amount", controller.RequestAmount)
|
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.POST("/aff_transfer", controller.TransferAffQuota)
|
||||||
selfRoute.PUT("/setting", controller.UpdateUserSetting)
|
selfRoute.PUT("/setting", controller.UpdateUserSetting)
|
||||||
}
|
}
|
||||||
|
|||||||
7
setting/payment_stripe.go
Normal file
7
setting/payment_stripe.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package setting
|
||||||
|
|
||||||
|
var StripeApiSecret = ""
|
||||||
|
var StripeWebhookSecret = ""
|
||||||
|
var StripePriceId = ""
|
||||||
|
var StripeUnitPrice = 8.0
|
||||||
|
var StripeMinTopUp = 1
|
||||||
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
|
|||||||
import { Card, Spin } from '@douyinfe/semi-ui';
|
import { Card, Spin } from '@douyinfe/semi-ui';
|
||||||
import SettingsGeneralPayment from '../../pages/Setting/Payment/SettingsGeneralPayment.js';
|
import SettingsGeneralPayment from '../../pages/Setting/Payment/SettingsGeneralPayment.js';
|
||||||
import SettingsPaymentGateway from '../../pages/Setting/Payment/SettingsPaymentGateway.js';
|
import SettingsPaymentGateway from '../../pages/Setting/Payment/SettingsPaymentGateway.js';
|
||||||
|
import SettingsPaymentGatewayStripe from '../../pages/Setting/Payment/SettingsPaymentGatewayStripe.js';
|
||||||
import { API, showError, toBoolean } from '../../helpers';
|
import { API, showError, toBoolean } from '../../helpers';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -17,6 +18,12 @@ const PaymentSetting = () => {
|
|||||||
TopupGroupRatio: '',
|
TopupGroupRatio: '',
|
||||||
CustomCallbackAddress: '',
|
CustomCallbackAddress: '',
|
||||||
PayMethods: '',
|
PayMethods: '',
|
||||||
|
|
||||||
|
StripeApiSecret: '',
|
||||||
|
StripeWebhookSecret: '',
|
||||||
|
StripePriceId: '',
|
||||||
|
StripeUnitPrice: 8.0,
|
||||||
|
StripeMinTopUp: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
let [loading, setLoading] = useState(false);
|
let [loading, setLoading] = useState(false);
|
||||||
@@ -38,6 +45,8 @@ const PaymentSetting = () => {
|
|||||||
break;
|
break;
|
||||||
case 'Price':
|
case 'Price':
|
||||||
case 'MinTopUp':
|
case 'MinTopUp':
|
||||||
|
case 'StripeUnitPrice':
|
||||||
|
case 'StripeMinTopUp':
|
||||||
newInputs[item.key] = parseFloat(item.value);
|
newInputs[item.key] = parseFloat(item.value);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
@@ -80,6 +89,9 @@ const PaymentSetting = () => {
|
|||||||
<Card style={{ marginTop: '10px' }}>
|
<Card style={{ marginTop: '10px' }}>
|
||||||
<SettingsPaymentGateway options={inputs} refresh={onRefresh} />
|
<SettingsPaymentGateway options={inputs} refresh={onRefresh} />
|
||||||
</Card>
|
</Card>
|
||||||
|
<Card style={{ marginTop: '10px' }}>
|
||||||
|
<SettingsPaymentGatewayStripe options={inputs} refresh={onRefresh} />
|
||||||
|
</Card>
|
||||||
</Spin>
|
</Spin>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
195
web/src/pages/Setting/Payment/SettingsPaymentGatewayStripe.js
Normal file
195
web/src/pages/Setting/Payment/SettingsPaymentGatewayStripe.js
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -59,6 +59,13 @@ const TopUp = () => {
|
|||||||
statusState?.status?.enable_online_topup || false,
|
statusState?.status?.enable_online_topup || false,
|
||||||
);
|
);
|
||||||
const [priceRatio, setPriceRatio] = useState(statusState?.status?.price || 1);
|
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 [userQuota, setUserQuota] = useState(0);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -161,6 +168,7 @@ const TopUp = () => {
|
|||||||
showError(t('管理员未开启在线充值!'));
|
showError(t('管理员未开启在线充值!'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setPayWay(payment);
|
||||||
setPaymentLoading(true);
|
setPaymentLoading(true);
|
||||||
try {
|
try {
|
||||||
await getAmount();
|
await getAmount();
|
||||||
@@ -168,7 +176,6 @@ const TopUp = () => {
|
|||||||
showError(t('充值数量不能小于') + minTopUp);
|
showError(t('充值数量不能小于') + minTopUp);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPayWay(payment);
|
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(t('获取金额失败'));
|
showError(t('获取金额失败'));
|
||||||
@@ -186,7 +193,6 @@ const TopUp = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setConfirmLoading(true);
|
setConfirmLoading(true);
|
||||||
setOpen(false);
|
|
||||||
try {
|
try {
|
||||||
const res = await API.post('/api/user/pay', {
|
const res = await API.post('/api/user/pay', {
|
||||||
amount: parseInt(topUpCount),
|
amount: parseInt(topUpCount),
|
||||||
@@ -227,10 +233,69 @@ const TopUp = () => {
|
|||||||
console.log(err);
|
console.log(err);
|
||||||
showError(t('支付请求失败'));
|
showError(t('支付请求失败'));
|
||||||
} finally {
|
} finally {
|
||||||
|
setOpen(false);
|
||||||
setConfirmLoading(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 () => {
|
const getUserQuota = async () => {
|
||||||
setUserDataLoading(true);
|
setUserDataLoading(true);
|
||||||
let res = await API.get(`/api/user/self`);
|
let res = await API.get(`/api/user/self`);
|
||||||
@@ -327,6 +392,10 @@ const TopUp = () => {
|
|||||||
setTopUpLink(statusState.status.top_up_link || '');
|
setTopUpLink(statusState.status.top_up_link || '');
|
||||||
setEnableOnlineTopUp(statusState.status.enable_online_topup || false);
|
setEnableOnlineTopUp(statusState.status.enable_online_topup || false);
|
||||||
setPriceRatio(statusState.status.price || 1);
|
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]);
|
}, [statusState?.status]);
|
||||||
|
|
||||||
@@ -334,6 +403,10 @@ const TopUp = () => {
|
|||||||
return amount + ' ' + t('元');
|
return amount + ' ' + t('元');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderStripeAmount = () => {
|
||||||
|
return stripeAmount + ' ' + t('元');
|
||||||
|
};
|
||||||
|
|
||||||
const getAmount = async (value) => {
|
const getAmount = async (value) => {
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
value = topUpCount;
|
value = topUpCount;
|
||||||
@@ -361,10 +434,42 @@ const TopUp = () => {
|
|||||||
setAmountLoading(false);
|
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 = () => {
|
const handleCancel = () => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleStripeCancel = () => {
|
||||||
|
setStripeOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
const handleTransferCancel = () => {
|
const handleTransferCancel = () => {
|
||||||
setOpenTransfer(false);
|
setOpenTransfer(false);
|
||||||
};
|
};
|
||||||
@@ -374,6 +479,9 @@ const TopUp = () => {
|
|||||||
setTopUpCount(preset.value);
|
setTopUpCount(preset.value);
|
||||||
setSelectedPreset(preset.value);
|
setSelectedPreset(preset.value);
|
||||||
setAmount(preset.value * priceRatio);
|
setAmount(preset.value * priceRatio);
|
||||||
|
|
||||||
|
setStripeTopUpCount(preset.value);
|
||||||
|
setStripeAmount(preset.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 格式化大数字显示
|
// 格式化大数字显示
|
||||||
@@ -496,6 +604,25 @@ const TopUp = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</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='grid grid-cols-1 lg:grid-cols-12 gap-6'>
|
||||||
{/* 左侧充值区域 */}
|
{/* 左侧充值区域 */}
|
||||||
<div className='lg:col-span-7 space-y-6 w-full'>
|
<div className='lg:col-span-7 space-y-6 w-full'>
|
||||||
@@ -798,7 +925,7 @@ const TopUp = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!enableOnlineTopUp && (
|
{!enableOnlineTopUp && !enableStripeTopUp && (
|
||||||
<Banner
|
<Banner
|
||||||
type='warning'
|
type='warning'
|
||||||
description={t(
|
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' }}>
|
<Divider style={{ margin: '24px 0' }}>
|
||||||
<Text className='text-sm font-medium'>{t('兑换码充值')}</Text>
|
<Text className='text-sm font-medium'>{t('兑换码充值')}</Text>
|
||||||
</Divider>
|
</Divider>
|
||||||
|
|||||||
Reference in New Issue
Block a user