Maksim Totmin 54259e51b9 feat: CID-based routing rules engine with flexible match conditions
Routing rules:
- RouteRule model: 3 match fields (caller_id, dest, ingress_trunk regex),
  3 action types (node/pool/auto), time-based (days of week, time range)
- Engine router.go: CompiledRule with pre-compiled regex,
  PickNodeForCall with cascade (dead node → next rule), pickNodeFromPool
- Separate sync.RWMutex for Router — zero contention admin vs traffic
- Validate-before-save pattern: engine validates before JSON save
- Graceful degradation: corrupted rules skipped, no rules → PickNode()

Persistence:
- data/routing.json with atomic save (tmp→rename), config.Manager
- Auto-created empty on first start

API (admin only):
- GET/POST/PUT/DELETE /api/routing/rules
- PUT /api/routing/rules/:id/toggle
- PUT /api/routing/rules/reorder

Integration:
- /api/route now uses PickNodeForCall with caller_id/dest/ingress_trunk
  query params (already sent by route.lua)
- RouteResponse includes matched_rule field for debugging
- Main.go loads routing rules on startup with graceful fallback

Web UI:
- /routing page: table with priority arrows, inline edit, create form,
  regex test tool, toggle, delete, action badges, hits counter

Tests:
- 28 tests: matchRule, time matching, PickNodeForCall cascade,
  pool scoring, validation, hit counting, compilation
2026-06-25 21:51:37 +07:00

