6f846a0a3c
Consume configurable queues, format signals (including period), share NotifyService with HTTP, and drop duplicate bodies within 1h.
1160 lines
37 KiB
Markdown
1160 lines
37 KiB
Markdown
# 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` 在后续任务中名称一致。
|