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
This commit is contained in:
Maksim Totmin
2026-06-25 21:51:37 +07:00
parent 54d56690c6
commit 54259e51b9
13 changed files with 1928 additions and 16 deletions
+43 -3
View File
@@ -225,6 +225,11 @@ func NewManager(dataDir string) (*Manager, error) {
return nil, fmt.Errorf("создание users.json: %w", err)
}
}
if _, err := os.Stat(m.routingRulesPath()); os.IsNotExist(err) {
if err := m.saveFile(m.routingRulesPath(), []models.RouteRule{}); err != nil {
return nil, fmt.Errorf("создание routing.json: %w", err)
}
}
return m, nil
}
@@ -327,11 +332,46 @@ func (m *Manager) saveUsersLocked(users []models.User) error {
return m.saveFile(m.usersPath(), data)
}
// ReadRoutingRules читает routing.json. Потокобезопасно.
func (m *Manager) ReadRoutingRules() ([]models.RouteRule, error) {
m.mu.RLock()
defer m.mu.RUnlock()
return m.readRoutingRulesLocked()
}
func (m *Manager) readRoutingRulesLocked() ([]models.RouteRule, error) {
data, err := os.ReadFile(m.routingRulesPath())
if err != nil {
return nil, fmt.Errorf("чтение routing.json: %w", err)
}
var rules []models.RouteRule
if err := json.Unmarshal(data, &rules); err != nil {
return nil, fmt.Errorf("парсинг routing.json: %w", err)
}
return rules, nil
}
// SaveRoutingRules атомарно сохраняет routing.json.
func (m *Manager) SaveRoutingRules(rules []models.RouteRule) error {
m.mu.Lock()
defer m.mu.Unlock()
return m.saveRoutingRulesLocked(rules)
}
func (m *Manager) saveRoutingRulesLocked(rules []models.RouteRule) error {
data, err := json.MarshalIndent(rules, "", " ")
if err != nil {
return fmt.Errorf("сериализация routing.json: %w", err)
}
return m.saveFile(m.routingRulesPath(), data)
}
// --- Приватные методы ---
func (m *Manager) configPath() string { return filepath.Join(m.dir, "config.json") }
func (m *Manager) trunksPath() string { return filepath.Join(m.dir, "trunks.json") }
func (m *Manager) usersPath() string { return filepath.Join(m.dir, "users.json") }
func (m *Manager) configPath() string { return filepath.Join(m.dir, "config.json") }
func (m *Manager) trunksPath() string { return filepath.Join(m.dir, "trunks.json") }
func (m *Manager) usersPath() string { return filepath.Join(m.dir, "users.json") }
func (m *Manager) routingRulesPath() string { return filepath.Join(m.dir, "routing.json") }
// saveFile атомарно сохраняет данные в файл: tmp → rename.
// Вызывающий держит write lock.