Files
aiaa-notification-server/internal/subscriber/subscriber.go
T
ryan ae25409e56 feat(通知模板): 支持缺字段按空值渲染与空行省略
Motivation:
统一 crypto-strategy 与 trade-signal 两类交易信号的模板字段,使同一套通知模板可复用;字段缺失或为空时不再导致渲染报错或输出空行,通知内容更整洁。

Changes:

* 渲染器缺失字段改为按空值处理,新增 line 函数实现空值整行省略
* crypto-strategy 信号补充止盈价、止损价、平均价、收益额等模板字段
* trade-signal 信号保留原始报文中的额外字段以适配统一模板
* 修复结构化日志将 error 作为值直接输出导致的格式问题
2026-08-16 00:19:50 +08:00

207 lines
5.1 KiB
Go

package subscriber
import (
"context"
"fmt"
"log/slog"
"net/url"
"time"
"aiaa-notification-service/internal/config"
"aiaa-notification-service/internal/subscriber/cryptostrategy"
"aiaa-notification-service/internal/subscriber/tradesignal"
amqp "github.com/rabbitmq/amqp091-go"
)
type Subscriber struct {
cfg config.SubscriptionConfig
conv MessageConverter
lookup SourceLookup
process ProcessFunc
deduper Deduper
}
func New(cfg config.SubscriptionConfig, lookup SourceLookup, process ProcessFunc, deduper Deduper) (*Subscriber, error) {
var conv MessageConverter
switch cfg.Formatter {
case "trade_signal":
conv = tradesignal.NewConverter(cfg.StrategyOverrides)
case "crypto_strategy":
conv = cryptostrategy.NewConverter()
default:
return nil, fmt.Errorf("unknown formatter %q", cfg.Formatter)
}
return &Subscriber{
cfg: cfg,
conv: conv,
lookup: lookup,
process: process,
deduper: deduper,
}, nil
}
func (s *Subscriber) Run(ctx context.Context) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if err := s.consumeOnce(ctx); err != nil {
slog.Error("subscriber error, reconnecting",
"name", s.cfg.Name,
"host", brokerHost(s.cfg.URL),
"error", err.Error())
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(5 * time.Second):
}
}
}
}
func (s *Subscriber) consumeOnce(ctx context.Context) error {
conn, err := amqp.Dial(s.cfg.URL)
if err != nil {
return fmt.Errorf("dial rabbitmq: %w", err)
}
defer conn.Close()
ch, err := conn.Channel()
if err != nil {
return fmt.Errorf("open channel: %w", err)
}
defer ch.Close()
if err := ch.Qos(1, 0, false); err != nil {
return fmt.Errorf("set qos: %w", err)
}
if err := s.ensureQueue(ch); err != nil {
return err
}
deliveries, err := ch.Consume(s.cfg.Queue, s.cfg.Name, false, false, false, false, nil)
if err != nil {
return fmt.Errorf("consume queue %s: %w", s.cfg.Queue, err)
}
slog.Info("listening on queue", "name", s.cfg.Name, "queue", s.cfg.Queue)
for {
select {
case <-ctx.Done():
return ctx.Err()
case d, ok := <-deliveries:
if !ok {
return fmt.Errorf("delivery channel closed")
}
s.handleDelivery(ch, d)
}
}
}
func (s *Subscriber) ensureQueue(ch *amqp.Channel) error {
if s.cfg.Exchange != "" {
if err := ch.ExchangeDeclare(s.cfg.Exchange, s.cfg.ExchangeType, true, false, false, false, nil); err != nil {
return fmt.Errorf("declare exchange %q: %w", s.cfg.Exchange, err)
}
}
if _, err := ch.QueueDeclare(s.cfg.Queue, true, false, false, false, nil); err != nil {
return fmt.Errorf("declare queue %q: %w", s.cfg.Queue, err)
}
if s.cfg.Exchange != "" {
if err := ch.QueueBind(s.cfg.Queue, s.cfg.RoutingKey, s.cfg.Exchange, false, nil); err != nil {
return fmt.Errorf("bind queue %q to exchange %q: %w", s.cfg.Queue, s.cfg.Exchange, err)
}
slog.Info("queue bound", "queue", s.cfg.Queue, "exchange", s.cfg.Exchange, "type", s.cfg.ExchangeType, "routing_key", s.cfg.RoutingKey)
}
if s.cfg.DeadLetterQueue != "" {
if _, err := ch.QueueDeclare(s.cfg.DeadLetterQueue, true, false, false, false, nil); err != nil {
slog.Warn("declare dead letter queue failed", "queue", s.cfg.DeadLetterQueue, "error", err.Error())
}
}
return nil
}
func (s *Subscriber) handleDelivery(ch *amqp.Channel, d amqp.Delivery) {
disp := HandleMessage(context.Background(), HandleInput{
Body: d.Body,
Headers: map[string]any(d.Headers),
Name: s.cfg.Name,
Queue: s.cfg.Queue,
SourceName: s.cfg.Source,
MaxRetry: s.cfg.MaxRetry,
Deduper: s.deduper,
}, s.conv, s.lookup, s.process)
switch disp {
case DispositionRetry:
s.republish(ch, d, s.cfg.Queue)
case DispositionDLQ:
if s.cfg.DeadLetterQueue == "" {
slog.Warn("max retry reached, discarded", "name", s.cfg.Name)
_ = d.Ack(false)
return
}
s.republish(ch, d, s.cfg.DeadLetterQueue)
default:
_ = d.Ack(false)
}
}
func (s *Subscriber) republish(ch *amqp.Channel, d amqp.Delivery, queue string) {
headers := copyAMQPHeaders(d.Headers)
headers[retryHeader] = RetryCount(map[string]any(d.Headers)) + 1
if err := publishToQueue(ch, queue, d.Body, headers); err != nil {
slog.Error("requeue failed", "queue", queue, "error", err.Error())
_ = d.Nack(false, true)
return
}
_ = d.Ack(false)
if queue == s.cfg.DeadLetterQueue {
slog.Warn("message moved to dlq", "name", s.cfg.Name, "retries", s.cfg.MaxRetry)
return
}
slog.Info("message requeued", "name", s.cfg.Name, "retry", headers[retryHeader], "max", s.cfg.MaxRetry)
}
func brokerHost(raw string) string {
u, err := url.Parse(raw)
if err != nil || u.Hostname() == "" {
return ""
}
if u.Port() != "" {
return u.Hostname() + ":" + u.Port()
}
return u.Hostname()
}
func copyAMQPHeaders(headers amqp.Table) amqp.Table {
out := amqp.Table{}
for k, v := range headers {
out[k] = v
}
return out
}
func publishToQueue(ch *amqp.Channel, queue string, body []byte, headers amqp.Table) error {
return ch.Publish(
"",
queue,
false,
false,
amqp.Publishing{
ContentType: "application/json",
DeliveryMode: amqp.Persistent,
Headers: headers,
Body: body,
Timestamp: time.Now(),
},
)
}