# 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`;有均价才写 `avgPrice` - `period` 原样使用;非空则在「交易品种」后输出 `周期: {period}` - AMQP URL 只用 `${RABBITMQ_URL}`,禁止把账号写进任何文件 - 未知 `formatter` 在配置规范化时失败;空则默认 `trade_signal` - `max_retry<=0` → 3;`exchange_type` 空 → `fanout`;`name` 空 → 用 `queue` - `url` 为空的订阅不启动;全部未启动时 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 }` tags `open,add,reduce,close` - `type StrategyOverride struct { QuantityMultipliers QuantityMultipliers; Leverage *int }` - `func (o StrategyOverride) QuantityMultiplierFor(action string) float64` — `<=0` 视为 1 - `type SubscriptionConfig` 字段见下方 - `func (c *Config) NormalizeSubscriptions() error` - `func (c *Config) ActiveSubscriptions() []SubscriptionConfig` — 仅 `url != ""` - `Load` 在 Unmarshal 之后调用 `NormalizeSubscriptions` - [ ] **Step 1: Write the failing test** Create `internal/config/config_test.go`: ```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`: ```go Subscriptions []SubscriptionConfig `mapstructure:"subscriptions"` ``` Add types and methods: ```go 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)`: ```go if err := cfg.NormalizeSubscriptions(); err != nil { return nil, err } ``` Append to `config/config.yaml`(**不要**写入真实账号): ```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**(仅当用户要求) ```bash 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 error` - `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 }` - `func NewService(m RuleMatcher, t TemplateStore, r *engine.Renderer, rt ChannelRouter, logs MessageLogger) *Service` - `func (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`: ```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`: ```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 之后: ```go 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**(仅当用户要求) ```bash 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.Time` - `type FormatOptions struct { AvgPrice *float64 }` - `func Format(signal *Signal, opts ...FormatOptions) string` - 在 `交易品种` 下一行:`period` trim 后非空则 `周期: {period}` - [ ] **Step 1: Write the failing tests** Create `internal/subscriber/tradesignal/format_test.go`: ```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`。在 ```go lines = append(lines, fmt.Sprintf("交易品种: %s", symbol)) ``` 之后立刻插入: ```go 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**(仅当用户要求) ```bash 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() *Tracker` - `func (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**(仅当用户要求) ```bash 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 error` - `type Converter struct` 内含 overrides 与 `*Tracker` - `func NewConverter(overrides map[string]config.StrategyOverride) *Converter` - `func (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** ```go 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** ```go 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**(仅当用户要求) ```bash 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`、`DispositionDLQ` - `func DecideRetry(retryCount, maxRetry int) Disposition` — `retryCount+1 > maxRetry` → DLQ,否则 Retry - `func 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** ```go 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` 伪代码: ```go 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**(仅当用户要求) ```bash 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 取消后 `Shutdown` HTTP - [ ] **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` 包装: ```go 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` 换成: ```go 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**(仅当用户要求) ```bash 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` 在后续任务中名称一致。