From 03d847fe6857cba3555c36e9c494453906d40c64 Mon Sep 17 00:00:00 2001 From: Buer <42402987+MartialBE@users.noreply.github.com> Date: Wed, 5 Jun 2024 13:30:12 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20payment=20(#132)=20(#?= =?UTF-8?q?250)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 + common/config/payment.go | 4 + common/utils/helper.go | 35 ++ controller/misc.go | 2 + controller/order.go | 223 +++++++++++ controller/payment.go | 140 +++++++ go.mod | 1 + go.sum | 2 + model/main.go | 8 + model/option.go | 6 + model/order.go | 107 ++++++ model/payment.go | 110 ++++++ payment/gateway/epay/client.go | 80 ++++ payment/gateway/epay/payment.go | 91 +++++ payment/gateway/epay/type.go | 45 +++ payment/payment.go | 20 + payment/service.go | 81 ++++ payment/types/types.go | 27 ++ router/api-router.go | 16 + web/package.json | 4 +- web/public/ali_pay.png | Bin 0 -> 3741 bytes web/public/logo-loading-white.svg | 5 + web/public/logo-loading.svg | 5 + web/public/wechat_pay.png | Bin 0 -> 3119 bytes web/src/assets/images/success.svg | 1 + web/src/config.js | 2 +- web/src/menu-items/panel.js | 15 +- web/src/routes/MainRoutes.js | 5 + web/src/store/siteInfoReducer.js | 2 +- web/src/views/Payment/Gateway.js | 294 ++++++++++++++ web/src/views/Payment/Order.js | 220 +++++++++++ web/src/views/Payment/component/EditModal.js | 359 ++++++++++++++++++ .../views/Payment/component/OrderTableRow.js | 45 +++ .../Payment/component/OrderTableToolBar.js | 138 +++++++ web/src/views/Payment/component/TableRow.js | 153 ++++++++ .../views/Payment/component/TableToolBar.js | 73 ++++ web/src/views/Payment/index.js | 86 +++++ web/src/views/Payment/type/Config.js | 73 ++++ .../Setting/component/OperationSetting.js | 62 ++- web/src/views/Topup/component/PayDialog.js | 129 +++++++ web/src/views/Topup/component/TopupCard.js | 188 ++++++++- web/yarn.lock | 49 ++- 42 files changed, 2890 insertions(+), 20 deletions(-) create mode 100644 common/config/payment.go create mode 100644 controller/order.go create mode 100644 controller/payment.go create mode 100644 model/order.go create mode 100644 model/payment.go create mode 100644 payment/gateway/epay/client.go create mode 100644 payment/gateway/epay/payment.go create mode 100644 payment/gateway/epay/type.go create mode 100644 payment/payment.go create mode 100644 payment/service.go create mode 100644 payment/types/types.go create mode 100644 web/public/ali_pay.png create mode 100644 web/public/logo-loading-white.svg create mode 100644 web/public/logo-loading.svg create mode 100644 web/public/wechat_pay.png create mode 100644 web/src/assets/images/success.svg create mode 100644 web/src/views/Payment/Gateway.js create mode 100644 web/src/views/Payment/Order.js create mode 100644 web/src/views/Payment/component/EditModal.js create mode 100644 web/src/views/Payment/component/OrderTableRow.js create mode 100644 web/src/views/Payment/component/OrderTableToolBar.js create mode 100644 web/src/views/Payment/component/TableRow.js create mode 100644 web/src/views/Payment/component/TableToolBar.js create mode 100644 web/src/views/Payment/index.js create mode 100644 web/src/views/Payment/type/Config.js create mode 100644 web/src/views/Topup/component/PayDialog.js 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 0000000000000000000000000000000000000000..8bbe6632313f72dd25e68c6cf49800c47c7a39da GIT binary patch literal 3741 zcmV;O4r1|%P)Px@S4l)cRCr$Poo$laI1YyGzQN2MD4oNkD!0h=EmD=kBstJ-ZqS~kWwm5m5(yF@ z0OIwJot{qg5@YW)57&-DLaK5ch+S(TYfoPXVyegA#`?=Qc; zfBEa7jM#KSHhc~_YvxJcgD?ZIW$?@$-!+$DS_1@lIHV0gh*Df&IDMO{_6C7C;AxC+ z>3_d}`OiN!wIkO7AO+gLLE#99qe(5E2EgQ%4?s3#G!_s!Bbb3fNVX*@0CJPcedy#N zY|0sa?cXnN@GOh$!UM8+i^x!H!GydeNhKs8fP(5>;=&RN5P(4iW81St3c*4E0x&qh zcx)p^t}-+r07C+dSfC6E$k!kL{pWN#-}Wtf7zz4Fn~@`iUPA#gd5p_Xn5(7)#sXys zKoCl402>B`?mp! zIv{|fB+6FhG=;lx{#;=f@NIGMrmzqdXT690ywN0poj6C(?Q*zdylj` zAPB2^t9YdT`a{wc5Wq2#v1-kwZ@ASB5Wt~jP$Tx+50Wl`0FDujRC`W+AQ1-yaA+IS z$og#kX!y00R*9y1*3^J z1{p}$0RbG*4Aatl`STH0K#+G>GM1=g;mt>w0YTmo)hzAIn>W;A0|cQJ#YEE0vb8_l zrI?o-A0#Y*0FJ!MrV{NjGTwylC+pM`6Cen!@@i{`BlQ75@{(RkwC5wufWUi{Dhz;> zN{X*sd#Kd}2;iWo5`a*o#aFh?i!=g)(5hUK0Hj=2oF&`5NHrkv9#ka)5URxZ%Gb-j z3J`1v)~K#tN$cK_q}Ry)x*k<^l6)l~xMrEICICX08E1{UP>T!*LaSP(0gzhB@ztYy zkqSVNnAa%`fYiy2x7PPBf0JyUR2bf)MOgr(MS6U7X?h?60`F0;D4JsS`azOjuYtKH z#fuaJ0`JkP7y!~LLB9Isxd;Iec#k#(0gyKNan>$h(JBH2-h)Fq0E9z|d~K9@kwQS= zJ=zrmK-y)<*+Q8Y$pYfjdz3vLIK_>&CJFd@%OY|e67UDe>2$PlxC@Bi z@CtwsZJM>XM?j83@<9Q)ys>JXrLmXf${UXRS+5ekzp@NTq>-0Tr<1p%iT0O#4*>BR zUWsU(p;7FEFbGkwiS31*O+b8ySAsHZY8rqviBG&T2@rS>etiHCewlK%xE*Fac@UpT z7N?-3+5#X_lI3gn*^6udg73}w1io$wfXK*|v(;xWvgJX3`dfwI6p~b107Oc%eC-wi zg8YL|;P4EGAgpSb$6qQvedo z$XlixCI6UYG$rbD4uT!@7RxbPmp7!0lYcw`5+(66Yso?b;{zmCcr~dbtz_Li2mleX zP}OEB7sccT8WE86X74%95^v4~M*Dl&>E)%fjul=lD_Q55Pli&n@LY~Fy!!KcI$eHx zmPXy!FK+;(PI3-v@g3kD_aO>E92k1t5P9>Ds5vSCsk1)wwGdwMsObDFCF$4$;gP1H zRyYBP@*2{X`T&R_qvjh5!YeLW0uU}q8fu5rdqiEm`dH>gh-JvKnTEm%NaXM;spINF zqOMRd*`u*$5MFUA1b}c#(?DDB9^6U@=t0uK4fAh4z~Lox^V9l9r*vpR`dPe134j0) zvIORnOn6QedF+1LWuAY!Cw?HjA{$4x!hjy+!$Z7r+zFVK;6{Pz1<={+oWIqyL9YYv zL6=#+#y}f|S1{C|$ccLKG%~=zd*n1w_xAw+u?@zaT6lqh_oy@fTrJldg+9U9QppmM z)#fmqfMD5w(UDs~_zWWV4AJ;I)w^(oS8-o!-_x>7K={6M&@g^#pLtmL9u@%PQ9G^x z87HKoLNb@%f(72AR+=UC+=;5gRY3J_ZdH|&L0{y*-rTVehl zAPPpgA8uH9k+NLOxsn#(7y%USH;lLS;slW1v1rc7YyZx>QpFpm^MhL}C2Bp69s9i{ z_Dw*ng;~6YKovhndH!TvP9}aqsh7$AoR0u86=vx{+X@K3*XSdQ5kN_jD*4E(0+2}w z8Xr*|o`DUgLVk@V9=S!TJWJ#H$>=!LaK=`aGq!j4y9kk}DatI4t$#PNCQR z!g3T)9=*!(ymEjjJ+br3um_187$r}^cCbrZ$m^!GCCze+{WJig=S8lRf6l#2r29mj z)hi2zt`3e&cnnZ-kRE!Fn{t%ANIuk>C#U00x`3f1`<&UV|2{S-SwM83VC9f3VMyu9 z1;q&NyhZvK@$05_ps$h1TxU#BN&!)PlFxHcy1uwa zz}a3AAi7>8_1-4*NPyVzB7Rp* zVuRrmlnOvBeFp2pn*a>I$vhbl{S~e4rN#7LEnUY9D5z<(&4*nrAj&IRd&1Yf+6+)o zp|HP?QU%E5MJy!j$F5u2tMG0}av0Z0ZP@TA)qvOxwe}1oZ30f4e-ku+&Aa187IpIs zx3cfA`UX-cdfWR}smR~DGC0t{kP0{n1@0fKx4Hr!VNw8K3p zV-xWpH$ae&P&9$E)e?}r{8S7O)d zv1im54i!KENo*6ZpM8Hg)Br)aRc|ISYwEzE3J4&HY3lS@>%pN82q2-GORO5YaF_rD zki;_i^4aRcVFnODQf@XeO4@+K6d-^khUw>LDf@0!zW@6YU0yYz49dpMwUiN~ERO`_ zFGZ~xr#V38N#NC#8g+WDV<)6~l%QEa5OR4ncIvq8!eK54*%B>6E~>$KDQ`2Ra^*qR zgGI>Y#qdkUY1?b8XJ9(SdK5JvDKS3lW&FV5BE;GbyR6?$V%qH(2Cu&|-T4gWUMCDxaDF zAQDntwfPKB`eOoU{kyzA598y>zxq=${tpP}PzedOpbq~9E9swsf!>}F00000NkvXX Hu0mjf)9~X= literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..554c2f74124c06e5ceba70b07c794358ed6eb973 GIT binary patch literal 3119 zcmai%Ydq5p8^`~f&1RTYV@@$S+zwfrcm=b1bRHSki6_vZKeUT>}!*LBy<#zGV!jQ{{Z)RJoEu&=%U z7F=+@KXs`t+!xR_2MZIR`i<-y00_@nni-#o@K`Ev%b3;^q3CMleW`c;&p_q2wzkIU zWUi{ycm~x35IWd%wnAw%D(L~So$j&QxMB=K6VFb{@QMILz6O9bgCWkV0@y?t0)P|f zU@d4UDkpdi@d%(p-uvBGa|FmfEkhbfVBnxn$+ufZY!DkJSujusR!E0bx9 z-v=1$?N%NMRkHT0_k*mHMr z(os-P`Cm7&%L8`f`f+tHAcoK84>!OV(g&Qe!$v~4Jy0CkRqpj`*^oh7+4A!y!10m! zIb`hNQp>Lh{^jOoQTF-81N6nNbH&->NwSqjG5SDufdlX((W^8|4YMd6dm(PiWtS@% z#36Wr!RTkJ+luO{R{&75t7Lbw}LTKlEVtjC-tD?GWaNr>$I>Q0f;|S7MbB-mez64FXfG~_TXkr$ESS?Pyx3~M= zu{4GbSPVwXUtSIl9)_VNB{j(;*$@nsCjgd}i@(hA_Cs2_k%^AF0iKZt%VmrP0SM6$ z`hK=&SR_M_4)n}|=2m~@k-fy=x_5debH8Q}DAYnB#7jbEgA4duFOjIp71=ZOhx4SA z{&fd|W}``$oF~>nAmsNKI-hg9+O{bL+|g>gB^>wQ&3)9ZJWt_w|DDRa_GH&7W~t5lH?#pEO{`o z4*;dF40$NfiA0TW5qhM1uZ@(C{gnwe2~g&>;R|@pT`K;&Y59-%&*nidNdK5Mdlt*R z7&}(NqbSiQ<>GsDo~MN4^|qe4mm*OU-am*nt{yj43+Ffm7o?tCWxDQl%;MYQ`Q9)cW{4kzjvWqsiIhx1x3etSjTuZa4X_>XaL`KcOYvmJK4Qd z1T|sWa-d{mVMfA$W`1{>f&*if9lHGwfl@2AU2p8;;Smvl;wUDUW*+nD`J2e{#VSdJk1=*xY$h0qd}?P!Rj4YMtFT*p zVe=x)k#e~jK&p7Rx3K4WsQT*1@bQ}=?6*!j1yd(()OHfE(;WHtKgae0kGJRjf_T%? z4%DRuR@uxmw8`d=jT)gd?mmIX<&@fOM%o`X9#*rt+I~&aLK|>cK4NS4Ja&4EmU3l5 zTDI^Y`hW z2v(!bEqbbBQLOmmN$yui3ZJ9W{^6gdk$}$H^l+5vcchj&))yms`mZ2ZpQ>j=?8%a_ znkt?+SZg`w?g^aNUYd}kQ`}SiWjD>6U$E2Qx})n|+e}tu@MpfxQp^%zK2`SAh`r59Yt-N(3~5+(NcMj_!|76xfqzAPAhWA72HY&i7cE5E_g2TAz* zDS&xjVL+o=lu9(z=eCT*y_Yg25()*wBEN@gkr#|2H=HuhfmWA}(CwuMB$g_HoK-8X zN~IA-p`Z@_i${3!v5KgTjX4j&2PBMDM~}ANn9=0yuzH`<3f)m|=|=${u7OhD#31Fd z_R-Gs292|u#@|}}SI@R^^$8g^mI+N1qXSs&cM6tkj`nB5A^0<2b)>A;WNux%Aa;({ zc>Tb1(ktxa~kb!s=ZQxdE6bWM4L*3`q@Wkw3F~BSC~w7OksB7XXI%(gc4zzE5E^6>XyS4)2wvedYBR)20DEn+#Cl!-9^B>pd53iI zlrl8(X1so1LTjRW0ucy7f)oORg1uP za9EBaly9CXh!F2*-`Whc5aCawQw(hm&5S{Uh2b^_kN>U9{fJI)RO5Qc<(nzG zJW3mqO^=~ZDhbqys4rI_E3l5aR>Q7=1`fqvM{E@)^&z&OILz3 z&a3ggG3#pDPG$68E_}sK%Up_Y&)xGa-IDYwU7=&~vyyGyj;*vbCZI5?l|GE@Yz-F$ z=1}5RTXsbY6r(=5Ur&v>Xki$0v9jaj>tNe=9xdVIh-&wnJ>%*6XAEdcqVxvQY}I;E zvF=9ukD#edi*AzuX*}Rl#r_%7FcLQv9YdS~>*GrvOjB;NK8yijkHm}La3wX6wB z9{5A2(4k&5QIjU(ZpvZfyU!hUCkL^6E862TPmXu!(>F+)v9Ho1)-K(HkhiO16)FAx zs@ts=upw&AHeG=SHn`#BX-@t^yFF9zd8|csQkZdbPP}?ZIq_Rzm>?POUY1;HF$ke^ zIP&x%-7qh!lj>NG6yb9{UwsE~xW2bs2LM=7Y|N@nJmdch{mY5; literal 0 HcmV?d00001 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 ( + <> + + 支付网关 + + + + + + + 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 ( + + + {paymentId ? '编辑支付' : '新建支付'} + + + + + {({ errors, handleBlur, handleChange, handleSubmit, touched, values, isSubmitting }) => ( +
+ + 类型 + + {touched.type && errors.type ? ( + + {errors.type} + + ) : ( + 支付类型 + )} + + + + 名称 + + {touched.name && errors.name && ( + + {errors.name} + + )} + + + + 图标 + + {touched.icon && errors.icon && ( + + {errors.icon} + + )} + + + + 回调域名 + + {touched.notify_domain && errors.notify_domain ? ( + + {errors.notify_domain} + + ) : ( + 支付回调的域名,除非你自行配置过,否则保持为空 + )} + + + + 固定手续费 + + {touched.fixed_fee && errors.fixed_fee ? ( + + {errors.fixed_fee} + + ) : ( + 每次支付收取固定的手续费,单位 美元 + )} + + + + 百分比手续费 + + {touched.percent_fee && errors.percent_fee ? ( + + {errors.percent_fee} + + ) : ( + 每次支付按百分比收取手续费,如果为5%,请填写 0.05 + )} + + + + 网关货币类型 + + {touched.currency && errors.currency ? ( + + {errors.currency} + + ) : ( + 该网关是收取什么货币的,请查询对应网关文档 + )} + + + {PaymentConfig[values.type] && + Object.keys(PaymentConfig[values.type]).map((configKey) => { + const param = PaymentConfig[values.type][configKey]; + const name = `config.${configKey}`; + return param.type === 'select' ? ( + + {param.name} + + {param.description} + + ) : ( + + + {param.description} + + ); + })} + + + + + +
+ )} +
+
+
+ ); +}; + +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] || '未知'} + + icon + + {item.fixed_fee} + {item.percent_fee} + + + + + { + managePayment(item.id, 'status', !item.enable); + }} + /> + + {timestamp2string(item.created_at)} + + + + + + + + + { + handleCloseMenu(); + handleOpenModal(); + setModalPaymentId(item.id); + }} + > + + 编辑 + + + + + 删除 + + + + + 删除通道 + + 是否删除通道 {item.name}? + + + + + + + + ); +} + +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 ( + + ); +} + +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 ( + + 支付 + { + if (intervalId) { + clearInterval(intervalId); + setIntervalId(null); + } + onClose(); + }} + sx={{ + position: 'absolute', + right: 8, + top: 8, + color: (theme) => theme.palette.grey[500] + }} + > + + + + + + {loading && loading} + {qrCodeUrl && ( + + )} + {success && success} + {message} + + + + + ); +}; + +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==