diff --git a/README.md b/README.md index 8c80682..522dcf8 100644 --- a/README.md +++ b/README.md @@ -486,7 +486,7 @@ Query:`page`、`page_size`。**200:** `{ "data": Channel[], "total", "page" | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | `source_name` | string | 是 | Source 名称 | -| `event` | string | 是 | 事件名;text 模式请用 `default` | +| `event` | string | 是 | 事件名;支持 `*` / `?` 通配(如 `trade.*`)。精确匹配优先于通配,更具体的通配优先于 `*`。text 模式请用 `default` | | `template_name` | string | 是 | Template 名称 | | `channels` | string[] | 否 | Channel 名称列表 | | `conditions` | object[] | 否 | 过滤条件,全部 AND;省略则不过滤 | diff --git a/internal/engine/event_match.go b/internal/engine/event_match.go new file mode 100644 index 0000000..85bbe37 --- /dev/null +++ b/internal/engine/event_match.go @@ -0,0 +1,55 @@ +package engine + +import ( + "path" + "strings" + + "aiaa-notification-service/internal/model" +) + +// PickEventRule selects the best enabled rule for an event. +// Exact event wins; otherwise glob patterns (* and ?) via path.Match. +// Among globs, more literal characters win; ties go to the smaller ID. +func PickEventRule(event string, rules []model.Rule) *model.Rule { + var exact *model.Rule + var best *model.Rule + bestScore := -1 + for i := range rules { + r := &rules[i] + if r.Enabled == 0 { + continue + } + if r.Event == event { + if exact == nil || r.ID < exact.ID { + exact = r + } + continue + } + if !strings.ContainsAny(r.Event, "*?") { + continue + } + ok, err := path.Match(r.Event, event) + if err != nil || !ok { + continue + } + score := globSpecificity(r.Event) + if score > bestScore || (score == bestScore && (best == nil || r.ID < best.ID)) { + bestScore = score + best = r + } + } + if exact != nil { + return exact + } + return best +} + +func globSpecificity(pattern string) int { + n := 0 + for _, r := range pattern { + if r != '*' && r != '?' { + n++ + } + } + return n +} diff --git a/internal/engine/event_match_test.go b/internal/engine/event_match_test.go new file mode 100644 index 0000000..902f03b --- /dev/null +++ b/internal/engine/event_match_test.go @@ -0,0 +1,62 @@ +package engine + +import ( + "testing" + + "aiaa-notification-service/internal/model" +) + +func TestPickEventRuleExact(t *testing.T) { + rules := []model.Rule{ + {ID: 1, Event: "trade.*", Enabled: 1}, + {ID: 2, Event: "trade.open", Enabled: 1}, + } + got := PickEventRule("trade.open", rules) + if got == nil || got.ID != 2 { + t.Fatalf("want exact id=2, got %#v", got) + } +} + +func TestPickEventRuleWildcard(t *testing.T) { + rules := []model.Rule{ + {ID: 1, Event: "trade.*", Enabled: 1}, + } + got := PickEventRule("trade.close", rules) + if got == nil || got.Event != "trade.*" { + t.Fatalf("want trade.*, got %#v", got) + } + if PickEventRule("order.open", rules) != nil { + t.Fatal("trade.* must not match order.open") + } +} + +func TestPickEventRuleMoreSpecificWildcardWins(t *testing.T) { + rules := []model.Rule{ + {ID: 1, Event: "*", Enabled: 1}, + {ID: 2, Event: "trade.*", Enabled: 1}, + } + got := PickEventRule("trade.open", rules) + if got == nil || got.ID != 2 { + t.Fatalf("want trade.* id=2, got %#v", got) + } +} + +func TestPickEventRuleSkipsDisabled(t *testing.T) { + rules := []model.Rule{ + {ID: 1, Event: "trade.*", Enabled: 0}, + {ID: 2, Event: "*", Enabled: 1}, + } + got := PickEventRule("trade.open", rules) + if got == nil || got.ID != 2 { + t.Fatalf("want * id=2, got %#v", got) + } +} + +func TestPickEventRuleNoMatch(t *testing.T) { + rules := []model.Rule{ + {ID: 1, Event: "trade.open", Enabled: 1}, + } + if PickEventRule("trade.close", rules) != nil { + t.Fatal("expected no match") + } +} diff --git a/internal/engine/matcher.go b/internal/engine/matcher.go index 91c8b0b..9fc8c90 100644 --- a/internal/engine/matcher.go +++ b/internal/engine/matcher.go @@ -33,28 +33,38 @@ func (m *Matcher) Match(ctx context.Context, sourceID int, event string) (*model } } - // Fall back to DB rule, err := m.store.GetRuleBySourceEvent(ctx, sourceID, event) if err != nil { - return nil, fmt.Errorf("match rule: %w", err) - } - - // Warm cache - if m.cache != nil { - tmpl, err := m.store.GetTemplate(ctx, rule.TemplateID) - if err != nil { - return rule, nil // rule found but template fetch failed — still return rule - } - cr := &cache.CachedRule{ - RuleID: rule.ID, - TemplateID: rule.TemplateID, - Content: tmpl.Content, - } - if rule.Conditions != nil { - cr.Conditions = string(*rule.Conditions) - } - _ = m.cache.SetRule(ctx, sourceID, event, cr) + rules, listErr := m.store.ListEnabledRulesBySource(ctx, sourceID) + if listErr != nil { + return nil, fmt.Errorf("match rule: %w", listErr) + } + picked := PickEventRule(event, rules) + if picked == nil { + return nil, fmt.Errorf("match rule: %w", err) + } + return picked, nil } + m.warmRuleCache(ctx, sourceID, event, rule) return rule, nil } + +func (m *Matcher) warmRuleCache(ctx context.Context, sourceID int, event string, rule *model.Rule) { + if m.cache == nil { + return + } + tmpl, err := m.store.GetTemplate(ctx, rule.TemplateID) + if err != nil { + return + } + cr := &cache.CachedRule{ + RuleID: rule.ID, + TemplateID: rule.TemplateID, + Content: tmpl.Content, + } + if rule.Conditions != nil { + cr.Conditions = string(*rule.Conditions) + } + _ = m.cache.SetRule(ctx, sourceID, event, cr) +} diff --git a/internal/store/rule.go b/internal/store/rule.go index d86347b..f3f7298 100644 --- a/internal/store/rule.go +++ b/internal/store/rule.go @@ -66,6 +66,19 @@ func (s *Store) GetRuleBySourceEvent(ctx context.Context, sourceID int, event st return &r, nil } +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) + if err != nil { + return nil, fmt.Errorf("list enabled rules by source: %w", err) + } + defer rows.Close() + rules, err := scanRules(rows) + if err != nil { + return nil, err + } + return rules, nil +} + func (s *Store) ListRules(ctx context.Context, page PageFilter) ([]model.Rule, int, error) { var count int if err := s.DB.GetContext(ctx, &count, `SELECT COUNT(*) FROM notification_rule`); err != nil {