feat(通知模板): 支持 case 取值映射与事件名匹配渲染
Motivation: 让通知模板能够将 action、event 等原始枚举值映射为可读中文文案,并支持按事件名后缀匹配;同时简化 SafeW 消息发送格式,避免 Markdown 转义引入的显示问题。 Changes: * 新增 `case` 模板函数,按值匹配 key/文案并支持末尾奇数参数作为默认值 * `case` 匹配兼容事件名后缀(如 trade.close 匹配 .close) * `line` 前缀自动去除末尾冒号,避免重复冒号 * 模板渲染前自动注入 event 字段,便于模板按事件名取值 * SafeW 消息改为纯文本发送,移除 MarkdownV2 转义
This commit is contained in:
@@ -303,11 +303,15 @@ Body 同创建。成功:`{"ok": true}`
|
|||||||
|
|
||||||
可用 `line` 把前缀和值包在一起:值为空则整行不输出(含前缀和换行)。
|
可用 `line` 把前缀和值包在一起:值为空则整行不输出(含前缀和换行)。
|
||||||
|
|
||||||
|
`case` 类似 switch:按值匹配成对的 key/文案,最后一个奇数参数是默认值。`trade.close` 能匹配 `.close` / `close` / `CLOSE`。
|
||||||
|
|
||||||
```
|
```
|
||||||
### {{.symbol}} {{.action}}
|
### {{.symbol}} {{case .action "OPEN" "开仓" "CLOSE" "平仓" "GAIN" "止盈" "SELL" "卖出" "ADD" "加仓" "REDUCE" "减仓"}}
|
||||||
{{line "币种" .symbol}}{{line "周期" .period}}{{line "方向" .side}}{{line "价格" .price}}{{line "平均价" .totalAvgPx}}{{line "止盈价" .takeProfitPrice}}{{line "止损价" .stopLossPrice}}
|
{{line "币种" .symbol}}{{line "周期" .period}}{{line "方向" .side}}{{line "价格" .price}}{{line "平均价" .totalAvgPx}}{{line "止盈价" .takeProfitPrice}}{{line "止损价" .stopLossPrice}}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
也可用事件名:`{{case .event ".open" "开仓" ".close" "平仓"}}`
|
||||||
|
|
||||||
等价写法:`{{with .totalAvgPx}}平均价:{{.}}{{end}}`
|
等价写法:`{{with .totalAvgPx}}平均价:{{.}}{{end}}`
|
||||||
|
|
||||||
示例:
|
示例:
|
||||||
@@ -422,7 +426,7 @@ SMTP 使用全局 `config.yaml` 的 `smtp` 段;支持 587 STARTTLS / 465 TLS
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`chat_id` 可为数字或字符串(含 `@username`)。消息以 MarkdownV2 发送:标题加粗,标题和正文均自动转义。
|
`chat_id` 可为数字或字符串(含 `@username`)。消息以纯文本发送,不做 Markdown 转义。
|
||||||
|
|
||||||
#### `POST /api/v1/channels/safew/chats` — 列出已监控的 SafeW 群
|
#### `POST /api/v1/channels/safew/chats` — 列出已监控的 SafeW 群
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ type safewConfig struct {
|
|||||||
type safewMessage struct {
|
type safewMessage struct {
|
||||||
ChatID string `json:"chat_id"`
|
ChatID string `json:"chat_id"`
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
ParseMode string `json:"parse_mode"`
|
ParseMode string `json:"parse_mode,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type safewAPIResponse struct {
|
type safewAPIResponse struct {
|
||||||
@@ -53,8 +53,7 @@ func (s *SafeWSender) Send(title, content string, config json.RawMessage) error
|
|||||||
|
|
||||||
payload := safewMessage{
|
payload := safewMessage{
|
||||||
ChatID: chatID,
|
ChatID: chatID,
|
||||||
Text: "*" + escapeMarkdownV2(title) + "*\n" + escapeMarkdownV2(content),
|
Text: title + "\n" + content,
|
||||||
ParseMode: "MarkdownV2",
|
|
||||||
}
|
}
|
||||||
body, err := json.Marshal(payload)
|
body, err := json.Marshal(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -235,19 +234,6 @@ func safewErrorDescription(body []byte) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func escapeMarkdownV2(s string) string {
|
|
||||||
var b strings.Builder
|
|
||||||
b.Grow(len(s))
|
|
||||||
for _, r := range s {
|
|
||||||
switch r {
|
|
||||||
case '_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!', '\\':
|
|
||||||
b.WriteByte('\\')
|
|
||||||
}
|
|
||||||
b.WriteRune(r)
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
type SafewChat struct {
|
type SafewChat struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
|
|||||||
@@ -11,32 +11,6 @@ import (
|
|||||||
"aiaa-notification-service/internal/config"
|
"aiaa-notification-service/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestEscapeMarkdownV2(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
in string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{name: "underscore and dot", in: "hello_world.", want: `hello\_world\.`},
|
|
||||||
{name: "empty", in: "", want: ""},
|
|
||||||
{name: "no specials", in: "hello", want: "hello"},
|
|
||||||
{name: "backslash first", in: `a\b`, want: `a\\b`},
|
|
||||||
{
|
|
||||||
name: "all specials",
|
|
||||||
in: "_*[]()~`>#+-=|{}.!\\",
|
|
||||||
want: "\\_\\*\\[\\]\\(\\)\\~\\`\\>\\#\\+\\-\\=\\|\\{\\}\\.\\!\\\\",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got := escapeMarkdownV2(tt.in)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Fatalf("escapeMarkdownV2(%q) = %q, want %q", tt.in, got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewSenderSafew(t *testing.T) {
|
func TestNewSenderSafew(t *testing.T) {
|
||||||
s, err := NewSender("safew", &config.SMTPConfig{}, nil)
|
s, err := NewSender("safew", &config.SMTPConfig{}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -72,10 +46,10 @@ func TestSafeWSenderSendSuccess(t *testing.T) {
|
|||||||
if gotBody["chat_id"] != "123456789" {
|
if gotBody["chat_id"] != "123456789" {
|
||||||
t.Fatalf("chat_id = %#v, want \"123456789\"", gotBody["chat_id"])
|
t.Fatalf("chat_id = %#v, want \"123456789\"", gotBody["chat_id"])
|
||||||
}
|
}
|
||||||
if gotBody["parse_mode"] != "MarkdownV2" {
|
if gotBody["parse_mode"] != nil && gotBody["parse_mode"] != "" {
|
||||||
t.Fatalf("parse_mode = %#v, want MarkdownV2", gotBody["parse_mode"])
|
t.Fatalf("parse_mode = %#v, want omitted/plain", gotBody["parse_mode"])
|
||||||
}
|
}
|
||||||
wantText := "*" + escapeMarkdownV2("hello_world.") + "*\n" + escapeMarkdownV2("price=1.5")
|
wantText := "hello_world.\nprice=1.5"
|
||||||
if gotBody["text"] != wantText {
|
if gotBody["text"] != wantText {
|
||||||
t.Fatalf("text = %#v, want %#v", gotBody["text"], wantText)
|
t.Fatalf("text = %#v, want %#v", gotBody["text"], wantText)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,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").
|
tmpl, err := template.New("notify").
|
||||||
Option("missingkey=zero").
|
Option("missingkey=zero").
|
||||||
Funcs(template.FuncMap{"line": templateLine}).
|
Funcs(template.FuncMap{
|
||||||
|
"line": templateLine,
|
||||||
|
"case": templateCase,
|
||||||
|
}).
|
||||||
Parse(tmplContent)
|
Parse(tmplContent)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("parse template: %w", err)
|
return "", fmt.Errorf("parse template: %w", err)
|
||||||
@@ -33,7 +36,41 @@ func templateLine(label string, v any) string {
|
|||||||
if isEmptyValue(v) {
|
if isEmptyValue(v) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return label + ":" + fmt.Sprint(v) + "\n"
|
return strings.TrimRight(label, "::") + ":" + fmt.Sprint(v) + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
func templateCase(value any, pairs ...any) string {
|
||||||
|
got := stringify(value)
|
||||||
|
n := len(pairs)
|
||||||
|
def := got
|
||||||
|
if n%2 == 1 {
|
||||||
|
def = stringify(pairs[n-1])
|
||||||
|
pairs = pairs[:n-1]
|
||||||
|
}
|
||||||
|
for i := 0; i+1 < len(pairs); i += 2 {
|
||||||
|
if caseKeyMatch(got, stringify(pairs[i])) {
|
||||||
|
return stringify(pairs[i+1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func caseKeyMatch(value, key string) bool {
|
||||||
|
v := strings.ToLower(strings.TrimSpace(value))
|
||||||
|
k := strings.ToLower(strings.TrimSpace(key))
|
||||||
|
k = strings.TrimPrefix(k, ".")
|
||||||
|
vSeg := v
|
||||||
|
if i := strings.LastIndex(v, "."); i >= 0 {
|
||||||
|
vSeg = v[i+1:]
|
||||||
|
}
|
||||||
|
return k == v || k == strings.TrimPrefix(v, ".") || k == vSeg
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringify(v any) string {
|
||||||
|
if v == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprint(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
func isEmptyValue(v any) bool {
|
func isEmptyValue(v any) bool {
|
||||||
|
|||||||
@@ -46,6 +46,16 @@ func TestRendererLineOmitsEmpty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRendererLineLabelAlreadyHasColon(t *testing.T) {
|
||||||
|
r := NewRenderer()
|
||||||
|
out, err := r.Render(`{{line "平均价:" .totalAvgPx}}`, map[string]interface{}{"totalAvgPx": 1029})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Count(out, ":") != 1 || !strings.Contains(out, "平均价:1029") {
|
||||||
|
t.Fatalf("out=%q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
func TestRendererLineMissingKey(t *testing.T) {
|
func TestRendererLineMissingKey(t *testing.T) {
|
||||||
r := NewRenderer()
|
r := NewRenderer()
|
||||||
out, err := r.Render(`{{line "平均价" .totalAvgPx}}{{line "币种" .symbol}}`, map[string]interface{}{"symbol": "ICP"})
|
out, err := r.Render(`{{line "平均价" .totalAvgPx}}{{line "币种" .symbol}}`, map[string]interface{}{"symbol": "ICP"})
|
||||||
@@ -56,3 +66,45 @@ func TestRendererLineMissingKey(t *testing.T) {
|
|||||||
t.Fatalf("out=%q", out)
|
t.Fatalf("out=%q", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRendererCaseSwitch(t *testing.T) {
|
||||||
|
r := NewRenderer()
|
||||||
|
tmpl := `{{case .action "OPEN" "开仓" "CLOSE" "平仓" "GAIN" "止盈"}}`
|
||||||
|
out, err := r.Render(tmpl, map[string]interface{}{"action": "CLOSE"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if out != "平仓" {
|
||||||
|
t.Fatalf("action CLOSE: %q", out)
|
||||||
|
}
|
||||||
|
out, err = r.Render(tmpl, map[string]interface{}{"action": "open"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if out != "开仓" {
|
||||||
|
t.Fatalf("action open: %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRendererCaseEventSuffix(t *testing.T) {
|
||||||
|
r := NewRenderer()
|
||||||
|
tmpl := `{{case .event ".open" "开仓" ".close" "平仓"}}`
|
||||||
|
out, err := r.Render(tmpl, map[string]interface{}{"event": "trade.close"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if out != "平仓" {
|
||||||
|
t.Fatalf("event trade.close: %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRendererCaseDefault(t *testing.T) {
|
||||||
|
r := NewRenderer()
|
||||||
|
out, err := r.Render(`{{case .action "OPEN" "开仓" "未知"}}`, map[string]interface{}{"action": "HOLD"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if out != "未知" {
|
||||||
|
t.Fatalf("got %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -79,6 +79,13 @@ func (s *Service) Process(ctx context.Context, req Request) (Result, error) {
|
|||||||
return Result{}, fmt.Errorf("template not found")
|
return Result{}, fmt.Errorf("template not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.Data == nil {
|
||||||
|
req.Data = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
if _, ok := req.Data["event"]; !ok && req.Event != "" {
|
||||||
|
req.Data["event"] = req.Event
|
||||||
|
}
|
||||||
|
|
||||||
content, err := s.renderer.Render(tmpl.Content, req.Data)
|
content, err := s.renderer.Render(tmpl.Content, req.Data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Result{}, fmt.Errorf("%w: template render failed: %s", ErrUnprocessable, err.Error())
|
return Result{}, fmt.Errorf("%w: template render failed: %s", ErrUnprocessable, err.Error())
|
||||||
|
|||||||
@@ -92,6 +92,25 @@ func TestProcessMatched(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProcessTemplateCanSwitchOnEvent(t *testing.T) {
|
||||||
|
svc := newSvc(
|
||||||
|
&fakeMatcher{rule: &model.Rule{ID: 9, TemplateID: 1}},
|
||||||
|
&fakeTemplates{tmpl: &model.Template{ID: 1, Content: `{{case .event ".open" "开仓" ".close" "平仓"}}`}},
|
||||||
|
&fakeRouter{channels: []string{"safew:1"}},
|
||||||
|
)
|
||||||
|
res, err := svc.Process(context.Background(), Request{
|
||||||
|
Source: &model.Source{ID: 1, Name: "crypto-strategy"},
|
||||||
|
Event: "trade.close",
|
||||||
|
Data: map[string]interface{}{"symbol": "XAU"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !res.Matched {
|
||||||
|
t.Fatalf("%+v", res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestProcessInvalidConditions(t *testing.T) {
|
func TestProcessInvalidConditions(t *testing.T) {
|
||||||
raw := json.RawMessage(`not-json`)
|
raw := json.RawMessage(`not-json`)
|
||||||
svc := newSvc(&fakeMatcher{rule: &model.Rule{ID: 1, TemplateID: 1, Conditions: &raw}}, &fakeTemplates{}, &fakeRouter{})
|
svc := newSvc(&fakeMatcher{rule: &model.Rule{ID: 1, TemplateID: 1, Conditions: &raw}}, &fakeTemplates{}, &fakeRouter{})
|
||||||
|
|||||||
Reference in New Issue
Block a user