feat: subscribe to RabbitMQ trade signals and notify by rules

Consume configurable queues, format signals (including period), share NotifyService with HTTP, and drop duplicate bodies within 1h.
This commit is contained in:
2026-08-15 17:34:49 +08:00
parent 1f4fe2fb75
commit 6f846a0a3c
25 changed files with 3129 additions and 114 deletions
+19 -101
View File
@@ -1,47 +1,34 @@
package handler
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"strconv"
"strings"
"aiaa-notification-service/internal/cache"
"aiaa-notification-service/internal/condition"
"aiaa-notification-service/internal/engine"
"aiaa-notification-service/internal/model"
"aiaa-notification-service/internal/notify"
"aiaa-notification-service/internal/parser"
"aiaa-notification-service/internal/store"
"github.com/gin-gonic/gin"
)
type NotifyHandler struct {
store *store.Store
cache *cache.Cache // used by engine
matcher *engine.Matcher
renderer *engine.Renderer
router *engine.Router
svc *notify.Service
}
func NewNotifyHandler(s *store.Store, c *cache.Cache, m *engine.Matcher, r *engine.Renderer, rt *engine.Router) *NotifyHandler {
return &NotifyHandler{store: s, cache: c, matcher: m, renderer: r, router: rt}
func NewNotifyHandler(svc *notify.Service) *NotifyHandler {
return &NotifyHandler{svc: svc}
}
func (h *NotifyHandler) Handle(c *gin.Context) {
src := c.MustGet("source").(*model.Source)
// 1. Read raw body
body, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "read body failed"})
return
}
// 2. Parse message
p, err := parser.NewParser(src.ParseMode, src.ParsePattern)
if err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "parser setup: " + err.Error()})
@@ -53,93 +40,24 @@ func (h *NotifyHandler) Handle(c *gin.Context) {
return
}
// 3. Match rule
rule, err := h.matcher.Match(c.Request.Context(), src.ID, msg.Event)
res, err := h.svc.Process(c.Request.Context(), notify.Request{
Source: src, Event: msg.Event, Data: msg.Data,
})
if err != nil {
// No matching rule → 200 with matched: false
if errors.Is(err, notify.ErrUnprocessable) {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if !res.Matched {
c.JSON(http.StatusOK, gin.H{"matched": false})
return
}
// 4. Evaluate conditions
if rule.Conditions != nil {
var conds []model.Condition
if err := json.Unmarshal(*rule.Conditions, &conds); err != nil {
slog.Error("failed to unmarshal rule conditions", "rule_id", rule.ID, "error", err)
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid rule conditions"})
return
}
if !condition.Evaluate(conds, msg.Data) {
c.JSON(http.StatusOK, gin.H{
"matched": true,
"filtered": true,
"reason": "condition not met",
})
return
}
}
// 5. Get template content
tmpl, err := h.store.GetTemplate(c.Request.Context(), rule.TemplateID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "template not found"})
if res.Filtered {
c.JSON(http.StatusOK, gin.H{"matched": true, "filtered": true, "reason": res.Reason})
return
}
// 6. Render template
content, err := h.renderer.Render(tmpl.Content, msg.Data)
if err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "template render failed: " + err.Error()})
return
}
// 7. Route to channels
title := src.Name + ": " + msg.Event
channels := h.router.Route(c.Request.Context(), rule, title, content)
// 8. Log message (best effort, detached context)
go func() {
payloadJSON, _ := json.Marshal(msg.Data)
ctx := context.Background()
for _, chName := range channels {
chID := parseChannelID(chName)
ml := &model.MessageLog{
RuleID: rule.ID,
ChannelID: chID,
Source: src.Name,
Event: msg.Event,
Payload: payloadJSON,
Content: content,
Status: "pending",
}
if err := h.store.CreateMessageLog(ctx, ml); err != nil {
slog.Warn("failed to create message log", "error", err)
}
}
}()
slog.Info("notification accepted",
"source", src.Name,
"event", msg.Event,
"channels", channels,
)
c.JSON(http.StatusOK, gin.H{
"matched": true,
"channels": channels,
"accepted": true,
})
}
// parseChannelID extracts the numeric channel ID from a channel name formatted as "type:id".
func parseChannelID(chName string) int {
idx := strings.LastIndex(chName, ":")
if idx < 0 || idx == len(chName)-1 {
return 0
}
id, err := strconv.Atoi(chName[idx+1:])
if err != nil {
return 0
}
return id
c.JSON(http.StatusOK, gin.H{"matched": true, "channels": res.Channels, "accepted": true})
}