package api import ( "context" "log/slog" "net/http" "time" "github.com/gin-gonic/gin" ) // HealthPinger 抽象「能對某依賴跑一次連線檢查」的能力。 // // db.Pool(Postgres)與 db.RedisClient(Redis)都有 Ping(ctx) error,天然滿足。 // 用窄介面而非綁 concrete type:方便 unit test 注入 fake ping(成功 / 失敗)。 type HealthPinger interface { Ping(ctx context.Context) error } // HealthDeps 是 /healthz 要檢查的依賴(DB 接入塊 5.4)。 // // 啟用語意(對齊使用者拍板的 fail-fast 策略): // - DBPool 非 nil(Postgres 啟用)→ 每次 /healthz 都 ping,失敗回 503。 // - Redis 非 nil(Redis 啟用)→ 每次 /healthz 都 ping,失敗回 503。 // - 兩者皆 nil(in-memory 模式 / 未啟用)→ 略過,維持「process 活著就 ok」的舊行為。 // // 為什麼 ping 失敗回 503:讓 load balancer / K8s readiness 把這台不健康的實例拉出輪替, // 而非繼續送流量進來碰 DB 拿 503/假資料。 type HealthDeps struct { DBPool HealthPinger // Postgres 連線池(db.Pool);nil = 未啟用 Redis HealthPinger // Redis client(db.RedisClient);nil = 未啟用 Logger *slog.Logger } // healthPingTimeout 是單一依賴 ping 的逾時上限。 // /healthz 須快速回應(load balancer 高頻打),故給短逾時——ping 卡住即視為不健康。 const healthPingTimeout = 2 * time.Second // HealthzHandler 是 K8s liveness / readiness 用的健康檢查。 // // DB 接入塊 5.4:擴充為「啟用的依賴(Postgres / Redis)都 ping 一次」。任一啟用依賴 ping // 失敗 → 503(body 標出哪個依賴不健康,但不洩漏 raw error,只進 log);全數通過 → 200。 // // 未啟用任何依賴(deps 全 nil)時退化為原本的「process 活著就 ok」最小檢查。 func HealthzHandler(deps HealthDeps) gin.HandlerFunc { return func(c *gin.Context) { ctx, cancel := context.WithTimeout(c.Request.Context(), healthPingTimeout) defer cancel() checks := gin.H{} healthy := true if deps.DBPool != nil { if err := deps.DBPool.Ping(ctx); err != nil { healthy = false checks["postgres"] = "down" logOrDefault(deps.Logger).Error("healthz: postgres ping failed", "error", err, "request_id", RequestIDFrom(c)) } else { checks["postgres"] = "ok" } } if deps.Redis != nil { if err := deps.Redis.Ping(ctx); err != nil { healthy = false checks["redis"] = "down" logOrDefault(deps.Logger).Error("healthz: redis ping failed", "error", err, "request_id", RequestIDFrom(c)) } else { checks["redis"] = "ok" } } if !healthy { c.JSON(http.StatusServiceUnavailable, gin.H{"status": "unavailable", "checks": checks}) return } c.JSON(http.StatusOK, gin.H{"status": "ok", "checks": checks}) } } // registerSystemRoutes 註冊 /api/system/* 的 routes。 // // MVP 範圍(B4 + B5): // - GET /api/system/health → 回 api-server 自己 + tunnel 連線狀態 // - GET /api/system/info → 回版本資訊(雛形 hard-coded) // - GET /api/system/deps → 走 tunnel proxy 查 local agent 的依賴狀態(B5) func registerSystemRoutes(g *gin.RouterGroup, deps Deps) { g.GET("/system/health", systemHealthHandler(deps)) g.GET("/system/info", systemInfoHandler()) // /api/system/deps 透過 tunnel proxy 到 local agent 的同路徑。 g.GET("/system/deps", newProxyHandler(deps, proxyOptions{})) } // SystemHealthResponse 是 GET /api/system/health 的 data payload。 // // 對齊 api-spec.md §7: // // { // "api_server": "ok", // "tunnel_connected": true, // "agent_last_seen_at": "..." // } type SystemHealthResponse struct { APIServer string `json:"api_server"` TunnelConnected bool `json:"tunnel_connected"` AgentLastSeenAt *time.Time `json:"agent_last_seen_at,omitempty"` AgentSessionCount int `json:"agent_session_count"` } // systemHealthHandler 回報 api-server + tunnel 狀態。 // // 「tunnel_connected」的判定方式:呼叫 SessionStore.List。若有任一 session // 在線就視為 connected。雛形是單一 user 場景,所以這個語義足以呈現「我這邊 // 有沒有 agent 連著」;多 user / 多 device 階段會改成 per-user 查詢(B5)。 func systemHealthHandler(deps Deps) gin.HandlerFunc { return func(c *gin.Context) { resp := SystemHealthResponse{ APIServer: "ok", } if deps.SessionStore != nil { // 給一個短 timeout — health 檢查不該卡住整個 request ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second) defer cancel() summaries, err := deps.SessionStore.List(ctx) if err != nil { // SessionStore 失敗不致命;只回 tunnel_connected=false 加 warning logOrDefault(deps.Logger).Warn("system/health: list sessions failed", "error", err, "request_id", RequestIDFrom(c)) } else { resp.AgentSessionCount = len(summaries) if len(summaries) > 0 { resp.TunnelConnected = true // 取最新一個 LastHeartbeat 作為 agent_last_seen_at var latest time.Time for _, s := range summaries { if s.LastHeartbeat.After(latest) { latest = s.LastHeartbeat } } if !latest.IsZero() { resp.AgentLastSeenAt = &latest } } } } WriteSuccess(c, http.StatusOK, resp) } } // SystemInfoResponse 是 GET /api/system/info 的 data payload。 type SystemInfoResponse struct { Service string `json:"service"` Version string `json:"version"` Phase string `json:"phase"` } // logOrDefault 是 nil-safe slog 取用 helper;給 handler 共用。 // // Deps.validate 已會把 nil logger fallback 到 slog.Default,但測試直接呼叫 // register*Routes 時可能跳過 validate;這個 helper 讓 handler 不必每處都 nil 檢查。 func logOrDefault(l *slog.Logger) *slog.Logger { if l == nil { return slog.Default() } return l } // systemInfoHandler 回報版本與環境階段。 // // 雛形版本字串 hard-coded;B6(CI/CD)會改用 build flag 注入 git commit hash。 func systemInfoHandler() gin.HandlerFunc { return func(c *gin.Context) { WriteSuccess(c, http.StatusOK, SystemInfoResponse{ Service: "visiona-api-server", Version: "0.0.0-phase0", Phase: "phase-0-prototype", }) } }