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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user