feat(通知模板): 支持缺字段按空值渲染与空行省略
Motivation: 统一 crypto-strategy 与 trade-signal 两类交易信号的模板字段,使同一套通知模板可复用;字段缺失或为空时不再导致渲染报错或输出空行,通知内容更整洁。 Changes: * 渲染器缺失字段改为按空值处理,新增 line 函数实现空值整行省略 * crypto-strategy 信号补充止盈价、止损价、平均价、收益额等模板字段 * trade-signal 信号保留原始报文中的额外字段以适配统一模板 * 修复结构化日志将 error 作为值直接输出导致的格式问题
This commit is contained in:
@@ -35,6 +35,7 @@ type payload struct {
|
||||
|
||||
type remark struct {
|
||||
OrderID string `json:"orderId"`
|
||||
Revenue string `json:"revenue"`
|
||||
}
|
||||
|
||||
type Converter struct{}
|
||||
@@ -60,31 +61,39 @@ func Convert(body []byte) (string, map[string]interface{}, error) {
|
||||
text := format(env, p, action)
|
||||
|
||||
data := map[string]interface{}{
|
||||
"eventType": env.EventType,
|
||||
"correlationId": env.CorrelationID,
|
||||
"symbol": firstNonEmpty(env.Symbol, p.Currency),
|
||||
"direction": env.Direction,
|
||||
"side": strings.ToUpper(env.Direction),
|
||||
"action": action,
|
||||
"eventTime": env.EventTime,
|
||||
"strategyCode": p.StrategyCode,
|
||||
"period": p.Period,
|
||||
"currency": p.Currency,
|
||||
"isSale": p.IsSale,
|
||||
"isClose": p.IsClose,
|
||||
"isGain": p.IsGain,
|
||||
"gainTarget": p.GainTarget,
|
||||
"price": p.Price,
|
||||
"lossPrice": p.LossPrice,
|
||||
"gainPrices": p.GainPrices,
|
||||
"leverage": p.Leverage,
|
||||
"formatted": text,
|
||||
"eventType": env.EventType,
|
||||
"correlationId": env.CorrelationID,
|
||||
"symbol": firstNonEmpty(env.Symbol, p.Currency),
|
||||
"direction": env.Direction,
|
||||
"side": strings.ToUpper(env.Direction),
|
||||
"action": action,
|
||||
"eventTime": env.EventTime,
|
||||
"strategyCode": p.StrategyCode,
|
||||
"period": p.Period,
|
||||
"currency": p.Currency,
|
||||
"isSale": p.IsSale,
|
||||
"isClose": p.IsClose,
|
||||
"isGain": p.IsGain,
|
||||
"gainTarget": p.GainTarget,
|
||||
"price": p.Price,
|
||||
"lossPrice": p.LossPrice,
|
||||
"gainPrices": p.GainPrices,
|
||||
"leverage": p.Leverage,
|
||||
"formatted": text,
|
||||
"stopLossPrice": p.LossPrice,
|
||||
"takeProfitPrice": takeProfitPrice(p),
|
||||
"totalAvgPx": "",
|
||||
}
|
||||
if p.TotalGainTarget != 0 {
|
||||
data["totalGainTarget"] = p.TotalGainTarget
|
||||
}
|
||||
if oid := parseOrderID(p.Remark); oid != "" {
|
||||
data["orderId"] = oid
|
||||
if r := parseRemark(p.Remark); r.OrderID != "" || r.Revenue != "" {
|
||||
if r.OrderID != "" {
|
||||
data["orderId"] = r.OrderID
|
||||
}
|
||||
if r.Revenue != "" {
|
||||
data["revenue"] = r.Revenue
|
||||
}
|
||||
}
|
||||
return event, data, nil
|
||||
}
|
||||
@@ -128,16 +137,26 @@ func inferAction(p payload) string {
|
||||
}
|
||||
}
|
||||
|
||||
func parseOrderID(raw string) string {
|
||||
func takeProfitPrice(p payload) interface{} {
|
||||
if gp := strings.TrimSpace(p.GainPrices); gp != "" {
|
||||
return gp
|
||||
}
|
||||
if p.IsGain && p.Price > 0 {
|
||||
return p.Price
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseRemark(raw string) remark {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
return remark{}
|
||||
}
|
||||
var r remark
|
||||
if err := json.Unmarshal([]byte(raw), &r); err != nil {
|
||||
return ""
|
||||
return remark{}
|
||||
}
|
||||
return r.OrderID
|
||||
return r
|
||||
}
|
||||
|
||||
func format(env envelope, p payload, action string) string {
|
||||
|
||||
@@ -3,6 +3,8 @@ package cryptostrategy
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"aiaa-notification-service/internal/engine"
|
||||
)
|
||||
|
||||
const sampleBody = `{
|
||||
@@ -124,6 +126,37 @@ func TestConvertPayloadObject(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertRendersSharedSignalTemplate(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"eventType": "SIGNAL_RECEIVED",
|
||||
"correlationId": "0_0_0",
|
||||
"symbol": "ICP",
|
||||
"direction": "LONG",
|
||||
"payload": "{\"strategyCode\":\"ai-crypto-signals\",\"period\":\"1h\",\"currency\":\"ICP\",\"isSale\":false,\"isClose\":true,\"isGain\":true,\"gainTarget\":1,\"price\":2.273,\"remark\":\"{\\\"orderId\\\":\\\"uJs3zQI8IaRV16n8NERq\\\",\\\"revenue\\\":\\\"14.1088%\\\"}\",\"totalGainTarget\":5}",
|
||||
"eventTime": 1786808929754
|
||||
}`)
|
||||
_, data, err := Convert(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, key := range []string{"totalAvgPx", "takeProfitPrice", "stopLossPrice", "side", "action", "symbol", "period", "price"} {
|
||||
if _, ok := data[key]; !ok {
|
||||
t.Fatalf("missing template key %q in %v", key, data)
|
||||
}
|
||||
}
|
||||
if data["orderId"] != "uJs3zQI8IaRV16n8NERq" || data["revenue"] != "14.1088%" {
|
||||
t.Fatalf("remark=%v", data)
|
||||
}
|
||||
tmpl := "### {{.symbol}} {{.action}}\n币种:{{.symbol}}\n周期:{{.period}}\n方向:{{.side}}\n价格:{{.price}}\n平均价:{{.totalAvgPx}}\n止盈价:{{.takeProfitPrice}}\n止损价:{{.stopLossPrice}}"
|
||||
out, err := engine.NewRenderer().Render(tmpl, data)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "ICP") || !strings.Contains(out, "CLOSE") {
|
||||
t.Fatalf("out=%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertInvalidJSON(t *testing.T) {
|
||||
_, _, err := Convert([]byte(`{not json`))
|
||||
if err == nil {
|
||||
|
||||
@@ -70,6 +70,13 @@ type MessageConverter interface {
|
||||
Convert(body []byte) (event string, data map[string]interface{}, err error)
|
||||
}
|
||||
|
||||
func errText(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func HandleMessage(ctx context.Context, in HandleInput, conv MessageConverter, lookup SourceLookup, process ProcessFunc) Disposition {
|
||||
owned := false
|
||||
hash := ""
|
||||
@@ -77,7 +84,7 @@ func HandleMessage(ctx context.Context, in HandleInput, conv MessageConverter, l
|
||||
hash = MessageHash(in.Body)
|
||||
ok, err := in.Deduper.Claim(ctx, hash)
|
||||
if err != nil {
|
||||
slog.Warn("dedup claim failed, processing anyway", "hash", hash, "error", err)
|
||||
slog.Warn("dedup claim failed, processing anyway", "hash", hash, "error", errText(err))
|
||||
} else if !ok {
|
||||
slog.Info("duplicate message, ack", "hash", hash)
|
||||
return DispositionAck
|
||||
@@ -94,13 +101,13 @@ func HandleMessage(ctx context.Context, in HandleInput, conv MessageConverter, l
|
||||
|
||||
event, data, err := conv.Convert(in.Body)
|
||||
if err != nil {
|
||||
slog.Warn("invalid signal, ack", "hash", hash, "raw", raw, "error", err)
|
||||
slog.Warn("invalid signal, ack", "hash", hash, "raw", raw, "error", errText(err))
|
||||
return DispositionAck
|
||||
}
|
||||
|
||||
src, err := lookup(ctx, in.SourceName)
|
||||
if err != nil || src == nil || src.Status != 1 {
|
||||
slog.Warn("source unavailable, ack", "source", in.SourceName, "hash", hash, "raw", raw, "error", err)
|
||||
slog.Warn("source unavailable, ack", "source", in.SourceName, "hash", hash, "raw", raw, "error", errText(err))
|
||||
return DispositionAck
|
||||
}
|
||||
|
||||
@@ -116,14 +123,14 @@ func HandleMessage(ctx context.Context, in HandleInput, conv MessageConverter, l
|
||||
return DispositionAck
|
||||
}
|
||||
if errors.Is(err, notify.ErrUnprocessable) {
|
||||
slog.Warn("unprocessable notify, ack", "source", src.Name, "event", event, "hash", hash, "raw", raw, "error", err)
|
||||
slog.Warn("unprocessable notify, ack", "source", src.Name, "event", event, "hash", hash, "raw", raw, "error", errText(err))
|
||||
return DispositionAck
|
||||
}
|
||||
|
||||
disp := DecideRetry(RetryCount(in.Headers), in.MaxRetry)
|
||||
if owned && in.Deduper != nil {
|
||||
if relErr := in.Deduper.Release(ctx, hash); relErr != nil {
|
||||
slog.Warn("dedup release failed", "hash", hash, "error", relErr)
|
||||
slog.Warn("dedup release failed", "hash", hash, "error", errText(relErr))
|
||||
}
|
||||
}
|
||||
return disp
|
||||
|
||||
@@ -122,7 +122,7 @@ func (s *Subscriber) ensureQueue(ch *amqp.Channel) error {
|
||||
|
||||
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)
|
||||
slog.Warn("declare dead letter queue failed", "queue", s.cfg.DeadLetterQueue, "error", err.Error())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -158,7 +158,7 @@ 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)
|
||||
slog.Error("requeue failed", "queue", queue, "error", err.Error())
|
||||
_ = d.Nack(false, true)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ func (c *Converter) Convert(body []byte) (string, map[string]interface{}, error)
|
||||
opts.AvgPrice = &avg
|
||||
}
|
||||
text := Format(out, opts)
|
||||
data, err := toData(out)
|
||||
data, err := toData(body, out)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
@@ -61,14 +61,21 @@ func (c *Converter) overrideFor(code string) *config.StrategyOverride {
|
||||
return &override
|
||||
}
|
||||
|
||||
func toData(sig *Signal) (map[string]interface{}, error) {
|
||||
func toData(body []byte, sig *Signal) (map[string]interface{}, error) {
|
||||
data := make(map[string]interface{})
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := json.Marshal(sig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data := make(map[string]interface{})
|
||||
if err := json.Unmarshal(raw, &data); err != nil {
|
||||
overlay := make(map[string]interface{})
|
||||
if err := json.Unmarshal(raw, &overlay); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range overlay {
|
||||
data[k] = v
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
@@ -36,6 +36,19 @@ func TestConvertOpen(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertKeepsExtraJSONFields(t *testing.T) {
|
||||
_, data, err := NewConverter(nil).Convert([]byte(`{
|
||||
"action":"OPEN","symbol":"BTCUSDT","price":63014.61,
|
||||
"totalAvgPx":63014.61,"investmentAmount":100095.24
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if data["totalAvgPx"] != 63014.61 {
|
||||
t.Fatalf("totalAvgPx=%v", data["totalAvgPx"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertInvalidJSON(t *testing.T) {
|
||||
_, _, err := NewConverter(nil).Convert([]byte(`{`))
|
||||
if !errors.Is(err, ErrInvalidSignal) {
|
||||
|
||||
Reference in New Issue
Block a user