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
+118
View File
@@ -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)
}
}