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:
+74
-12
@@ -7,6 +7,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -15,6 +16,12 @@ import (
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// parseFloat is the minimal helper used by the settings bridge; kept
|
||||
// here so settings_bridge.go stays import-free.
|
||||
func parseFloat(s string) (float64, error) {
|
||||
return strconv.ParseFloat(strings.TrimSpace(s), 64)
|
||||
}
|
||||
|
||||
var (
|
||||
HTTPAccessLog bool
|
||||
SQLDebug bool
|
||||
@@ -124,6 +131,57 @@ func resolveConfigFilePath() (string, error) {
|
||||
return normalizeConfiguredPath(".env")
|
||||
}
|
||||
|
||||
// NeedsInstall returns true when the first-run install wizard should run.
|
||||
// It returns true if the config file does not exist yet, or if the file
|
||||
// exists and contains install=true (an explicit reset flag).
|
||||
// Existing installs that have no install key are NOT affected.
|
||||
func NeedsInstall() bool {
|
||||
envPath, exists := ResolveConfigPath()
|
||||
if !exists {
|
||||
return true
|
||||
}
|
||||
// Read just the install key without triggering full Init().
|
||||
v := viper.New()
|
||||
v.SetConfigFile(envPath)
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
// Unreadable config → treat as needing install.
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(v.GetString("install")), "true")
|
||||
}
|
||||
|
||||
// ResolveConfigPath returns the absolute path to the .env file and whether
|
||||
// it currently exists on disk. Unlike resolveConfigFilePath it does not
|
||||
// return an error when the file is absent — callers (e.g. the install
|
||||
// wizard) need the target path even before the file is written.
|
||||
func ResolveConfigPath() (path string, exists bool) {
|
||||
candidate := explicitConfigPath
|
||||
if candidate == "" {
|
||||
if e := strings.TrimSpace(os.Getenv("EPUSDT_CONFIG")); e != "" {
|
||||
candidate = e
|
||||
} else {
|
||||
candidate = ".env"
|
||||
}
|
||||
}
|
||||
// Resolve relative paths against cwd.
|
||||
p := strings.TrimSpace(candidate)
|
||||
if !filepath.IsAbs(p) {
|
||||
cwd, err := os.Getwd()
|
||||
if err == nil {
|
||||
p = filepath.Join(cwd, p)
|
||||
}
|
||||
}
|
||||
info, err := os.Stat(p)
|
||||
if err == nil && info.IsDir() {
|
||||
p = filepath.Join(p, ".env")
|
||||
info, err = os.Stat(p)
|
||||
}
|
||||
if err != nil {
|
||||
return p, false
|
||||
}
|
||||
return p, true
|
||||
}
|
||||
|
||||
func normalizeConfiguredPath(input string) (string, error) {
|
||||
path := strings.TrimSpace(input)
|
||||
if path == "" {
|
||||
@@ -178,11 +236,19 @@ func GetAppUri() string {
|
||||
return viper.GetString("app_uri")
|
||||
}
|
||||
|
||||
func GetApiAuthToken() string {
|
||||
return viper.GetString("api_auth_token")
|
||||
func GetRateApiUrl() string {
|
||||
// settings table wins (admin-configurable); .env and env var remain
|
||||
// as fallbacks for smooth migration from the old layout.
|
||||
if db := settingsRateApiUrl(); db != "" {
|
||||
return db
|
||||
}
|
||||
return GetRateApiUrlFromEnv()
|
||||
}
|
||||
|
||||
func GetRateApiUrl() string {
|
||||
// GetRateApiUrlFromEnv returns the rate API URL read only from the
|
||||
// .env / environment variables, bypassing the settings table. Used
|
||||
// by bootstrap to seed the settings table from the initial .env value.
|
||||
func GetRateApiUrlFromEnv() string {
|
||||
rateURL := viper.GetString("api_rate_url")
|
||||
if rateURL == "" {
|
||||
rateURL = os.Getenv("API_RATE_URL")
|
||||
@@ -190,7 +256,6 @@ func GetRateApiUrl() string {
|
||||
if rateURL == "" {
|
||||
log.Println("api_rate_url is empty")
|
||||
}
|
||||
RateApiUrl = rateURL
|
||||
return rateURL
|
||||
}
|
||||
|
||||
@@ -250,6 +315,11 @@ func GetRateForCoin(coin string, base string) float64 {
|
||||
}
|
||||
|
||||
func GetUsdtRate() float64 {
|
||||
// Prefer the DB-backed override (admin-configurable). Fall back to
|
||||
// the legacy .env var, then the hardcoded 6.4 default.
|
||||
if forced := settingsForcedUsdtRate(); forced > 0 {
|
||||
return forced
|
||||
}
|
||||
forcedUsdtRate := viper.GetFloat64("forced_usdt_rate")
|
||||
if forcedUsdtRate > 0 {
|
||||
return forcedUsdtRate
|
||||
@@ -340,11 +410,3 @@ func GetSolanaRpcUrl() string {
|
||||
func GetEthereumWsUrl() string {
|
||||
return strings.TrimSpace(viper.GetString("ethereum_ws_url"))
|
||||
}
|
||||
|
||||
func GetEpayPid() int {
|
||||
return viper.GetInt("epay_pid")
|
||||
}
|
||||
|
||||
func GetEpayKey() string {
|
||||
return viper.GetString("epay_key")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package config
|
||||
|
||||
// Settings lookups are injected at runtime by bootstrap (after DB init)
|
||||
// to keep the config package free of a `model/data` import, which would
|
||||
// create a cycle via `model/dao -> config`.
|
||||
//
|
||||
// If unset (e.g. during early startup or tests) the getters return the
|
||||
// zero string / 0 and callers fall through to the .env / default path.
|
||||
|
||||
// SettingsGetString is installed by bootstrap.Init with a closure that
|
||||
// reads from the settings table. Runtime-only.
|
||||
var SettingsGetString func(key string) string
|
||||
|
||||
func settingsRateApiUrl() string {
|
||||
if SettingsGetString == nil {
|
||||
return ""
|
||||
}
|
||||
return SettingsGetString("rate.api_url")
|
||||
}
|
||||
|
||||
func settingsForcedUsdtRate() float64 {
|
||||
if SettingsGetString == nil {
|
||||
return 0
|
||||
}
|
||||
raw := SettingsGetString("rate.forced_usdt_rate")
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
f, err := parseFloat(raw)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return f
|
||||
}
|
||||
Reference in New Issue
Block a user