package api import ( "encoding/json" "net/http" "os" "github.com/pulse-lets-go/internal/esl" "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}) } // GetBalanceGateway возвращает gateway для ноды из in-memory кэша (O(1)). func (a *API) GetBalanceGateway(nodeID string) (string, bool) { return a.cachedBalanceGateway(nodeID) } // GetFallbackGateway возвращает fallback gateway из in-memory кэша (O(1)). func (a *API) GetFallbackGateway() string { return a.cachedFallbackGateway() } // SetRouteCachePath устанавливает путь к файловому кэшу маршрута. func (a *API) SetRouteCachePath(path string) { a.routeCachePath = path } // PushRouteCache читает best-node из engine, резолвит gateway из trunk cache, // атомарно пишет файловый кэш и пушит маршрут в FreeSWITCH через ESL. func (a *API) PushRouteCache() { nodeID, score, fallback := a.engine.GetBestNode() var gw string if nodeID != "" && !fallback { gw, _ = a.cachedBalanceGateway(nodeID) } if gw == "" { gw = a.cachedFallbackGateway() } entry := buildRouteCacheEntry(nodeID, gw, score, fallback) if a.routeCachePath != "" { writeRouteCacheFile(a.routeCachePath, entry) } if a.eslClient != nil && a.eslClient.IsConnected() { esl.PushRoute(a.eslClient, entry) } } // 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 } // buildRouteCacheEntry строит запись для кэширования в ESL global / файл. func buildRouteCacheEntry(nodeID, gw string, score float64, fallback bool) esl.RouteCacheEntry { entry := esl.RouteCacheEntry{ SIPGateway: gw, Score: score, Fallback: fallback, } if nodeID == "" { entry.Error = "no_nodes_registered" } else if fallback { entry.Reason = "all_nodes_unhealthy" } else { entry.NodeID = nodeID } return entry } // writeRouteCacheFile атомарно записывает файловый кэш маршрута. func writeRouteCacheFile(path string, entry esl.RouteCacheEntry) { b, err := json.Marshal(entry) if err != nil { return } tmpPath := path + ".tmp" if err := os.WriteFile(tmpPath, b, 0644); err != nil { return } os.Rename(tmpPath, path) } // 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 }