Files
aiaa-notification-server/cmd/server/main.go
T
ryan b396638438 feat(交易信号): 持仓状态持久化至 Redis 以保障重启与多实例下均价计算准确
Motivation:
持仓与已处理信号的状态此前仅存于进程内存,服务重启或多实例部署后会丢失,导致加仓均价、仓位大小等计算失真,重复信号也无法跨实例幂等。通过将状态持久化到 Redis,保证持仓跟踪跨重启、跨实例连续一致,提升通知内容的准确性与可靠性。

Changes:

* 新增持仓存储抽象,支持内存与 Redis 两种实现,持仓状态与已处理信号快照按 TTL 持久化
* 持仓变更通过 Redis 事务管道原子提交,保证状态更新与幂等记录一致写入
* 存储写入失败时消息进入重试而非直接确认,避免状态丢失导致通知失真
* 缓存层新增原始值读取与批量事务写入能力,并在订阅器初始化时注入 Redis 依赖
* 补充跨实例持久化、幂等去重与存储失败场景的测试覆盖
2026-08-23 14:43:53 +08:00

306 lines
8.6 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"aiaa-notification-service/internal/adapter"
"aiaa-notification-service/internal/cache"
"aiaa-notification-service/internal/config"
"aiaa-notification-service/internal/engine"
"aiaa-notification-service/internal/handler"
"aiaa-notification-service/internal/model"
"aiaa-notification-service/internal/notify"
"aiaa-notification-service/internal/safew"
"aiaa-notification-service/internal/store"
"aiaa-notification-service/internal/subscriber"
"github.com/gin-gonic/gin"
"github.com/logbull/logbull-go/logbull"
)
func main() {
// Load config first (need logbull settings for logger setup)
cfg, err := config.Load("config/config.yaml")
if err != nil {
slog.Error("failed to load config", "error", err)
os.Exit(1)
}
// Setup dual logger: stdout JSON + Logbull remote
stdoutHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})
var logbullHandler slog.Handler
if cfg.Logbull.Host != "" {
lbCfg := logbull.Config{
Host: cfg.Logbull.Host,
ProjectID: cfg.Logbull.ProjectID,
APIKey: cfg.Logbull.APIKey,
}
switch cfg.Logbull.LogLevel {
case "DEBUG":
lbCfg.LogLevel = logbull.DEBUG
case "WARNING":
lbCfg.LogLevel = logbull.WARNING
case "ERROR":
lbCfg.LogLevel = logbull.ERROR
default:
lbCfg.LogLevel = logbull.INFO
}
h, err := logbull.NewSlogHandler(lbCfg)
if err != nil {
slog.Warn("logbull init failed, using stdout only", "error", err)
} else {
logbullHandler = h
}
}
var baseHandler slog.Handler
if logbullHandler != nil {
baseHandler = &teeHandler{handlers: []slog.Handler{stdoutHandler, logbullHandler}}
} else {
baseHandler = stdoutHandler
}
logger := slog.New(baseHandler)
slog.SetDefault(logger)
// Flush logbull on exit
defer func() {
if lbh, ok := logbullHandler.(interface{ Flush() }); ok {
lbh.Flush()
}
if lbh, ok := logbullHandler.(interface{ Shutdown() }); ok {
lbh.Shutdown()
}
time.Sleep(2 * time.Second) // allow async flush
}()
// Connect MySQL
st, err := store.NewStore(cfg.Database)
if err != nil {
slog.Error("failed to connect to MySQL", "error", err)
os.Exit(1)
}
defer st.Close()
// Connect Redis
var redisCache *cache.Cache
if cfg.Redis.Host != "" {
redisCache, err = cache.NewCache(cfg.Redis)
if err != nil {
slog.Warn("redis connection failed, running without cache", "error", err)
redisCache = nil
}
} else {
slog.Warn("redis not configured, running without cache")
}
if redisCache != nil {
defer redisCache.Close()
}
// Build engine
matcher := engine.NewMatcher(st, redisCache)
renderer := engine.NewRenderer()
// DingTalk per-robot rate limit (queue/wait when over 18/min by default)
var dingtalkLimiter *adapter.DingTalkLimiter
limitPerMin := cfg.RateLimit.DingTalkPerMin
if redisCache != nil {
dingtalkLimiter = adapter.NewDingTalkLimiter(redisCache, limitPerMin)
} else {
dingtalkLimiter = adapter.NewMemoryDingTalkLimiter(limitPerMin)
slog.Warn("dingtalk rate limit using in-memory store (single instance only)")
}
// Build sender factory
senderFactory := func(channelType string) (adapter.ChannelSender, error) {
return adapter.NewSender(channelType, &cfg.SMTP, dingtalkLimiter)
}
router := engine.NewRouter(st, redisCache, senderFactory)
// Build handlers
notifySvc := notify.NewService(matcher, st, renderer, router, st)
notifyH := handler.NewNotifyHandler(notifySvc)
sourceH := handler.NewSourceHandler(st, redisCache)
templateH := handler.NewTemplateHandler(st, redisCache)
poller := func(token string, offset int64, timeout int) ([]adapter.SafewChat, int64, error) {
return (&adapter.SafeWSender{}).PollGroupChats(token, offset, timeout)
}
var watcherStore safew.ChatStore
if redisCache != nil {
watcherStore = redisCache
} else {
watcherStore = safew.NewMemStore()
slog.Warn("safew chats using in-memory store")
}
safewWatcher := safew.NewWatcher(watcherStore, poller)
defer safewWatcher.Stop()
if tokens, err := st.ListSafewTokens(context.Background()); err != nil {
slog.Warn("list safew tokens", "error", err)
} else {
safewWatcher.StartTokens(tokens)
slog.Info("safew watchers ensured", "count", len(tokens))
}
channelH := handler.NewChannelHandler(st, redisCache, safewWatcher)
ruleH := handler.NewRuleHandler(st, redisCache)
msgLogH := handler.NewMessageLogHandler(st)
// Setup Gin
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(gin.Recovery())
// Health check
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
api := r.Group("/api/v1")
// Notify endpoint (source auth + rate limit)
notifyGroup := api.Group("/notify")
notifyGroup.Use(handler.SourceAuth(st))
if redisCache != nil {
notifyGroup.Use(handler.RateLimit(redisCache, cfg.RateLimit.Default))
}
notifyGroup.POST("", notifyH.Handle)
// Management endpoints (admin auth)
admin := api.Group("")
admin.Use(handler.AdminAuth(cfg.Server.AdminKey))
// Sources
admin.POST("/sources", sourceH.Create)
admin.GET("/sources", sourceH.List)
admin.GET("/sources/:id", sourceH.Get)
admin.PUT("/sources/:id", sourceH.Update)
admin.DELETE("/sources/:id", sourceH.Delete)
// Templates
admin.POST("/templates", templateH.Create)
admin.GET("/templates", templateH.List)
admin.GET("/templates/:id", templateH.Get)
admin.PUT("/templates/:id", templateH.Update)
admin.DELETE("/templates/:id", templateH.Delete)
// Channels
admin.POST("/channels/safew/chats", channelH.ListSafewChats)
admin.GET("/channels/:id/chats", channelH.ListChannelSafewChats)
admin.POST("/channels", channelH.Create)
admin.GET("/channels", channelH.List)
admin.GET("/channels/:id", channelH.Get)
admin.PUT("/channels/:id", channelH.Update)
admin.DELETE("/channels/:id", channelH.Delete)
// Rules
admin.POST("/rules", ruleH.Create)
admin.GET("/rules", ruleH.List)
admin.GET("/rules/:id", ruleH.Get)
admin.PUT("/rules/:id", ruleH.Update)
admin.DELETE("/rules/:id", ruleH.Delete)
admin.PATCH("/rules/:id/enable", ruleH.Enable)
admin.PATCH("/rules/:id/disable", ruleH.Disable)
admin.PATCH("/rules/:id/channels/:channel_id/enable", ruleH.EnableChannel)
admin.PATCH("/rules/:id/channels/:channel_id/disable", ruleH.DisableChannel)
// Message Logs
admin.GET("/message-logs", msgLogH.List)
// Start server
srv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
Handler: r,
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
lookup := func(ctx context.Context, name string) (*model.Source, error) {
return st.GetSourceByName(ctx, name)
}
deduper := subscriber.NewCacheDeduper(redisCache, cfg.SubscriptionDedupTTL)
for _, sub := range cfg.ActiveSubscriptions() {
sub := sub
cons, err := subscriber.New(sub, lookup, notifySvc.Process, deduper, redisCache)
if err != nil {
slog.Error("subscriber init", "name", sub.Name, "error", err)
os.Exit(1)
}
go func() {
if err := cons.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
slog.Error("subscriber stopped", "name", sub.Name, "error", err)
}
}()
slog.Info("subscriber started", "name", sub.Name, "queue", sub.Queue, "source", sub.Source)
}
go func() {
slog.Info("server starting", "port", cfg.Server.Port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("server error", "error", err)
os.Exit(1)
}
}()
<-ctx.Done()
slog.Info("shutting down...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
slog.Error("forced shutdown", "error", err)
}
slog.Info("server stopped")
}
// teeHandler fans out log records to multiple handlers (stdout + remote).
type teeHandler struct {
handlers []slog.Handler
}
func (t *teeHandler) Enabled(ctx context.Context, level slog.Level) bool {
for _, h := range t.handlers {
if h.Enabled(ctx, level) {
return true
}
}
return false
}
func (t *teeHandler) Handle(ctx context.Context, r slog.Record) error {
for _, h := range t.handlers {
if h.Enabled(ctx, r.Level) {
if err := h.Handle(ctx, r.Clone()); err != nil {
return err
}
}
}
return nil
}
func (t *teeHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
handlers := make([]slog.Handler, len(t.handlers))
for i, h := range t.handlers {
handlers[i] = h.WithAttrs(attrs)
}
return &teeHandler{handlers: handlers}
}
func (t *teeHandler) WithGroup(name string) slog.Handler {
handlers := make([]slog.Handler, len(t.handlers))
for i, h := range t.handlers {
handlers[i] = h.WithGroup(name)
}
return &teeHandler{handlers: handlers}
}