From 0f9f15496993d95af3634c5eaae9a10cea4ee5b8 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 17 Aug 2026 00:01:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=A7=84=E5=88=99):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=90=8D=E7=A7=B0=E4=BB=A3=E5=8F=B7=EF=BC=8C=E4=BE=BF=E4=BA=8E?= =?UTF-8?q?=E5=8C=BA=E5=88=86=E5=92=8C=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 创建与更新规则时必填全局唯一 name,已有规则迁移回填为 rule-{id}。 --- README.md | 3 ++ docs/httpie/curls.md | 2 ++ docs/httpie/postman_collection.json | 4 +-- internal/handler/rule.go | 3 ++ internal/handler/rule_test.go | 43 +++++++++++++++++++++++++++++ internal/model/model.go | 1 + internal/store/rule.go | 22 +++++++-------- migrations/002_rule_name.down.sql | 2 ++ migrations/002_rule_name.up.sql | 7 +++++ test/e2e/notify_flow_test.go | 2 ++ 10 files changed, 76 insertions(+), 13 deletions(-) create mode 100644 internal/handler/rule_test.go create mode 100644 migrations/002_rule_name.down.sql create mode 100644 migrations/002_rule_name.up.sql diff --git a/README.md b/README.md index bf7424f..c6a5971 100644 --- a/README.md +++ b/README.md @@ -486,6 +486,7 @@ Query:`page`、`page_size`。**200:** `{ "data": Channel[], "total", "page" ```json { + "name": "trade-open-alert", "source_name": "trading-system", "event": "trade.open", "template_name": "trade_open", @@ -500,6 +501,7 @@ Query:`page`、`page_size`。**200:** `{ "data": Channel[], "total", "page" | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| +| `name` | string | 是 | 规则名称(代号),全局唯一 | | `source_name` | string | 是 | Source 名称 | | `event` | string | 是 | 事件名;支持 `*` / `?` 通配(如 `trade.*`)。精确匹配优先于通配,更具体的通配优先于 `*`。text 模式请用 `default` | | `template_name` | string | 是 | Template 名称 | @@ -606,6 +608,7 @@ curl -s -X POST http://localhost:8080/api/v1/channels \ curl -s -X POST http://localhost:8080/api/v1/rules \ -H "$ADMIN" -H "Content-Type: application/json" \ -d '{ + "name":"trade-open-alert", "source_name":"trading-system", "event":"trade.open", "template_name":"trade_open", diff --git a/docs/httpie/curls.md b/docs/httpie/curls.md index 8da680f..d519758 100644 --- a/docs/httpie/curls.md +++ b/docs/httpie/curls.md @@ -341,6 +341,7 @@ curl -X POST 'http://localhost:8080/api/v1/rules' \ -H 'Authorization: Bearer admin-sk-change-me' \ -H 'Content-Type: application/json' \ -d '{ + "name": "trade-open-alert", "source_name": "trading-system", "event": "trade.open", "template_name": "trade_open", @@ -374,6 +375,7 @@ curl -X PUT 'http://localhost:8080/api/v1/rules/1' \ -H 'Authorization: Bearer admin-sk-change-me' \ -H 'Content-Type: application/json' \ -d '{ + "name": "trade-open-alert", "source_name": "trading-system", "event": "trade.open", "template_name": "trade_open", diff --git a/docs/httpie/postman_collection.json b/docs/httpie/postman_collection.json index c3fe645..804f22e 100644 --- a/docs/httpie/postman_collection.json +++ b/docs/httpie/postman_collection.json @@ -425,7 +425,7 @@ ], "body": { "mode": "raw", - "raw": "{\n \"source_name\": \"trading-system\",\n \"event\": \"trade.open\",\n \"template_name\": \"trade_open\",\n \"channels\": [\"email-ops\"],\n \"conditions\": [\n {\"field\": \"symbol\", \"op\": \"exists\"},\n {\"field\": \"price\", \"op\": \"gt\", \"value\": \"0\"}\n ],\n \"enabled\": 1\n}" + "raw": "{\n \"name\": \"trade-open-alert\",\n \"source_name\": \"trading-system\",\n \"event\": \"trade.open\",\n \"template_name\": \"trade_open\",\n \"channels\": [\"email-ops\"],\n \"conditions\": [\n {\"field\": \"symbol\", \"op\": \"exists\"},\n {\"field\": \"price\", \"op\": \"gt\", \"value\": \"0\"}\n ],\n \"enabled\": 1\n}" }, "url": "{{baseUrl}}/api/v1/rules" } @@ -467,7 +467,7 @@ ], "body": { "mode": "raw", - "raw": "{\n \"source_name\": \"trading-system\",\n \"event\": \"trade.open\",\n \"template_name\": \"trade_open\",\n \"channels\": [\"email-ops\"],\n \"enabled\": 1\n}" + "raw": "{\n \"name\": \"trade-open-alert\",\n \"source_name\": \"trading-system\",\n \"event\": \"trade.open\",\n \"template_name\": \"trade_open\",\n \"channels\": [\"email-ops\"],\n \"enabled\": 1\n}" }, "url": "{{baseUrl}}/api/v1/rules/{{ruleId}}" } diff --git a/internal/handler/rule.go b/internal/handler/rule.go index b4942af..dfaca2f 100644 --- a/internal/handler/rule.go +++ b/internal/handler/rule.go @@ -22,6 +22,7 @@ func NewRuleHandler(s *store.Store, c *cache.Cache) *RuleHandler { } type createRuleReq struct { + Name string `json:"name" binding:"required"` SourceName string `json:"source_name" binding:"required"` Event string `json:"event" binding:"required"` TemplateName string `json:"template_name" binding:"required"` @@ -65,6 +66,7 @@ func (h *RuleHandler) Create(c *gin.Context) { } rule := &model.Rule{ + Name: req.Name, SourceID: src.ID, Event: req.Event, TemplateID: tmpl.ID, @@ -138,6 +140,7 @@ func (h *RuleHandler) Update(c *gin.Context) { } rule := &model.Rule{ + Name: req.Name, SourceID: src.ID, Event: req.Event, TemplateID: tmpl.ID, diff --git a/internal/handler/rule_test.go b/internal/handler/rule_test.go new file mode 100644 index 0000000..8c88b80 --- /dev/null +++ b/internal/handler/rule_test.go @@ -0,0 +1,43 @@ +package handler + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestCreateRuleReqBindsName(t *testing.T) { + var req createRuleReq + err := json.Unmarshal([]byte(`{ + "name":"高低分短线", + "source_name":"crypto-strategy", + "event":"HLSS.*", + "template_name":"高低分短线" + }`), &req) + if err != nil { + t.Fatal(err) + } + if req.Name != "高低分短线" { + t.Fatalf("name=%q", req.Name) + } +} + +func TestCreateRuleRequiresName(t *testing.T) { + gin.SetMode(gin.TestMode) + h := NewRuleHandler(nil, nil) + r := gin.New() + r.POST("/api/v1/rules", h.Create) + req := httptest.NewRequest(http.MethodPost, "/api/v1/rules", bytes.NewReader([]byte( + `{"source_name":"s","event":"trade.open","template_name":"t"}`, + ))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("code=%d body=%s", w.Code, w.Body.String()) + } +} diff --git a/internal/model/model.go b/internal/model/model.go index bdb062b..7ef32c9 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -37,6 +37,7 @@ type Channel struct { type Rule struct { ID int `db:"id" json:"id"` + Name string `db:"name" json:"name"` SourceID int `db:"source_id" json:"source_id"` Event string `db:"event" json:"event"` TemplateID int `db:"template_id" json:"template_id"` diff --git a/internal/store/rule.go b/internal/store/rule.go index f3f7298..50d4863 100644 --- a/internal/store/rule.go +++ b/internal/store/rule.go @@ -16,12 +16,12 @@ func (s *Store) CreateRule(ctx context.Context, r *model.Rule, channelIDs []int) } defer tx.Rollback() - query := `INSERT INTO notification_rule (source_id, event, template_id, conditions, enabled) VALUES (?, ?, ?, ?, ?)` + query := `INSERT INTO notification_rule (name, source_id, event, template_id, conditions, enabled) VALUES (?, ?, ?, ?, ?, ?)` condsJSON, err := marshalJSON(r.Conditions) if err != nil { return fmt.Errorf("marshal conditions: %w", err) } - result, err := tx.ExecContext(ctx, query, r.SourceID, r.Event, r.TemplateID, condsJSON, r.Enabled) + result, err := tx.ExecContext(ctx, query, r.Name, r.SourceID, r.Event, r.TemplateID, condsJSON, r.Enabled) if err != nil { return fmt.Errorf("create rule: %w", err) } @@ -40,8 +40,8 @@ func (s *Store) CreateRule(ctx context.Context, r *model.Rule, channelIDs []int) func (s *Store) GetRule(ctx context.Context, id int) (*model.Rule, error) { var r model.Rule var condsBytes []byte - row := s.DB.QueryRowContext(ctx, `SELECT id, source_id, event, template_id, conditions, enabled, created_at, updated_at FROM notification_rule WHERE id = ?`, id) - if err := row.Scan(&r.ID, &r.SourceID, &r.Event, &r.TemplateID, &condsBytes, &r.Enabled, &r.CreatedAt, &r.UpdatedAt); err != nil { + row := s.DB.QueryRowContext(ctx, `SELECT id, name, source_id, event, template_id, conditions, enabled, created_at, updated_at FROM notification_rule WHERE id = ?`, id) + if err := row.Scan(&r.ID, &r.Name, &r.SourceID, &r.Event, &r.TemplateID, &condsBytes, &r.Enabled, &r.CreatedAt, &r.UpdatedAt); err != nil { return nil, fmt.Errorf("get rule %d: %w", id, err) } if len(condsBytes) > 0 && string(condsBytes) != "null" { @@ -54,9 +54,9 @@ func (s *Store) GetRule(ctx context.Context, id int) (*model.Rule, error) { func (s *Store) GetRuleBySourceEvent(ctx context.Context, sourceID int, event string) (*model.Rule, error) { var r model.Rule var condsBytes []byte - query := `SELECT id, source_id, event, template_id, conditions, enabled, created_at, updated_at FROM notification_rule WHERE source_id = ? AND event = ? AND enabled = 1` + query := `SELECT id, name, source_id, event, template_id, conditions, enabled, created_at, updated_at FROM notification_rule WHERE source_id = ? AND event = ? AND enabled = 1` row := s.DB.QueryRowContext(ctx, query, sourceID, event) - if err := row.Scan(&r.ID, &r.SourceID, &r.Event, &r.TemplateID, &condsBytes, &r.Enabled, &r.CreatedAt, &r.UpdatedAt); err != nil { + if err := row.Scan(&r.ID, &r.Name, &r.SourceID, &r.Event, &r.TemplateID, &condsBytes, &r.Enabled, &r.CreatedAt, &r.UpdatedAt); err != nil { return nil, fmt.Errorf("get rule by source+event: %w", err) } if len(condsBytes) > 0 && string(condsBytes) != "null" { @@ -67,7 +67,7 @@ func (s *Store) GetRuleBySourceEvent(ctx context.Context, sourceID int, event st } func (s *Store) ListEnabledRulesBySource(ctx context.Context, sourceID int) ([]model.Rule, error) { - rows, err := s.DB.QueryContext(ctx, `SELECT id, source_id, event, template_id, conditions, enabled, created_at, updated_at FROM notification_rule WHERE source_id = ? AND enabled = 1`, sourceID) + rows, err := s.DB.QueryContext(ctx, `SELECT id, name, source_id, event, template_id, conditions, enabled, created_at, updated_at FROM notification_rule WHERE source_id = ? AND enabled = 1`, sourceID) if err != nil { return nil, fmt.Errorf("list enabled rules by source: %w", err) } @@ -86,7 +86,7 @@ func (s *Store) ListRules(ctx context.Context, page PageFilter) ([]model.Rule, i } page.Normalize() - rows, err := s.DB.QueryContext(ctx, `SELECT id, source_id, event, template_id, conditions, enabled, created_at, updated_at FROM notification_rule ORDER BY id LIMIT ? OFFSET ?`, page.PageSize, page.Offset()) + rows, err := s.DB.QueryContext(ctx, `SELECT id, name, source_id, event, template_id, conditions, enabled, created_at, updated_at FROM notification_rule ORDER BY id LIMIT ? OFFSET ?`, page.PageSize, page.Offset()) if err != nil { return nil, 0, fmt.Errorf("list rules: %w", err) } @@ -109,8 +109,8 @@ func (s *Store) UpdateRule(ctx context.Context, id int, r *model.Rule, channelID if err != nil { return fmt.Errorf("marshal conditions: %w", err) } - _, err = tx.ExecContext(ctx, `UPDATE notification_rule SET source_id=?, event=?, template_id=?, conditions=?, enabled=? WHERE id=?`, - r.SourceID, r.Event, r.TemplateID, condsJSON, r.Enabled, id) + _, err = tx.ExecContext(ctx, `UPDATE notification_rule SET name=?, source_id=?, event=?, template_id=?, conditions=?, enabled=? WHERE id=?`, + r.Name, r.SourceID, r.Event, r.TemplateID, condsJSON, r.Enabled, id) if err != nil { return fmt.Errorf("update rule: %w", err) } @@ -183,7 +183,7 @@ func scanRules(rows *sql.Rows) ([]model.Rule, error) { for rows.Next() { var r model.Rule var condsBytes []byte - if err := rows.Scan(&r.ID, &r.SourceID, &r.Event, &r.TemplateID, &condsBytes, &r.Enabled, &r.CreatedAt, &r.UpdatedAt); err != nil { + if err := rows.Scan(&r.ID, &r.Name, &r.SourceID, &r.Event, &r.TemplateID, &condsBytes, &r.Enabled, &r.CreatedAt, &r.UpdatedAt); err != nil { return nil, err } if len(condsBytes) > 0 && string(condsBytes) != "null" { diff --git a/migrations/002_rule_name.down.sql b/migrations/002_rule_name.down.sql new file mode 100644 index 0000000..5030cbc --- /dev/null +++ b/migrations/002_rule_name.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE notification_rule DROP INDEX uk_name; +ALTER TABLE notification_rule DROP COLUMN name; diff --git a/migrations/002_rule_name.up.sql b/migrations/002_rule_name.up.sql new file mode 100644 index 0000000..3b0a71f --- /dev/null +++ b/migrations/002_rule_name.up.sql @@ -0,0 +1,7 @@ +ALTER TABLE notification_rule + ADD COLUMN name VARCHAR(64) NOT NULL DEFAULT '' AFTER id; + +UPDATE notification_rule SET name = CONCAT('rule-', id) WHERE name = ''; + +ALTER TABLE notification_rule + ADD UNIQUE KEY uk_name (name); diff --git a/test/e2e/notify_flow_test.go b/test/e2e/notify_flow_test.go index 8a134aa..79ca8b1 100644 --- a/test/e2e/notify_flow_test.go +++ b/test/e2e/notify_flow_test.go @@ -116,6 +116,7 @@ func setupFixture(t *testing.T) *fixture { }) rule := mustAdminJSON(t, client, base, adminKey, http.MethodPost, "/api/v1/rules", map[string]any{ + "name": "e2e-rule-" + suffix, "source_name": f.srcName, "event": "trade.open", "template_name": tmplName, @@ -361,6 +362,7 @@ func TestNotifyFlow_DifferentTemplatesPerRule(t *testing.T) { }) rule := mustAdminJSON(t, client, base, adminKey, http.MethodPost, "/api/v1/rules", map[string]any{ + "name": "e2e-rule-" + r.event + "-" + suffix, "source_name": srcName, "event": r.event, "template_name": tmplName,