feat(通知模板): 支持缺字段按空值渲染与空行省略

Motivation:
统一 crypto-strategy 与 trade-signal 两类交易信号的模板字段,使同一套通知模板可复用;字段缺失或为空时不再导致渲染报错或输出空行,通知内容更整洁。

Changes:

* 渲染器缺失字段改为按空值处理,新增 line 函数实现空值整行省略
* crypto-strategy 信号补充止盈价、止损价、平均价、收益额等模板字段
* trade-signal 信号保留原始报文中的额外字段以适配统一模板
* 修复结构化日志将 error 作为值直接输出导致的格式问题
This commit is contained in:
2026-08-16 00:19:50 +08:00
parent de7a52e81f
commit ae25409e56
9 changed files with 197 additions and 43 deletions
+11 -2
View File
@@ -299,13 +299,22 @@ Body 同创建。成功:`{"ok": true}`
前缀:`/api/v1/templates` 前缀:`/api/v1/templates`
**鉴权:** Admin Key **鉴权:** Admin Key
模板语法为 Go `text/template`,变量来自解析后的 data。 模板语法为 Go `text/template`,变量来自解析后的 data。缺字段不再报错,按空值处理。
可用 `line` 把前缀和值包在一起:值为空则整行不输出(含前缀和换行)。
```
### {{.symbol}} {{.action}}
{{line "币种" .symbol}}{{line "周期" .period}}{{line "方向" .side}}{{line "价格" .price}}{{line "平均价" .totalAvgPx}}{{line "止盈价" .takeProfitPrice}}{{line "止损价" .stopLossPrice}}
```
等价写法:`{{with .totalAvgPx}}平均价:{{.}}{{end}}`
示例: 示例:
``` ```
### {{.symbol}} 开仓 ### {{.symbol}} 开仓
价格: {{.price}} {{line "价格" .price}}
``` ```
Text 模式示例:`{{.Body}}` Text 模式示例:`{{.Body}}`
+36 -1
View File
@@ -3,6 +3,7 @@ package engine
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"strings"
"text/template" "text/template"
) )
@@ -13,7 +14,10 @@ func NewRenderer() *Renderer {
} }
func (r *Renderer) Render(tmplContent string, data map[string]interface{}) (string, error) { func (r *Renderer) Render(tmplContent string, data map[string]interface{}) (string, error) {
tmpl, err := template.New("notify").Option("missingkey=error").Parse(tmplContent) tmpl, err := template.New("notify").
Option("missingkey=zero").
Funcs(template.FuncMap{"line": templateLine}).
Parse(tmplContent)
if err != nil { if err != nil {
return "", fmt.Errorf("parse template: %w", err) return "", fmt.Errorf("parse template: %w", err)
} }
@@ -23,3 +27,34 @@ func (r *Renderer) Render(tmplContent string, data map[string]interface{}) (stri
} }
return buf.String(), nil return buf.String(), nil
} }
// templateLine renders "labelvalue\n" or empty if value is missing/zero.
func templateLine(label string, v any) string {
if isEmptyValue(v) {
return ""
}
return label + "" + fmt.Sprint(v) + "\n"
}
func isEmptyValue(v any) bool {
switch t := v.(type) {
case nil:
return true
case string:
return strings.TrimSpace(t) == ""
case bool:
return !t
case int:
return t == 0
case int32:
return t == 0
case int64:
return t == 0
case float32:
return t == 0
case float64:
return t == 0
default:
return false
}
}
+35 -4
View File
@@ -18,10 +18,41 @@ func TestRenderer(t *testing.T) {
} }
} }
func TestRenderer_Error(t *testing.T) { func TestRendererMissingKeyEmpty(t *testing.T) {
r := NewRenderer() r := NewRenderer()
_, err := r.Render("{{.nonexistent}}", map[string]interface{}{}) _, err := r.Render("x{{.nonexistent}}y", map[string]interface{}{})
if err == nil { if err != nil {
t.Error("expected error for missing field, got nil") t.Fatal(err)
}
}
func TestRendererLineOmitsEmpty(t *testing.T) {
r := NewRenderer()
tmpl := `{{line "币种" .symbol}}{{line "平均价" .totalAvgPx}}{{line "价格" .price}}{{line "止损价" .stopLossPrice}}`
out, err := r.Render(tmpl, map[string]interface{}{
"symbol": "ICP",
"price": 2.273,
"totalAvgPx": "",
"stopLossPrice": 0,
})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, "币种:ICP") || !strings.Contains(out, "价格:2.273") {
t.Fatalf("out=%q", out)
}
if strings.Contains(out, "平均价") || strings.Contains(out, "止损价") {
t.Fatalf("empty lines should be omitted, out=%q", out)
}
}
func TestRendererLineMissingKey(t *testing.T) {
r := NewRenderer()
out, err := r.Render(`{{line "平均价" .totalAvgPx}}{{line "币种" .symbol}}`, map[string]interface{}{"symbol": "ICP"})
if err != nil {
t.Fatal(err)
}
if strings.Contains(out, "平均价") || !strings.Contains(out, "币种:ICP") {
t.Fatalf("out=%q", out)
} }
} }
+44 -25
View File
@@ -35,6 +35,7 @@ type payload struct {
type remark struct { type remark struct {
OrderID string `json:"orderId"` OrderID string `json:"orderId"`
Revenue string `json:"revenue"`
} }
type Converter struct{} type Converter struct{}
@@ -60,31 +61,39 @@ func Convert(body []byte) (string, map[string]interface{}, error) {
text := format(env, p, action) text := format(env, p, action)
data := map[string]interface{}{ data := map[string]interface{}{
"eventType": env.EventType, "eventType": env.EventType,
"correlationId": env.CorrelationID, "correlationId": env.CorrelationID,
"symbol": firstNonEmpty(env.Symbol, p.Currency), "symbol": firstNonEmpty(env.Symbol, p.Currency),
"direction": env.Direction, "direction": env.Direction,
"side": strings.ToUpper(env.Direction), "side": strings.ToUpper(env.Direction),
"action": action, "action": action,
"eventTime": env.EventTime, "eventTime": env.EventTime,
"strategyCode": p.StrategyCode, "strategyCode": p.StrategyCode,
"period": p.Period, "period": p.Period,
"currency": p.Currency, "currency": p.Currency,
"isSale": p.IsSale, "isSale": p.IsSale,
"isClose": p.IsClose, "isClose": p.IsClose,
"isGain": p.IsGain, "isGain": p.IsGain,
"gainTarget": p.GainTarget, "gainTarget": p.GainTarget,
"price": p.Price, "price": p.Price,
"lossPrice": p.LossPrice, "lossPrice": p.LossPrice,
"gainPrices": p.GainPrices, "gainPrices": p.GainPrices,
"leverage": p.Leverage, "leverage": p.Leverage,
"formatted": text, "formatted": text,
"stopLossPrice": p.LossPrice,
"takeProfitPrice": takeProfitPrice(p),
"totalAvgPx": "",
} }
if p.TotalGainTarget != 0 { if p.TotalGainTarget != 0 {
data["totalGainTarget"] = p.TotalGainTarget data["totalGainTarget"] = p.TotalGainTarget
} }
if oid := parseOrderID(p.Remark); oid != "" { if r := parseRemark(p.Remark); r.OrderID != "" || r.Revenue != "" {
data["orderId"] = oid if r.OrderID != "" {
data["orderId"] = r.OrderID
}
if r.Revenue != "" {
data["revenue"] = r.Revenue
}
} }
return event, data, nil 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) raw = strings.TrimSpace(raw)
if raw == "" { if raw == "" {
return "" return remark{}
} }
var r remark var r remark
if err := json.Unmarshal([]byte(raw), &r); err != nil { 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 { func format(env envelope, p payload, action string) string {
@@ -3,6 +3,8 @@ package cryptostrategy
import ( import (
"strings" "strings"
"testing" "testing"
"aiaa-notification-service/internal/engine"
) )
const sampleBody = `{ 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) { func TestConvertInvalidJSON(t *testing.T) {
_, _, err := Convert([]byte(`{not json`)) _, _, err := Convert([]byte(`{not json`))
if err == nil { if err == nil {
+12 -5
View File
@@ -70,6 +70,13 @@ type MessageConverter interface {
Convert(body []byte) (event string, data map[string]interface{}, err error) 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 { func HandleMessage(ctx context.Context, in HandleInput, conv MessageConverter, lookup SourceLookup, process ProcessFunc) Disposition {
owned := false owned := false
hash := "" hash := ""
@@ -77,7 +84,7 @@ func HandleMessage(ctx context.Context, in HandleInput, conv MessageConverter, l
hash = MessageHash(in.Body) hash = MessageHash(in.Body)
ok, err := in.Deduper.Claim(ctx, hash) ok, err := in.Deduper.Claim(ctx, hash)
if err != nil { 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 { } else if !ok {
slog.Info("duplicate message, ack", "hash", hash) slog.Info("duplicate message, ack", "hash", hash)
return DispositionAck return DispositionAck
@@ -94,13 +101,13 @@ func HandleMessage(ctx context.Context, in HandleInput, conv MessageConverter, l
event, data, err := conv.Convert(in.Body) event, data, err := conv.Convert(in.Body)
if err != nil { 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 return DispositionAck
} }
src, err := lookup(ctx, in.SourceName) src, err := lookup(ctx, in.SourceName)
if err != nil || src == nil || src.Status != 1 { 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 return DispositionAck
} }
@@ -116,14 +123,14 @@ func HandleMessage(ctx context.Context, in HandleInput, conv MessageConverter, l
return DispositionAck return DispositionAck
} }
if errors.Is(err, notify.ErrUnprocessable) { 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 return DispositionAck
} }
disp := DecideRetry(RetryCount(in.Headers), in.MaxRetry) disp := DecideRetry(RetryCount(in.Headers), in.MaxRetry)
if owned && in.Deduper != nil { if owned && in.Deduper != nil {
if relErr := in.Deduper.Release(ctx, hash); relErr != 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 return disp
+2 -2
View File
@@ -122,7 +122,7 @@ func (s *Subscriber) ensureQueue(ch *amqp.Channel) error {
if s.cfg.DeadLetterQueue != "" { if s.cfg.DeadLetterQueue != "" {
if _, err := ch.QueueDeclare(s.cfg.DeadLetterQueue, true, false, false, false, nil); err != nil { 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 return nil
@@ -158,7 +158,7 @@ func (s *Subscriber) republish(ch *amqp.Channel, d amqp.Delivery, queue string)
headers := copyAMQPHeaders(d.Headers) headers := copyAMQPHeaders(d.Headers)
headers[retryHeader] = RetryCount(map[string]any(d.Headers)) + 1 headers[retryHeader] = RetryCount(map[string]any(d.Headers)) + 1
if err := publishToQueue(ch, queue, d.Body, headers); err != nil { 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) _ = d.Nack(false, true)
return return
} }
+11 -4
View File
@@ -39,7 +39,7 @@ func (c *Converter) Convert(body []byte) (string, map[string]interface{}, error)
opts.AvgPrice = &avg opts.AvgPrice = &avg
} }
text := Format(out, opts) text := Format(out, opts)
data, err := toData(out) data, err := toData(body, out)
if err != nil { if err != nil {
return "", nil, err return "", nil, err
} }
@@ -61,14 +61,21 @@ func (c *Converter) overrideFor(code string) *config.StrategyOverride {
return &override 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) raw, err := json.Marshal(sig)
if err != nil { if err != nil {
return nil, err return nil, err
} }
data := make(map[string]interface{}) overlay := make(map[string]interface{})
if err := json.Unmarshal(raw, &data); err != nil { if err := json.Unmarshal(raw, &overlay); err != nil {
return nil, err return nil, err
} }
for k, v := range overlay {
data[k] = v
}
return data, nil 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) { func TestConvertInvalidJSON(t *testing.T) {
_, _, err := NewConverter(nil).Convert([]byte(`{`)) _, _, err := NewConverter(nil).Convert([]byte(`{`))
if !errors.Is(err, ErrInvalidSignal) { if !errors.Is(err, ErrInvalidSignal) {