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
+70
View File
@@ -0,0 +1,70 @@
// Package notify fans out domain events (order paid, order expired,
// etc.) to every NotificationChannel subscribed to the event. Senders
// are type-keyed (telegram/webhook/email/...) so adding a new push
// method means registering one function; no callers change.
package notify
import (
"encoding/json"
"sync"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/util/log"
)
// Sender delivers an already-rendered text message via a channel row.
// Config is the channel's Config JSON string (type-specific shape).
type Sender func(config, text string) error
var (
sendersMu sync.RWMutex
senders = map[string]Sender{}
)
// RegisterSender wires a Sender for a channel type. Called once at
// package init of each sender file (e.g. telegram_sender.go).
func RegisterSender(channelType string, s Sender) {
sendersMu.Lock()
senders[channelType] = s
sendersMu.Unlock()
}
func getSender(channelType string) (Sender, bool) {
sendersMu.RLock()
defer sendersMu.RUnlock()
s, ok := senders[channelType]
return s, ok
}
// Dispatch delivers text to every enabled channel subscribed to event.
// Per-channel failures are logged but don't abort the fan-out — one
// broken TG token shouldn't prevent webhook delivery.
func Dispatch(event, text string) {
channels, err := data.ListEnabledChannelsByEvent(event)
if err != nil {
log.Sugar.Errorf("[notify] list channels failed: %v", err)
return
}
for _, ch := range channels {
sender, ok := getSender(ch.Type)
if !ok {
log.Sugar.Warnf("[notify] no sender registered for type=%s (channel_id=%d)", ch.Type, ch.ID)
continue
}
go func(c mdb.NotificationChannel) {
if err := sender(c.Config, text); err != nil {
log.Sugar.Errorf("[notify] send failed type=%s channel_id=%d: %v", c.Type, c.ID, err)
}
}(ch)
}
}
// ParseConfig helper for senders: unmarshal channel Config JSON into
// an arbitrary struct, returning a typed error on invalid JSON.
func ParseConfig(raw string, out interface{}) error {
if raw == "" {
return ErrEmptyConfig
}
return json.Unmarshal([]byte(raw), out)
}
+8
View File
@@ -0,0 +1,8 @@
package notify
import "errors"
var (
ErrNoSender = errors.New("no sender registered for channel type")
ErrEmptyConfig = errors.New("channel config is empty")
)
+147
View File
@@ -0,0 +1,147 @@
package notify
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"sync"
"time"
tb "gopkg.in/telebot.v3"
)
// TelegramConfig mirrors the Config JSON of a telegram channel row.
type TelegramConfig struct {
BotToken string `json:"bot_token"`
ChatID int64 `json:"chat_id"`
Proxy string `json:"proxy"`
}
func init() {
RegisterSender("telegram", sendTelegram)
}
// botCache caches *tb.Bot instances keyed by bot_token so we don't
// create a new Bot (and call getMe) on every notification.
var (
botCacheMu sync.RWMutex
botCache = map[string]*tb.Bot{}
)
func getOrCreateBot(cfg *TelegramConfig) (*tb.Bot, error) {
botCacheMu.RLock()
bot, ok := botCache[cfg.BotToken]
botCacheMu.RUnlock()
if ok {
return bot, nil
}
settings := tb.Settings{
Token: cfg.BotToken,
Poller: &tb.LongPoller{Timeout: 1 * time.Second},
Synchronous: true,
Offline: true,
}
if cfg.Proxy != "" {
settings.URL = cfg.Proxy
}
bot, err := tb.NewBot(settings)
if err != nil {
return nil, err
}
botCacheMu.Lock()
botCache[cfg.BotToken] = bot
botCacheMu.Unlock()
return bot, nil
}
// ParseTelegramConfig decodes telegram channel config and supports
// chat_id in either numeric or string form so API clients can send
// both JSON number and quoted text.
func ParseTelegramConfig(raw string) (*TelegramConfig, error) {
if strings.TrimSpace(raw) == "" {
return nil, ErrEmptyConfig
}
var payload map[string]interface{}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return nil, err
}
cfg := &TelegramConfig{}
cfg.BotToken = pickFirstString(payload, "bot_token", "botToken", "bot-token")
if cfg.BotToken == "" {
return nil, fmt.Errorf("telegram config.bot_token required")
}
chatRaw, ok := pickFirstValue(payload, "chat_id", "chatId", "chat-id")
if !ok {
return nil, fmt.Errorf("telegram config.chat_id required")
}
chatID, err := parseTelegramChatID(chatRaw)
if err != nil {
return nil, err
}
cfg.ChatID = chatID
cfg.Proxy = pickFirstString(payload, "proxy", "proxy_url", "proxyUrl")
return cfg, nil
}
func pickFirstString(payload map[string]interface{}, keys ...string) string {
for _, k := range keys {
if v, ok := payload[k].(string); ok {
s := strings.TrimSpace(v)
if s != "" {
return s
}
}
}
return ""
}
func pickFirstValue(payload map[string]interface{}, keys ...string) (interface{}, bool) {
for _, k := range keys {
if v, ok := payload[k]; ok {
return v, true
}
}
return nil, false
}
func parseTelegramChatID(v interface{}) (int64, error) {
switch t := v.(type) {
case float64:
return int64(t), nil
case json.Number:
return t.Int64()
case string:
s := strings.TrimSpace(t)
if s == "" {
return 0, fmt.Errorf("telegram config.chat_id required")
}
id, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return 0, fmt.Errorf("telegram config.chat_id invalid: %w", err)
}
return id, nil
default:
return 0, fmt.Errorf("telegram config.chat_id invalid type")
}
}
func sendTelegram(configJSON, text string) error {
cfg, err := ParseTelegramConfig(configJSON)
if err != nil {
return err
}
bot, err := getOrCreateBot(cfg)
if err != nil {
return err
}
_, err = bot.Send(&tb.User{ID: cfg.ChatID}, text, &tb.SendOptions{ParseMode: tb.ModeHTML})
return err
}
+53
View File
@@ -0,0 +1,53 @@
package notify
import "testing"
func TestParseTelegramConfigAcceptsNumericChatID(t *testing.T) {
raw := `{"bot_token":"123:ABC","chat_id":123456789}`
cfg, err := ParseTelegramConfig(raw)
if err != nil {
t.Fatalf("parse config: %v", err)
}
if cfg.BotToken != "123:ABC" {
t.Fatalf("bot token = %q, want %q", cfg.BotToken, "123:ABC")
}
if cfg.ChatID != 123456789 {
t.Fatalf("chat id = %d, want %d", cfg.ChatID, 123456789)
}
}
func TestParseTelegramConfigAcceptsStringChatID(t *testing.T) {
raw := `{"bot_token":"123:ABC","chat_id":"-1001234567890"}`
cfg, err := ParseTelegramConfig(raw)
if err != nil {
t.Fatalf("parse config: %v", err)
}
if cfg.ChatID != -1001234567890 {
t.Fatalf("chat id = %d, want %d", cfg.ChatID, int64(-1001234567890))
}
}
func TestParseTelegramConfigRejectsInvalidChatID(t *testing.T) {
raw := `{"bot_token":"123:ABC","chat_id":"not-a-number"}`
_, err := ParseTelegramConfig(raw)
if err == nil {
t.Fatal("expected parse error for invalid chat_id")
}
}
func TestParseTelegramConfigAcceptsCamelCaseKeys(t *testing.T) {
raw := `{"botToken":"123:ABC","chatId":"-1001234567890","proxyUrl":"http://127.0.0.1:7890"}`
cfg, err := ParseTelegramConfig(raw)
if err != nil {
t.Fatalf("parse config: %v", err)
}
if cfg.BotToken != "123:ABC" {
t.Fatalf("bot token = %q, want %q", cfg.BotToken, "123:ABC")
}
if cfg.ChatID != -1001234567890 {
t.Fatalf("chat id = %d, want %d", cfg.ChatID, int64(-1001234567890))
}
if cfg.Proxy != "http://127.0.0.1:7890" {
t.Fatalf("proxy = %q, want %q", cfg.Proxy, "http://127.0.0.1:7890")
}
}