package app import ( "context" "database/sql" "fmt" "net/http" "strings" "sub2api-cn-relay-manager/internal/routing" "sub2api-cn-relay-manager/internal/store/sqlite" ) const ( routeRuntimeStatusHealthy = "healthy" routeRuntimeStatusCooldown = "cooldown" routeRuntimeStatusFailing = "failing" routeRuntimeStatusDisabled = "disabled" defaultRouteHealthFailoverLimit = 20 ) type ListRouteHealthRequest struct { LogicalGroupID string RouteID string Status string } type RouteHealthInfo struct { Backend string `json:"backend"` RouteID string `json:"route_id"` RouteName string `json:"route_name,omitempty"` LogicalGroupID string `json:"logical_group_id"` LogicalGroupDisplayName string `json:"logical_group_display_name,omitempty"` LogicalGroupStatus string `json:"logical_group_status,omitempty"` ConfiguredStatus string `json:"configured_status,omitempty"` ShadowHostID string `json:"shadow_host_id"` ShadowGroupID string `json:"shadow_group_id"` Priority int `json:"priority"` Weight int `json:"weight,omitempty"` RuntimeStatus string `json:"runtime_status"` FailureCount int `json:"failure_count"` CooldownUntil string `json:"cooldown_until,omitempty"` CooldownReason string `json:"cooldown_reason,omitempty"` LastErrorClass string `json:"last_error_class,omitempty"` LastSelectedAt string `json:"last_selected_at,omitempty"` LastUpstreamStatus int `json:"last_upstream_status,omitempty"` LastRequestID string `json:"last_request_id,omitempty"` LastPublicModel string `json:"last_public_model,omitempty"` RecentFailoverCount int `json:"recent_failover_count"` UpstreamBaseURLHint string `json:"upstream_base_url_hint,omitempty"` UpdatedAt string `json:"updated_at,omitempty"` } func handleListRouteHealth(w http.ResponseWriter, r *http.Request, fn func(context.Context, ListRouteHealthRequest) ([]RouteHealthInfo, error)) { if fn == nil { writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "list-route-health action is not configured"}) return } req, err := decodeListRouteHealthRequest(r) if err != nil { writeHTTPError(w, err) return } items, actionErr := fn(r.Context(), req) if actionErr != nil { writeHTTPError(w, classifyError(actionErr)) return } writeJSON(w, http.StatusOK, map[string]any{"route_health": items}) } func buildListRouteHealthAction(sqliteDSN string, stickyRuntime stickyStoreRuntime) func(context.Context, ListRouteHealthRequest) ([]RouteHealthInfo, error) { return func(ctx context.Context, req ListRouteHealthRequest) ([]RouteHealthInfo, error) { store, err := sqlite.Open(ctx, sqliteDSN) if err != nil { return nil, err } defer store.Close() groups, routes, err := loadRouteHealthScope(ctx, store, req) if err != nil { return nil, err } items := make([]RouteHealthInfo, 0, len(routes)) for _, route := range routes { group, ok := groups[route.LogicalGroupID] if !ok { return nil, fmt.Errorf("logical group %q not found for route %q", route.LogicalGroupID, route.RouteID) } item, err := buildRouteHealthInfo(ctx, store, stickyRuntime, group, route) if err != nil { return nil, err } if req.Status != "" && !strings.EqualFold(item.RuntimeStatus, req.Status) { continue } items = append(items, item) } return items, nil } } func decodeListRouteHealthRequest(r *http.Request) (ListRouteHealthRequest, *httpError) { status := strings.TrimSpace(r.URL.Query().Get("status")) if status != "" && !isSupportedRouteHealthStatus(status) { return ListRouteHealthRequest{}, &httpError{ StatusCode: http.StatusBadRequest, Code: "bad_request", Message: fmt.Sprintf("unsupported route health status %q", status), } } return ListRouteHealthRequest{ LogicalGroupID: strings.TrimSpace(r.URL.Query().Get("logical_group_id")), RouteID: strings.TrimSpace(r.URL.Query().Get("route_id")), Status: strings.ToLower(status), }, nil } func loadRouteHealthScope(ctx context.Context, store *sqlite.DB, req ListRouteHealthRequest) (map[string]sqlite.LogicalGroup, []sqlite.LogicalGroupRoute, error) { if req.RouteID != "" { route, err := store.LogicalGroupRoutes().GetByRouteID(ctx, req.RouteID) if err != nil { if err == sql.ErrNoRows { return map[string]sqlite.LogicalGroup{}, nil, nil } return nil, nil, err } if req.LogicalGroupID != "" && route.LogicalGroupID != req.LogicalGroupID { return map[string]sqlite.LogicalGroup{}, nil, nil } group, err := store.LogicalGroups().GetByLogicalGroupID(ctx, route.LogicalGroupID) if err != nil { return nil, nil, err } return map[string]sqlite.LogicalGroup{group.LogicalGroupID: group}, []sqlite.LogicalGroupRoute{route}, nil } if req.LogicalGroupID != "" { group, err := store.LogicalGroups().GetByLogicalGroupID(ctx, req.LogicalGroupID) if err != nil { return nil, nil, err } routes, err := store.LogicalGroupRoutes().ListByLogicalGroupID(ctx, req.LogicalGroupID) if err != nil { return nil, nil, err } return map[string]sqlite.LogicalGroup{group.LogicalGroupID: group}, routes, nil } groupRows, err := store.LogicalGroups().List(ctx) if err != nil { return nil, nil, err } groupMap := make(map[string]sqlite.LogicalGroup, len(groupRows)) routes := make([]sqlite.LogicalGroupRoute, 0) for _, group := range groupRows { groupMap[group.LogicalGroupID] = group groupRoutes, err := store.LogicalGroupRoutes().ListByLogicalGroupID(ctx, group.LogicalGroupID) if err != nil { return nil, nil, err } routes = append(routes, groupRoutes...) } return groupMap, routes, nil } func buildRouteHealthInfo(ctx context.Context, store *sqlite.DB, stickyRuntime stickyStoreRuntime, group sqlite.LogicalGroup, route sqlite.LogicalGroupRoute) (RouteHealthInfo, error) { failureState, hasFailure, err := stickyRuntime.store.GetRouteFailure(ctx, route.RouteID) if err != nil { return RouteHealthInfo{}, err } cooldownState, hasCooldown, err := stickyRuntime.store.GetCooldown(ctx, route.RouteID) if err != nil { return RouteHealthInfo{}, err } // 兼容 route 表上的手工 cooldown 标记,避免健康页漏掉已配置的禁用窗口。 if !hasCooldown && !routeExitsCooldown(route.CooldownUntil) { hasCooldown = true cooldownState = routing.RouteCooldownState{ RouteID: route.RouteID, Reason: "configured_cooldown", Until: route.CooldownUntil, } } decisionRows, err := store.RouteDecisionLogs().ListRecent(ctx, sqlite.RouteDecisionLogFilter{ SelectedRouteID: route.RouteID, Limit: 1, }) if err != nil { return RouteHealthInfo{}, err } failoverFrom, err := store.RouteFailoverEvents().ListRecent(ctx, sqlite.RouteFailoverEventFilter{ FromRouteID: route.RouteID, Limit: defaultRouteHealthFailoverLimit, }) if err != nil { return RouteHealthInfo{}, err } failoverTo, err := store.RouteFailoverEvents().ListRecent(ctx, sqlite.RouteFailoverEventFilter{ ToRouteID: route.RouteID, Limit: defaultRouteHealthFailoverLimit, }) if err != nil { return RouteHealthInfo{}, err } item := RouteHealthInfo{ Backend: stickyRuntime.backend, RouteID: route.RouteID, RouteName: route.Name, LogicalGroupID: route.LogicalGroupID, LogicalGroupDisplayName: group.DisplayName, LogicalGroupStatus: group.Status, ConfiguredStatus: route.Status, ShadowHostID: route.ShadowHostID, ShadowGroupID: route.ShadowGroupID, Priority: route.Priority, Weight: route.Weight, RuntimeStatus: deriveRouteRuntimeStatus(group.Status, route.Status, hasCooldown, failureCountFromState(failureState, hasFailure)), FailureCount: failureCountFromState(failureState, hasFailure), CooldownUntil: cooldownUntilFromState(cooldownState, hasCooldown), CooldownReason: cooldownReasonFromState(cooldownState, hasCooldown), LastErrorClass: lastErrorClassFromStates(failureState, hasFailure, decisionRows), RecentFailoverCount: len(failoverFrom) + len(failoverTo), UpstreamBaseURLHint: route.UpstreamBaseURLHint, UpdatedAt: route.UpdatedAt, } if len(decisionRows) > 0 { item.LastSelectedAt = decisionRows[0].CreatedAt item.LastUpstreamStatus = decisionRows[0].UpstreamStatus item.LastRequestID = decisionRows[0].RequestID item.LastPublicModel = decisionRows[0].PublicModel } return item, nil } func deriveRouteRuntimeStatus(groupStatus, routeStatus string, hasCooldown bool, failureCount int) string { if !isActiveStatus(groupStatus) || !isActiveStatus(routeStatus) { return routeRuntimeStatusDisabled } if hasCooldown { return routeRuntimeStatusCooldown } if failureCount > 0 { return routeRuntimeStatusFailing } return routeRuntimeStatusHealthy } func failureCountFromState(state routing.RouteFailureState, ok bool) int { if !ok { return 0 } return state.FailureCount } func cooldownUntilFromState(state routing.RouteCooldownState, ok bool) string { if !ok { return "" } return strings.TrimSpace(state.Until) } func cooldownReasonFromState(state routing.RouteCooldownState, ok bool) string { if !ok { return "" } return strings.TrimSpace(state.Reason) } func lastErrorClassFromStates(state routing.RouteFailureState, hasFailure bool, decisions []sqlite.RouteDecisionLog) string { if hasFailure && strings.TrimSpace(state.LastErrorClass) != "" { return strings.TrimSpace(state.LastErrorClass) } if len(decisions) == 0 { return "" } return strings.TrimSpace(decisions[0].ErrorClass) } func isSupportedRouteHealthStatus(status string) bool { switch strings.ToLower(strings.TrimSpace(status)) { case routeRuntimeStatusHealthy, routeRuntimeStatusCooldown, routeRuntimeStatusFailing, routeRuntimeStatusDisabled: return true default: return false } }