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
+130
View File
@@ -0,0 +1,130 @@
package notify
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strconv"
"strings"
"aiaa-notification-service/internal/condition"
"aiaa-notification-service/internal/engine"
"aiaa-notification-service/internal/model"
)
var ErrUnprocessable = errors.New("unprocessable")
type Request struct {
Source *model.Source
Event string
Data map[string]interface{}
}
type Result struct {
Matched bool
Filtered bool
Channels []string
Reason string
}
type RuleMatcher interface {
Match(ctx context.Context, sourceID int, event string) (*model.Rule, error)
}
type TemplateStore interface {
GetTemplate(ctx context.Context, id int) (*model.Template, error)
}
type ChannelRouter interface {
Route(ctx context.Context, rule *model.Rule, title, content string) []string
}
type MessageLogger interface {
CreateMessageLog(ctx context.Context, ml *model.MessageLog) error
}
type Service struct {
matcher RuleMatcher
templates TemplateStore
renderer *engine.Renderer
router ChannelRouter
logs MessageLogger
}
func NewService(m RuleMatcher, t TemplateStore, r *engine.Renderer, rt ChannelRouter, logs MessageLogger) *Service {
return &Service{matcher: m, templates: t, renderer: r, router: rt, logs: logs}
}
func (s *Service) Process(ctx context.Context, req Request) (Result, error) {
rule, err := s.matcher.Match(ctx, req.Source.ID, req.Event)
if err != nil {
return Result{Matched: false}, nil
}
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)
return Result{}, fmt.Errorf("%w: invalid rule conditions", ErrUnprocessable)
}
if !condition.Evaluate(conds, req.Data) {
return Result{Matched: true, Filtered: true, Reason: "condition not met"}, nil
}
}
tmpl, err := s.templates.GetTemplate(ctx, rule.TemplateID)
if err != nil {
return Result{}, fmt.Errorf("template not found")
}
content, err := s.renderer.Render(tmpl.Content, req.Data)
if err != nil {
return Result{}, fmt.Errorf("%w: template render failed: %s", ErrUnprocessable, err.Error())
}
title := req.Source.Name + ": " + req.Event
channels := s.router.Route(ctx, rule, title, content)
if s.logs != nil {
go func() {
payloadJSON, _ := json.Marshal(req.Data)
logCtx := context.Background()
for _, chName := range channels {
ml := &model.MessageLog{
RuleID: rule.ID,
ChannelID: parseChannelID(chName),
Source: req.Source.Name,
Event: req.Event,
Payload: payloadJSON,
Content: content,
Status: "pending",
}
if err := s.logs.CreateMessageLog(logCtx, ml); err != nil {
slog.Warn("failed to create message log", "error", err)
}
}
}()
}
slog.Info("notification accepted",
"source", req.Source.Name,
"event", req.Event,
"channels", channels,
)
return Result{Matched: true, Channels: channels}, nil
}
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
}