把 visionA-backend 6 個 in-memory store 接到資料庫持久化,範圍=完整 (PG 全接 + session 接 Redis + 交易韌性)。interface / handler 不動, 只加 DB 實作 + 換 wiring,config 未設 DB 時保留 in-memory fallback。 - 塊 0 基礎建設:pgx/v5 連線池 + DatabaseConfig/RedisConfig + golang-migrate runner(embed)+ cmd/migrate + testcontainers 測試基礎建設 - 塊 1 model → Postgres:array 映射、upsert 保留 CreatedAt、faa_object_key、 三維 filter(owner/chip/source)、soft-delete partial index - 塊 2 device → Postgres:partial unique(已刪 serial 可重註冊)、雙狀態欄位 - 塊 3 token → Postgres:pairing_tokens + session_tokens 分表、token_hash 當 PK - 塊 4 userSession → Redis:idle + absolute 雙 TTL 取代 cleanup goroutine (tunnel session 維持 in-memory,yamux handle 不可序列化) - 塊 5 交易/韌性:WithTx helper + 刪 device cascade 撤銷 token(同 tx 原子) + /healthz ping PG/Redis(fail-fast 503)+ pgx error 統一映射(不洩漏 raw error) 降級策略(fail-fast):PG 掉 → 持久資料 API 回 503;Redis 掉 → session 失敗 不自動 fallback in-memory(避免多機 session 不同步)。 DB:PostgreSQL 14.23(gen_random_uuid 內建、無 citext → email 用 lower() unique index)。每塊經 Reviewer 審查 + 真 PG/Redis testcontainers 全量 dbtest 綠燈, in-memory fallback 未受影響。 docs: 同步更新 database.md(schema/config/migration 清單)+ api-spec.md (409/503 錯誤碼、/healthz 新行為、device unpair cascade)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
186 lines
6.2 KiB
Go
186 lines
6.2 KiB
Go
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",
|
||
})
|
||
}
|
||
}
|