ae25409e56
Motivation: 统一 crypto-strategy 与 trade-signal 两类交易信号的模板字段,使同一套通知模板可复用;字段缺失或为空时不再导致渲染报错或输出空行,通知内容更整洁。 Changes: * 渲染器缺失字段改为按空值处理,新增 line 函数实现空值整行省略 * crypto-strategy 信号补充止盈价、止损价、平均价、收益额等模板字段 * trade-signal 信号保留原始报文中的额外字段以适配统一模板 * 修复结构化日志将 error 作为值直接输出导致的格式问题
207 lines
5.1 KiB
Go
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(),
|
|
},
|
|
)
|
|
}
|