Files
aiaa-notification-server/docs/superpowers/plans/2026-08-15-rabbitmq-subscriber.md
T
ryan 6f846a0a3c 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.
2026-08-15 17:34:49 +08:00

1160 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 不造 quantityOPEN+ADD 均价 100/200 → 150CLOSE 报入场均价;同 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 DLQQoS 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 |
| 抽出 ProcessHTTP 共用 | 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` 在后续任务中名称一致。