diff --git a/backend/internal/api/handlers/payment.go b/backend/internal/api/handlers/payment.go new file mode 100644 index 0000000..0019531 --- /dev/null +++ b/backend/internal/api/handlers/payment.go @@ -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, ¶ms) + + 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[:]) +} diff --git a/backend/internal/api/handlers/payment_channel.go b/backend/internal/api/handlers/payment_channel.go index d2775b0..37b4d27 100644 --- a/backend/internal/api/handlers/payment_channel.go +++ b/backend/internal/api/handlers/payment_channel.go @@ -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) diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index f373b3a..2a5444e 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -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") diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ebe80f3..0cf2204 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,10 +9,12 @@ "version": "0.0.0", "dependencies": { "@element-plus/icons-vue": "^2.3.2", + "@types/qrcode": "^1.5.6", "@vitejs/plugin-vue": "^6.0.5", "axios": "^1.15.0", "element-plus": "^2.13.6", "pinia": "^3.0.4", + "qrcode": "^1.5.4", "sass": "^1.99.0", "vue-i18n": "^9.14.5", "vue-router": "^4.6.4" @@ -1236,6 +1238,24 @@ "@types/lodash": "*" } }, + "node_modules/@types/node": { + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/web-bluetooth": { "version": "0.0.20", "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", @@ -1427,6 +1447,30 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/async-validator": { "version": "4.2.5", "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", @@ -1472,6 +1516,15 @@ "node": ">= 0.4" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -1487,6 +1540,35 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1526,6 +1608,15 @@ "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", "license": "MIT" }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1545,6 +1636,12 @@ "node": ">=8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1585,6 +1682,12 @@ "vue": "^3.3.0" } }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/entities": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", @@ -1706,6 +1809,19 @@ } } }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/follow-redirects": { "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", @@ -1765,6 +1881,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -1875,6 +2000,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -1900,6 +2034,18 @@ "url": "https://github.com/sponsors/mesqueeb" } }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -2005,6 +2151,51 @@ "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", "license": "BSD-3-Clause" }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", @@ -2050,6 +2241,15 @@ } } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.9", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", @@ -2087,6 +2287,23 @@ "node": ">=10" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -2100,6 +2317,21 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/rfdc": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", @@ -2170,6 +2402,12 @@ "@parcel/watcher": "^2.4.1" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2188,6 +2426,32 @@ "node": ">=0.10.0" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/superjson": { "version": "2.2.6", "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", @@ -2230,6 +2494,12 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "license": "MIT" + }, "node_modules/vite": { "version": "6.4.2", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", @@ -2378,6 +2648,67 @@ "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", "license": "MIT" + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } } } } diff --git a/frontend/package.json b/frontend/package.json index 797c193..e4d3694 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,10 +14,12 @@ }, "dependencies": { "@element-plus/icons-vue": "^2.3.2", + "@types/qrcode": "^1.5.6", "@vitejs/plugin-vue": "^6.0.5", "axios": "^1.15.0", "element-plus": "^2.13.6", "pinia": "^3.0.4", + "qrcode": "^1.5.4", "sass": "^1.99.0", "vue-i18n": "^9.14.5", "vue-router": "^4.6.4" diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 0fc8aa0..de82028 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -1,5 +1,9 @@ import api from '../utils/request' +export const paymentChannelApi = { + list: () => api.get('/payment-channels'), +} + export const uploadApi = { uploadImage: (file: File) => { const formData = new FormData() @@ -74,6 +78,8 @@ export const orderApi = { refund: (id: number, data: any) => api.post(`/orders/${id}/refund`, data), cancel: (id: number) => api.put(`/orders/${id}/cancel`), confirmReceipt: (id: number) => api.put(`/orders/${id}/confirm-receipt`), + createPayment: (id: number) => api.post(`/orders/${id}/pay`), + checkPaymentStatus: (id: number) => api.get(`/orders/${id}/payment-status`), } export const articleApi = { diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 5410b69..87e4a45 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -18,6 +18,7 @@ const routes = [ { path: 'cart', name: 'Cart', component: () => import('../views/user/Cart.vue') }, { path: 'orders', name: 'Orders', component: () => import('../views/user/Orders.vue') }, { path: 'orders/:id', name: 'OrderDetail', component: () => import('../views/user/OrderDetail.vue') }, + { path: 'checkout/:id', name: 'Checkout', component: () => import('../views/user/Checkout.vue') }, { path: 'profile', name: 'Profile', component: () => import('../views/user/Profile.vue') }, { path: 'addresses', name: 'Addresses', component: () => import('../views/user/Addresses.vue') }, { path: 'lotteries', name: 'Lotteries', component: () => import('../views/user/Lotteries.vue') }, diff --git a/frontend/src/views/user/Cart.vue b/frontend/src/views/user/Cart.vue index f49b544..0be057a 100644 --- a/frontend/src/views/user/Cart.vue +++ b/frontend/src/views/user/Cart.vue @@ -226,7 +226,7 @@ import { Wallet, Coin, ChatDotRound } from '@element-plus/icons-vue' import { useCartStore } from '../../store/cart' -import { systemApi, addressApi, orderApi } from '../../api' +import { systemApi, addressApi, orderApi, paymentChannelApi } from '../../api' import { getFirstImage } from '../../utils/image' import BackNav from '../../components/BackNav.vue' @@ -252,21 +252,23 @@ const addressForm = reactive({ is_default: false }) -const allPaymentMethods = [ - { value: 'balance', label: '余额支付', icon: markRaw(Wallet) }, - { value: 'alipay', label: '支付宝', icon: markRaw(Coin) }, - { value: 'wechat', label: '微信支付', icon: markRaw(ChatDotRound) }, -] +const typeIconMap: Record = { + balance: markRaw(Wallet), + alipay: markRaw(Coin), + wechat: markRaw(ChatDotRound), + bepusdt: markRaw(Coin), + bank: markRaw(CreditCard), + other: markRaw(Coin), +} + +const paymentChannels = ref([]) const availablePayments = computed(() => { - const enabledStr = settings.value.enabled_payments - if (!enabledStr) return [allPaymentMethods[0]] - try { - const enabled = JSON.parse(enabledStr) - return allPaymentMethods.filter(m => enabled.includes(m.value)) - } catch { - return [allPaymentMethods[0]] - } + return paymentChannels.value.map(ch => ({ + value: ch.code, + label: ch.name, + icon: typeIconMap[ch.type] || markRaw(Coin), + })) }) const selectAll = computed({ @@ -321,8 +323,15 @@ async function fetchSettings() { try { const res: any = await systemApi.getPublicSettings() settings.value = res.data || {} - if (availablePayments.value.length > 0 && !selectedPayment.value) { - selectedPayment.value = availablePayments.value[0].value + } catch {} +} + +async function fetchPaymentChannels() { + try { + const res: any = await paymentChannelApi.list() + paymentChannels.value = res.data || [] + if (paymentChannels.value.length > 0 && !selectedPayment.value) { + selectedPayment.value = paymentChannels.value[0].code } } catch {} } @@ -416,14 +425,20 @@ async function handleCheckout() { submitting.value = true try { - await orderApi.create({ + const res: any = await orderApi.create({ shipping_address_id: selectedAddressId.value, payment_method: selectedPayment.value, cart_item_ids: selectedIds.value }) ElMessage.success('订单创建成功') await cartStore.fetchCart() - router.push('/orders') + const orderId = res.data?.id + const selectedChannel = paymentChannels.value.find(ch => ch.code === selectedPayment.value) + if (selectedChannel?.type === 'bepusdt' && orderId) { + router.push(`/checkout/${orderId}`) + } else { + router.push('/orders') + } } catch (err: any) { ElMessage.error(err.response?.data?.error || '订单创建失败') } finally { @@ -441,6 +456,7 @@ onMounted(() => { cartStore.fetchCart() fetchSettings() fetchAddresses() + fetchPaymentChannels() }) diff --git a/frontend/src/views/user/Checkout.vue b/frontend/src/views/user/Checkout.vue new file mode 100644 index 0000000..ed23c8c --- /dev/null +++ b/frontend/src/views/user/Checkout.vue @@ -0,0 +1,329 @@ + + + + +