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

132 lines
3.8 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 api
import (
"net/http"
"github.com/pulse-lets-go/internal/models"
)
// handleRoute — GET /api/route — главный эндпоинт маршрутизации вызова.
// Принимает query-параметры: caller_id, dest, ingress_trunk (передаются из route.lua).
// Использует routing rules engine (PickNodeForCall) с priority-based first-match разрешением.
func (a *API) handleRoute(w http.ResponseWriter, r *http.Request) {
a.engine.IncrementRouteRequests()
callerID := r.URL.Query().Get("caller_id")
dest := r.URL.Query().Get("dest")
ingressTrunk := r.URL.Query().Get("ingress_trunk")
nodeID, score, fallback, matchedRule := a.engine.PickNodeForCall(callerID, dest, ingressTrunk)
// Нет зарегистрированных нод
if nodeID == "" {
a.engine.IncrementRouteFallbacks()
writeJSON(w, http.StatusServiceUnavailable, models.RouteResponse{
Error: "no_nodes_registered",
MatchedRule: matchedRule,
})
return
}
// Все ноды unhealthy — fallback
if fallback || score < 0 {
a.engine.IncrementRouteFallbacks()
fallbackGW := a.cachedFallbackGateway()
nodes := a.getRouteNodeInfo()
writeJSON(w, http.StatusOK, models.RouteResponse{
Fallback: true,
SIPGateway: fallbackGW,
Reason: "all_nodes_unhealthy",
Nodes: nodes,
MatchedRule: matchedRule,
})
return
}
// Нормальный маршрут — O(1) из in-memory кэша
gw, ok := a.cachedBalanceGateway(nodeID)
if !ok {
writeError(w, http.StatusInternalServerError, "gateway не найден для ноды")
return
}
writeJSON(w, http.StatusOK, models.RouteResponse{
NodeID: nodeID,
Score: score,
SIPGateway: gw,
MatchedRule: matchedRule,
})
}
// handleSetRouteMode — PUT /api/route/mode (admin only).
func (a *API) handleSetRouteMode(w http.ResponseWriter, r *http.Request) {
var req struct {
Mode string `json:"mode"`
}
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "некорректный JSON")
return
}
if req.Mode != "best" && req.Mode != "weighted_random" {
writeError(w, http.StatusBadRequest, "mode должен быть 'best' или 'weighted_random'")
return
}
// Сохраняем в config.json атомарно
if a.configManager != nil {
cfg, err := a.configManager.ReadConfig()
if err == nil {
cfg.BalanceMode = req.Mode
a.configManager.SaveConfig(cfg)
}
}
a.engine.SetBalancingMode(req.Mode)
// Немедленный WS-push для обновления UI
go a.broadcastBalancerUpdate()
writeJSON(w, http.StatusOK, map[string]string{"mode": req.Mode})
}
// cachedBalanceGateway возвращает gateway для balance-транка из in-memory кэша (O(1)).
func (a *API) cachedBalanceGateway(nodeID string) (string, bool) {
a.trunkCacheMu.RLock()
defer a.trunkCacheMu.RUnlock()
gw, ok := a.balanceGateways[nodeID]
return gw, ok
}
// cachedFallbackGateway возвращает fallback gateway из in-memory кэша (O(1)).
func (a *API) cachedFallbackGateway() string {
a.trunkCacheMu.RLock()
defer a.trunkCacheMu.RUnlock()
return a.fallbackGateway
}
// getRouteNodeInfo возвращает список нод с их скорами для ответа маршрутизации.
func (a *API) getRouteNodeInfo() []models.RouteNodeInfo {
nodes := a.engine.GetAllNodes()
result := make([]models.RouteNodeInfo, 0, len(nodes))
for _, n := range nodes {
info := models.RouteNodeInfo{
ID: n.NodeID,
Score: n.Score,
Reason: n.LethalReason,
Status: n.Status,
}
if n.Disabled {
info.Status = "disabled"
} else if n.IsStale {
info.Status = "stale"
} else if n.Score < 0 {
info.Status = "unhealthy"
} else {
info.Status = "healthy"
}
result = append(result, info)
}
return result
}