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