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:
+18
-9
@@ -7,16 +7,23 @@ import (
|
||||
)
|
||||
|
||||
// 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()
|
||||
|
||||
nodeID, score, fallback := a.engine.PickNode()
|
||||
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",
|
||||
Error: "no_nodes_registered",
|
||||
MatchedRule: matchedRule,
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -28,10 +35,11 @@ func (a *API) handleRoute(w http.ResponseWriter, r *http.Request) {
|
||||
nodes := a.getRouteNodeInfo()
|
||||
|
||||
writeJSON(w, http.StatusOK, models.RouteResponse{
|
||||
Fallback: true,
|
||||
SIPGateway: fallbackGW,
|
||||
Reason: "all_nodes_unhealthy",
|
||||
Nodes: nodes,
|
||||
Fallback: true,
|
||||
SIPGateway: fallbackGW,
|
||||
Reason: "all_nodes_unhealthy",
|
||||
Nodes: nodes,
|
||||
MatchedRule: matchedRule,
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -44,9 +52,10 @@ func (a *API) handleRoute(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, models.RouteResponse{
|
||||
NodeID: nodeID,
|
||||
Score: score,
|
||||
SIPGateway: gw,
|
||||
NodeID: nodeID,
|
||||
Score: score,
|
||||
SIPGateway: gw,
|
||||
MatchedRule: matchedRule,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -103,6 +103,15 @@ func (a *API) Handler() http.Handler {
|
||||
mux.Handle("PUT /api/users/{id}", a.authMiddleware(a.adminOnly(http.HandlerFunc(a.handleUpdateUser))))
|
||||
mux.Handle("DELETE /api/users/{id}", a.authMiddleware(a.adminOnly(http.HandlerFunc(a.handleDeleteUser))))
|
||||
|
||||
// --- Админка: Routing Rules (admin + viewer for GET, admin only for write) ---
|
||||
|
||||
mux.Handle("GET /api/routing/rules", a.authMiddleware(http.HandlerFunc(a.handleGetRoutingRules)))
|
||||
mux.Handle("POST /api/routing/rules", a.authMiddleware(a.adminOnly(http.HandlerFunc(a.handleCreateRoutingRule))))
|
||||
mux.Handle("PUT /api/routing/rules/{id}", a.authMiddleware(a.adminOnly(http.HandlerFunc(a.handleUpdateRoutingRule))))
|
||||
mux.Handle("DELETE /api/routing/rules/{id}", a.authMiddleware(a.adminOnly(http.HandlerFunc(a.handleDeleteRoutingRule))))
|
||||
mux.Handle("PUT /api/routing/rules/{id}/toggle", a.authMiddleware(a.adminOnly(http.HandlerFunc(a.handleToggleRoutingRule))))
|
||||
mux.Handle("PUT /api/routing/rules/reorder", a.authMiddleware(a.adminOnly(http.HandlerFunc(a.handleReorderRoutingRules))))
|
||||
|
||||
// Логирование + CORS (навешиваем после mux)
|
||||
var wrapped http.Handler = mux
|
||||
wrapped = requestLoggingMiddleware(a.logFormat)(wrapped)
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/pulse-lets-go/internal/models"
|
||||
"github.com/pulse-lets-go/internal/util"
|
||||
)
|
||||
|
||||
// handleGetRoutingRules — GET /api/routing/rules — список всех правил с hits.
|
||||
func (a *API) handleGetRoutingRules(w http.ResponseWriter, r *http.Request) {
|
||||
rules, err := a.configManager.ReadRoutingRules()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "ошибка чтения правил маршрутизации")
|
||||
return
|
||||
}
|
||||
|
||||
// Сортировка по priority
|
||||
sort.Slice(rules, func(i, j int) bool {
|
||||
return rules[i].Priority < rules[j].Priority
|
||||
})
|
||||
|
||||
// Подмешиваем hits из engine в модель
|
||||
compiledRules := a.engine.GetCompiledRules()
|
||||
hitsMap := make(map[string]int64, len(compiledRules))
|
||||
for _, cr := range compiledRules {
|
||||
hitsMap[cr.ID] = cr.GetHits()
|
||||
}
|
||||
|
||||
type RuleWithHits struct {
|
||||
models.RouteRule
|
||||
Hits int64 `json:"hits"`
|
||||
}
|
||||
result := make([]RuleWithHits, len(rules))
|
||||
for i, r := range rules {
|
||||
result[i] = RuleWithHits{RouteRule: r, Hits: hitsMap[r.ID]}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// handleCreateRoutingRule — POST /api/routing/rules — создание нового правила.
|
||||
func (a *API) handleCreateRoutingRule(w http.ResponseWriter, r *http.Request) {
|
||||
var rule models.RouteRule
|
||||
if err := decodeJSON(r, &rule); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "некорректный JSON")
|
||||
return
|
||||
}
|
||||
|
||||
// Генерируем ID
|
||||
rule.ID = "rr-" + util.RandomHex(6)
|
||||
now := time.Now().UTC()
|
||||
rule.CreatedAt = now
|
||||
rule.UpdatedAt = now
|
||||
rule.Enabled = true
|
||||
if rule.Priority <= 0 {
|
||||
rule.Priority = 10
|
||||
}
|
||||
|
||||
// Читаем текущие правила
|
||||
rules, err := a.configManager.ReadRoutingRules()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "ошибка чтения правил")
|
||||
return
|
||||
}
|
||||
|
||||
// Добавляем новое правило
|
||||
rules = append(rules, rule)
|
||||
|
||||
// Валидируем (validate-before-save)
|
||||
if err := a.engine.ValidateRules(rules); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Сохраняем на диск
|
||||
if err := a.configManager.SaveRoutingRules(rules); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "ошибка сохранения правила")
|
||||
return
|
||||
}
|
||||
|
||||
// Загружаем в память
|
||||
a.engine.SetRules(rules)
|
||||
|
||||
writeJSON(w, http.StatusCreated, rule)
|
||||
}
|
||||
|
||||
// handleUpdateRoutingRule — PUT /api/routing/rules/{id} — обновление правила.
|
||||
func (a *API) handleUpdateRoutingRule(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
writeError(w, http.StatusBadRequest, "id обязателен")
|
||||
return
|
||||
}
|
||||
|
||||
var updates models.RouteRule
|
||||
if err := decodeJSON(r, &updates); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "некорректный JSON")
|
||||
return
|
||||
}
|
||||
|
||||
rules, err := a.configManager.ReadRoutingRules()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "ошибка чтения правил")
|
||||
return
|
||||
}
|
||||
|
||||
idx := -1
|
||||
for i, rule := range rules {
|
||||
if rule.ID == id {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
writeError(w, http.StatusNotFound, "правило не найдено")
|
||||
return
|
||||
}
|
||||
|
||||
// Частичное обновление: применяем только непустые поля
|
||||
target := &rules[idx]
|
||||
if updates.Name != "" {
|
||||
target.Name = updates.Name
|
||||
}
|
||||
if updates.Priority > 0 {
|
||||
target.Priority = updates.Priority
|
||||
}
|
||||
if updates.MatchCallerID != "" {
|
||||
target.MatchCallerID = updates.MatchCallerID
|
||||
}
|
||||
if updates.MatchDestination != "" {
|
||||
target.MatchDestination = updates.MatchDestination
|
||||
}
|
||||
if updates.MatchIngressTrunk != "" {
|
||||
target.MatchIngressTrunk = updates.MatchIngressTrunk
|
||||
}
|
||||
if updates.MatchDaysOfWeek != "" {
|
||||
target.MatchDaysOfWeek = updates.MatchDaysOfWeek
|
||||
}
|
||||
if updates.MatchTimeStart != "" {
|
||||
target.MatchTimeStart = updates.MatchTimeStart
|
||||
}
|
||||
if updates.MatchTimeEnd != "" {
|
||||
target.MatchTimeEnd = updates.MatchTimeEnd
|
||||
}
|
||||
if updates.ActionType != "" {
|
||||
target.ActionType = updates.ActionType
|
||||
target.ActionNodeID = updates.ActionNodeID
|
||||
target.ActionPoolIDs = updates.ActionPoolIDs
|
||||
}
|
||||
if updates.Description != "" {
|
||||
target.Description = updates.Description
|
||||
}
|
||||
// Позволяем очистить match-поля через пустую строку в JSON (omitempty не подходит)
|
||||
// Для этого используем указатели, но для упрощения: если поле не передано — оставляем старое
|
||||
// Если передано явно (даже пустое) — нужно смотреть raw JSON, здесь упрощённо
|
||||
target.UpdatedAt = time.Now().UTC()
|
||||
|
||||
// Валидируем
|
||||
if err := a.engine.ValidateRules(rules); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Сохраняем
|
||||
if err := a.configManager.SaveRoutingRules(rules); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "ошибка сохранения правила")
|
||||
return
|
||||
}
|
||||
|
||||
// Загружаем в память
|
||||
a.engine.SetRules(rules)
|
||||
|
||||
writeJSON(w, http.StatusOK, *target)
|
||||
}
|
||||
|
||||
// handleDeleteRoutingRule — DELETE /api/routing/rules/{id} — удаление правила.
|
||||
func (a *API) handleDeleteRoutingRule(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
writeError(w, http.StatusBadRequest, "id обязателен")
|
||||
return
|
||||
}
|
||||
|
||||
rules, err := a.configManager.ReadRoutingRules()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "ошибка чтения правил")
|
||||
return
|
||||
}
|
||||
|
||||
found := false
|
||||
filtered := make([]models.RouteRule, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
if rule.ID == id {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, rule)
|
||||
}
|
||||
|
||||
if !found {
|
||||
writeError(w, http.StatusNotFound, "правило не найдено")
|
||||
return
|
||||
}
|
||||
|
||||
if err := a.configManager.SaveRoutingRules(filtered); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "ошибка сохранения правил")
|
||||
return
|
||||
}
|
||||
|
||||
// Загружаем в память (валидация не нужна — удаление не может нарушить валидность)
|
||||
a.engine.SetRules(filtered)
|
||||
|
||||
writeJSON(w, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
// handleToggleRoutingRule — PUT /api/routing/rules/{id}/toggle — вкл/выкл правила.
|
||||
func (a *API) handleToggleRoutingRule(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
writeError(w, http.StatusBadRequest, "id обязателен")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "некорректный JSON")
|
||||
return
|
||||
}
|
||||
|
||||
rules, err := a.configManager.ReadRoutingRules()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "ошибка чтения правил")
|
||||
return
|
||||
}
|
||||
|
||||
idx := -1
|
||||
for i, rule := range rules {
|
||||
if rule.ID == id {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
writeError(w, http.StatusNotFound, "правило не найдено")
|
||||
return
|
||||
}
|
||||
|
||||
rules[idx].Enabled = req.Enabled
|
||||
rules[idx].UpdatedAt = time.Now().UTC()
|
||||
|
||||
if err := a.configManager.SaveRoutingRules(rules); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "ошибка сохранения правила")
|
||||
return
|
||||
}
|
||||
|
||||
a.engine.SetRules(rules)
|
||||
|
||||
writeJSON(w, http.StatusOK, rules[idx])
|
||||
}
|
||||
|
||||
// handleReorderRoutingRules — PUT /api/routing/rules/reorder — переупорядочивание правил.
|
||||
// Принимает массив ID в новом порядке. Переназначает Priority = index * 10.
|
||||
func (a *API) handleReorderRoutingRules(w http.ResponseWriter, r *http.Request) {
|
||||
var order []string
|
||||
if err := decodeJSON(r, &order); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "некорректный JSON: ожидается массив id")
|
||||
return
|
||||
}
|
||||
|
||||
if len(order) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "пустой порядок")
|
||||
return
|
||||
}
|
||||
|
||||
rules, err := a.configManager.ReadRoutingRules()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "ошибка чтения правил")
|
||||
return
|
||||
}
|
||||
|
||||
// Строим map для быстрого поиска
|
||||
ruleMap := make(map[string]*models.RouteRule, len(rules))
|
||||
for i := range rules {
|
||||
ruleMap[rules[i].ID] = &rules[i]
|
||||
}
|
||||
|
||||
// Проверяем что все ID из order присутствуют
|
||||
for _, id := range order {
|
||||
if _, ok := ruleMap[id]; !ok {
|
||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("правило с id '%s' не найдено", id))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Переназначаем приоритеты
|
||||
now := time.Now().UTC()
|
||||
for i, id := range order {
|
||||
r := ruleMap[id]
|
||||
r.Priority = (i + 1) * 10
|
||||
r.UpdatedAt = now
|
||||
}
|
||||
|
||||
if err := a.configManager.SaveRoutingRules(rules); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "ошибка сохранения порядка")
|
||||
return
|
||||
}
|
||||
|
||||
a.engine.SetRules(rules)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
Reference in New Issue
Block a user