// 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 } }