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:
line-6000
2026-04-22 02:28:31 +08:00
parent 097c716714
commit 6bb47d4b00
247 changed files with 9933 additions and 1279 deletions
+20 -4
View File
@@ -16,8 +16,8 @@ var Errno = map[int]string{
10011: "exceeded maximum sub-order limit",
10012: "cannot switch network on a sub-order",
10013: "order is not awaiting payment",
10014: "supported asset already exists",
10015: "supported asset not found",
10014: "chain is not enabled",
10016: "supported asset not found",
}
var (
@@ -36,8 +36,8 @@ var (
SubOrderLimitExceeded = Err(10011)
CannotSwitchSubOrder = Err(10012)
OrderNotWaitPay = Err(10013)
SupportedAssetAlreadyExists = Err(10014)
SupportedAssetNotFound = Err(10015)
ChainNotEnabled = Err(10014)
SupportedAssetNotFound = Err(10016)
)
type RspError struct {
@@ -60,3 +60,19 @@ func Err(code int) (err error) {
func (re *RspError) Render() (code int, msg string) {
return re.Code, re.Msg
}
// HttpStatus maps a RspError code to the HTTP status the handler
// should use on the wire. Small codes (< 1000) are treated as real
// HTTP status codes (e.g. 400 system error, 401 signature failure) so
// clients see the right status. Business codes (>= 1000) are all
// client-side problems that map to HTTP 400; the specific code still
// lives in the body's `status_code` field for the frontend to branch on.
func (re *RspError) HttpStatus() int {
if re == nil {
return 500
}
if re.Code >= 400 && re.Code < 600 {
return re.Code
}
return 400
}
+10 -5
View File
@@ -58,16 +58,21 @@ func (r *Resp) SucJsonPage(e echo.Context, data interface{}, pagination page.Pag
return r.SucJson(e, pageDate, message...)
}
// FailJson 失败json
// FailJson 失败json — Propagates semantic HTTP status codes to the
// wire. RspError.Code in the 4xx/5xx range is used directly; business
// codes (>=1000) map to HTTP 400 with the specific code still visible
// in the body's status_code field.
func (r *Resp) FailJson(e echo.Context, err error) error {
rr := new(Response)
switch err.(type) {
httpStatus := http.StatusBadRequest
switch t := err.(type) {
case *constant.RspError:
rr.StatusCode, rr.Message = err.(*constant.RspError).Render()
rr.StatusCode, rr.Message = t.Render()
httpStatus = t.HttpStatus()
default:
rr.StatusCode = 400
rr.StatusCode = http.StatusBadRequest
rr.Message = err.Error()
}
rr.RequestID = e.Request().Header.Get(echo.HeaderXRequestID)
return r.Json(e, http.StatusOK, &rr)
return r.Json(e, httpStatus, &rr)
}
+30
View File
@@ -0,0 +1,30 @@
package http
import (
"os"
"path/filepath"
"strings"
)
// ResolveSPAFilePath normalizes a wildcard SPA path and maps it under wwwRoot.
// It strips any leading slash and blocks path traversal outside wwwRoot.
// The second return value indicates whether the caller should try os.Stat
// against the returned path (true) or directly fall back to index.html (false).
func ResolveSPAFilePath(wwwRoot, wildcard string) (string, bool) {
indexPath := filepath.Join(wwwRoot, "index.html")
cleaned := strings.TrimPrefix(filepath.Clean(wildcard), "/")
if cleaned == "" || cleaned == "." {
return indexPath, false
}
requestedPath := filepath.Join(wwwRoot, cleaned)
base := filepath.Clean(wwwRoot)
resolved := filepath.Clean(requestedPath)
if resolved != base && !strings.HasPrefix(resolved, base+string(os.PathSeparator)) {
return indexPath, false
}
return requestedPath, true
}
+68
View File
@@ -0,0 +1,68 @@
package http
import (
"path/filepath"
"testing"
)
func TestResolveSPAFilePath(t *testing.T) {
root := filepath.Join("tmp", "www")
indexPath := filepath.Join(root, "index.html")
tests := []struct {
name string
wildcard string
wantPath string
wantTryStat bool
}{
{
name: "relative asset path",
wildcard: "assets/app.js",
wantPath: filepath.Join(root, "assets", "app.js"),
wantTryStat: true,
},
{
name: "absolute style asset path",
wildcard: "/assets/app.js",
wantPath: filepath.Join(root, "assets", "app.js"),
wantTryStat: true,
},
{
name: "empty wildcard",
wildcard: "",
wantPath: indexPath,
wantTryStat: false,
},
{
name: "dot wildcard",
wildcard: ".",
wantPath: indexPath,
wantTryStat: false,
},
{
name: "directory traversal fallback",
wildcard: "../../etc/passwd",
wantPath: indexPath,
wantTryStat: false,
},
{
name: "absolute directory traversal fallback",
wildcard: "/../../etc/passwd",
wantPath: filepath.Join(root, "etc", "passwd"),
wantTryStat: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotPath, gotTryStat := ResolveSPAFilePath(root, tt.wildcard)
if gotTryStat != tt.wantTryStat {
t.Fatalf("tryStat = %v, want %v", gotTryStat, tt.wantTryStat)
}
if gotPath != tt.wantPath {
t.Fatalf("path = %q, want %q", gotPath, tt.wantPath)
}
})
}
}
+78
View File
@@ -0,0 +1,78 @@
package jwt
import (
"crypto/rand"
"encoding/hex"
"errors"
"time"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/golang-jwt/jwt/v4"
)
// DefaultExpiration is the token lifetime for admin sessions.
const DefaultExpiration = 24 * time.Hour
// AdminClaims is the JWT payload for admin sessions.
type AdminClaims struct {
AdminUserID uint64 `json:"uid"`
Username string `json:"usr"`
jwt.RegisteredClaims
}
// EnsureSecret reads system.jwt_secret from settings; if absent,
// generates a new 32-byte random hex string and persists it. Called once
// at startup so subsequent sign/verify can assume the secret exists.
func EnsureSecret() (string, error) {
secret := data.GetSettingString(mdb.SettingKeyJwtSecret, "")
if secret != "" {
return secret, nil
}
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
secret = hex.EncodeToString(buf)
if err := data.SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyJwtSecret, secret, mdb.SettingTypeString); err != nil {
return "", err
}
return secret, nil
}
// Sign returns a signed JWT for the given admin user.
func Sign(userID uint64, username string) (string, error) {
secret, err := EnsureSecret()
if err != nil {
return "", err
}
claims := AdminClaims{
AdminUserID: userID,
Username: username,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(DefaultExpiration)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(secret))
}
// Parse validates a token string and returns its claims.
func Parse(tokenStr string) (*AdminClaims, error) {
secret, err := EnsureSecret()
if err != nil {
return nil, err
}
claims := &AdminClaims{}
_, err = jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return []byte(secret), nil
})
if err != nil {
return nil, err
}
return claims, nil
}