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

37 KiB
Raw Blame History

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.Processsubscriber 声明/绑定/重连/重试/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 → 3exchange_type 空 → fanoutname 空 → 用 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 SubscriptionConfigStrategyOverrideNormalizeSubscriptionsActiveSubscriptions
internal/config/config_test.go 默认值、跳过空 URL、未知 formatter
config/config.yaml 第一条订阅,URL 为 ${RABBITMQ_URL}
internal/notify/service.go ProcessErrUnprocessableparseChannelID
internal/notify/service_test.go 无规则 / 过滤 / 命中 / 不可处理 / 内部错误
internal/handler/notify.go 解析 body 后调用 Process
cmd/server/main.go 构造 notify.ServiceTask 7 再启动订阅
internal/subscriber/tradesignal/signal.go SignalParsedEventTime
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 HandleMessageDecideRetry(无 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:

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.Matchengine.Renderer.Renderengine.Router.Routestore.GetTemplatestore.CreateMessageLogcondition.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:

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 仍异步 goparseChannelID 移到本文件。

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: PASSgo 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 标签与参考项目一致(signalIdstrategyCodeperiod 等)
    • 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

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() *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.goApply + config.StrategyOverride
  • /Users/ryan/Documents/code/go/test-mq-to-ali/internal/position/tracker_test.goNewTracker().Apply

包名 tradesignalptr 已在 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 移植,改用本包 Signalconfig.StrategyOverride

position.go:从 /Users/ryan/Documents/code/go/test-mq-to-ali/internal/position/tracker.go 原样移植(包名 tradesignalmodel.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: FormatApplyTracker.Applyconfig.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,再设 formattedHasAvg 时设 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
}

toDatajson.Marshal 信号再 Unmarshalmap[string]interface{}overrideForstrategyCode 查 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.Converttradesignal.ErrInvalidSignalnotify.Processnotify.ErrUnprocessable

  • Produces:

    • type Disposition intDispositionAckDispositionRetryDispositionDLQ
    • func DecideRetry(retryCount, maxRetry int) DispositionretryCount+1 > maxRetry → DLQ,否则 Retry
    • func RetryCount(headers map[string]any) int — 读 x-retry-countint/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.SubscriptionConfigHandleMessagenotify.Service.Processstore.GetSourceByName

  • Produces:

    • func New(cfg config.SubscriptionConfig, lookup SourceLookup, process ProcessFunc) (*Subscriber, error)formatter 必须是 trade_signalNormalize 已保证)
    • func (s *Subscriber) Run(ctx context.Context) error — 断线 5s 重连;ctx 取消则返回
    • 声明 durable exchange(若配置)、durable 队列、绑定、durable DLQQoS 1autoAck=falseconsumer tag = cfg.Name
    • Retryx-retry-count+1Publish 到本队列;DLQPublishDeadLetterQueue(空则 Ack 丢掉)
    • mainsignal.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.goRun / consumeOnce / ensureQueue / retryOrDLQ / publishToQueue。差别:

  • 用 slog,不用 log
  • 每条消息:HandleMessage → 按 Disposition Ack / 重投 / DLQ
  • retryOrDLQ 只在 DispositionRetry / DispositionDLQ 时调用;先把 header 里的 x-retry-count 写成 RetryCount+1
  • 不要钉钉 sent/abandoned header
  • Newtradesignal.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
抽出 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)ErrUnprocessableConvertHandleMessage / Disposition* / SourceLookup / ProcessFunc 在后续任务中名称一致。