pulse-lets-go/internal/api/routing_rules.go
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

318 lines
9.0 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 (
"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"})
}