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:
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"aiaa-notification-service/internal/engine"
|
||||
"aiaa-notification-service/internal/model"
|
||||
)
|
||||
|
||||
type fakeMatcher struct {
|
||||
rule *model.Rule
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeMatcher) Match(context.Context, int, string) (*model.Rule, error) {
|
||||
return f.rule, f.err
|
||||
}
|
||||
|
||||
type fakeTemplates struct {
|
||||
tmpl *model.Template
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeTemplates) GetTemplate(context.Context, int) (*model.Template, error) {
|
||||
return f.tmpl, f.err
|
||||
}
|
||||
|
||||
type fakeRouter struct{ channels []string }
|
||||
|
||||
func (f *fakeRouter) Route(context.Context, *model.Rule, string, string) []string {
|
||||
return f.channels
|
||||
}
|
||||
|
||||
type fakeLogs struct{}
|
||||
|
||||
func (f *fakeLogs) CreateMessageLog(context.Context, *model.MessageLog) error { return nil }
|
||||
|
||||
func newSvc(m *fakeMatcher, t *fakeTemplates, rt *fakeRouter) *Service {
|
||||
return NewService(m, t, engine.NewRenderer(), rt, &fakeLogs{})
|
||||
}
|
||||
|
||||
func TestProcessNoRule(t *testing.T) {
|
||||
svc := newSvc(&fakeMatcher{err: errors.New("no rule")}, &fakeTemplates{}, &fakeRouter{})
|
||||
res, err := svc.Process(context.Background(), Request{
|
||||
Source: &model.Source{ID: 1, Name: "trade-signal"},
|
||||
Event: "trade.open",
|
||||
Data: map[string]interface{}{"symbol": "BTCUSDT"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Matched {
|
||||
t.Fatal("expected unmatched")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFiltered(t *testing.T) {
|
||||
raw := json.RawMessage(`[{"field":"symbol","op":"eq","value":"ETHUSDT"}]`)
|
||||
svc := newSvc(&fakeMatcher{rule: &model.Rule{ID: 1, TemplateID: 1, Conditions: &raw}}, &fakeTemplates{}, &fakeRouter{})
|
||||
res, err := svc.Process(context.Background(), Request{
|
||||
Source: &model.Source{ID: 1, Name: "trade-signal"},
|
||||
Event: "trade.open",
|
||||
Data: map[string]interface{}{"symbol": "BTCUSDT"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !res.Matched || !res.Filtered || res.Reason != "condition not met" {
|
||||
t.Fatalf("%+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessMatched(t *testing.T) {
|
||||
svc := newSvc(
|
||||
&fakeMatcher{rule: &model.Rule{ID: 9, TemplateID: 1}},
|
||||
&fakeTemplates{tmpl: &model.Template{ID: 1, Content: "{{.formatted}}"}},
|
||||
&fakeRouter{channels: []string{"dingtalk:3"}},
|
||||
)
|
||||
res, err := svc.Process(context.Background(), Request{
|
||||
Source: &model.Source{ID: 1, Name: "trade-signal"},
|
||||
Event: "trade.open",
|
||||
Data: map[string]interface{}{"formatted": "多单开仓"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !res.Matched || res.Filtered || len(res.Channels) != 1 || res.Channels[0] != "dingtalk:3" {
|
||||
t.Fatalf("%+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessInvalidConditions(t *testing.T) {
|
||||
raw := json.RawMessage(`not-json`)
|
||||
svc := newSvc(&fakeMatcher{rule: &model.Rule{ID: 1, TemplateID: 1, Conditions: &raw}}, &fakeTemplates{}, &fakeRouter{})
|
||||
_, err := svc.Process(context.Background(), Request{
|
||||
Source: &model.Source{ID: 1, Name: "s"},
|
||||
Event: "e",
|
||||
Data: map[string]interface{}{},
|
||||
})
|
||||
if !errors.Is(err, ErrUnprocessable) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessTemplateMissing(t *testing.T) {
|
||||
svc := newSvc(&fakeMatcher{rule: &model.Rule{ID: 1, TemplateID: 1}}, &fakeTemplates{err: errors.New("nope")}, &fakeRouter{})
|
||||
_, err := svc.Process(context.Background(), Request{
|
||||
Source: &model.Source{ID: 1, Name: "s"},
|
||||
Event: "e",
|
||||
Data: map[string]interface{}{},
|
||||
})
|
||||
if err == nil || errors.Is(err, ErrUnprocessable) {
|
||||
t.Fatalf("want retryable error, got %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user