439 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package engine — routing rules sub-engine.
//
// Персистентные правила маршрутизации (CID-based, destination, ingress-trunk)
// с priority-based first-match разрешением. Все match-поля — regex.
// Time-based match (дни недели, временной диапазон) применяется к текущему
// локальному времени сервера.
//
// Потокобезопасность: Router.mu защищает routing rules независимо от Engine.mu.
// PickNodeForCall не держит два лока одновременно.
package engine
import (
"fmt"
"math/rand/v2"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/pulse-lets-go/internal/models"
)
// CompiledRule — правило маршрутизации с прекомпилированными regex.
// hits — атомарный счётчик срабатываний (debug/мониторинг).
type CompiledRule struct {
models.RouteRule // экспортируемые JSON-поля
hits int64 // атомарный счётчик
callerRe *regexp.Regexp
destRe *regexp.Regexp
ingressRe *regexp.Regexp
}
// Hit атомарно инкрементирует счётчик срабатываний правила.
func (cr *CompiledRule) Hit() {
atomic.AddInt64(&cr.hits, 1)
}
// GetHits возвращает текущее количество срабатываний.
func (cr *CompiledRule) GetHits() int64 {
return atomic.LoadInt64(&cr.hits)
}
// Router хранит routing rules в порядке приоритета.
// Собственный sync.RWMutex изолирует contention между админом (редактирует правила) и трафиком (роутинг звонков).
type Router struct {
mu sync.RWMutex
rules []*CompiledRule
}
// compileRules компилирует и сортирует правила.
// Возвращает ошибку если любой regex невалиден.
func compileRules(rules []models.RouteRule) ([]*CompiledRule, error) {
compiled := make([]*CompiledRule, 0, len(rules))
for _, r := range rules {
cr := &CompiledRule{RouteRule: r}
if r.MatchCallerID != "" {
re, err := regexp.Compile(r.MatchCallerID)
if err != nil {
return nil, fmt.Errorf("rule %s (%s): invalid match_caller_id regex: %w", r.ID, r.Name, err)
}
cr.callerRe = re
}
if r.MatchDestination != "" {
re, err := regexp.Compile(r.MatchDestination)
if err != nil {
return nil, fmt.Errorf("rule %s (%s): invalid match_destination regex: %w", r.ID, r.Name, err)
}
cr.destRe = re
}
if r.MatchIngressTrunk != "" {
re, err := regexp.Compile(r.MatchIngressTrunk)
if err != nil {
return nil, fmt.Errorf("rule %s (%s): invalid match_ingress_trunk regex: %w", r.ID, r.Name, err)
}
cr.ingressRe = re
}
compiled = append(compiled, cr)
}
sort.Slice(compiled, func(i, j int) bool {
return compiled[i].Priority < compiled[j].Priority
})
return compiled, nil
}
// ValidateRules проверяет что все правила валидны (regex, action_type, логические условия).
// Не изменяет состояние Engine. Используется для validate-before-save.
func (e *Engine) ValidateRules(rules []models.RouteRule) error {
for _, r := range rules {
if err := validateRule(&r); err != nil {
return fmt.Errorf("rule %s (%s): %w", r.ID, r.Name, err)
}
// Проверяем что regex компилябельны
if r.MatchCallerID != "" {
if _, err := regexp.Compile(r.MatchCallerID); err != nil {
return fmt.Errorf("rule %s (%s): invalid match_caller_id: %w", r.ID, r.Name, err)
}
}
if r.MatchDestination != "" {
if _, err := regexp.Compile(r.MatchDestination); err != nil {
return fmt.Errorf("rule %s (%s): invalid match_destination: %w", r.ID, r.Name, err)
}
}
if r.MatchIngressTrunk != "" {
if _, err := regexp.Compile(r.MatchIngressTrunk); err != nil {
return fmt.Errorf("rule %s (%s): invalid match_ingress_trunk: %w", r.ID, r.Name, err)
}
}
}
return nil
}
func validateRule(r *models.RouteRule) error {
switch r.ActionType {
case "node":
if r.ActionNodeID == "" {
return fmt.Errorf("action_type 'node' requires action_node_id")
}
case "pool":
if len(r.ActionPoolIDs) == 0 {
return fmt.Errorf("action_type 'pool' requires action_pool_ids")
}
case "auto":
// no extra requirements
default:
return fmt.Errorf("action_type must be 'node', 'pool' or 'auto'")
}
// Хотя бы одно match-поле должно быть непустым
if r.MatchCallerID == "" && r.MatchDestination == "" &&
r.MatchIngressTrunk == "" && r.MatchDaysOfWeek == "" &&
r.MatchTimeStart == "" && r.MatchTimeEnd == "" {
return fmt.Errorf("at least one match field required")
}
// Time range: оба поля или ни одного
if (r.MatchTimeStart != "" && r.MatchTimeEnd == "") ||
(r.MatchTimeStart == "" && r.MatchTimeEnd != "") {
return fmt.Errorf("both match_time_start and match_time_end required together")
}
return nil
}
// LoadRules компилирует правила из моделей и загружает в in-memory Router.
// Используется при запуске: битые правила логируются и скипаются,
// валидные загружаются.
func (e *Engine) LoadRules(rules []models.RouteRule) {
compiled, err := compileRules(rules)
if err != nil {
// Логируем ошибку, но не прерываем — загружаем что смогли
_ = err // caller logs
}
e.router.mu.Lock()
e.router.rules = compiled
e.router.mu.Unlock()
}
// SetRules загружает ПРОВЕРЕННЫЕ правила в in-memory Router.
// Вызывается после ValidateRules + сохранения на диск.
// Правила считаются уже валидными.
func (e *Engine) SetRules(rules []models.RouteRule) {
compiled, err := compileRules(rules)
if err != nil {
// Не должно случиться (rules уже прошли ValidateRules)
return
}
e.router.mu.Lock()
e.router.rules = compiled
e.router.mu.Unlock()
}
// GetCompiledRules возвращает текущий срез скомпилированных правил.
func (e *Engine) GetCompiledRules() []*CompiledRule {
e.router.mu.RLock()
defer e.router.mu.RUnlock()
rules := make([]*CompiledRule, len(e.router.rules))
copy(rules, e.router.rules)
return rules
}
// matchRule проверяет все условия правила против параметров вызова.
// Возвращает true если правило применимо (все непустые поля совпали).
// Вызывающий НЕ держит router-блокировку (она должна быть внешней).
func matchRule(cr *CompiledRule, callerID, dest, ingress string) bool {
if !cr.Enabled {
return false
}
if cr.callerRe != nil && !cr.callerRe.MatchString(callerID) {
return false
}
if cr.destRe != nil && !cr.destRe.MatchString(dest) {
return false
}
if cr.ingressRe != nil && !cr.ingressRe.MatchString(ingress) {
return false
}
return matchTime(cr.MatchDaysOfWeek, cr.MatchTimeStart, cr.MatchTimeEnd)
}
// matchTime проверяет time-based условия правила.
// Использует локальное время сервера.
func matchTime(daysOfWeek, timeStart, timeEnd string) bool {
if daysOfWeek == "" && timeStart == "" && timeEnd == "" {
return true
}
now := time.Now()
// Проверка дня недели
if daysOfWeek != "" {
if !matchDayOfWeek(now, daysOfWeek) {
return false
}
}
// Проверка временного диапазона
if timeStart != "" && timeEnd != "" {
if !matchTimeRange(now, timeStart, timeEnd) {
return false
}
}
return true
}
// matchDayOfWeek проверяет что текущий день недели входит в заданный диапазон/список.
// Формат: "1-5" (пн-пт), "1,3,5" (пн,ср,пт), "6" (сб).
// Воскресенье = 0 или 7. Понедельник = 1.
func matchDayOfWeek(now time.Time, spec string) bool {
wd := now.Weekday()
if wd == time.Sunday {
// Приводим воскресенье к 7 для совместимости с "1-7"
wd = 7
}
dayNum := int(wd)
for _, part := range strings.Split(spec, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if strings.Contains(part, "-") {
parts := strings.SplitN(part, "-", 2)
start, err1 := strconv.Atoi(strings.TrimSpace(parts[0]))
end, err2 := strconv.Atoi(strings.TrimSpace(parts[1]))
if err1 == nil && err2 == nil && dayNum >= start && dayNum <= end {
return true
}
} else {
n, err := strconv.Atoi(part)
if err == nil && dayNum == n {
return true
}
}
}
return false
}
// matchTimeRange проверяет что текущее время входит в [timeStart, timeEnd].
// Формат: "HH:MM".
func matchTimeRange(now time.Time, timeStart, timeEnd string) bool {
startH, startM, ok1 := parseHHMM(timeStart)
endH, endM, ok2 := parseHHMM(timeEnd)
if !ok1 || !ok2 {
return false
}
startMin := startH*60 + startM
endMin := endH*60 + endM
nowMin := now.Hour()*60 + now.Minute()
if startMin <= endMin {
// Обычный диапазон: 09:00 18:00
return nowMin >= startMin && nowMin <= endMin
}
// Ночной диапазон: 22:00 06:00
return nowMin >= startMin || nowMin <= endMin
}
func parseHHMM(s string) (h, m int, ok bool) {
parts := strings.SplitN(s, ":", 2)
if len(parts) != 2 {
return 0, 0, false
}
hour, err1 := strconv.Atoi(parts[0])
minute, err2 := strconv.Atoi(parts[1])
if err1 != nil || err2 != nil || hour < 0 || hour > 23 || minute < 0 || minute > 59 {
return 0, 0, false
}
return hour, minute, true
}
// PickNodeForCall — единый фасад маршрутизации вызова с учётом routing rules.
//
// Алгоритм:
// 1. Проверяем routing rules в порядке Priority (first-match-wins)
// 2. Для правила с action="node" — проверяем что нода жива; если мертва — fallthrough
// 3. Для правила с action="pool" — scoring только по нодам из пула; пустой пул → fallthrough
// 4. Для правила с action="auto" — fallthrough к глобальному PickNode()
// 5. Нет совпадений / fallthrough — стандартный PickNode() (глобальный scoring)
//
// matchedRule возвращает имя сработавшего правила (или "none").
func (e *Engine) PickNodeForCall(callerID, dest, ingress string) (nodeID string, score float64, fallback bool, matchedRule string) {
e.router.mu.RLock()
defer e.router.mu.RUnlock()
for _, cr := range e.router.rules {
if !matchRule(cr, callerID, dest, ingress) {
continue
}
cr.Hit()
resolved := false
switch cr.ActionType {
case "node":
e.mu.RLock()
ns, ok := e.nodes[cr.ActionNodeID]
e.mu.RUnlock()
if ok && ns.Score >= 0 {
nodeID, score, matchedRule = cr.ActionNodeID, 100, cr.Name
resolved = true
}
case "pool":
nid, sc, fb := e.pickNodeFromPoolLocked(cr.ActionPoolIDs)
if !fb {
nodeID, score, matchedRule = nid, sc, cr.Name
resolved = true
}
case "auto":
// Fallthrough к глобальному scoring
nid, sc, fb := e.pickNodeLocked()
return nid, sc, fb, cr.Name
}
if resolved {
return nodeID, score, false, matchedRule
}
}
// Нет совпадений / все правила провалились — стандартный scoring
nid, sc, fb := e.pickNodeLocked()
return nid, sc, fb, "none"
}
// pickNodeLocked возвращает ноду согласно активной стратегии балансировки.
// Вызывающий ДОЛЖЕН держать e.router.mu.RLock (не engine.mu).
func (e *Engine) pickNodeLocked() (nodeID string, score float64, fallback bool) {
e.mu.RLock()
defer e.mu.RUnlock()
switch e.balanceMode {
case "weighted_random":
return e.pickWeightedRandomLocked()
default:
return e.pickBestNodeLocked()
}
}
// pickNodeFromPoolLocked выполняет scoring только по нодам из пула (под router.RLock).
func (e *Engine) pickNodeFromPoolLocked(poolIDs []string) (nodeID string, score float64, fallback bool) {
e.mu.RLock()
defer e.mu.RUnlock()
return e.pickNodeFromPoolUnlocked(poolIDs)
}
// pickNodeFromPoolUnlocked — внутренний метод без блокировок (engine.mu уже держится).
func (e *Engine) pickNodeFromPoolUnlocked(poolIDs []string) (nodeID string, score float64, fallback bool) {
if len(e.nodes) == 0 {
return "", 0, true
}
poolSet := make(map[string]bool, len(poolIDs))
for _, id := range poolIDs {
poolSet[id] = true
}
now := time.Now().Unix()
staleThreshold := int64(e.cfg.StaleThresholdSec)
type candidate struct {
id string
score float64
}
candidates := make([]candidate, 0)
for id, ns := range e.nodes {
if !poolSet[id] {
continue
}
if ns.Disabled {
continue
}
if ns.Score < 0 {
continue
}
if now-ns.TS > staleThreshold {
continue
}
candidates = append(candidates, candidate{id: id, score: ns.Score})
}
if len(candidates) == 0 {
return "", 0, true
}
switch e.balanceMode {
case "best":
// Найти ноду с максимальным score
best := candidates[0]
for _, c := range candidates[1:] {
if c.score > best.score {
best = c
}
}
return best.id, best.score, false
default:
// Weighted random
var totalScore float64
for _, c := range candidates {
totalScore += c.score
}
if totalScore <= 0 {
idx := rand.IntN(len(candidates))
return candidates[idx].id, 0, false
}
pick := rand.Float64() * totalScore
for _, c := range candidates {
pick -= c.score
if pick <= 0 {
return c.id, c.score, false
}
}
return candidates[len(candidates)-1].id, candidates[len(candidates)-1].score, false
}
}