feat: 实现站内USDT收银台支付流程

- 后端: payment.go 支付处理(创建订单/回调通知/状态查询/签名算法)
- 后端: 新增公开支付通道API(PublicList,不暴露config)
- 前端: Checkout.vue 收银台页面(倒计时/二维码/支付轮询)
- 前端: Cart.vue 对接动态支付通道,选择bepusdt跳转收银台
- 前端: API添加paymentChannelApi和orderApi支付方法
- 路由: 注册checkout路由和支付相关API
This commit is contained in:
2026-05-26 16:43:31 +08:00
parent 675b365b8b
commit 09f1ee4601
9 changed files with 1002 additions and 19 deletions
+259
View File
@@ -0,0 +1,259 @@
package handlers
import (
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strconv"
"strings"
"sale/internal/models"
"sale/internal/utils"
"github.com/gin-gonic/gin"
)
type PaymentHandler struct{}
func NewPaymentHandler() *PaymentHandler {
return &PaymentHandler{}
}
type BepUsdtCreateRequest struct {
Address string `json:"address"`
TradeType string `json:"trade_type"`
OrderID string `json:"order_id"`
Amount float64 `json:"amount"`
Signature string `json:"signature"`
NotifyURL string `json:"notify_url"`
RedirectURL string `json:"redirect_url"`
Timeout int `json:"timeout"`
Rate string `json:"rate"`
}
type BepUsdtCreateResponse struct {
StatusCode int `json:"status_code"`
Message string `json:"message"`
TradeID string `json:"trade_id"`
OrderID string `json:"order_id"`
Amount string `json:"amount"`
ActualAmount string `json:"actual_amount"`
Token string `json:"token"`
Expiration int `json:"expiration_time"`
PaymentURL string `json:"payment_url"`
}
func (h *PaymentHandler) CreatePayment(c *gin.Context) {
userID := c.GetUint("user_id")
orderID, _ := strconv.Atoi(c.Param("id"))
var order models.Order
if err := utils.DB.Where("id = ? AND user_id = ?", orderID, userID).First(&order).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Order not found"})
return
}
if order.Status != models.OrderStatusPendingPayment {
c.JSON(http.StatusBadRequest, gin.H{"error": "Order is not pending payment"})
return
}
var channels []models.PaymentChannel
utils.DB.Where("is_enabled = ? AND type = ?", true, "bepusdt").Order("sort_order ASC").Find(&channels)
if len(channels) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "No available payment channel"})
return
}
channel := channels[0]
var config map[string]string
json.Unmarshal([]byte(channel.Config), &config)
apiURL := config["bepusdt_api_url"]
apiToken := config["bepusdt_api_token"]
tradeType := config["bepusdt_trade_type"]
if tradeType == "" {
tradeType = "usdt.trc20"
}
timeout, _ := strconv.Atoi(config["bepusdt_timeout"])
if timeout < 60 {
timeout = 600
}
rate := config["bepusdt_rate"]
scheme := "http"
if c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" {
scheme = "https"
}
host := c.Request.Host
baseURL := fmt.Sprintf("%s://%s", scheme, host)
notifyURL := fmt.Sprintf("%s/api/payment/bepusdt/notify", baseURL)
redirectURL := fmt.Sprintf("%s/orders/%d", baseURL, order.ID)
params := map[string]string{
"order_id": strconv.Itoa(int(order.ID)),
"amount": fmt.Sprintf("%.2f", order.TotalAmount),
"notify_url": notifyURL,
"redirect_url": redirectURL,
"trade_type": tradeType,
}
if timeout > 0 {
params["timeout"] = strconv.Itoa(timeout)
}
if rate != "" {
params["rate"] = rate
}
signature := signBepUsdt(params, apiToken)
reqBody := BepUsdtCreateRequest{
TradeType: tradeType,
OrderID: strconv.Itoa(int(order.ID)),
Amount: order.TotalAmount,
Signature: signature,
NotifyURL: notifyURL,
RedirectURL: redirectURL,
Timeout: timeout,
Rate: rate,
}
body, _ := json.Marshal(reqBody)
resp, err := http.Post(apiURL+"/api/v1/order/create-transaction", "application/json", strings.NewReader(string(body)))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to connect payment gateway"})
return
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
var result map[string]interface{}
json.Unmarshal(respBody, &result)
if statusCode, ok := result["status_code"].(float64); !ok || int(statusCode) != 200 {
errMsg := "Payment creation failed"
if msg, ok := result["message"].(string); ok {
errMsg = msg
}
c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg})
return
}
data := result["data"].(map[string]interface{})
tradeID, _ := data["trade_id"].(string)
actualAmount, _ := data["actual_amount"].(string)
token, _ := data["token"].(string)
expiration, _ := data["expiration_time"].(float64)
utils.DB.Model(&order).Updates(map[string]interface{}{
"payment_method": "bepusdt",
})
paymentInfo := map[string]interface{}{
"trade_id": tradeID,
"order_id": order.ID,
"amount": data["amount"],
"actual_amount": actualAmount,
"token": token,
"expiration_time": int(expiration),
"trade_type": tradeType,
"channel_id": channel.ID,
}
c.JSON(http.StatusOK, gin.H{"data": paymentInfo})
}
func (h *PaymentHandler) BepUsdtNotify(c *gin.Context) {
body, _ := io.ReadAll(c.Request.Body)
var params map[string]interface{}
json.Unmarshal(body, &params)
orderIDStr, _ := params["order_id"].(string)
status, _ := params["status"].(float64)
signature, _ := params["signature"].(string)
orderID, err := strconv.Atoi(orderIDStr)
if err != nil {
c.String(http.StatusOK, "ok")
return
}
var order models.Order
if err := utils.DB.First(&order, orderID).Error; err != nil {
c.String(http.StatusOK, "ok")
return
}
var channels []models.PaymentChannel
utils.DB.Where("type = ?", "bepusdt").Find(&channels)
if len(channels) == 0 {
c.String(http.StatusOK, "ok")
return
}
channel := channels[0]
var config map[string]string
json.Unmarshal([]byte(channel.Config), &config)
apiToken := config["bepusdt_api_token"]
stringParams := make(map[string]string)
for k, v := range params {
if k != "signature" {
stringParams[k] = fmt.Sprintf("%v", v)
}
}
expectedSign := signBepUsdt(stringParams, apiToken)
if signature != expectedSign {
c.String(http.StatusOK, "ok")
return
}
if int(status) == 2 {
utils.DB.Model(&order).Update("status", models.OrderStatusPendingConfirm)
} else if int(status) == 3 {
utils.DB.Model(&order).Update("status", models.OrderStatusCancelled)
}
c.String(http.StatusOK, "ok")
}
func (h *PaymentHandler) CheckPaymentStatus(c *gin.Context) {
userID := c.GetUint("user_id")
orderID, _ := strconv.Atoi(c.Param("id"))
var order models.Order
if err := utils.DB.Where("id = ? AND user_id = ?", orderID, userID).First(&order).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Order not found"})
return
}
c.JSON(http.StatusOK, gin.H{"data": gin.H{
"status": order.Status,
}})
}
func signBepUsdt(params map[string]string, token string) string {
keys := make([]string, 0, len(params))
for k := range params {
if params[k] != "" {
keys = append(keys, k)
}
}
sort.Strings(keys)
var parts []string
for _, k := range keys {
parts = append(parts, fmt.Sprintf("%s=%s", k, params[k]))
}
str := strings.Join(parts, "&") + token
hash := md5.Sum([]byte(str))
return hex.EncodeToString(hash[:])
}
@@ -16,6 +16,40 @@ func NewPaymentChannelHandler() *PaymentChannelHandler {
return &PaymentChannelHandler{}
}
func (h *PaymentChannelHandler) PublicList(c *gin.Context) {
var channels []models.PaymentChannel
utils.DB.Where("is_enabled = ?", true).Order("sort_order ASC, id ASC").Find(&channels)
type publicChannel struct {
ID uint `json:"id"`
Name string `json:"name"`
Code string `json:"code"`
Type string `json:"type"`
Icon string `json:"icon"`
FeeRate float64 `json:"fee_rate"`
MinAmount float64 `json:"min_amount"`
MaxAmount float64 `json:"max_amount"`
SortOrder int `json:"sort_order"`
}
var result []publicChannel
for _, ch := range channels {
result = append(result, publicChannel{
ID: ch.ID,
Name: ch.Name,
Code: ch.Code,
Type: ch.Type,
Icon: ch.Icon,
FeeRate: ch.FeeRate,
MinAmount: ch.MinAmount,
MaxAmount: ch.MaxAmount,
SortOrder: ch.SortOrder,
})
}
c.JSON(http.StatusOK, gin.H{"data": result})
}
func (h *PaymentChannelHandler) List(c *gin.Context) {
var channels []models.PaymentChannel
utils.DB.Order("sort_order ASC, id ASC").Find(&channels)
+6 -1
View File
@@ -24,6 +24,7 @@ func SetupRoutes(r *gin.Engine) {
uploadHandler := handlers.NewUploadHandler()
bannerHandler := handlers.NewBannerHandler()
paymentChannelHandler := handlers.NewPaymentChannelHandler()
paymentHandler := handlers.NewPaymentHandler()
r.Use(middlewares.CORSMiddleware())
@@ -66,6 +67,8 @@ func SetupRoutes(r *gin.Engine) {
api.GET("/lotteries", lotteryHandler.List)
api.GET("/lotteries/:id", lotteryHandler.GetByID)
api.GET("/settings/public", systemHandler.GetPublicSettings)
api.GET("/payment-channels", paymentChannelHandler.PublicList)
api.POST("/payment/bepusdt/notify", paymentHandler.BepUsdtNotify)
authed := api.Group("")
authed.Use(middlewares.AuthMiddleware())
@@ -106,7 +109,9 @@ func SetupRoutes(r *gin.Engine) {
orders.POST("", orderHandler.Create)
orders.POST("/:id/refund", orderHandler.Refund)
orders.PUT("/:id/cancel", orderHandler.CancelOrder)
orders.PUT("/:id/confirm-receipt", orderHandler.ConfirmReceipt)
orders.PUT("/:id/confirm-receipt", orderHandler.ConfirmReceipt)
orders.POST("/:id/pay", paymentHandler.CreatePayment)
orders.GET("/:id/payment-status", paymentHandler.CheckPaymentStatus)
}
lotteries := authed.Group("/lotteries")