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 2d9d179b0a
commit a7722af29e
13 changed files with 1928 additions and 16 deletions
+35
View File
@@ -91,6 +91,38 @@ type LoginResponse struct {
ExpiresIn int64 `json:"expires_in"`
}
// --- Routing Rules ---
// RouteRule — правило маршрутизации: CID-based, destination-based, ingress-trunk routing.
// Правила проверяются по порядку Priority (first-match-wins).
// Все непустые Match-поля должны совпасть (AND-логика).
// Пустые Match-поля — не проверяются.
type RouteRule struct {
ID string `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
Priority int `json:"priority"`
// Match conditions — regex, пустая строка = не проверять
MatchCallerID string `json:"match_caller_id,omitempty"`
MatchDestination string `json:"match_destination,omitempty"`
MatchIngressTrunk string `json:"match_ingress_trunk,omitempty"`
// Time-based match — пустые = не проверять
MatchDaysOfWeek string `json:"match_days,omitempty"` // "1-5" | "1,3,5"
MatchTimeStart string `json:"match_time_start,omitempty"` // "HH:MM"
MatchTimeEnd string `json:"match_time_end,omitempty"` // "HH:MM"
// Action
ActionType string `json:"action_type"` // "node" | "pool" | "auto"
ActionNodeID string `json:"action_node_id,omitempty"`
ActionPoolIDs []string `json:"action_pool_ids,omitempty"`
Description string `json:"description,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// --- /api/route ---
// RouteResponse — ответ на запрос маршрутизации.
@@ -100,6 +132,9 @@ type RouteResponse struct {
Score float64 `json:"score,omitempty"`
SIPGateway string `json:"sip_gateway,omitempty"`
// Какое правило маршрутизации применилось (пустая строка = none)
MatchedRule string `json:"matched_rule,omitempty"`
// Fallback
Fallback bool `json:"fallback,omitempty"`
Reason string `json:"reason,omitempty"`