mirror of
https://github.com/GMWalletApp/epusdt.git
synced 2026-07-07 10:16:15 +00:00
feat: add admin panel with multi-chain support and notification system
- Add full admin REST API: auth, API keys, chains, chain tokens, wallets, orders, RPC nodes, settings, dashboard stats, notifications - Add JWT-based admin authentication and API key auth middleware - Add multi-chain listeners: BSC, ETH, Polygon, Plasma, EVM WebSocket - Add RPC node health check job with automatic failover - Add Telegram notification channel for order events - Add installer for first-run database initialization - Add new DB models: admin_user, api_key, chain, chain_token, rpc_node, settings, notification_channel - Add order statistics aggregation (daily/monthly/by-chain) - Serve built admin SPA from embedded www/ assets - Add comprehensive test coverage for all new features
This commit is contained in:
+147
-56
@@ -2,19 +2,22 @@ package route
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/assimon/luuu/config"
|
||||
"github.com/assimon/luuu/controller/admin"
|
||||
"github.com/assimon/luuu/controller/comm"
|
||||
"github.com/assimon/luuu/middleware"
|
||||
"github.com/assimon/luuu/model/data"
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
"github.com/assimon/luuu/util/constant"
|
||||
"github.com/assimon/luuu/util/sign"
|
||||
"github.com/labstack/echo/v4"
|
||||
echoMiddleware "github.com/labstack/echo/v4/middleware"
|
||||
)
|
||||
|
||||
// RegisterRoute 路由注册
|
||||
@@ -31,55 +34,20 @@ func RegisterRoute(e *echo.Echo) {
|
||||
// payment routes
|
||||
paymentRoute := e.Group("/payments")
|
||||
|
||||
// for epusdt
|
||||
epusdtV1 := paymentRoute.Group("/epusdt/v1")
|
||||
epusdtV1.POST("/order/create-transaction", func(ctx echo.Context) error {
|
||||
// add default token and currency for old plugin
|
||||
|
||||
body := make(map[string]interface{})
|
||||
if err := ctx.Bind(&body); err != nil {
|
||||
return comm.Ctrl.FailJson(ctx, err)
|
||||
}
|
||||
if _, ok := body["token"]; !ok {
|
||||
body["token"] = "usdt"
|
||||
}
|
||||
if _, ok := body["currency"]; !ok {
|
||||
body["currency"] = "cny"
|
||||
}
|
||||
if _, ok := body["network"]; !ok {
|
||||
body["network"] = "tron"
|
||||
}
|
||||
ctx.Set("request_body", body)
|
||||
|
||||
jsonBytes, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return comm.Ctrl.FailJson(ctx, err)
|
||||
}
|
||||
ctx.Request().Body = io.NopCloser(bytes.NewBuffer(jsonBytes))
|
||||
ctx.Request().ContentLength = int64(len(jsonBytes))
|
||||
|
||||
return comm.Ctrl.CreateTransaction(ctx)
|
||||
}, middleware.CheckApiSign())
|
||||
|
||||
// gmpay v1 routes
|
||||
gmpayV1 := paymentRoute.Group("/gmpay/v1")
|
||||
gmpayV1.POST("/order/create-transaction", comm.Ctrl.CreateTransaction, middleware.CheckApiSign())
|
||||
gmpayV1.GET("/supported-assets", comm.Ctrl.GetSupportedAssets)
|
||||
gmpayV1.GET("/supported-assets/records", comm.Ctrl.ListSupportedAssetRecords)
|
||||
gmpayV1.GET("/supported-assets/:id", comm.Ctrl.GetSupportedAsset)
|
||||
gmpayV1.POST("/supported-assets/add", comm.Ctrl.AddSupportedAsset, middleware.CheckApiToken())
|
||||
gmpayV1.POST("/supported-assets/:id/update", comm.Ctrl.UpdateSupportedAsset, middleware.CheckApiToken())
|
||||
gmpayV1.POST("/supported-assets/:id/delete", comm.Ctrl.DeleteSupportedAsset, middleware.CheckApiToken())
|
||||
|
||||
// wallet management routes
|
||||
walletV1 := gmpayV1.Group("/wallet", middleware.CheckApiToken())
|
||||
walletV1.POST("/add", comm.Ctrl.AddWallet)
|
||||
walletV1.GET("/list", comm.Ctrl.ListWallets)
|
||||
walletV1.GET("/:id", comm.Ctrl.GetWallet)
|
||||
walletV1.POST("/:id/status", comm.Ctrl.ChangeWalletStatus)
|
||||
walletV1.POST("/:id/delete", comm.Ctrl.DeleteWallet)
|
||||
// gmpayV1.GET("/supported-assets/records", comm.Ctrl.ListSupportedAssetRecords)
|
||||
// gmpayV1.GET("/supported-assets/:id", comm.Ctrl.GetSupportedAsset)
|
||||
|
||||
// epay v1 routes
|
||||
//
|
||||
// Signature uses the pid from the request as the api_keys lookup
|
||||
// key; the matching row's secret_key plays the role of the legacy
|
||||
// EPAY "key" value. Env-based (EPAY_PID/EPAY_KEY) fallback was
|
||||
// removed — the default seeded api_key is always available, and
|
||||
// having two sources of truth led to inbound/outbound sig mismatch.
|
||||
epayV1 := paymentRoute.Group("/epay/v1")
|
||||
epayV1.Match([]string{http.MethodPost, http.MethodGet}, "/order/create-transaction/submit.php", func(ctx echo.Context) error {
|
||||
params := make(map[string]interface{})
|
||||
@@ -107,11 +75,11 @@ func RegisterRoute(e *echo.Echo) {
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
}
|
||||
return s
|
||||
return ""
|
||||
}
|
||||
|
||||
signstr := getString(params, "sign")
|
||||
@@ -122,17 +90,29 @@ func RegisterRoute(e *echo.Echo) {
|
||||
delete(params, "sign")
|
||||
delete(params, "sign_type")
|
||||
|
||||
// we need to add pid to params for signature verification
|
||||
params["pid"] = config.GetEpayPid()
|
||||
pidStr := getString(params, "pid")
|
||||
if pidStr == "" {
|
||||
return constant.SignatureErr
|
||||
}
|
||||
apiKeyRow, err := data.GetEnabledApiKey(pidStr)
|
||||
if err != nil || apiKeyRow == nil || apiKeyRow.ID == 0 {
|
||||
return constant.SignatureErr
|
||||
}
|
||||
|
||||
checkSignature, err := sign.Get(params, config.GetApiAuthToken())
|
||||
checkSignature, err := sign.Get(params, apiKeyRow.SecretKey)
|
||||
if err != nil {
|
||||
return constant.SignatureErr
|
||||
}
|
||||
if checkSignature != signstr {
|
||||
if subtle.ConstantTimeCompare([]byte(checkSignature), []byte(signstr)) != 1 {
|
||||
return constant.SignatureErr
|
||||
}
|
||||
|
||||
if !middleware.IsIPWhitelisted(apiKeyRow.IpWhitelist, ctx.RealIP()) {
|
||||
return constant.SignatureErr
|
||||
}
|
||||
|
||||
_ = data.TouchApiKeyUsage(apiKeyRow.ID)
|
||||
|
||||
money := getString(params, "money")
|
||||
name := getString(params, "name")
|
||||
notifyURL := getString(params, "notify_url")
|
||||
@@ -145,9 +125,9 @@ func RegisterRoute(e *echo.Echo) {
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"token": "usdt",
|
||||
"currency": "cny",
|
||||
"network": "tron",
|
||||
"token": data.GetSettingString(mdb.SettingKeyEpayDefaultToken, "usdt"),
|
||||
"currency": data.GetSettingString(mdb.SettingKeyEpayDefaultCurrency, "cny"),
|
||||
"network": data.GetSettingString(mdb.SettingKeyEpayDefaultNetwork, "tron"),
|
||||
"amount": amountFloat,
|
||||
"notify_url": notifyURL,
|
||||
"order_id": outTradeNo,
|
||||
@@ -158,6 +138,8 @@ func RegisterRoute(e *echo.Echo) {
|
||||
}
|
||||
|
||||
ctx.Set("request_body", body)
|
||||
ctx.Set(middleware.ApiKeyIDKey, apiKeyRow.ID)
|
||||
ctx.Set(middleware.ApiKeyRowKey, apiKeyRow)
|
||||
|
||||
jsonBytes, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
@@ -170,6 +152,115 @@ func RegisterRoute(e *echo.Echo) {
|
||||
ctx.Request().Header.Set("Content-Type", "application/json")
|
||||
|
||||
return comm.Ctrl.CreateTransactionAndRedirect(ctx)
|
||||
|
||||
})
|
||||
|
||||
registerAdminRoutes(e)
|
||||
}
|
||||
|
||||
// registerAdminRoutes wires the management console API surface under
|
||||
// /admin/api/v1. Everything except /auth/login requires a valid JWT.
|
||||
func registerAdminRoutes(e *echo.Echo) {
|
||||
// CORS for the management console. The admin SPA is commonly served
|
||||
// from a different origin (local dev, CDN, etc.), so allow any origin
|
||||
// but require explicit echoing — browsers refuse wildcard + credentials.
|
||||
adminCORS := echoMiddleware.CORSWithConfig(echoMiddleware.CORSConfig{
|
||||
AllowOriginFunc: func(origin string) (bool, error) { return true, nil },
|
||||
AllowMethods: []string{
|
||||
http.MethodGet,
|
||||
http.MethodPost,
|
||||
http.MethodPatch,
|
||||
http.MethodPut,
|
||||
http.MethodDelete,
|
||||
http.MethodOptions,
|
||||
},
|
||||
AllowHeaders: []string{
|
||||
echo.HeaderOrigin,
|
||||
echo.HeaderContentType,
|
||||
echo.HeaderAuthorization,
|
||||
echo.HeaderAccept,
|
||||
echo.HeaderXRequestedWith,
|
||||
},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 86400,
|
||||
})
|
||||
|
||||
adminV1 := e.Group("/admin/api/v1", adminCORS)
|
||||
|
||||
// Public (no JWT)
|
||||
adminV1.POST("/auth/login", admin.Ctrl.Login)
|
||||
|
||||
// Authenticated
|
||||
authed := adminV1.Group("", middleware.CheckAdminJWT())
|
||||
authed.POST("/auth/logout", admin.Ctrl.Logout)
|
||||
authed.GET("/auth/me", admin.Ctrl.Me)
|
||||
authed.POST("/auth/password", admin.Ctrl.ChangePassword)
|
||||
|
||||
// API key management
|
||||
authed.GET("/api-keys", admin.Ctrl.ListApiKeys)
|
||||
authed.POST("/api-keys", admin.Ctrl.CreateApiKey)
|
||||
authed.PATCH("/api-keys/:id", admin.Ctrl.UpdateApiKey)
|
||||
authed.POST("/api-keys/:id/status", admin.Ctrl.ChangeApiKeyStatus)
|
||||
authed.POST("/api-keys/:id/rotate-secret", admin.Ctrl.RotateApiKeySecret)
|
||||
authed.DELETE("/api-keys/:id", admin.Ctrl.DeleteApiKey)
|
||||
authed.GET("/api-keys/:id/stats", admin.Ctrl.GetApiKeyStats)
|
||||
authed.GET("/api-keys/:id/secret", admin.Ctrl.GetApiKeySecret)
|
||||
|
||||
// Notification channels
|
||||
authed.GET("/notification-channels", admin.Ctrl.ListNotificationChannels)
|
||||
authed.POST("/notification-channels", admin.Ctrl.CreateNotificationChannel)
|
||||
authed.PATCH("/notification-channels/:id", admin.Ctrl.UpdateNotificationChannel)
|
||||
authed.POST("/notification-channels/:id/status", admin.Ctrl.ChangeNotificationChannelStatus)
|
||||
authed.DELETE("/notification-channels/:id", admin.Ctrl.DeleteNotificationChannel)
|
||||
|
||||
// gmpayV1.GET("/supported-assets", comm.Ctrl.GetSupportedAssets)
|
||||
authed.GET("/supported-assets", comm.Ctrl.GetSupportedAssets) // wrap for admin console, same handler as public endpoint
|
||||
|
||||
// Chains
|
||||
authed.GET("/chains", admin.Ctrl.ListChains)
|
||||
authed.PATCH("/chains/:network", admin.Ctrl.UpdateChain)
|
||||
|
||||
// Chain tokens (per-chain token catalog)
|
||||
authed.GET("/chain-tokens", admin.Ctrl.ListChainTokens)
|
||||
authed.POST("/chain-tokens", admin.Ctrl.CreateChainToken)
|
||||
authed.PATCH("/chain-tokens/:id", admin.Ctrl.UpdateChainToken)
|
||||
authed.POST("/chain-tokens/:id/status", admin.Ctrl.ChangeChainTokenStatus)
|
||||
authed.DELETE("/chain-tokens/:id", admin.Ctrl.DeleteChainToken)
|
||||
|
||||
// RPC nodes
|
||||
authed.GET("/rpc-nodes", admin.Ctrl.ListRpcNodes)
|
||||
authed.POST("/rpc-nodes", admin.Ctrl.CreateRpcNode)
|
||||
authed.PATCH("/rpc-nodes/:id", admin.Ctrl.UpdateRpcNode)
|
||||
authed.DELETE("/rpc-nodes/:id", admin.Ctrl.DeleteRpcNode)
|
||||
authed.POST("/rpc-nodes/:id/health-check", admin.Ctrl.HealthCheckRpcNode)
|
||||
|
||||
// Wallet (address) management
|
||||
authed.GET("/wallets", admin.Ctrl.AdminListWallets)
|
||||
authed.POST("/wallets", admin.Ctrl.AdminAddWallet)
|
||||
authed.GET("/wallets/:id", admin.Ctrl.AdminGetWallet)
|
||||
authed.PATCH("/wallets/:id", admin.Ctrl.AdminUpdateWallet)
|
||||
authed.POST("/wallets/:id/status", admin.Ctrl.AdminChangeWalletStatus)
|
||||
authed.DELETE("/wallets/:id", admin.Ctrl.AdminDeleteWallet)
|
||||
authed.POST("/wallets/batch-import", admin.Ctrl.AdminBatchImportWallets)
|
||||
|
||||
// Orders
|
||||
authed.GET("/orders", admin.Ctrl.ListOrders)
|
||||
authed.GET("/orders/export", admin.Ctrl.ExportOrders)
|
||||
authed.GET("/orders/list-with-sub", admin.Ctrl.ListOrdersWithSub)
|
||||
authed.GET("/orders/:trade_id", admin.Ctrl.GetOrder)
|
||||
authed.GET("/orders/:trade_id/with-sub", admin.Ctrl.GetOrderWithSub)
|
||||
authed.POST("/orders/:trade_id/close", admin.Ctrl.CloseOrder)
|
||||
authed.POST("/orders/:trade_id/mark-paid", admin.Ctrl.MarkOrderPaid)
|
||||
authed.POST("/orders/:trade_id/resend-callback", admin.Ctrl.ResendCallback)
|
||||
|
||||
// Dashboard
|
||||
authed.GET("/dashboard/overview", admin.Ctrl.Overview)
|
||||
authed.GET("/dashboard/asset-trend", admin.Ctrl.AssetTrend)
|
||||
authed.GET("/dashboard/revenue-trend", admin.Ctrl.RevenueTrend)
|
||||
authed.GET("/dashboard/order-stats", admin.Ctrl.OrderStats)
|
||||
authed.GET("/dashboard/recent-orders", admin.Ctrl.RecentOrders)
|
||||
|
||||
// Settings
|
||||
authed.GET("/settings", admin.Ctrl.ListSettings)
|
||||
authed.PUT("/settings", admin.Ctrl.UpsertSettings)
|
||||
authed.DELETE("/settings/:key", admin.Ctrl.DeleteSetting)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user