From a9b62342086165251bab8d936af83f75c4aad6f1 Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 16 Aug 2026 00:45:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E9=80=9A=E7=9F=A5=E6=A8=A1=E6=9D=BF):=20?= =?UTF-8?q?=E6=94=AF=E6=8C=81=20case=20=E5=8F=96=E5=80=BC=E6=98=A0?= =?UTF-8?q?=E5=B0=84=E4=B8=8E=E4=BA=8B=E4=BB=B6=E5=90=8D=E5=8C=B9=E9=85=8D?= =?UTF-8?q?=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Motivation: 让通知模板能够将 action、event 等原始枚举值映射为可读中文文案,并支持按事件名后缀匹配;同时简化 SafeW 消息发送格式,避免 Markdown 转义引入的显示问题。 Changes: * 新增 `case` 模板函数,按值匹配 key/文案并支持末尾奇数参数作为默认值 * `case` 匹配兼容事件名后缀(如 trade.close 匹配 .close) * `line` 前缀自动去除末尾冒号,避免重复冒号 * 模板渲染前自动注入 event 字段,便于模板按事件名取值 * SafeW 消息改为纯文本发送,移除 MarkdownV2 转义 --- README.md | 8 +++-- internal/adapter/safew.go | 20 ++---------- internal/adapter/safew_test.go | 32 ++------------------ internal/engine/renderer.go | 41 +++++++++++++++++++++++-- internal/engine/renderer_test.go | 52 ++++++++++++++++++++++++++++++++ internal/notify/service.go | 7 +++++ internal/notify/service_test.go | 19 ++++++++++++ 7 files changed, 129 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index e4a16bf..77ba04f 100644 --- a/README.md +++ b/README.md @@ -303,11 +303,15 @@ Body 同创建。成功:`{"ok": true}` 可用 `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}} ``` +也可用事件名:`{{case .event ".open" "开仓" ".close" "平仓"}}` + 等价写法:`{{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 群 diff --git a/internal/adapter/safew.go b/internal/adapter/safew.go index 0660f6d..d7b504f 100644 --- a/internal/adapter/safew.go +++ b/internal/adapter/safew.go @@ -26,7 +26,7 @@ type safewConfig struct { type safewMessage struct { ChatID string `json:"chat_id"` Text string `json:"text"` - ParseMode string `json:"parse_mode"` + ParseMode string `json:"parse_mode,omitempty"` } type safewAPIResponse struct { @@ -52,9 +52,8 @@ func (s *SafeWSender) Send(title, content string, config json.RawMessage) error } payload := safewMessage{ - ChatID: chatID, - Text: "*" + escapeMarkdownV2(title) + "*\n" + escapeMarkdownV2(content), - ParseMode: "MarkdownV2", + ChatID: chatID, + Text: title + "\n" + content, } body, err := json.Marshal(payload) 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 { ID string `json:"id"` Type string `json:"type"` diff --git a/internal/adapter/safew_test.go b/internal/adapter/safew_test.go index e5ec9da..b3059b4 100644 --- a/internal/adapter/safew_test.go +++ b/internal/adapter/safew_test.go @@ -11,32 +11,6 @@ import ( "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) { s, err := NewSender("safew", &config.SMTPConfig{}, nil) if err != nil { @@ -72,10 +46,10 @@ func TestSafeWSenderSendSuccess(t *testing.T) { if gotBody["chat_id"] != "123456789" { t.Fatalf("chat_id = %#v, want \"123456789\"", gotBody["chat_id"]) } - if gotBody["parse_mode"] != "MarkdownV2" { - t.Fatalf("parse_mode = %#v, want MarkdownV2", gotBody["parse_mode"]) + if gotBody["parse_mode"] != nil && 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 { t.Fatalf("text = %#v, want %#v", gotBody["text"], wantText) } diff --git a/internal/engine/renderer.go b/internal/engine/renderer.go index f049dbf..db83c63 100644 --- a/internal/engine/renderer.go +++ b/internal/engine/renderer.go @@ -16,7 +16,10 @@ func NewRenderer() *Renderer { func (r *Renderer) Render(tmplContent string, data map[string]interface{}) (string, error) { tmpl, err := template.New("notify"). Option("missingkey=zero"). - Funcs(template.FuncMap{"line": templateLine}). + Funcs(template.FuncMap{ + "line": templateLine, + "case": templateCase, + }). Parse(tmplContent) if err != nil { return "", fmt.Errorf("parse template: %w", err) @@ -33,7 +36,41 @@ func templateLine(label string, v any) string { if isEmptyValue(v) { 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 { diff --git a/internal/engine/renderer_test.go b/internal/engine/renderer_test.go index 4b90a83..c5f3a25 100644 --- a/internal/engine/renderer_test.go +++ b/internal/engine/renderer_test.go @@ -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) { r := NewRenderer() 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) } } + +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) + } +} diff --git a/internal/notify/service.go b/internal/notify/service.go index 84c555a..2cf8fce 100644 --- a/internal/notify/service.go +++ b/internal/notify/service.go @@ -79,6 +79,13 @@ func (s *Service) Process(ctx context.Context, req Request) (Result, error) { 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) if err != nil { return Result{}, fmt.Errorf("%w: template render failed: %s", ErrUnprocessable, err.Error()) diff --git a/internal/notify/service_test.go b/internal/notify/service_test.go index 50201d2..7a1c5db 100644 --- a/internal/notify/service_test.go +++ b/internal/notify/service_test.go @@ -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) { raw := json.RawMessage(`not-json`) svc := newSvc(&fakeMatcher{rule: &model.Rule{ID: 1, TemplateID: 1, Conditions: &raw}}, &fakeTemplates{}, &fakeRouter{})