diff --git a/README.md b/README.md
index 4b427f71..f7c248d8 100644
--- a/README.md
+++ b/README.md
@@ -43,6 +43,10 @@ _本项目是基于[one-api](https://github.com/songquanpeng/one-api)二次开
+> [!WARNING]
+> 本项目为个人学习使用,不保证稳定性,且不提供任何技术支持,使用者必须在遵循 OpenAI 的使用条款以及法律法规的情况下使用,不得用于非法用途。
+> 根据[《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)的要求,请勿对中国地区公众提供一切未经备案的生成式人工智能服务。
+
## 功能变化
- 全新的 UI 界面
diff --git a/common/config/payment.go b/common/config/payment.go
new file mode 100644
index 00000000..cf9d1c37
--- /dev/null
+++ b/common/config/payment.go
@@ -0,0 +1,4 @@
+package config
+
+var PaymentUSDRate = 7.3
+var PaymentMinAmount = 1
diff --git a/common/utils/helper.go b/common/utils/helper.go
index a9e408fd..eda33a40 100644
--- a/common/utils/helper.go
+++ b/common/utils/helper.go
@@ -14,10 +14,21 @@ import (
"strings"
"time"
+ "github.com/bwmarrin/snowflake"
"github.com/google/uuid"
"github.com/spf13/viper"
)
+var node *snowflake.Node
+
+func init() {
+ var err error
+ node, err = snowflake.NewNode(1)
+ if err != nil {
+ log.Fatalf("snowflake.NewNode failed: %v", err)
+ }
+}
+
func OpenBrowser(url string) {
var err error
@@ -203,6 +214,14 @@ func String2Int(str string) int {
return num
}
+func String2Int64(str string) int64 {
+ num, err := strconv.ParseInt(str, 10, 64)
+ if err != nil {
+ return 0
+ }
+ return num
+}
+
func IsFileExist(path string) bool {
_, err := os.Stat(path)
return err == nil || os.IsExist(err)
@@ -256,3 +275,19 @@ func Marshal[T interface{}](data T) string {
}
return string(res)
}
+
+func GenerateTradeNo() string {
+ id := node.Generate()
+
+ return id.String()
+}
+
+func Decimal(value float64, decimalPlace int) float64 {
+ format := fmt.Sprintf("%%.%df", decimalPlace)
+ value, _ = strconv.ParseFloat(fmt.Sprintf(format, value), 64)
+ return value
+}
+
+func GetUnixTime() int64 {
+ return time.Now().Unix()
+}
diff --git a/controller/misc.go b/controller/misc.go
index 98aa405c..f2cb05d6 100644
--- a/controller/misc.go
+++ b/controller/misc.go
@@ -47,6 +47,8 @@ func GetStatus(c *gin.Context) {
"mj_notify_enabled": config.MjNotifyEnabled,
"chat_cache_enabled": config.ChatCacheEnabled,
"chat_links": config.ChatLinks,
+ "PaymentUSDRate": config.PaymentUSDRate,
+ "PaymentMinAmount": config.PaymentMinAmount,
},
})
}
diff --git a/controller/order.go b/controller/order.go
new file mode 100644
index 00000000..90320976
--- /dev/null
+++ b/controller/order.go
@@ -0,0 +1,223 @@
+package controller
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "sync"
+
+ "one-api/common"
+ "one-api/common/config"
+ "one-api/common/logger"
+ "one-api/common/utils"
+ "one-api/model"
+ "one-api/payment"
+ "one-api/payment/types"
+
+ "github.com/gin-gonic/gin"
+)
+
+type OrderRequest struct {
+ UUID string `json:"uuid" binding:"required"`
+ Amount int `json:"amount" binding:"required"`
+}
+
+type OrderResponse struct {
+ TradeNo string `json:"trade_no"`
+ *types.PayRequest
+}
+
+// CreateOrder
+func CreateOrder(c *gin.Context) {
+ var orderReq OrderRequest
+ if err := c.ShouldBindJSON(&orderReq); err != nil {
+ common.APIRespondWithError(c, http.StatusOK, errors.New("invalid request"))
+
+ return
+ }
+
+ if orderReq.Amount <= 0 || orderReq.Amount < config.PaymentMinAmount {
+ common.APIRespondWithError(c, http.StatusOK, fmt.Errorf("金额必须大于等于 %d", config.PaymentMinAmount))
+
+ return
+ }
+
+ userId := c.GetInt("id")
+ // 关闭用户未完成的订单
+ go model.CloseUnfinishedOrder()
+
+ paymentService, err := payment.NewPaymentService(orderReq.UUID)
+ if err != nil {
+ common.APIRespondWithError(c, http.StatusOK, err)
+ return
+ }
+
+ // 获取手续费和支付金额
+ fee, payMoney := calculateOrderAmount(paymentService.Payment, orderReq.Amount)
+
+ // 开始支付
+ tradeNo := utils.GenerateTradeNo()
+ payRequest, err := paymentService.Pay(tradeNo, payMoney)
+ if err != nil {
+ common.APIRespondWithError(c, http.StatusOK, errors.New("创建支付失败,请稍后再试"))
+ return
+ }
+
+ // 创建订单
+ order := &model.Order{
+ UserId: userId,
+ TradeNo: tradeNo,
+ Amount: orderReq.Amount,
+ OrderAmount: payMoney,
+ OrderCurrency: paymentService.Payment.Currency,
+ Fee: fee,
+ Status: model.OrderStatusPending,
+ Quota: orderReq.Amount * int(config.QuotaPerUnit),
+ }
+
+ err = order.Insert()
+ if err != nil {
+ common.APIRespondWithError(c, http.StatusOK, errors.New("创建订单失败,请稍后再试"))
+ return
+ }
+
+ orderResp := &OrderResponse{
+ TradeNo: tradeNo,
+ PayRequest: payRequest,
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "",
+ "data": orderResp,
+ })
+}
+
+// tradeNo lock
+var orderLocks sync.Map
+var createLock sync.Mutex
+
+// LockOrder 尝试对给定订单号加锁
+func LockOrder(tradeNo string) {
+ lock, ok := orderLocks.Load(tradeNo)
+ if !ok {
+ createLock.Lock()
+ defer createLock.Unlock()
+ lock, ok = orderLocks.Load(tradeNo)
+ if !ok {
+ lock = new(sync.Mutex)
+ orderLocks.Store(tradeNo, lock)
+ }
+ }
+ lock.(*sync.Mutex).Lock()
+}
+
+// UnlockOrder 释放给定订单号的锁
+func UnlockOrder(tradeNo string) {
+ lock, ok := orderLocks.Load(tradeNo)
+ if ok {
+ lock.(*sync.Mutex).Unlock()
+ }
+}
+
+func PaymentCallback(c *gin.Context) {
+ uuid := c.Param("uuid")
+ paymentService, err := payment.NewPaymentService(uuid)
+ if err != nil {
+ common.APIRespondWithError(c, http.StatusOK, errors.New("payment not found"))
+ return
+ }
+
+ payNotify, err := paymentService.HandleCallback(c, paymentService.Payment.Config)
+ if err != nil {
+ return
+ }
+
+ LockOrder(payNotify.GatewayNo)
+ defer UnlockOrder(payNotify.GatewayNo)
+
+ order, err := model.GetOrderByTradeNo(payNotify.TradeNo)
+ if err != nil {
+ logger.SysError(fmt.Sprintf("gateway callback failed to find order, trade_no: %s,", payNotify.TradeNo))
+ return
+ }
+ fmt.Println(order.Status, order.Status != model.OrderStatusPending)
+
+ if order.Status != model.OrderStatusPending {
+ return
+ }
+
+ order.GatewayNo = payNotify.GatewayNo
+ order.Status = model.OrderStatusSuccess
+ err = order.Update()
+ if err != nil {
+ logger.SysError(fmt.Sprintf("gateway callback failed to update order, trade_no: %s,", payNotify.TradeNo))
+ return
+ }
+
+ err = model.IncreaseUserQuota(order.UserId, order.Quota)
+ if err != nil {
+ logger.SysError(fmt.Sprintf("gateway callback failed to increase user quota, trade_no: %s,", payNotify.TradeNo))
+ return
+ }
+
+ model.RecordLog(order.UserId, model.LogTypeTopup, fmt.Sprintf("在线充值成功,充值quota: %d,支付金额:%.2f %s", order.Quota, order.OrderAmount, order.OrderCurrency))
+
+}
+
+func CheckOrderStatus(c *gin.Context) {
+ tradeNo := c.Query("trade_no")
+ userId := c.GetInt("id")
+ success := false
+
+ if tradeNo != "" {
+ order, err := model.GetUserOrder(userId, tradeNo)
+ if err == nil {
+ if order.Status == model.OrderStatusSuccess {
+ success = true
+ }
+ }
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "success": success,
+ "message": "",
+ })
+}
+
+func calculateOrderAmount(payment *model.Payment, amount int) (fee, payMoney float64) {
+ if payment.PercentFee > 0 {
+ fee = utils.Decimal(float64(amount)*payment.PercentFee, 2)
+ } else if payment.FixedFee > 0 {
+ fee = payment.FixedFee
+ }
+
+ total := utils.Decimal(float64(amount)+fee, 2)
+
+ if payment.Currency == model.CurrencyTypeUSD {
+ payMoney = total
+ } else {
+ payMoney = utils.Decimal(total*config.PaymentUSDRate, 2)
+ }
+
+ return
+}
+
+func GetOrderList(c *gin.Context) {
+ var params model.SearchOrderParams
+ if err := c.ShouldBindQuery(¶ms); err != nil {
+ common.APIRespondWithError(c, http.StatusOK, err)
+ return
+ }
+
+ payments, err := model.GetOrderList(¶ms)
+ if err != nil {
+ common.APIRespondWithError(c, http.StatusOK, err)
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "",
+ "data": payments,
+ })
+}
diff --git a/controller/payment.go b/controller/payment.go
new file mode 100644
index 00000000..32585749
--- /dev/null
+++ b/controller/payment.go
@@ -0,0 +1,140 @@
+package controller
+
+import (
+ "net/http"
+ "one-api/common"
+ "one-api/model"
+ "strconv"
+
+ "github.com/gin-gonic/gin"
+)
+
+func GetPaymentList(c *gin.Context) {
+ var params model.SearchPaymentParams
+ if err := c.ShouldBindQuery(¶ms); err != nil {
+ common.APIRespondWithError(c, http.StatusOK, err)
+ return
+ }
+
+ payments, err := model.GetPanymentList(¶ms)
+ if err != nil {
+ common.APIRespondWithError(c, http.StatusOK, err)
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "",
+ "data": payments,
+ })
+}
+
+func GetPayment(c *gin.Context) {
+ id, err := strconv.Atoi(c.Param("id"))
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": err.Error(),
+ })
+ return
+ }
+
+ payment, err := model.GetPaymentByID(id)
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": err.Error(),
+ })
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "",
+ "data": payment,
+ })
+}
+
+func AddPayment(c *gin.Context) {
+ payment := model.Payment{}
+ err := c.ShouldBindJSON(&payment)
+ if err != nil {
+ common.APIRespondWithError(c, http.StatusOK, err)
+ return
+ }
+
+ err = payment.Insert()
+ if err != nil {
+ common.APIRespondWithError(c, http.StatusOK, err)
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "",
+ "data": payment,
+ })
+}
+
+func UpdatePayment(c *gin.Context) {
+ payment := model.Payment{}
+ err := c.ShouldBindJSON(&payment)
+ if err != nil {
+ common.APIRespondWithError(c, http.StatusOK, err)
+ return
+ }
+
+ overwrite := true
+
+ if payment.UUID == "" {
+ overwrite = false
+ }
+
+ err = payment.Update(overwrite)
+ if err != nil {
+ common.APIRespondWithError(c, http.StatusOK, err)
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "",
+ "data": payment,
+ })
+}
+
+func DeletePayment(c *gin.Context) {
+ id, err := strconv.Atoi(c.Param("id"))
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": err.Error(),
+ })
+ return
+ }
+
+ payment := model.Payment{ID: id}
+ err = payment.Delete()
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": err.Error(),
+ })
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "",
+ })
+}
+
+func GetUserPaymentList(c *gin.Context) {
+ payments, err := model.GetUserPaymentList()
+ if err != nil {
+ common.APIRespondWithError(c, http.StatusOK, err)
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "",
+ "data": payments,
+ })
+}
diff --git a/go.mod b/go.mod
index f6f09dbf..1faf6c06 100644
--- a/go.mod
+++ b/go.mod
@@ -35,6 +35,7 @@ require (
require (
filippo.io/edwards25519 v1.1.0 // indirect
+ github.com/bwmarrin/snowflake v0.3.0 // indirect
github.com/chenzhuoyu/iasm v0.9.1 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
diff --git a/go.sum b/go.sum
index ad73d3d6..50ff550d 100644
--- a/go.sum
+++ b/go.sum
@@ -14,6 +14,8 @@ github.com/aws/smithy-go v1.20.1/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff/go.mod h1:+RTT1BOk5P97fT2CiHkbFQwkK3mjsFAP6zCYV2aXtjw=
github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA=
github.com/bradleypeabody/gorilla-sessions-memcache v0.0.0-20181103040241-659414f458e1/go.mod h1:dkChI7Tbtx7H1Tj7TqGSZMOeGpMP5gLHtjroHd4agiI=
+github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0=
+github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
github.com/bytedance/sonic v1.11.3 h1:jRN+yEjakWh8aK5FzrciUHG8OFXK+4/KrAX/ysEtHAA=
diff --git a/model/main.go b/model/main.go
index e9d5ac14..0772f71a 100644
--- a/model/main.go
+++ b/model/main.go
@@ -154,6 +154,14 @@ func InitDB() (err error) {
if err != nil {
return err
}
+ err = db.AutoMigrate(&Payment{})
+ if err != nil {
+ return err
+ }
+ err = db.AutoMigrate(&Order{})
+ if err != nil {
+ return err
+ }
logger.SysLog("database migrated")
err = createRootAccountIfNeed()
return err
diff --git a/model/option.go b/model/option.go
index a5c881c7..a111b9d9 100644
--- a/model/option.go
+++ b/model/option.go
@@ -84,6 +84,9 @@ func InitOptionMap() {
config.OptionMap["ChatImageRequestProxy"] = ""
+ config.OptionMap["PaymentUSDRate"] = strconv.FormatFloat(config.PaymentUSDRate, 'f', -1, 64)
+ config.OptionMap["PaymentMinAmount"] = strconv.Itoa(config.PaymentMinAmount)
+
config.OptionMapRWMutex.Unlock()
loadOptionsFromDatabase()
}
@@ -132,6 +135,7 @@ var optionIntMap = map[string]*int{
"RetryTimes": &config.RetryTimes,
"RetryCooldownSeconds": &config.RetryCooldownSeconds,
"ChatCacheExpireMinute": &config.ChatCacheExpireMinute,
+ "PaymentMinAmount": &config.PaymentMinAmount,
}
var optionBoolMap = map[string]*bool{
@@ -206,6 +210,8 @@ func updateOptionMap(key string, value string) (err error) {
config.ChannelDisableThreshold, _ = strconv.ParseFloat(value, 64)
case "QuotaPerUnit":
config.QuotaPerUnit, _ = strconv.ParseFloat(value, 64)
+ case "PaymentUSDRate":
+ config.PaymentUSDRate, _ = strconv.ParseFloat(value, 64)
}
return err
}
diff --git a/model/order.go b/model/order.go
new file mode 100644
index 00000000..d08cb6ef
--- /dev/null
+++ b/model/order.go
@@ -0,0 +1,107 @@
+package model
+
+import (
+ "time"
+
+ "gorm.io/gorm"
+)
+
+type OrderStatus string
+
+const (
+ OrderStatusPending OrderStatus = "pending"
+ OrderStatusSuccess OrderStatus = "success"
+ OrderStatusFailed OrderStatus = "failed"
+ OrderStatusClosed OrderStatus = "closed"
+)
+
+type Order struct {
+ ID int `json:"id"`
+ UserId int `json:"user_id"`
+ TradeNo string `json:"trade_no" gorm:"type:varchar(50);uniqueIndex"`
+ GatewayNo string `json:"gateway_no" gorm:"type:varchar(100)"`
+ Amount int `json:"amount" gorm:"default:0"`
+ OrderAmount float64 `json:"order_amount" gorm:"type:decimal(10,2);default:0"`
+ OrderCurrency CurrencyType `json:"order_currency" gorm:"type:varchar(16)"`
+ Quota int `json:"quota" gorm:"type:int;default:0"`
+ Fee float64 `json:"fee" gorm:"type:decimal(10,2);default:0"`
+ Status OrderStatus `json:"status" gorm:"type:varchar(32)"`
+ CreatedAt int `json:"created_at"`
+ UpdatedAt int `json:"-"`
+ DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
+}
+
+// 查询并关闭未完成的订单
+func CloseUnfinishedOrder() error {
+ // 关闭超过 3 小时未支付的订单
+ unixTime := time.Now().Unix() - 3*3600
+ return DB.Model(&Order{}).Where("status = ? AND created_at < ?", OrderStatusPending, unixTime).Update("status", OrderStatusClosed).Error
+}
+
+func GetOrderByTradeNo(tradeNo string) (*Order, error) {
+ var order Order
+ err := DB.Where("trade_no = ?", tradeNo).First(&order).Error
+ return &order, err
+}
+
+func GetUserOrder(userId int, tradeNo string) (*Order, error) {
+ var order Order
+ err := DB.Where("user_id = ? AND trade_no = ?", userId, tradeNo).First(&order).Error
+ return &order, err
+}
+
+func (o *Order) Insert() error {
+ return DB.Create(o).Error
+}
+
+func (o *Order) Update() error {
+ return DB.Save(o).Error
+}
+
+var allowedOrderFields = map[string]bool{
+ "id": true,
+ "user_id": true,
+ "status": true,
+ "created_at": true,
+}
+
+type SearchOrderParams struct {
+ UserId int `form:"user_id"`
+ TradeNo string `form:"trade_no"`
+ GatewayNo string `form:"gateway_no"`
+ Status string `form:"status"`
+ StartTimestamp int64 `form:"start_timestamp"`
+ EndTimestamp int64 `form:"end_timestamp"`
+ PaginationParams
+}
+
+func GetOrderList(params *SearchOrderParams) (*DataResult[Order], error) {
+ var orders []*Order
+
+ db := DB.Omit("key")
+
+ if params.UserId != 0 {
+ db = db.Where("user_id = ?", params.UserId)
+ }
+
+ if params.TradeNo != "" {
+ db = db.Where("trade_no = ?", params.TradeNo)
+ }
+
+ if params.GatewayNo != "" {
+ db = db.Where("gateway_no = ?", params.GatewayNo)
+ }
+
+ if params.Status != "" {
+ db = db.Where("status = ?", params.Status)
+ }
+
+ if params.StartTimestamp != 0 {
+ db = db.Where("created_at >= ?", params.StartTimestamp)
+ }
+ if params.EndTimestamp != 0 {
+ db = db.Where("created_at <= ?", params.EndTimestamp)
+ }
+
+ return PaginateAndOrder(db, ¶ms.PaginationParams, &orders, allowedOrderFields)
+}
diff --git a/model/payment.go b/model/payment.go
new file mode 100644
index 00000000..1f3aa08d
--- /dev/null
+++ b/model/payment.go
@@ -0,0 +1,110 @@
+package model
+
+import (
+ "one-api/common/utils"
+
+ "gorm.io/gorm"
+)
+
+type CurrencyType string
+
+const (
+ CurrencyTypeUSD CurrencyType = "USD"
+ CurrencyTypeCNY CurrencyType = "CNY"
+)
+
+type Payment struct {
+ ID int `json:"id"`
+ Type string `json:"type" form:"type" gorm:"type:varchar(16)"`
+ UUID string `json:"uuid" form:"uuid" gorm:"type:char(32);uniqueIndex"`
+ Name string `json:"name" form:"name" gorm:"type:varchar(255); not null"`
+ Icon string `json:"icon" form:"icon" gorm:"type:varchar(300)"`
+ NotifyDomain string `json:"notify_domain" form:"notify_domain" gorm:"type:varchar(300)"`
+ FixedFee float64 `json:"fixed_fee" form:"fixed_fee" gorm:"type:decimal(10,2); default:0.00"`
+ PercentFee float64 `json:"percent_fee" form:"percent_fee" gorm:"type:decimal(10,2); default:0.00"`
+ Currency CurrencyType `json:"currency" form:"currency" gorm:"type:varchar(5)"`
+ Config string `json:"config" form:"config" gorm:"type:text"`
+ Sort int `json:"sort" form:"sort" gorm:"default:1"`
+ Enable *bool `json:"enable" form:"enable" gorm:"default:true"`
+ CreatedAt int64 `json:"created_at" gorm:"bigint"`
+ UpdatedAt int64 `json:"-" gorm:"bigint"`
+ DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
+}
+
+func GetPaymentByID(id int) (*Payment, error) {
+ var payment Payment
+ err := DB.First(&payment, id).Error
+ return &payment, err
+}
+
+func GetPaymentByUUID(uuid string) (*Payment, error) {
+ var payment Payment
+ err := DB.Where("uuid = ? AND enable = ?", uuid, true).First(&payment).Error
+ return &payment, err
+}
+
+var allowedPaymentOrderFields = map[string]bool{
+ "id": true,
+ "uuid": true,
+ "name": true,
+ "type": true,
+ "sort": true,
+ "enable": true,
+ "created_at": true,
+}
+
+type SearchPaymentParams struct {
+ Payment
+ PaginationParams
+}
+
+func GetPanymentList(params *SearchPaymentParams) (*DataResult[Payment], error) {
+ var payments []*Payment
+
+ db := DB.Omit("key")
+
+ if params.Type != "" {
+ db = db.Where("type = ?", params.Type)
+ }
+
+ if params.Name != "" {
+ db = db.Where("name LIKE ?", params.Name+"%")
+ }
+
+ if params.UUID != "" {
+ db = db.Where("uuid = ?", params.UUID)
+ }
+
+ if params.Currency != "" {
+ db = db.Where("currency = ?", params.Currency)
+ }
+
+ return PaginateAndOrder(db, ¶ms.PaginationParams, &payments, allowedPaymentOrderFields)
+}
+
+func GetUserPaymentList() ([]*Payment, error) {
+ var payments []*Payment
+ err := DB.Model(payments).Select("uuid, name, icon, fixed_fee, percent_fee, currency, sort").Where("enable = ?", true).Find(&payments).Error
+ return payments, err
+}
+
+func (p *Payment) Insert() error {
+ p.UUID = utils.GetUUID()
+ return DB.Create(p).Error
+}
+
+func (p *Payment) Update(overwrite bool) error {
+ var err error
+
+ if overwrite {
+ err = DB.Model(p).Select("*").Updates(p).Error
+ } else {
+ err = DB.Model(p).Updates(p).Error
+ }
+
+ return err
+}
+
+func (p *Payment) Delete() error {
+ return DB.Delete(p).Error
+}
diff --git a/payment/gateway/epay/client.go b/payment/gateway/epay/client.go
new file mode 100644
index 00000000..2e588b29
--- /dev/null
+++ b/payment/gateway/epay/client.go
@@ -0,0 +1,80 @@
+package epay
+
+import (
+ "crypto/md5"
+ "encoding/hex"
+ "sort"
+ "strings"
+
+ "github.com/mitchellh/mapstructure"
+)
+
+type Client struct {
+ PayDomain string `json:"pay_domain"`
+ PartnerID string `json:"partner_id"`
+ Key string `json:"key"`
+}
+
+// FormPay 表单支付
+func (c *Client) FormPay(args *PayArgs) (string, map[string]string, error) {
+ formPayArgs := map[string]string{
+ "type": string(args.Type),
+ "pid": c.PartnerID,
+ "out_trade_no": args.OutTradeNo,
+ "notify_url": args.NotifyUrl,
+ "return_url": args.ReturnUrl,
+ "name": args.Name,
+ "money": args.Money,
+ // "device": string(args.Device),
+ }
+
+ formPayArgs["sign"] = c.Sign(formPayArgs)
+ formPayArgs["sign_type"] = FormArgsSignType
+
+ domain := strings.TrimSuffix(c.PayDomain, "/")
+
+ return domain + FormSubmitUrl, formPayArgs, nil
+
+}
+
+func (c *Client) Verify(params map[string]string) (*PaymentResult, bool) {
+ sign := params["sign"]
+ tradeStatus := params["trade_status"]
+
+ if sign == "" || tradeStatus != TradeStatusSuccess {
+ return nil, false
+ }
+
+ if sign != c.Sign(params) {
+ return nil, false
+ }
+
+ var paymentResult PaymentResult
+ mapstructure.Decode(params, &paymentResult)
+
+ return &paymentResult, true
+}
+
+// Sign 签名
+func (c *Client) Sign(args map[string]string) string {
+ keys := make([]string, 0, len(args))
+ for k := range args {
+ if k != "sign" && k != "sign_type" && args[k] != "" {
+ keys = append(keys, k)
+ }
+ }
+
+ sort.Strings(keys)
+
+ signStrs := make([]string, len(keys))
+ for i, k := range keys {
+ signStrs[i] = k + "=" + args[k]
+ }
+
+ signStr := strings.Join(signStrs, "&") + c.Key
+
+ h := md5.New()
+ h.Write([]byte(signStr))
+
+ return hex.EncodeToString(h.Sum(nil))
+}
diff --git a/payment/gateway/epay/payment.go b/payment/gateway/epay/payment.go
new file mode 100644
index 00000000..b1b315a0
--- /dev/null
+++ b/payment/gateway/epay/payment.go
@@ -0,0 +1,91 @@
+package epay
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "one-api/payment/types"
+ "strconv"
+
+ "github.com/gin-gonic/gin"
+)
+
+type Epay struct{}
+
+type EpayConfig struct {
+ PayType PayType `json:"pay_type"`
+ Client
+}
+
+func (e *Epay) Name() string {
+ return "易支付"
+}
+
+func (e *Epay) Pay(config *types.PayConfig, gatewayConfig string) (*types.PayRequest, error) {
+ epayConfig, err := getEpayConfig(gatewayConfig)
+ if err != nil {
+ return nil, err
+ }
+
+ payArgs := &PayArgs{
+ Type: epayConfig.PayType,
+ OutTradeNo: config.TradeNo,
+ NotifyUrl: config.NotifyURL,
+ ReturnUrl: config.ReturnURL,
+ Name: config.TradeNo,
+ Money: strconv.FormatFloat(config.Money, 'f', 2, 64),
+ }
+
+ formPayURL, formPayArgs, err := epayConfig.FormPay(payArgs)
+ if err != nil {
+ return nil, err
+ }
+
+ payRequest := &types.PayRequest{
+ Type: 1,
+ Data: types.PayRequestData{
+ URL: formPayURL,
+ Params: &formPayArgs,
+ Method: http.MethodPost,
+ },
+ }
+
+ return payRequest, nil
+}
+
+func (e *Epay) HandleCallback(c *gin.Context, gatewayConfig string) (*types.PayNotify, error) {
+ queryMap := make(map[string]string)
+ if err := c.ShouldBindQuery(&queryMap); err != nil {
+ c.Writer.Write([]byte("fail"))
+ return nil, err
+ }
+
+ epayConfig, err := getEpayConfig(gatewayConfig)
+ if err != nil {
+ c.Writer.Write([]byte("fail"))
+ return nil, fmt.Errorf("tradeNo: %s, PaymentNo: %s, err: %v", queryMap["out_trade_no"], queryMap["trade_no"], err)
+ }
+
+ paymentResult, success := epayConfig.Verify(queryMap)
+ if paymentResult != nil && success {
+ c.Writer.Write([]byte("success"))
+ payNotify := &types.PayNotify{
+ TradeNo: paymentResult.OutTradeNo,
+ GatewayNo: paymentResult.TradeNo,
+ }
+ return payNotify, nil
+ }
+
+ c.Writer.Write([]byte("fail"))
+ return nil, fmt.Errorf("tradeNo: %s, PaymentNo: %s, Verify Sign failed", queryMap["out_trade_no"], queryMap["trade_no"])
+}
+
+func getEpayConfig(gatewayConfig string) (*EpayConfig, error) {
+ var epayConfig EpayConfig
+ if err := json.Unmarshal([]byte(gatewayConfig), &epayConfig); err != nil {
+ return nil, errors.New("config error")
+ }
+
+ return &epayConfig, nil
+}
diff --git a/payment/gateway/epay/type.go b/payment/gateway/epay/type.go
new file mode 100644
index 00000000..ebe1ff7e
--- /dev/null
+++ b/payment/gateway/epay/type.go
@@ -0,0 +1,45 @@
+package epay
+
+type PayType string
+
+var (
+ EpayPay PayType = "" // 网关
+ Alipay PayType = "alipay" // 支付宝
+ Wechat PayType = "wxpay" // 微信
+ QQ PayType = "qqpay" // QQ
+ Bank PayType = "bank" // 银行
+ JD PayType = "jdpay" // 京东
+ PayPal PayType = "paypal" // PayPal
+ USDT PayType = "usdt" // USDT
+)
+
+// type DeviceType string
+
+// var (
+// PC DeviceType = "pc" // PC
+// Mobile DeviceType = "mobile" // 移动端
+// )
+
+const (
+ FormArgsSignType = "MD5"
+ FormSubmitUrl = "/submit.php"
+ TradeStatusSuccess = "TRADE_SUCCESS"
+)
+
+type PayArgs struct {
+ Type PayType `json:"type,omitempty"`
+ OutTradeNo string `json:"out_trade_no"`
+ NotifyUrl string `json:"notify_url"`
+ ReturnUrl string `json:"return_url"`
+ Name string `json:"name"`
+ Money string `json:"money"`
+}
+
+type PaymentResult struct {
+ Type PayType `mapstructure:"type"`
+ TradeNo string `mapstructure:"trade_no"`
+ OutTradeNo string `mapstructure:"out_trade_no"`
+ Name string `mapstructure:"name"`
+ Money string `mapstructure:"money"`
+ TradeStatus string `mapstructure:"trade_status"`
+}
diff --git a/payment/payment.go b/payment/payment.go
new file mode 100644
index 00000000..1f02f9ee
--- /dev/null
+++ b/payment/payment.go
@@ -0,0 +1,20 @@
+package payment
+
+import (
+ "one-api/payment/gateway/epay"
+ "one-api/payment/types"
+
+ "github.com/gin-gonic/gin"
+)
+
+type PaymentProcessor interface {
+ Name() string
+ Pay(config *types.PayConfig, gatewayConfig string) (*types.PayRequest, error)
+ HandleCallback(c *gin.Context, gatewayConfig string) (*types.PayNotify, error)
+}
+
+var Gateways = make(map[string]PaymentProcessor)
+
+func init() {
+ Gateways["epay"] = &epay.Epay{}
+}
diff --git a/payment/service.go b/payment/service.go
new file mode 100644
index 00000000..2665d849
--- /dev/null
+++ b/payment/service.go
@@ -0,0 +1,81 @@
+package payment
+
+import (
+ "errors"
+ "fmt"
+ "one-api/common/config"
+ "one-api/common/logger"
+ "one-api/model"
+ "one-api/payment/types"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+)
+
+type PaymentService struct {
+ Payment *model.Payment
+ gateway PaymentProcessor
+}
+
+type PayMoney struct {
+ Amount float64
+ Currency model.CurrencyType
+}
+
+func NewPaymentService(uuid string) (*PaymentService, error) {
+ payment, err := model.GetPaymentByUUID(uuid)
+ if err != nil {
+ return nil, errors.New("payment not found")
+ }
+
+ gateway, ok := Gateways[payment.Type]
+ if !ok {
+ return nil, errors.New("payment gateway not found")
+ }
+
+ return &PaymentService{
+ Payment: payment,
+ gateway: gateway,
+ }, nil
+}
+
+func (s *PaymentService) Pay(tradeNo string, amount float64) (*types.PayRequest, error) {
+ config := &types.PayConfig{
+ Money: amount,
+ TradeNo: tradeNo,
+ NotifyURL: s.getNotifyURL(),
+ ReturnURL: s.getReturnURL(),
+ }
+
+ payRequest, err := s.gateway.Pay(config, s.Payment.Config)
+ if err != nil {
+ return nil, err
+ }
+
+ return payRequest, nil
+}
+
+func (s *PaymentService) HandleCallback(c *gin.Context, gatewayConfig string) (*types.PayNotify, error) {
+ payNotify, err := s.gateway.HandleCallback(c, gatewayConfig)
+ if err != nil {
+ logger.SysError(fmt.Sprintf("%s payment callback error: %v", s.gateway.Name(), err))
+
+ }
+
+ return payNotify, err
+}
+
+func (s *PaymentService) getNotifyURL() string {
+ notifyDomain := s.Payment.NotifyDomain
+ if notifyDomain == "" {
+ notifyDomain = config.ServerAddress
+ }
+
+ notifyDomain = strings.TrimSuffix(notifyDomain, "/")
+ return fmt.Sprintf("%s/api/payment/notify/%s", notifyDomain, s.Payment.UUID)
+}
+
+func (s *PaymentService) getReturnURL() string {
+ serverAdd := strings.TrimSuffix(config.ServerAddress, "/")
+ return fmt.Sprintf("%s/panel/log", serverAdd)
+}
diff --git a/payment/types/types.go b/payment/types/types.go
new file mode 100644
index 00000000..31f5d4a7
--- /dev/null
+++ b/payment/types/types.go
@@ -0,0 +1,27 @@
+package types
+
+// 支付网关的通用配置
+type PayConfig struct {
+ NotifyURL string `json:"notify_url"`
+ ReturnURL string `json:"return_url"`
+ TradeNo string `json:"trade_no"`
+ Money float64 `json:"money"`
+}
+
+// 请求支付时的数据结构
+type PayRequest struct {
+ Type int `json:"type"` // 支付类型 1 url 2 qrcode
+ Data PayRequestData `json:"data"`
+}
+
+type PayRequestData struct {
+ URL string `json:"url"`
+ Method string `json:"method,omitempty"`
+ Params any `json:"params,omitempty"`
+}
+
+// 支付回调时的数据结构
+type PayNotify struct {
+ TradeNo string `json:"trade_no"`
+ GatewayNo string `json:"gateway_no"`
+}
diff --git a/router/api-router.go b/router/api-router.go
index 4bc0e3d8..30fd9736 100644
--- a/router/api-router.go
+++ b/router/api-router.go
@@ -31,6 +31,8 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.GET("/oauth/wechat/bind", middleware.CriticalRateLimit(), middleware.UserAuth(), controller.WeChatBind)
apiRouter.GET("/oauth/email/bind", middleware.CriticalRateLimit(), middleware.UserAuth(), controller.EmailBind)
+ apiRouter.Any("/payment/notify/:uuid", controller.PaymentCallback)
+
userRoute := apiRouter.Group("/user")
{
userRoute.POST("/register", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register)
@@ -48,6 +50,9 @@ func SetApiRouter(router *gin.Engine) {
selfRoute.GET("/aff", controller.GetAffCode)
selfRoute.POST("/topup", controller.TopUp)
selfRoute.GET("/models", relay.ListModels)
+ selfRoute.GET("/payment", controller.GetUserPaymentList)
+ selfRoute.POST("/order", controller.CreateOrder)
+ selfRoute.GET("/order/status", controller.CheckOrderStatus)
}
adminRoute := userRoute.Group("/")
@@ -148,6 +153,17 @@ func SetApiRouter(router *gin.Engine) {
}
+ paymentRoute := apiRouter.Group("/payment")
+ paymentRoute.Use(middleware.AdminAuth())
+ {
+ paymentRoute.GET("/order", controller.GetOrderList)
+ paymentRoute.GET("/", controller.GetPaymentList)
+ paymentRoute.GET("/:id", controller.GetPayment)
+ paymentRoute.POST("/", controller.AddPayment)
+ paymentRoute.PUT("/", controller.UpdatePayment)
+ paymentRoute.DELETE("/:id", controller.DeletePayment)
+ }
+
mjRoute := apiRouter.Group("/mj")
mjRoute.GET("/self", middleware.UserAuth(), controller.GetUserMidjourney)
mjRoute.GET("/", middleware.AdminAuth(), controller.GetAllMidjourney)
diff --git a/web/package.json b/web/package.json
index da71bedc..1fffb36b 100644
--- a/web/package.json
+++ b/web/package.json
@@ -31,6 +31,7 @@
"react-device-detect": "^2.2.3",
"react-dom": "^18.2.0",
"react-perfect-scrollbar": "^1.5.8",
+ "react-qrcode-logo": "^3.0.0",
"react-redux": "^9.1.0",
"react-router": "6.21.3",
"react-router-dom": "6.21.3",
@@ -82,5 +83,6 @@
"immutable": "^4.3.5",
"prettier": "^3.2.4",
"sass": "^1.70.0"
- }
+ },
+ "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
diff --git a/web/public/ali_pay.png b/web/public/ali_pay.png
new file mode 100644
index 00000000..8bbe6632
Binary files /dev/null and b/web/public/ali_pay.png differ
diff --git a/web/public/logo-loading-white.svg b/web/public/logo-loading-white.svg
new file mode 100644
index 00000000..1545de44
--- /dev/null
+++ b/web/public/logo-loading-white.svg
@@ -0,0 +1,5 @@
+
diff --git a/web/public/logo-loading.svg b/web/public/logo-loading.svg
new file mode 100644
index 00000000..4a6ad7ce
--- /dev/null
+++ b/web/public/logo-loading.svg
@@ -0,0 +1,5 @@
+
diff --git a/web/public/wechat_pay.png b/web/public/wechat_pay.png
new file mode 100644
index 00000000..554c2f74
Binary files /dev/null and b/web/public/wechat_pay.png differ
diff --git a/web/src/assets/images/success.svg b/web/src/assets/images/success.svg
new file mode 100644
index 00000000..cb442cf5
--- /dev/null
+++ b/web/src/assets/images/success.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/web/src/config.js b/web/src/config.js
index d8211766..f7b1cfad 100644
--- a/web/src/config.js
+++ b/web/src/config.js
@@ -26,7 +26,7 @@ const config = {
lark_login: false,
lark_client_id: '',
telegram_bot: '',
- isLoading: true, // 添加加载状态
+ isLoading: true // 添加加载状态
}
};
diff --git a/web/src/menu-items/panel.js b/web/src/menu-items/panel.js
index e0f03f36..10065a63 100644
--- a/web/src/menu-items/panel.js
+++ b/web/src/menu-items/panel.js
@@ -14,7 +14,8 @@ import {
IconReceipt2,
IconBrush,
IconBrandGithubCopilot,
- IconBallFootball
+ IconBallFootball,
+ IconBrandPaypal
} from '@tabler/icons-react';
// constant
@@ -33,7 +34,8 @@ const icons = {
IconReceipt2,
IconBrush,
IconBrandGithubCopilot,
- IconBallFootball
+ IconBallFootball,
+ IconBrandPaypal
};
// ==============================|| DASHBOARD MENU ITEMS ||============================== //
@@ -154,6 +156,15 @@ const panel = {
breadcrumbs: false,
isAdmin: false
},
+ {
+ id: 'payment',
+ title: '支付',
+ type: 'item',
+ url: '/panel/payment',
+ icon: icons.IconBrandPaypal,
+ breadcrumbs: false,
+ isAdmin: true
+ },
{
id: 'setting',
title: '设置',
diff --git a/web/src/routes/MainRoutes.js b/web/src/routes/MainRoutes.js
index ef72ce69..04b5184c 100644
--- a/web/src/routes/MainRoutes.js
+++ b/web/src/routes/MainRoutes.js
@@ -19,6 +19,7 @@ const Pricing = Loadable(lazy(() => import('views/Pricing')));
const Midjourney = Loadable(lazy(() => import('views/Midjourney')));
const ModelPrice = Loadable(lazy(() => import('views/ModelPrice')));
const Playground = Loadable(lazy(() => import('views/Playground')));
+const Payment = Loadable(lazy(() => import('views/Payment')));
// dashboard routing
const Dashboard = Loadable(lazy(() => import('views/Dashboard')));
@@ -96,6 +97,10 @@ const MainRoutes = {
{
path: 'playground',
element:
+ },
+ {
+ path: 'payment',
+ element:
}
]
};
diff --git a/web/src/store/siteInfoReducer.js b/web/src/store/siteInfoReducer.js
index 2132c0e3..80f8fcbf 100644
--- a/web/src/store/siteInfoReducer.js
+++ b/web/src/store/siteInfoReducer.js
@@ -9,7 +9,7 @@ const siteInfoReducer = (state = initialState, action) => {
return {
...state,
...action.payload,
- isLoading: false, // 添加加载状态
+ isLoading: false // 添加加载状态
};
default:
return state;
diff --git a/web/src/views/Payment/Gateway.js b/web/src/views/Payment/Gateway.js
new file mode 100644
index 00000000..cf7925b9
--- /dev/null
+++ b/web/src/views/Payment/Gateway.js
@@ -0,0 +1,294 @@
+import { useState, useEffect, useCallback } from 'react';
+import { showError, trims, showSuccess } from 'utils/common';
+
+import Table from '@mui/material/Table';
+import TableBody from '@mui/material/TableBody';
+import TableContainer from '@mui/material/TableContainer';
+import PerfectScrollbar from 'react-perfect-scrollbar';
+import TablePagination from '@mui/material/TablePagination';
+import LinearProgress from '@mui/material/LinearProgress';
+import ButtonGroup from '@mui/material/ButtonGroup';
+import Toolbar from '@mui/material/Toolbar';
+
+import { Button, Card, Stack, Container, Typography, Box } from '@mui/material';
+import PaymentTableRow from './component/TableRow';
+import KeywordTableHead from 'ui-component/TableHead';
+import TableToolBar from './component/TableToolBar';
+import EditeModal from './component/EditModal';
+import { API } from 'utils/api';
+import { ITEMS_PER_PAGE } from 'constants';
+import { IconRefresh, IconSearch, IconPlus } from '@tabler/icons-react';
+
+export default function Gateway() {
+ const originalKeyword = {
+ p: 0,
+ type: '',
+ name: '',
+ uuid: '',
+ currency: ''
+ };
+
+ const [page, setPage] = useState(0);
+ const [order, setOrder] = useState('desc');
+ const [orderBy, setOrderBy] = useState('created_at');
+ const [rowsPerPage, setRowsPerPage] = useState(ITEMS_PER_PAGE);
+ const [listCount, setListCount] = useState(0);
+ const [searching, setSearching] = useState(false);
+ const [toolBarValue, setToolBarValue] = useState(originalKeyword);
+ const [searchKeyword, setSearchKeyword] = useState(originalKeyword);
+ const [refreshFlag, setRefreshFlag] = useState(false);
+ const [openModal, setOpenModal] = useState(false);
+ const [editPaymentId, setEditPaymentId] = useState(0);
+
+ const [payment, setPayment] = useState([]);
+
+ const handleSort = (event, id) => {
+ const isAsc = orderBy === id && order === 'asc';
+ if (id !== '') {
+ setOrder(isAsc ? 'desc' : 'asc');
+ setOrderBy(id);
+ }
+ };
+
+ const handleChangePage = (event, newPage) => {
+ setPage(newPage);
+ };
+
+ const handleOpenModal = (channelId) => {
+ setEditPaymentId(channelId);
+ setOpenModal(true);
+ };
+
+ const handleCloseModal = () => {
+ setOpenModal(false);
+ setEditPaymentId(0);
+ };
+
+ const handleOkModal = (status) => {
+ if (status === true) {
+ handleCloseModal();
+ handleRefresh();
+ }
+ };
+
+ const handleChangeRowsPerPage = (event) => {
+ setPage(0);
+ setRowsPerPage(parseInt(event.target.value, 10));
+ };
+
+ const search = async () => {
+ setPage(0);
+ setSearchKeyword(toolBarValue);
+ };
+
+ const handleToolBarValue = (event) => {
+ setToolBarValue({ ...toolBarValue, [event.target.name]: event.target.value });
+ };
+
+ const managePayment = async (id, action, value) => {
+ const url = '/api/payment/';
+ let data = { id };
+ let res;
+
+ try {
+ switch (action) {
+ case 'delete':
+ res = await API.delete(url + id);
+ break;
+ case 'status':
+ res = await API.put(url, {
+ ...data,
+ enable: value
+ });
+ break;
+ case 'sort':
+ res = await API.put(url, {
+ ...data,
+ sort: value
+ });
+ break;
+ }
+ const { success, message } = res.data;
+ if (success) {
+ showSuccess('操作成功完成!');
+ await handleRefresh();
+ } else {
+ showError(message);
+ }
+
+ return res.data;
+ } catch (error) {
+ return;
+ }
+ };
+
+ const fetchData = useCallback(async (page, rowsPerPage, keyword, order, orderBy) => {
+ setSearching(true);
+ keyword = trims(keyword);
+ try {
+ if (orderBy) {
+ orderBy = order === 'desc' ? '-' + orderBy : orderBy;
+ }
+ const res = await API.get('/api/payment/', {
+ params: {
+ page: page + 1,
+ size: rowsPerPage,
+ order: orderBy,
+ ...keyword
+ }
+ });
+ const { success, message, data } = res.data;
+ if (success) {
+ setListCount(data.total_count);
+ setPayment(data.data);
+ } else {
+ showError(message);
+ }
+ } catch (error) {
+ console.error(error);
+ }
+ setSearching(false);
+ }, []);
+
+ // 处理刷新
+ const handleRefresh = async () => {
+ setOrderBy('created_at');
+ setOrder('desc');
+ setToolBarValue(originalKeyword);
+ setSearchKeyword(originalKeyword);
+ setRefreshFlag(!refreshFlag);
+ };
+
+ useEffect(() => {
+ fetchData(page, rowsPerPage, searchKeyword, order, orderBy);
+ }, [page, rowsPerPage, searchKeyword, order, orderBy, fetchData, refreshFlag]);
+
+ return (
+ <>
+
+ 支付网关
+ } onClick={() => handleOpenModal(0)}>
+ 新建支付
+
+
+
+
+
+
+ theme.spacing(0, 1, 0, 3)
+ }}
+ >
+
+
+ }>
+ 刷新/清除搜索条件
+
+
+ }>
+ 搜索
+
+
+
+
+ {searching && }
+
+
+
+
+
+ {payment.map((row, index) => (
+
+ ))}
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/web/src/views/Payment/Order.js b/web/src/views/Payment/Order.js
new file mode 100644
index 00000000..b5c0d923
--- /dev/null
+++ b/web/src/views/Payment/Order.js
@@ -0,0 +1,220 @@
+import { useState, useEffect, useCallback } from 'react';
+import { showError, trims } from 'utils/common';
+
+import Table from '@mui/material/Table';
+import TableBody from '@mui/material/TableBody';
+import TableContainer from '@mui/material/TableContainer';
+import PerfectScrollbar from 'react-perfect-scrollbar';
+import TablePagination from '@mui/material/TablePagination';
+import LinearProgress from '@mui/material/LinearProgress';
+import ButtonGroup from '@mui/material/ButtonGroup';
+import Toolbar from '@mui/material/Toolbar';
+
+import { Button, Card, Stack, Container, Typography, Box } from '@mui/material';
+import LogTableRow from './component/OrderTableRow';
+import KeywordTableHead from 'ui-component/TableHead';
+import TableToolBar from './component/OrderTableToolBar';
+import { API } from 'utils/api';
+import { ITEMS_PER_PAGE } from 'constants';
+import { IconRefresh, IconSearch } from '@tabler/icons-react';
+import dayjs from 'dayjs';
+
+export default function Order() {
+ const originalKeyword = {
+ p: 0,
+ user_id: '',
+ trade_no: '',
+ status: '',
+ gateway_no: '',
+ start_timestamp: 0,
+ end_timestamp: dayjs().unix() + 3600
+ };
+
+ const [page, setPage] = useState(0);
+ const [order, setOrder] = useState('desc');
+ const [orderBy, setOrderBy] = useState('created_at');
+ const [rowsPerPage, setRowsPerPage] = useState(ITEMS_PER_PAGE);
+ const [listCount, setListCount] = useState(0);
+ const [searching, setSearching] = useState(false);
+ const [toolBarValue, setToolBarValue] = useState(originalKeyword);
+ const [searchKeyword, setSearchKeyword] = useState(originalKeyword);
+ const [refreshFlag, setRefreshFlag] = useState(false);
+
+ const [orderList, setOrderList] = useState([]);
+
+ const handleSort = (event, id) => {
+ const isAsc = orderBy === id && order === 'asc';
+ if (id !== '') {
+ setOrder(isAsc ? 'desc' : 'asc');
+ setOrderBy(id);
+ }
+ };
+
+ const handleChangePage = (event, newPage) => {
+ setPage(newPage);
+ };
+
+ const handleChangeRowsPerPage = (event) => {
+ setPage(0);
+ setRowsPerPage(parseInt(event.target.value, 10));
+ };
+
+ const searchLogs = async () => {
+ setPage(0);
+ setSearchKeyword(toolBarValue);
+ };
+
+ const handleToolBarValue = (event) => {
+ setToolBarValue({ ...toolBarValue, [event.target.name]: event.target.value });
+ };
+
+ const fetchData = useCallback(async (page, rowsPerPage, keyword, order, orderBy) => {
+ setSearching(true);
+ keyword = trims(keyword);
+ try {
+ if (orderBy) {
+ orderBy = order === 'desc' ? '-' + orderBy : orderBy;
+ }
+ const res = await API.get('/api/payment/order', {
+ params: {
+ page: page + 1,
+ size: rowsPerPage,
+ order: orderBy,
+ ...keyword
+ }
+ });
+ const { success, message, data } = res.data;
+ if (success) {
+ setListCount(data.total_count);
+ setOrderList(data.data);
+ } else {
+ showError(message);
+ }
+ } catch (error) {
+ console.error(error);
+ }
+ setSearching(false);
+ }, []);
+
+ // 处理刷新
+ const handleRefresh = async () => {
+ setOrderBy('created_at');
+ setOrder('desc');
+ setToolBarValue(originalKeyword);
+ setSearchKeyword(originalKeyword);
+ setRefreshFlag(!refreshFlag);
+ };
+
+ useEffect(() => {
+ fetchData(page, rowsPerPage, searchKeyword, order, orderBy);
+ }, [page, rowsPerPage, searchKeyword, order, orderBy, fetchData, refreshFlag]);
+
+ return (
+ <>
+
+ 日志
+
+
+
+
+
+ theme.spacing(0, 1, 0, 3)
+ }}
+ >
+
+
+ }>
+ 刷新/清除搜索条件
+
+
+ }>
+ 搜索
+
+
+
+
+ {searching && }
+
+
+
+
+
+ {orderList.map((row, index) => (
+
+ ))}
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/web/src/views/Payment/component/EditModal.js b/web/src/views/Payment/component/EditModal.js
new file mode 100644
index 00000000..af31fe96
--- /dev/null
+++ b/web/src/views/Payment/component/EditModal.js
@@ -0,0 +1,359 @@
+import PropTypes from 'prop-types';
+import * as Yup from 'yup';
+import { Formik } from 'formik';
+import { useTheme } from '@mui/material/styles';
+import { useState, useEffect } from 'react';
+import {
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ DialogActions,
+ Button,
+ Divider,
+ FormControl,
+ InputLabel,
+ OutlinedInput,
+ TextField,
+ Select,
+ MenuItem,
+ FormHelperText
+} from '@mui/material';
+
+import { showSuccess, showError, trims } from 'utils/common';
+import { API } from 'utils/api';
+import { PaymentType, CurrencyType, PaymentConfig } from '../type/Config';
+
+const validationSchema = Yup.object().shape({
+ is_edit: Yup.boolean(),
+ name: Yup.string().required('名称 不能为空'),
+ icon: Yup.string().required('图标 不能为空'),
+ fixed_fee: Yup.number().min(0, '固定手续费 不能小于 0'),
+ percent_fee: Yup.number().min(0, '百分比手续费 不能小于 0'),
+ currency: Yup.string().required('货币 不能为空')
+});
+
+const originInputs = {
+ is_edit: false,
+ type: 'epay',
+ uuid: '',
+ name: '',
+ icon: '',
+ notify_domain: '',
+ fixed_fee: 0,
+ percent_fee: 0,
+ currency: 'CNY',
+ config: {},
+ sort: 0,
+ enable: true
+};
+
+const EditModal = ({ open, paymentId, onCancel, onOk }) => {
+ const theme = useTheme();
+ const [inputs, setInputs] = useState(originInputs);
+
+ const submit = async (values, { setErrors, setStatus, setSubmitting }) => {
+ setSubmitting(true);
+
+ let config = JSON.stringify(values.config);
+ let res;
+ values = trims(values);
+ try {
+ if (values.is_edit) {
+ res = await API.put(`/api/payment/`, { ...values, id: parseInt(paymentId), config });
+ } else {
+ res = await API.post(`/api/payment/`, { ...values, config });
+ }
+ const { success, message } = res.data;
+ if (success) {
+ if (values.is_edit) {
+ showSuccess('更新成功!');
+ } else {
+ showSuccess('创建成功!');
+ }
+ setSubmitting(false);
+ setStatus({ success: true });
+ onOk(true);
+ } else {
+ showError(message);
+ setErrors({ submit: message });
+ }
+ } catch (error) {
+ return;
+ }
+ };
+
+ const loadPayment = async () => {
+ try {
+ let res = await API.get(`/api/payment/${paymentId}`);
+ const { success, message, data } = res.data;
+ if (success) {
+ data.is_edit = true;
+ data.config = JSON.parse(data.config);
+ setInputs(data);
+ } else {
+ showError(message);
+ }
+ } catch (error) {
+ return;
+ }
+ };
+
+ useEffect(() => {
+ if (paymentId) {
+ loadPayment().then();
+ } else {
+ setInputs(originInputs);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [paymentId]);
+
+ return (
+
+ );
+};
+
+export default EditModal;
+
+EditModal.propTypes = {
+ open: PropTypes.bool,
+ paymentId: PropTypes.number,
+ onCancel: PropTypes.func,
+ onOk: PropTypes.func
+};
diff --git a/web/src/views/Payment/component/OrderTableRow.js b/web/src/views/Payment/component/OrderTableRow.js
new file mode 100644
index 00000000..90ff7713
--- /dev/null
+++ b/web/src/views/Payment/component/OrderTableRow.js
@@ -0,0 +1,45 @@
+import PropTypes from 'prop-types';
+
+import { TableRow, TableCell } from '@mui/material';
+
+import { timestamp2string } from 'utils/common';
+import Label from 'ui-component/Label';
+
+const StatusType = {
+ pending: { name: '待支付', value: 'pending', color: 'primary' },
+ success: { name: '支付成功', value: 'success', color: 'success' },
+ failed: { name: '支付失败', value: 'failed', color: 'error' },
+ closed: { name: '已关闭', value: 'closed', color: 'default' }
+};
+
+function statusLabel(status) {
+ let statusOption = StatusType[status];
+
+ return ;
+}
+
+export { StatusType };
+
+export default function OrderTableRow({ item }) {
+ return (
+ <>
+
+ {timestamp2string(item.created_at)}
+ {item.user_id}
+ {item.trade_no}
+ {item.gateway_no}
+ ${item.amount}
+ ${item.fee}
+
+ {item.order_amount} {item.order_currency}
+
+ {item.quota}
+ {statusLabel(item.status)}
+
+ >
+ );
+}
+
+OrderTableRow.propTypes = {
+ item: PropTypes.object
+};
diff --git a/web/src/views/Payment/component/OrderTableToolBar.js b/web/src/views/Payment/component/OrderTableToolBar.js
new file mode 100644
index 00000000..677468af
--- /dev/null
+++ b/web/src/views/Payment/component/OrderTableToolBar.js
@@ -0,0 +1,138 @@
+import PropTypes from 'prop-types';
+import { OutlinedInput, Stack, FormControl, InputLabel, Select, MenuItem } from '@mui/material';
+import { LocalizationProvider, DateTimePicker } from '@mui/x-date-pickers';
+import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
+import dayjs from 'dayjs';
+import { StatusType } from './OrderTableRow';
+require('dayjs/locale/zh-cn');
+// ----------------------------------------------------------------------
+
+export default function OrderTableToolBar({ filterName, handleFilterName }) {
+ return (
+ <>
+
+
+ 用户ID
+
+
+
+ 订单号
+
+
+
+ 网关订单号
+
+
+
+
+
+ {
+ if (value === null) {
+ handleFilterName({ target: { name: 'start_timestamp', value: 0 } });
+ return;
+ }
+ handleFilterName({ target: { name: 'start_timestamp', value: value.unix() } });
+ }}
+ slotProps={{
+ actionBar: {
+ actions: ['clear', 'today', 'accept']
+ }
+ }}
+ />
+
+
+
+
+
+ {
+ if (value === null) {
+ handleFilterName({ target: { name: 'end_timestamp', value: 0 } });
+ return;
+ }
+ handleFilterName({ target: { name: 'end_timestamp', value: value.unix() } });
+ }}
+ slotProps={{
+ actionBar: {
+ actions: ['clear', 'today', 'accept']
+ }
+ }}
+ />
+
+
+
+ 状态
+
+
+
+ >
+ );
+}
+
+OrderTableToolBar.propTypes = {
+ filterName: PropTypes.object,
+ handleFilterName: PropTypes.func
+};
diff --git a/web/src/views/Payment/component/TableRow.js b/web/src/views/Payment/component/TableRow.js
new file mode 100644
index 00000000..134cec1d
--- /dev/null
+++ b/web/src/views/Payment/component/TableRow.js
@@ -0,0 +1,153 @@
+import PropTypes from 'prop-types';
+import { useState } from 'react';
+
+import {
+ TableRow,
+ TableCell,
+ Popover,
+ MenuItem,
+ IconButton,
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ DialogContentText,
+ DialogActions,
+ Button,
+ TextField
+} from '@mui/material';
+
+import { IconDotsVertical, IconEdit, IconTrash } from '@tabler/icons-react';
+import { timestamp2string, showError } from 'utils/common';
+import { PaymentType } from '../type/Config';
+import TableSwitch from 'ui-component/Switch';
+
+export default function PaymentTableRow({ item, managePayment, handleOpenModal, setModalPaymentId }) {
+ const [open, setOpen] = useState(null);
+ const [openDelete, setOpenDelete] = useState(false);
+ const [sortValve, setSort] = useState(item.sort);
+
+ const handleCloseMenu = () => {
+ setOpen(null);
+ };
+ const handleOpenMenu = (event) => {
+ setOpen(event.currentTarget);
+ };
+
+ const handleDeleteOpen = () => {
+ handleCloseMenu();
+ setOpenDelete(true);
+ };
+
+ const handleDeleteClose = () => {
+ setOpenDelete(false);
+ };
+
+ const handleDelete = async () => {
+ handleCloseMenu();
+ await managePayment(item.id, 'delete', '');
+ };
+
+ const handleSort = async (event) => {
+ const currentValue = parseInt(event.target.value);
+ if (isNaN(currentValue) || currentValue === sortValve) {
+ return;
+ }
+
+ if (currentValue < 0) {
+ showError('排序不能小于 0');
+ return;
+ }
+
+ await managePayment(item.id, 'sort', currentValue);
+ setSort(currentValue);
+ };
+
+ return (
+ <>
+
+ {item.id}
+ {item.uuid}
+ {item.name}
+ {PaymentType?.[item.type] || '未知'}
+
+
+
+ {item.fixed_fee}
+ {item.percent_fee}
+
+
+
+
+ {
+ managePayment(item.id, 'status', !item.enable);
+ }}
+ />
+
+ {timestamp2string(item.created_at)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+PaymentTableRow.propTypes = {
+ item: PropTypes.object,
+ managePayment: PropTypes.func,
+ handleOpenModal: PropTypes.func,
+ setModalPaymentId: PropTypes.func
+};
diff --git a/web/src/views/Payment/component/TableToolBar.js b/web/src/views/Payment/component/TableToolBar.js
new file mode 100644
index 00000000..b2af1f77
--- /dev/null
+++ b/web/src/views/Payment/component/TableToolBar.js
@@ -0,0 +1,73 @@
+import PropTypes from 'prop-types';
+import { OutlinedInput, Stack, FormControl, InputLabel, Select, MenuItem } from '@mui/material';
+import { PaymentType } from '../type/Config';
+require('dayjs/locale/zh-cn');
+// ----------------------------------------------------------------------
+
+export default function TableToolBar({ filterName, handleFilterName }) {
+ return (
+ <>
+
+
+ 名称
+
+
+
+ UUID
+
+
+
+ 类型
+
+
+
+ >
+ );
+}
+
+TableToolBar.propTypes = {
+ filterName: PropTypes.object,
+ handleFilterName: PropTypes.func
+};
diff --git a/web/src/views/Payment/index.js b/web/src/views/Payment/index.js
new file mode 100644
index 00000000..05de555c
--- /dev/null
+++ b/web/src/views/Payment/index.js
@@ -0,0 +1,86 @@
+import { useState, useEffect, useMemo } from 'react';
+import PropTypes from 'prop-types';
+import { Tabs, Tab, Box, Card } from '@mui/material';
+import Gateway from './Gateway';
+import Order from './Order';
+import AdminContainer from 'ui-component/AdminContainer';
+import { useLocation, useNavigate } from 'react-router-dom';
+
+function CustomTabPanel(props) {
+ const { children, value, index, ...other } = props;
+
+ return (
+
+ {value === index && {children}}
+
+ );
+}
+
+CustomTabPanel.propTypes = {
+ children: PropTypes.node,
+ index: PropTypes.number.isRequired,
+ value: PropTypes.number.isRequired
+};
+
+function a11yProps(index) {
+ return {
+ id: `setting-tab-${index}`,
+ 'aria-controls': `setting-tabpanel-${index}`
+ };
+}
+
+const Payment = () => {
+ const location = useLocation();
+ const navigate = useNavigate();
+ const hash = location.hash.replace('#', '');
+ const tabMap = useMemo(
+ () => ({
+ order: 0,
+ gateway: 1
+ }),
+ []
+ );
+ const [value, setValue] = useState(tabMap[hash] || 0);
+
+ const handleChange = (event, newValue) => {
+ setValue(newValue);
+ const hashArray = Object.keys(tabMap);
+ navigate(`#${hashArray[newValue]}`);
+ };
+
+ useEffect(() => {
+ const handleHashChange = () => {
+ const hash = location.hash.replace('#', '');
+ setValue(tabMap[hash] || 0);
+ };
+ window.addEventListener('hashchange', handleHashChange);
+ return () => {
+ window.removeEventListener('hashchange', handleHashChange);
+ };
+ }, [location, tabMap]);
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export default Payment;
diff --git a/web/src/views/Payment/type/Config.js b/web/src/views/Payment/type/Config.js
new file mode 100644
index 00000000..2e283b25
--- /dev/null
+++ b/web/src/views/Payment/type/Config.js
@@ -0,0 +1,73 @@
+const PaymentType = {
+ epay: '易支付'
+};
+
+const CurrencyType = {
+ CNY: '人民币',
+ USD: '美元'
+};
+
+const PaymentConfig = {
+ epay: {
+ pay_domain: {
+ name: '支付域名',
+ description: '支付域名',
+ type: 'text',
+ value: ''
+ },
+ partner_id: {
+ name: '商户号',
+ description: '商户号',
+ type: 'text',
+ value: ''
+ },
+ key: {
+ name: '密钥',
+ description: '密钥',
+ type: 'text',
+ value: ''
+ },
+ pay_type: {
+ name: '支付类型',
+ description: '支付类型,如果需要跳转到易支付收银台,请选择收银台',
+ type: 'select',
+ value: '',
+ options: [
+ {
+ name: '收银台',
+ value: ''
+ },
+ {
+ name: '支付宝',
+ value: 'alipay'
+ },
+ {
+ name: '微信',
+ value: 'wxpay'
+ },
+ {
+ name: 'QQ',
+ value: 'qqpay'
+ },
+ {
+ name: '京东',
+ value: 'jdpay'
+ },
+ {
+ name: '银联',
+ value: 'bank'
+ },
+ {
+ name: 'Paypal',
+ value: 'paypal'
+ },
+ {
+ name: 'USDT',
+ value: 'usdt'
+ }
+ ]
+ }
+ }
+};
+
+export { PaymentConfig, PaymentType, CurrencyType };
diff --git a/web/src/views/Setting/component/OperationSetting.js b/web/src/views/Setting/component/OperationSetting.js
index 18c65e45..9d9d091a 100644
--- a/web/src/views/Setting/component/OperationSetting.js
+++ b/web/src/views/Setting/component/OperationSetting.js
@@ -36,7 +36,9 @@ const OperationSetting = () => {
MjNotifyEnabled: '',
ChatCacheEnabled: '',
ChatCacheExpireMinute: 5,
- ChatImageRequestProxy: ''
+ ChatImageRequestProxy: '',
+ PaymentUSDRate: 0,
+ PaymentMinAmount: 1
});
const [originInputs, setOriginInputs] = useState({});
let [loading, setLoading] = useState(false);
@@ -178,6 +180,14 @@ const OperationSetting = () => {
await updateOption('ChatImageRequestProxy', inputs.ChatImageRequestProxy);
}
break;
+ case 'payment':
+ if (originInputs['PaymentUSDRate'] !== inputs.PaymentUSDRate) {
+ await updateOption('PaymentUSDRate', inputs.PaymentUSDRate);
+ }
+ if (originInputs['PaymentMinAmount'] !== inputs.PaymentMinAmount) {
+ await updateOption('PaymentMinAmount', inputs.PaymentMinAmount);
+ }
+ break;
}
showSuccess('保存成功!');
@@ -532,6 +542,56 @@ const OperationSetting = () => {
+
+
+
+
+ 支付设置:
+ 1. 美元汇率:用于计算充值金额的美元金额
+ 2. 最低充值金额(美元):最低充值金额,单位为美元,填写整数
+ 3. 页面都以美元为单位计算,实际用户支付的货币,按照支付网关设置的货币进行转换
+ 例如: A 网关设置货币为 CNY,用户支付 100 美元,那么实际支付金额为 100 * 美元汇率
B 网关设置货币为 USD,用户支付 100
+ 美元,那么实际支付金额为 100 美元
+
+
+
+
+ 美元汇率
+
+
+
+ 最低充值金额(美元)
+
+
+
+
+
+
diff --git a/web/src/views/Topup/component/PayDialog.js b/web/src/views/Topup/component/PayDialog.js
new file mode 100644
index 00000000..7e342057
--- /dev/null
+++ b/web/src/views/Topup/component/PayDialog.js
@@ -0,0 +1,129 @@
+import PropTypes from 'prop-types';
+import { useState, useEffect } from 'react';
+import { Dialog, DialogContent, DialogTitle, IconButton, Stack, Typography } from '@mui/material';
+import CloseIcon from '@mui/icons-material/Close';
+import { useTheme } from '@mui/material/styles';
+import { QRCode } from 'react-qrcode-logo';
+import successSvg from 'assets/images/success.svg';
+import { API } from 'utils/api';
+import { showError } from 'utils/common';
+
+const PayDialog = ({ open, onClose, amount, uuid }) => {
+ const theme = useTheme();
+ const defaultLogo = theme.palette.mode === 'light' ? '/logo-loading.svg' : '/logo-loading-white.svg';
+ const [message, setMessage] = useState('正在拉起支付中...');
+ const [loading, setLoading] = useState(false);
+ const [qrCodeUrl, setQrCodeUrl] = useState(null);
+ const [success, setSuccess] = useState(false);
+ const [intervalId, setIntervalId] = useState(null);
+
+ useEffect(() => {
+ if (!open) {
+ return;
+ }
+ setMessage('正在拉起支付中...');
+ setLoading(true);
+
+ API.post('/api/user/order', {
+ uuid: uuid,
+ amount: Number(amount)
+ }).then((response) => {
+ if (!response.data.success) {
+ showError(response.data.message);
+ setLoading(false);
+ onClose();
+ return;
+ }
+
+ const { type, data } = response.data.data;
+ if (type === 1) {
+ setMessage('等待支付中...');
+ const form = document.createElement('form');
+ form.method = data.method;
+ form.action = data.url;
+ form.target = '_blank';
+ for (const key in data.params) {
+ const input = document.createElement('input');
+ input.name = key;
+ input.value = data.params[key];
+ form.appendChild(input);
+ }
+ document.body.appendChild(form);
+ form.submit();
+ document.body.removeChild(form);
+ } else if (type === 2) {
+ setQrCodeUrl(data.url);
+ setLoading(false);
+ setMessage('请扫码支付');
+ }
+ pollOrderStatus(response.data.data.trade_no);
+ });
+ }, [open, onClose, amount, uuid]);
+
+ const pollOrderStatus = (tradeNo) => {
+ const id = setInterval(() => {
+ API.get(`/api/user/order/status?trade_no=${tradeNo}`).then((response) => {
+ if (response.data.success) {
+ setMessage('支付成功');
+ setLoading(false);
+ setSuccess(true);
+ clearInterval(id);
+ setIntervalId(null);
+ }
+ });
+ }, 3000);
+ setIntervalId(id);
+ };
+
+ return (
+
+ );
+};
+
+export default PayDialog;
+
+PayDialog.propTypes = {
+ open: PropTypes.bool,
+ onClose: PropTypes.func,
+ amount: PropTypes.number,
+ uuid: PropTypes.string
+};
diff --git a/web/src/views/Topup/component/TopupCard.js b/web/src/views/Topup/component/TopupCard.js
index 2743622b..6d072c51 100644
--- a/web/src/views/Topup/component/TopupCard.js
+++ b/web/src/views/Topup/component/TopupCard.js
@@ -1,8 +1,24 @@
-import { Typography, Stack, OutlinedInput, InputAdornment, Button, InputLabel, FormControl } from '@mui/material';
+import {
+ Typography,
+ Stack,
+ OutlinedInput,
+ InputAdornment,
+ Button,
+ InputLabel,
+ FormControl,
+ useMediaQuery,
+ TextField,
+ Box,
+ Grid,
+ Divider
+} from '@mui/material';
import { IconBuildingBank } from '@tabler/icons-react';
import { useTheme } from '@mui/material/styles';
import SubCard from 'ui-component/cards/SubCard';
import UserCard from 'ui-component/cards/UserCard';
+import AnimateButton from 'ui-component/extended/AnimateButton';
+import { useSelector } from 'react-redux';
+import PayDialog from './PayDialog';
import { API } from 'utils/api';
import React, { useEffect, useState } from 'react';
@@ -11,10 +27,17 @@ import { showError, showInfo, showSuccess, renderQuota, trims } from 'utils/comm
const TopupCard = () => {
const theme = useTheme();
const [redemptionCode, setRedemptionCode] = useState('');
- const [topUpLink, setTopUpLink] = useState('');
const [userQuota, setUserQuota] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
+ const [payment, setPayment] = useState([]);
+ const [selectedPayment, setSelectedPayment] = useState(null);
+ const [amount, setAmount] = useState(0);
+ const [open, setOpen] = useState(false);
+
+ const matchDownSM = useMediaQuery(theme.breakpoints.down('md'));
+ const siteInfo = useSelector((state) => state.siteInfo);
+
const topUp = async () => {
if (redemptionCode === '') {
showInfo('请输入充值码!');
@@ -42,12 +65,53 @@ const TopupCard = () => {
}
};
+ const handlePay = () => {
+ if (!selectedPayment) {
+ showError('请选择支付方式');
+ return;
+ }
+
+ if (amount <= 0 || amount < siteInfo.PaymentMinAmount) {
+ showError('金额不能小于' + siteInfo.PaymentMinAmount);
+ return;
+ }
+
+ if (amount > 1000000) {
+ showError('金额不能大于1000000');
+ return;
+ }
+
+ // 判读金额是否是正整数
+ if (!/^[1-9]\d*$/.test(amount)) {
+ showError('请输入正整数金额');
+ return;
+ }
+
+ setOpen(true);
+ };
+
+ const getPayment = async () => {
+ try {
+ let res = await API.get(`/api/user/payment`);
+ const { success, data } = res.data;
+ if (success) {
+ if (data.length > 0) {
+ data.sort((a, b) => b.sort - a.sort);
+ setPayment(data);
+ setSelectedPayment(data[0]);
+ }
+ }
+ } catch (error) {
+ return;
+ }
+ };
+
const openTopUpLink = () => {
- if (!topUpLink) {
+ if (!siteInfo.top_up_link) {
showError('超级管理员未设置充值链接!');
return;
}
- window.open(topUpLink, '_blank');
+ window.open(siteInfo.top_up_link, '_blank');
};
const getUserQuota = async () => {
@@ -64,14 +128,36 @@ const TopupCard = () => {
}
};
- useEffect(() => {
- let status = localStorage.getItem('siteInfo');
- if (status) {
- status = JSON.parse(status);
- if (status.top_up_link) {
- setTopUpLink(status.top_up_link);
- }
+ const handlePaymentSelect = (payment) => {
+ setSelectedPayment(payment);
+ };
+
+ const handleAmountChange = (event) => {
+ const value = event.target.value;
+ setAmount(value);
+ };
+ const calculateFee = () => {
+ if (!selectedPayment) return 0;
+
+ if (selectedPayment.fixed_fee > 0) {
+ return Number(selectedPayment.fixed_fee);
}
+
+ return parseFloat(selectedPayment.percent_fee * Number(amount)).toFixed(2);
+ };
+
+ const calculateTotal = () => {
+ if (amount === 0) return 0;
+
+ let total = Number(amount) + Number(calculateFee());
+ if (selectedPayment && selectedPayment.currency === 'CNY') {
+ total = parseFloat((total * 7.3).toFixed(2));
+ }
+ return total;
+ };
+
+ useEffect(() => {
+ getPayment().then();
getUserQuota().then();
}, []);
@@ -82,10 +168,90 @@ const TopupCard = () => {
当前额度:
{renderQuota(userQuota)}
+
+ {payment.length > 0 && (
+
+
+ {payment.map((item, index) => (
+
+
+
+ ))}
+
+
+
+
+
+ 充值金额:{' '}
+
+
+
+ ${Number(amount)}
+
+ {selectedPayment && (selectedPayment.percent_fee > 0 || selectedPayment.fixed_fee > 0) && (
+ <>
+
+
+ 手续费:
+ {selectedPayment &&
+ (selectedPayment.fixed_fee > 0
+ ? '(固定)'
+ : selectedPayment.percent_fee > 0
+ ? `(${selectedPayment.percent_fee * 100}%)`
+ : '')}{' '}
+
+
+
+ ${calculateFee()}
+
+ >
+ )}
+
+
+
+ 实际支付金额:{' '}
+
+
+
+ {calculateTotal()}{' '}
+ {selectedPayment &&
+ (selectedPayment.currency === 'CNY' ? `CNY (汇率:${siteInfo.PaymentUSDRate})` : selectedPayment.currency)}
+
+
+
+
+
+ setOpen(false)} amount={amount} uuid={selectedPayment.uuid} />
+
+ )}
+
兑换码
diff --git a/web/yarn.lock b/web/yarn.lock
index e566f75d..7d7e48d8 100644
--- a/web/yarn.lock
+++ b/web/yarn.lock
@@ -6709,6 +6709,11 @@ lodash.debounce@^4.0.8:
resolved "https://registry.npmmirror.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==
+lodash.isequal@^4.5.0:
+ version "4.5.0"
+ resolved "https://registry.npmmirror.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
+ integrity sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==
+
lodash.memoize@^4.1.2:
version "4.1.2"
resolved "https://registry.npmmirror.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
@@ -8077,6 +8082,11 @@ q@^1.1.2:
resolved "https://registry.npmmirror.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7"
integrity sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==
+qrcode-generator@^1.4.4:
+ version "1.4.4"
+ resolved "https://registry.npmmirror.com/qrcode-generator/-/qrcode-generator-1.4.4.tgz#63f771224854759329a99048806a53ed278740e7"
+ integrity sha512-HM7yY8O2ilqhmULxGMpcHSF1EhJJ9yBj8gvDEuZ6M+KGJ0YY2hKpnXvRD+hZPLrDVck3ExIGhmPtSdcjC+guuw==
+
qs@6.11.0:
version "6.11.0"
resolved "https://registry.npmmirror.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a"
@@ -8220,6 +8230,14 @@ react-perfect-scrollbar@^1.5.8:
perfect-scrollbar "^1.5.0"
prop-types "^15.6.1"
+react-qrcode-logo@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.npmmirror.com/react-qrcode-logo/-/react-qrcode-logo-3.0.0.tgz#71cb43ddef9b338cc151800968276ca210086d11"
+ integrity sha512-2+vZ3GNBdUpYxIKyt6SFZsDGXa0xniyUQ0wPI4O0hJTzRjttPIx1pPnH9IWQmp/4nDMoN47IBhi3Breu1KudYw==
+ dependencies:
+ lodash.isequal "^4.5.0"
+ qrcode-generator "^1.4.4"
+
react-redux@^9.1.0:
version "9.1.0"
resolved "https://registry.npmmirror.com/react-redux/-/react-redux-9.1.0.tgz#46a46d4cfed4e534ce5452bb39ba18e1d98a8197"
@@ -9000,7 +9018,16 @@ string-natural-compare@^3.0.1:
resolved "https://registry.npmmirror.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4"
integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==
-"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0:
+"string-width-cjs@npm:string-width@^4.2.0":
+ version "4.2.3"
+ resolved "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
+ integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
+ dependencies:
+ emoji-regex "^8.0.0"
+ is-fullwidth-code-point "^3.0.0"
+ strip-ansi "^6.0.1"
+
+string-width@^4.1.0, string-width@^4.2.0:
version "4.2.3"
resolved "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -9087,7 +9114,14 @@ stringify-object@^3.3.0:
is-obj "^1.0.1"
is-regexp "^1.0.0"
-"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
+"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
+ version "6.0.1"
+ resolved "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
+ integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
+ dependencies:
+ ansi-regex "^5.0.1"
+
+strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -10198,7 +10232,16 @@ workbox-window@6.6.1:
"@types/trusted-types" "^2.0.2"
workbox-core "6.6.1"
-"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
+"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
+ version "7.0.0"
+ resolved "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
+ integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
+ dependencies:
+ ansi-styles "^4.0.0"
+ string-width "^4.1.0"
+ strip-ansi "^6.0.0"
+
+wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==