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:
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user