Consume configurable queues, format signals (including period), share NotifyService with HTTP, and drop duplicate bodies within 1h.
37 KiB
RabbitMQ Subscriber Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 增加可配置 RabbitMQ 订阅:消费交易信号、格式化后走内部 NotifyService 按规则发到各渠道。
Architecture: 从现有 /notify 抽出 notify.Service.Process。subscriber 声明/绑定/重连/重试/DLQ,把 body 交给 tradesignal.Converter(覆盖 → 均价 → 格式化),再 Process。HTTP 与 MQ 共用同一入口。
Tech Stack: Go 1.22+、Viper、github.com/rabbitmq/amqp091-go、stdlib testing。不连真实 broker。
Global Constraints
- Spec:
docs/superpowers/specs/2026-08-15-rabbitmq-subscriber-design.md - 每条订阅绑一个 Source 名;
event = "trade." + strings.ToLower(action) data用覆盖后的信号字段(camelCase)+formatted;有均价才写avgPriceperiod原样使用;非空则在「交易品种」后输出周期: {period}- AMQP URL 只用
${RABBITMQ_URL},禁止把账号写进任何文件 - 未知
formatter在配置规范化时失败;空则默认trade_signal max_retry<=0→ 3;exchange_type空 →fanout;name空 → 用queueurl为空的订阅不启动;全部未启动时 HTTP 不受影响- JSON 无效 / 缺
action/ Source 不存在或禁用 / 无规则 / 条件未过 /ErrUnprocessable→ Ack,不进 DLQ Process其它 error → 重投;超过max_retry→ DLQ(未配置则丢弃 Ack)- 不移植钉钉直发、按目标过滤、
x-dingtalk-sent - 不写连真实 CloudAMQP 的测试
- 不新增管理 API 或表
- 用户未明确要求时不要 commit
File Structure
| File | Responsibility |
|---|---|
internal/config/config.go |
SubscriptionConfig、StrategyOverride、NormalizeSubscriptions、ActiveSubscriptions |
internal/config/config_test.go |
默认值、跳过空 URL、未知 formatter |
config/config.yaml |
第一条订阅,URL 为 ${RABBITMQ_URL} |
internal/notify/service.go |
Process、ErrUnprocessable、parseChannelID |
internal/notify/service_test.go |
无规则 / 过滤 / 命中 / 不可处理 / 内部错误 |
internal/handler/notify.go |
解析 body 后调用 Process |
cmd/server/main.go |
构造 notify.Service;Task 7 再启动订阅 |
internal/subscriber/tradesignal/signal.go |
Signal、ParsedEventTime |
internal/subscriber/tradesignal/format.go |
文案格式化(含周期) |
internal/subscriber/tradesignal/format_test.go |
开/平/加/减、周期、均价 |
internal/subscriber/tradesignal/override.go |
数量倍数、杠杆覆盖 |
internal/subscriber/tradesignal/position.go |
进程内均价 |
internal/subscriber/tradesignal/transform_test.go |
覆盖 + 均价 |
internal/subscriber/tradesignal/convert.go |
Convert(body) (event, data, error) |
internal/subscriber/tradesignal/convert_test.go |
event / formatted / period / 无效消息 |
internal/subscriber/handle.go |
HandleMessage、DecideRetry(无 AMQP) |
internal/subscriber/handle_test.go |
Ack / 重试 / DLQ |
internal/subscriber/subscriber.go |
Run:连 MQ、声明、消费、重连 |
README.md |
订阅配置说明 |
go.mod |
增加 amqp091-go |
Task 1: 订阅配置
Files:
- Modify:
internal/config/config.go - Create:
internal/config/config_test.go - Modify:
config/config.yaml
Interfaces:
-
Produces:
type QuantityMultipliers struct { Open, Add, Reduce, Close float64 }tagsopen,add,reduce,closetype StrategyOverride struct { QuantityMultipliers QuantityMultipliers; Leverage *int }func (o StrategyOverride) QuantityMultiplierFor(action string) float64—<=0视为 1type SubscriptionConfig字段见下方func (c *Config) NormalizeSubscriptions() errorfunc (c *Config) ActiveSubscriptions() []SubscriptionConfig— 仅url != ""Load在 Unmarshal 之后调用NormalizeSubscriptions
-
Step 1: Write the failing test
Create internal/config/config_test.go:
package config
import "testing"
func TestNormalizeSubscriptionDefaults(t *testing.T) {
cfg := &Config{Subscriptions: []SubscriptionConfig{{
URL: "amqps://example.invalid/vhost",
Queue: "trade.signal.notify.queue",
Source: "trade-signal",
}}}
if err := cfg.NormalizeSubscriptions(); err != nil {
t.Fatal(err)
}
s := cfg.Subscriptions[0]
if s.Name != "trade.signal.notify.queue" {
t.Fatalf("name=%q", s.Name)
}
if s.MaxRetry != 3 {
t.Fatalf("max_retry=%d", s.MaxRetry)
}
if s.ExchangeType != "fanout" {
t.Fatalf("exchange_type=%q", s.ExchangeType)
}
if s.Formatter != "trade_signal" {
t.Fatalf("formatter=%q", s.Formatter)
}
}
func TestNormalizeSkipsEmptyURL(t *testing.T) {
cfg := &Config{Subscriptions: []SubscriptionConfig{{
Queue: "q", Source: "s",
}}}
if err := cfg.NormalizeSubscriptions(); err != nil {
t.Fatal(err)
}
if n := len(cfg.ActiveSubscriptions()); n != 0 {
t.Fatalf("active=%d", n)
}
}
func TestNormalizeUnknownFormatter(t *testing.T) {
cfg := &Config{Subscriptions: []SubscriptionConfig{{
URL: "amqps://example.invalid/vhost", Queue: "q", Source: "s", Formatter: "other",
}}}
if err := cfg.NormalizeSubscriptions(); err == nil {
t.Fatal("expected error")
}
}
func TestQuantityMultiplierFor(t *testing.T) {
o := StrategyOverride{QuantityMultipliers: QuantityMultipliers{Open: 100, Add: 0}}
if o.QuantityMultiplierFor("OPEN") != 100 {
t.Fatalf("open=%v", o.QuantityMultiplierFor("OPEN"))
}
if o.QuantityMultiplierFor("ADD") != 1 {
t.Fatalf("add<=0 should be 1, got %v", o.QuantityMultiplierFor("ADD"))
}
if o.QuantityMultiplierFor("UNKNOWN") != 1 {
t.Fatalf("unknown=%v", o.QuantityMultiplierFor("UNKNOWN"))
}
}
- Step 2: Run test to verify it fails
Run: go test ./internal/config/ -count=1
Expected: FAIL — SubscriptionConfig / NormalizeSubscriptions undefined
- Step 3: Implement config types and normalize
In internal/config/config.go, add to Config:
Subscriptions []SubscriptionConfig `mapstructure:"subscriptions"`
Add types and methods:
type SubscriptionConfig struct {
Name string `mapstructure:"name"`
URL string `mapstructure:"url"`
Queue string `mapstructure:"queue"`
DeadLetterQueue string `mapstructure:"dead_letter_queue"`
Exchange string `mapstructure:"exchange"`
ExchangeType string `mapstructure:"exchange_type"`
RoutingKey string `mapstructure:"routing_key"`
MaxRetry int `mapstructure:"max_retry"`
Source string `mapstructure:"source"`
Formatter string `mapstructure:"formatter"`
StrategyOverrides map[string]StrategyOverride `mapstructure:"strategy_overrides"`
}
type StrategyOverride struct {
QuantityMultipliers QuantityMultipliers `mapstructure:"quantity_multipliers"`
Leverage *int `mapstructure:"leverage"`
}
type QuantityMultipliers struct {
Open float64 `mapstructure:"open"`
Add float64 `mapstructure:"add"`
Reduce float64 `mapstructure:"reduce"`
Close float64 `mapstructure:"close"`
}
func (o StrategyOverride) QuantityMultiplierFor(action string) float64 {
var v float64
switch strings.ToUpper(action) {
case "OPEN":
v = o.QuantityMultipliers.Open
case "ADD":
v = o.QuantityMultipliers.Add
case "REDUCE":
v = o.QuantityMultipliers.Reduce
case "CLOSE":
v = o.QuantityMultipliers.Close
default:
return 1
}
if v <= 0 {
return 1
}
return v
}
func (c *Config) NormalizeSubscriptions() error {
for i := range c.Subscriptions {
s := &c.Subscriptions[i]
if s.URL == "" {
continue
}
if s.Queue == "" {
return fmt.Errorf("subscriptions[%d]: queue is required", i)
}
if s.Source == "" {
return fmt.Errorf("subscriptions[%d]: source is required", i)
}
if s.Name == "" {
s.Name = s.Queue
}
if s.MaxRetry <= 0 {
s.MaxRetry = 3
}
if s.ExchangeType == "" {
s.ExchangeType = "fanout"
}
if s.Formatter == "" {
s.Formatter = "trade_signal"
}
if s.Formatter != "trade_signal" {
return fmt.Errorf("subscriptions[%d]: unknown formatter %q", i, s.Formatter)
}
}
return nil
}
func (c *Config) ActiveSubscriptions() []SubscriptionConfig {
out := make([]SubscriptionConfig, 0, len(c.Subscriptions))
for _, s := range c.Subscriptions {
if s.URL != "" {
out = append(out, s)
}
}
return out
}
In Load, after v.Unmarshal(&cfg):
if err := cfg.NormalizeSubscriptions(); err != nil {
return nil, err
}
Append to config/config.yaml(不要写入真实账号):
subscriptions:
- name: trade-signal
url: "${RABBITMQ_URL}"
queue: trade.signal.notify.queue
dead_letter_queue: trade.signal.notify.dlq
exchange: trade.signal.executor.queue
exchange_type: fanout
routing_key: ""
max_retry: 3
source: trade-signal
formatter: trade_signal
strategy_overrides:
BLONG:
quantity_multipliers:
open: 100
add: 100
reduce: 100
close: 100
leverage: 100
- Step 4: Run tests
Run: go test ./internal/config/ -count=1
Expected: PASS
- Step 5: Commit(仅当用户要求)
git add internal/config/config.go internal/config/config_test.go config/config.yaml
git commit -m "feat: add configurable RabbitMQ subscription settings"
Task 2: 抽出 NotifyService
Files:
- Create:
internal/notify/service.go - Create:
internal/notify/service_test.go - Modify:
internal/handler/notify.go - Modify:
cmd/server/main.go
Interfaces:
-
Consumes: 现有
engine.Matcher.Match、engine.Renderer.Render、engine.Router.Route、store.GetTemplate、store.CreateMessageLog、condition.Evaluate -
Produces:
var ErrUnprocessable errortype 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 }func NewService(m RuleMatcher, t TemplateStore, r *engine.Renderer, rt ChannelRouter, logs MessageLogger) *Servicefunc (s *Service) Process(ctx context.Context, req Request) (Result, error)- 标题:
req.Source.Name + ": " + req.Event - 无规则(Match error)→
Result{Matched:false}, nil - 条件 JSON 坏 / 渲染失败 →
fmt.Errorf("%w: ...", ErrUnprocessable) - 模板查找失败 → 普通 error(可重试)
NewNotifyHandler(svc *notify.Service)
-
Step 1: Write the failing tests
Create internal/notify/service_test.go:
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)
}
}
- Step 2: Run test to verify it fails
Run: go test ./internal/notify/ -count=1
Expected: FAIL — package / types undefined
- Step 3: Implement Service and thin handler
Create internal/notify/service.go:把 handler.Handle 的步骤 3–8 搬过来。条件 JSON 坏、渲染失败用 fmt.Errorf("%w: %s", ErrUnprocessable, msg)。模板找不到返回普通 error(文案 template not found)。message_log 仍异步 go。parseChannelID 移到本文件。
NewService 允许 logs == nil(不写 log)。
改 internal/handler/notify.go:
type NotifyHandler struct {
svc *notify.Service
}
func NewNotifyHandler(svc *notify.Service) *NotifyHandler {
return &NotifyHandler{svc: svc}
}
func (h *NotifyHandler) Handle(c *gin.Context) {
src := c.MustGet("source").(*model.Source)
body, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "read body failed"})
return
}
p, err := parser.NewParser(src.ParseMode, src.ParsePattern)
if err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "parser setup: " + err.Error()})
return
}
msg, err := p.Parse(body)
if err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "parse failed: " + err.Error()})
return
}
res, err := h.svc.Process(c.Request.Context(), notify.Request{
Source: src, Event: msg.Event, Data: msg.Data,
})
if err != nil {
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
}
if res.Filtered {
c.JSON(http.StatusOK, gin.H{"matched": true, "filtered": true, "reason": res.Reason})
return
}
c.JSON(http.StatusOK, gin.H{"matched": true, "channels": res.Channels, "accepted": true})
}
cmd/server/main.go 在构造 router 之后:
notifySvc := notify.NewService(matcher, st, renderer, router, st)
notifyH := handler.NewNotifyHandler(notifySvc)
删除未用 import。
- Step 4: Run tests
Run: go test ./internal/notify/ ./internal/handler/ ./internal/engine/ -count=1
Expected: PASS;go build ./cmd/server 成功
- Step 5: Commit(仅当用户要求)
git add internal/notify internal/handler/notify.go cmd/server/main.go
git commit -m "refactor: extract shared NotifyService from HTTP handler"
Task 3: 交易信号格式化(含周期)
Files:
- Create:
internal/subscriber/tradesignal/signal.go - Create:
internal/subscriber/tradesignal/format.go - Create:
internal/subscriber/tradesignal/format_test.go
Interfaces:
-
Produces:
type Signal— JSON 标签与参考项目一致(signalId、strategyCode、period等)func (s *Signal) ParsedEventTime() time.Timetype FormatOptions struct { AvgPrice *float64 }func Format(signal *Signal, opts ...FormatOptions) string- 在
交易品种下一行:periodtrim 后非空则周期: {period}
-
Step 1: Write the failing tests
Create internal/subscriber/tradesignal/format_test.go:
package tradesignal
import (
"strings"
"testing"
)
func ptr(v float64) *float64 { return &v }
func TestFormatOpenIncludesPeriodAfterSymbol(t *testing.T) {
out := Format(&Signal{
Symbol: "BTCUSDT", Side: "LONG", Action: "OPEN",
Quantity: ptr(0.01), Price: 64000.5, Leverage: 10,
Period: "1h", EventTime: "2026-06-23T01:30:00Z",
})
if !strings.Contains(out, "多单开仓") || !strings.Contains(out, "交易品种: BTC") {
t.Fatalf("%s", out)
}
idxSym := strings.Index(out, "交易品种: BTC")
idxPer := strings.Index(out, "周期: 1h")
idxPx := strings.Index(out, "开仓价格:")
if idxPer < 0 || idxPer < idxSym || idxPx < idxPer {
t.Fatalf("period placement:\n%s", out)
}
}
func TestFormatOmitsEmptyPeriod(t *testing.T) {
out := Format(&Signal{
Symbol: "BTCUSDT", Side: "LONG", Action: "OPEN",
Price: 1, EventTime: "2026-06-23T01:30:00Z",
})
if strings.Contains(out, "周期:") {
t.Fatalf("%s", out)
}
}
func TestFormatCloseLong(t *testing.T) {
pnl, bal := 941.0, 74744.90
out := Format(&Signal{
Symbol: "BTCUSDT", Side: "LONG", Action: "CLOSE",
Quantity: ptr(3), Price: 63175.76,
EventTime: "2026-07-07T05:52:14Z", PnL: &pnl, AccountBalance: &bal,
})
for _, want := range []string{"多单平仓", "平仓价格: 63175.76", "平仓盈亏: 941.00"} {
if !strings.Contains(out, want) {
t.Fatalf("missing %q in\n%s", want, out)
}
}
}
func TestFormatWithAvgPrice(t *testing.T) {
avg := 150.0
out := Format(&Signal{
Symbol: "BTCUSDT", Side: "LONG", Action: "ADD",
Quantity: ptr(1), Price: 200, EventTime: "2026-07-07T05:52:14Z",
}, FormatOptions{AvgPrice: &avg})
if !strings.Contains(out, "平均单价: 150.00") {
t.Fatalf("%s", out)
}
}
- Step 2: Run test to verify it fails
Run: go test ./internal/subscriber/tradesignal/ -count=1
Expected: FAIL — Format undefined
- Step 3: Port formatter and add period
signal.go:从 /Users/ryan/Documents/code/go/test-mq-to-ali/internal/model/signal.go 原样移植(包名改为 tradesignal)。
format.go:从 /Users/ryan/Documents/code/go/test-mq-to-ali/internal/formatter/formatter.go 移植,Options 改名为 FormatOptions。在
lines = append(lines, fmt.Sprintf("交易品种: %s", symbol))
之后立刻插入:
if p := strings.TrimSpace(signal.Period); p != "" {
lines = append(lines, fmt.Sprintf("周期: %s", p))
}
其余文案规则保持与参考项目一致。
- Step 4: Run tests
Run: go test ./internal/subscriber/tradesignal/ -count=1
Expected: PASS
- Step 5: Commit(仅当用户要求)
git add internal/subscriber/tradesignal
git commit -m "feat: port trade-signal formatter and include period"
Task 4: 策略覆盖与均价追踪
Files:
- Create:
internal/subscriber/tradesignal/override.go - Create:
internal/subscriber/tradesignal/position.go - Create:
internal/subscriber/tradesignal/transform_test.go
Interfaces:
-
Consumes:
config.StrategyOverride.QuantityMultiplierFor -
Produces:
func Apply(signal *Signal, override *config.StrategyOverride) *Signal— override 为 nil 时返回原指针;有 quantity>0 则乘倍数;Leverage != nil && *Leverage > 0则覆盖杠杆type Snapshot struct { AvgPrice float64; Size float64; HasAvg bool }func NewTracker() *Trackerfunc (t *Tracker) Apply(signal *Signal) Snapshot— key=strategyCode|symbol|side(大写);同signalId只应用一次;CLOSE/REDUCE 快照为减仓前均价
-
Step 1: Write the failing tests
Create internal/subscriber/tradesignal/transform_test.go,从参考项目移植并改 import:
/Users/ryan/Documents/code/go/test-mq-to-ali/internal/strategy/override_test.go→Apply+config.StrategyOverride/Users/ryan/Documents/code/go/test-mq-to-ali/internal/position/tracker_test.go→NewTracker().Apply
包名 tradesignal。ptr 已在 format_test.go 同包,本文件不要再定义 ptr。
至少覆盖:OPEN 倍数+杠杆;无 override 返回原指针;仅 margin ratio 不造 quantity;OPEN+ADD 均价 100/200 → 150;CLOSE 报入场均价;同 signalId 不重复计;LONG/SHORT 隔离。
- Step 2: Run test to verify it fails
Run: go test ./internal/subscriber/tradesignal/ -count=1
Expected: FAIL — Apply / NewTracker undefined
- Step 3: Port override and tracker
override.go:从 /Users/ryan/Documents/code/go/test-mq-to-ali/internal/strategy/override.go 移植,改用本包 Signal 与 config.StrategyOverride。
position.go:从 /Users/ryan/Documents/code/go/test-mq-to-ali/internal/position/tracker.go 原样移植(包名 tradesignal,model.Signal 改为 Signal)。
- Step 4: Run tests
Run: go test ./internal/subscriber/tradesignal/ -count=1
Expected: PASS
- Step 5: Commit(仅当用户要求)
git add internal/subscriber/tradesignal
git commit -m "feat: port trade-signal overrides and average-price tracker"
Task 5: Convert 管道
Files:
- Create:
internal/subscriber/tradesignal/convert.go - Create:
internal/subscriber/tradesignal/convert_test.go
Interfaces:
-
Consumes:
Format、Apply、Tracker.Apply、config.StrategyOverride -
Produces:
var ErrInvalidSignal errortype Converter struct内含 overrides 与*Trackerfunc NewConverter(overrides map[string]config.StrategyOverride) *Converterfunc (c *Converter) Convert(body []byte) (event string, data map[string]interface{}, err error)- 顺序:Unmarshal → action 空则
ErrInvalidSignal→ override → tracker → Format → data event = "trade." + strings.ToLower(action)- data:覆盖后信号 JSON 圆整为 map,再设
formatted;HasAvg时设avgPrice
-
Step 1: Write the failing tests
package tradesignal
import (
"errors"
"strings"
"testing"
"aiaa-notification-service/internal/config"
)
func TestConvertOpen(t *testing.T) {
lev := 100
c := NewConverter(map[string]config.StrategyOverride{
"BLONG": {QuantityMultipliers: config.QuantityMultipliers{Open: 100}, Leverage: &lev},
})
event, data, err := c.Convert([]byte(`{
"signalId":"s1","strategyCode":"BLONG","symbol":"BTCUSDT",
"side":"LONG","action":"OPEN","quantity":0.01,"price":64000,
"leverage":10,"period":"1h","eventTime":"2026-06-23T01:30:00Z"
}`))
if err != nil {
t.Fatal(err)
}
if event != "trade.open" {
t.Fatalf("event=%q", event)
}
formatted, _ := data["formatted"].(string)
if !strings.Contains(formatted, "周期: 1h") || !strings.Contains(formatted, "开仓数量: 1.00") {
t.Fatalf("formatted=\n%s", formatted)
}
if data["period"] != "1h" || data["strategyCode"] != "BLONG" {
t.Fatalf("data=%v", data)
}
if data["leverage"] != float64(100) && data["leverage"] != 100 {
t.Fatalf("leverage=%v", data["leverage"])
}
}
func TestConvertInvalidJSON(t *testing.T) {
_, _, err := NewConverter(nil).Convert([]byte(`{`))
if !errors.Is(err, ErrInvalidSignal) {
t.Fatalf("err=%v", err)
}
}
func TestConvertMissingAction(t *testing.T) {
_, _, err := NewConverter(nil).Convert([]byte(`{"symbol":"BTCUSDT"}`))
if !errors.Is(err, ErrInvalidSignal) {
t.Fatalf("err=%v", err)
}
}
- Step 2: Run test to verify it fails
Run: go test ./internal/subscriber/tradesignal/ -run Convert -count=1
Expected: FAIL — Convert undefined
- Step 3: Implement Convert
func (c *Converter) Convert(body []byte) (string, map[string]interface{}, error) {
var sig Signal
if err := json.Unmarshal(body, &sig); err != nil {
return "", nil, fmt.Errorf("%w: %v", ErrInvalidSignal, err)
}
if strings.TrimSpace(sig.Action) == "" {
return "", nil, fmt.Errorf("%w: missing action", ErrInvalidSignal)
}
out := Apply(&sig, c.overrideFor(sig.StrategyCode))
snap := c.positions.Apply(out)
var opts FormatOptions
if snap.HasAvg {
avg := snap.AvgPrice
opts.AvgPrice = &avg
}
text := Format(out, opts)
data, err := toData(out)
if err != nil {
return "", nil, err
}
data["formatted"] = text
if snap.HasAvg {
data["avgPrice"] = snap.AvgPrice
}
return "trade." + strings.ToLower(out.Action), data, nil
}
toData:json.Marshal 信号再 Unmarshal 到 map[string]interface{}。overrideFor 按 strategyCode 查 map,没有则 nil。
- Step 4: Run tests
Run: go test ./internal/subscriber/tradesignal/ -count=1
Expected: PASS
- Step 5: Commit(仅当用户要求)
git add internal/subscriber/tradesignal
git commit -m "feat: convert trade-signal messages into notify event and data"
Task 6: 投递处置(Ack / 重试 / DLQ)
Files:
- Create:
internal/subscriber/handle.go - Create:
internal/subscriber/handle_test.go
Interfaces:
-
Consumes:
tradesignal.Converter.Convert、tradesignal.ErrInvalidSignal、notify.Process、notify.ErrUnprocessable -
Produces:
type Disposition int—DispositionAck、DispositionRetry、DispositionDLQfunc DecideRetry(retryCount, maxRetry int) Disposition—retryCount+1 > maxRetry→ DLQ,否则 Retryfunc RetryCount(headers map[string]any) int— 读x-retry-count(int/int32/int64/float64/string)type SourceLookup func(ctx context.Context, name string) (*model.Source, error)type ProcessFunc func(ctx context.Context, req notify.Request) (notify.Result, error)type HandleInput struct { Body []byte; Headers map[string]any; SourceName string; MaxRetry int }func HandleMessage(ctx context.Context, in HandleInput, conv *tradesignal.Converter, lookup SourceLookup, process ProcessFunc) Disposition- 无效信号 / lookup error / source nil /
status != 1/ Process 无 error(含 unmatched、filtered、ErrUnprocessable)→ Ack - Process 其它 error →
DecideRetry(RetryCount(headers), maxRetry)
-
Step 1: Write the failing tests
package subscriber
import (
"context"
"errors"
"strings"
"testing"
"aiaa-notification-service/internal/model"
"aiaa-notification-service/internal/notify"
"aiaa-notification-service/internal/subscriber/tradesignal"
)
func TestDecideRetry(t *testing.T) {
if DecideRetry(0, 3) != DispositionRetry {
t.Fatal("first failure should retry")
}
if DecideRetry(3, 3) != DispositionDLQ {
t.Fatal("retry 4 > 3 should dlq")
}
}
func TestRetryCount(t *testing.T) {
if RetryCount(nil) != 0 {
t.Fatal()
}
if RetryCount(map[string]any{"x-retry-count": int32(2)}) != 2 {
t.Fatal()
}
}
func enabledSrc() *model.Source {
return &model.Source{ID: 1, Name: "trade-signal", Status: 1}
}
func TestHandleInvalidJSONAck(t *testing.T) {
d := HandleMessage(context.Background(), HandleInput{Body: []byte(`{`), SourceName: "trade-signal", MaxRetry: 3},
tradesignal.NewConverter(nil),
func(context.Context, string) (*model.Source, error) { return enabledSrc(), nil },
func(context.Context, notify.Request) (notify.Result, error) {
t.Fatal("process should not run")
return notify.Result{}, nil
})
if d != DispositionAck {
t.Fatalf("%v", d)
}
}
func TestHandleMissingSourceAck(t *testing.T) {
d := HandleMessage(context.Background(), HandleInput{
Body: []byte(`{"action":"OPEN","symbol":"BTCUSDT"}`), SourceName: "trade-signal", MaxRetry: 3,
}, tradesignal.NewConverter(nil),
func(context.Context, string) (*model.Source, error) { return nil, errors.New("not found") },
func(context.Context, notify.Request) (notify.Result, error) { t.Fatal("process"); return notify.Result{}, nil })
if d != DispositionAck {
t.Fatalf("%v", d)
}
}
func TestHandleDisabledSourceAck(t *testing.T) {
d := HandleMessage(context.Background(), HandleInput{
Body: []byte(`{"action":"OPEN"}`), SourceName: "trade-signal", MaxRetry: 3,
}, tradesignal.NewConverter(nil),
func(context.Context, string) (*model.Source, error) {
return &model.Source{ID: 1, Name: "trade-signal", Status: 0}, nil
},
func(context.Context, notify.Request) (notify.Result, error) { t.Fatal("process"); return notify.Result{}, nil })
if d != DispositionAck {
t.Fatalf("%v", d)
}
}
func TestHandleProcessUnprocessableAck(t *testing.T) {
d := HandleMessage(context.Background(), HandleInput{
Body: []byte(`{"action":"OPEN"}`), SourceName: "trade-signal", MaxRetry: 3,
}, tradesignal.NewConverter(nil),
func(context.Context, string) (*model.Source, error) { return enabledSrc(), nil },
func(context.Context, notify.Request) (notify.Result, error) {
return notify.Result{}, notify.ErrUnprocessable
})
if d != DispositionAck {
t.Fatalf("%v", d)
}
}
func TestHandleProcessErrorRetryThenDLQ(t *testing.T) {
process := func(context.Context, notify.Request) (notify.Result, error) {
return notify.Result{}, errors.New("db down")
}
lookup := func(context.Context, string) (*model.Source, error) { return enabledSrc(), nil }
conv := tradesignal.NewConverter(nil)
body := []byte(`{"action":"OPEN"}`)
d := HandleMessage(context.Background(), HandleInput{Body: body, SourceName: "s", MaxRetry: 3}, conv, lookup, process)
if d != DispositionRetry {
t.Fatalf("%v", d)
}
d = HandleMessage(context.Background(), HandleInput{
Body: body, Headers: map[string]any{"x-retry-count": 3}, SourceName: "s", MaxRetry: 3,
}, conv, lookup, process)
if d != DispositionDLQ {
t.Fatalf("%v", d)
}
}
func TestHandleSuccessAckPassesEventAndFormatted(t *testing.T) {
var got notify.Request
d := HandleMessage(context.Background(), HandleInput{
Body: []byte(`{"action":"CLOSE","symbol":"ETHUSDT","period":"4h","price":1}`),
SourceName: "trade-signal", MaxRetry: 3,
}, tradesignal.NewConverter(nil),
func(context.Context, string) (*model.Source, error) { return enabledSrc(), nil },
func(_ context.Context, req notify.Request) (notify.Result, error) {
got = req
return notify.Result{Matched: true, Channels: []string{"dingtalk:1"}}, nil
})
if d != DispositionAck {
t.Fatalf("%v", d)
}
if got.Event != "trade.close" {
t.Fatalf("event=%q", got.Event)
}
if got.Data["period"] != "4h" {
t.Fatalf("period=%v", got.Data["period"])
}
formatted, _ := got.Data["formatted"].(string)
if !strings.Contains(formatted, "周期: 4h") {
t.Fatalf("formatted=%s", formatted)
}
}
- Step 2: Run test to verify it fails
Run: go test ./internal/subscriber/ -count=1
Expected: FAIL — HandleMessage undefined
- Step 3: Implement handle.go
按 Interfaces 实现。HandleMessage 伪代码:
event, data, err := conv.Convert(in.Body)
if err != nil { // 含 ErrInvalidSignal
return DispositionAck
}
src, err := lookup(ctx, in.SourceName)
if err != nil || src == nil || src.Status != 1 {
return DispositionAck
}
_, err = process(ctx, notify.Request{Source: src, Event: event, Data: data})
if err == nil || errors.Is(err, notify.ErrUnprocessable) {
return DispositionAck
}
return DecideRetry(RetryCount(in.Headers), in.MaxRetry)
无匹配规则、条件未过由 Process 返回 error=nil,因此 Ack。
- Step 4: Run tests
Run: go test ./internal/subscriber/ ./internal/subscriber/tradesignal/ -count=1
Expected: PASS
- Step 5: Commit(仅当用户要求)
git add internal/subscriber
git commit -m "feat: decide RabbitMQ ack, retry, and DLQ without a live broker"
Task 7: 接入 AMQP、启动与文档
Files:
- Create:
internal/subscriber/subscriber.go - Modify:
cmd/server/main.go - Modify:
README.md - Modify:
go.mod/go.sum
Interfaces:
-
Consumes:
config.SubscriptionConfig、HandleMessage、notify.Service.Process、store.GetSourceByName -
Produces:
func New(cfg config.SubscriptionConfig, lookup SourceLookup, process ProcessFunc) (*Subscriber, error)—formatter必须是trade_signal(Normalize 已保证)func (s *Subscriber) Run(ctx context.Context) error— 断线 5s 重连;ctx取消则返回- 声明 durable exchange(若配置)、durable 队列、绑定、durable DLQ;QoS 1;
autoAck=false;consumer tag =cfg.Name - Retry:
x-retry-count+1后Publish到本队列;DLQ:Publish到DeadLetterQueue(空则 Ack 丢掉) - main:
signal.NotifyContext;对cfg.ActiveSubscriptions()各go Run(ctx);再等 ctx 取消后ShutdownHTTP
-
Step 1: Add dependency
Run: go get github.com/rabbitmq/amqp091-go
- Step 2: Implement subscriber.go
对照 /Users/ryan/Documents/code/go/test-mq-to-ali/internal/mq/consumer.go 的 Run / consumeOnce / ensureQueue / retryOrDLQ / publishToQueue。差别:
- 用 slog,不用 log
- 每条消息:
HandleMessage→ 按 Disposition Ack / 重投 / DLQ retryOrDLQ只在DispositionRetry/DispositionDLQ时调用;先把 header 里的x-retry-count写成RetryCount+1- 不要钉钉 sent/abandoned header
New里tradesignal.NewConverter(cfg.StrategyOverrides)
lookup 包装:
func (st *store.Store) /* in main */ {
lookup := func(ctx context.Context, name string) (*model.Source, error) {
return st.GetSourceByName(ctx, name)
}
}
- Step 3: Wire main.go
把现有 signal.Notify + <-quit 换成:
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
lookup := func(ctx context.Context, name string) (*model.Source, error) {
return st.GetSourceByName(ctx, name)
}
process := notifySvc.Process
for _, sub := range cfg.ActiveSubscriptions() {
sub := sub
cons, err := subscriber.New(sub, lookup, process)
if err != nil {
slog.Error("subscriber init", "name", sub.Name, "error", err)
os.Exit(1)
}
go func() {
if err := cons.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
slog.Error("subscriber stopped", "name", sub.Name, "error", err)
}
}()
slog.Info("subscriber started", "name", sub.Name, "queue", sub.Queue, "source", sub.Source)
}
// ListenAndServe in goroutine as today
<-ctx.Done()
slog.Info("shutting down...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
RABBITMQ_URL 未设时 url 展开为空,ActiveSubscriptions 为空,不启动消费。
- Step 4: README
在配置表增加:
| 配置项 | 说明 | 默认 |
|---|---|---|
subscriptions |
MQ 订阅列表;url 为空则跳过 |
空 |
subscriptions[].source |
对应已有 Source.name | 必填(有 url 时) |
subscriptions[].formatter |
目前仅 trade_signal |
trade_signal |
说明:设置环境变量 RABBITMQ_URL;需事先创建 Source trade-signal、模板(可用 {{.formatted}})、规则 trade.open / trade.add / trade.close / trade.reduce、渠道。条件可用 strategyCode / symbol / period。
- Step 5: Run tests and build
Run:
go test ./internal/... -count=1
go build -o /tmp/notification-server ./cmd/server
Expected: 全绿;编译成功
- Step 6: Commit(仅当用户要求)
git add internal/subscriber cmd/server/main.go README.md go.mod go.sum
git commit -m "feat: subscribe to RabbitMQ trade signals and notify by rules"
Self-review
Spec coverage
| Spec | Task |
|---|---|
订阅列表 + 默认值 + ${RABBITMQ_URL} |
1 |
| 抽出 Process,HTTP 共用 | 2 |
| 格式化 + period 原样 | 3 |
| 策略覆盖 + 进程内均价 | 4 |
| event / data.formatted / 原始字段 | 5 |
| Ack / 重试 / DLQ 判定 | 6 |
| 声明绑定、重连、main 启动、文档 | 7 |
| 不移植钉钉过滤 | 6/7 不包含 |
| 不连真实 broker | 全程 |
Placeholder scan: 无 TBD;实现步骤含代码或明确对照参考文件。
Type consistency: Process(ctx, Request) (Result, error)、ErrUnprocessable、Convert、HandleMessage / Disposition* / SourceLookup / ProcessFunc 在后续任务中名称一致。