jim800121chen 4d0b870480 feat(visionA-backend): DB 接入 — 6 store 接 PostgreSQL/Redis 持久化(塊 0-5)
把 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>
2026-06-20 18:28:04 +08:00

186 lines
6.2 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 (
"context"
"log/slog"
"net/http"
"time"
"github.com/gin-gonic/gin"
)
// HealthPinger 抽象「能對某依賴跑一次連線檢查」的能力。
//
// db.PoolPostgres與 db.RedisClientRedis都有 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 非 nilPostgres 啟用)→ 每次 /healthz 都 ping失敗回 503。
// - Redis 非 nilRedis 啟用)→ 每次 /healthz 都 ping失敗回 503。
// - 兩者皆 nilin-memory 模式 / 未啟用)→ 略過維持「process 活著就 ok」的舊行為。
//
// 為什麼 ping 失敗回 503讓 load balancer / K8s readiness 把這台不健康的實例拉出輪替,
// 而非繼續送流量進來碰 DB 拿 503/假資料。
type HealthDeps struct {
DBPool HealthPinger // Postgres 連線池db.Poolnil = 未啟用
Redis HealthPinger // Redis clientdb.RedisClientnil = 未啟用
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
// 失敗 → 503body 標出哪個依賴不健康,但不洩漏 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-codedB6CI/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",
})
}
}