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

499 lines
21 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.

// Command api-server 是 visionA-backend 的對前端 REST + WebSocket 伺服器。
//
// 雛形雙 binary 架構Q1 裁決):
// - api-server **無狀態**:所有 session 狀態都在 remote-proxy 那邊
// - 透過 ProxyClientStore + Forwarder 走 internal HTTP 跟 remote-proxy 溝通
//
// 對應文件:
// - `.autoflow/04-architecture/TDD.md` §2.4(雙 binary 部署)/ §10前端資料流
// - `.autoflow/04-architecture/api/api-spec.md`(前端用的 REST API
// - `.autoflow/04-architecture/api/api-internal.md`api-server ↔ remote-proxy
// - `.autoflow/04-architecture/tunnel.md` §5
package main
import (
"context"
"errors"
"net"
"net/http"
"os"
"os/signal"
"strconv"
"syscall"
"time"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
"visiona-backend/internal/api"
"visiona-backend/internal/auth"
"visiona-backend/internal/config"
"visiona-backend/internal/conversion"
"visiona-backend/internal/converter"
"visiona-backend/internal/db"
"visiona-backend/internal/device"
"visiona-backend/internal/fileaccess"
"visiona-backend/internal/logger"
"visiona-backend/internal/model"
"visiona-backend/internal/oidc"
"visiona-backend/internal/session"
"visiona-backend/internal/storage"
"visiona-backend/internal/usersession"
)
// defaultSigningSecret 與 config/load.go 保持一致 — 用於啟動警告。
const defaultSigningSecret = "dev-signing-secret-do-not-use-in-prod"
// shutdownTimeout 是收到 SIGINT/SIGTERM 後等待進行中請求完成的最長時間。
const shutdownTimeout = 10 * time.Second
// sessionCleanupInterval 是 OIDC user session store 的後台清理頻率。
// 設 5 分鐘是 dev / prod 都合理的值:足夠頻繁讓 idle session 不久留,
// 又不會過度消耗 CPU。
const sessionCleanupInterval = 5 * time.Minute
func main() {
cfg := config.Load()
log := logger.New(cfg.Logger.Level).With("service", "api-server")
// Validate config特別是 OIDC 啟用時的必填欄位)。
if err := cfg.Validate(); err != nil {
log.Error("invalid config", "error", err)
os.Exit(1)
}
// 啟動警告signing secret 為預設值(同 remote-proxy 行為)。
// 此 secret 同時給 storage presigned URL 與未來pairing token hash 用。
if cfg.Auth.SigningSecret == defaultSigningSecret {
log.Warn("signing secret 仍為預設 dev 值storage/pairing 共用)",
"action", "請在生產環境設定環境變數 VISIONA_STORAGE_SIGNING_SECRET",
"affects", "storage presigned URL, pairing token hash (phase 1)")
}
// ===== Storage =====
// 用 LocalFSPhase 0 雛形signing secret 共用同一份。
storageStore, err := storage.NewLocalFSStore(cfg.Storage.RootDir, cfg.Storage.BaseURL, cfg.Auth.SigningSecret)
if err != nil {
log.Error("failed to init storage", "error", err)
os.Exit(1)
}
log.Info("storage initialized",
"backend", cfg.Storage.Backend,
"root", cfg.Storage.RootDir,
"base_url", cfg.Storage.BaseURL)
// ===== PostgreSQL 連線池DB 接入塊 0DB 基礎建設) =====
// 對齊 docs/autoflow/04-architecture/database.md §5、§5.5.1。
//
// 啟用條件cfg.Database.Enabled()Host/User/DBName 全非空)。
// 未啟用 → dbPool 為 nil所有 repository 維持 in-memorylocal dev fallback雛形行為不變
//
// ⚠️ 塊 0 範圍:只建池 + 跑 migration**尚未**把 model/device/token repository 切到
// Postgres那是塊 13。因此即使 DB 啟用,目前 repository 仍是 in-memory
// 建池只為驗證基礎建設可用、並讓 schema 先就位。
var dbPool *db.Pool
if cfg.Database.Enabled() {
pool, dbErr := db.NewPool(context.Background(), cfg.Database, log)
if dbErr != nil {
// fail-fastDB 設定了卻連不上應停機,而非靜默 fallback in-memory 造成資料不一致。
log.Error("failed to init postgres pool", "error", dbErr, "target", db.SafeTarget(cfg.Database))
os.Exit(1)
}
dbPool = pool
defer dbPool.Close()
if cfg.Database.AutoMigrate {
if mErr := db.RunMigrations(cfg.Database, log); mErr != nil {
log.Error("failed to run migrations", "error", mErr, "target", db.SafeTarget(cfg.Database))
os.Exit(1)
}
log.Info("migrations applied", "target", db.SafeTarget(cfg.Database))
} else {
log.Info("auto-migrate disabled (set VISIONA_DB_AUTO_MIGRATE=true or run cmd/migrate)")
}
} else {
log.Info("postgres disabled (set VISIONA_DB_HOST + VISIONA_DB_USER + VISIONA_DB_NAME to enable); repositories use in-memory")
}
// ===== Redis 連線DB 接入塊 4僅 userSession browser cookie session =====
// 對齊 docs/autoflow/04-architecture/database.md §2.7、§5.5.2。
//
// 啟用條件cfg.Redis.Enabled()Host 非空無密碼也算啟用visionA 專用 Redis stage 內網可不設密碼)。
// 未啟用 → redisClient 為 niluserSession 維持 in-memory + cleanup goroutine雛形行為不變
//
// ⚠️ 範圍:只接 internal/usersessioncookie session。tunnel sessioninternal/session
// value 是活的 yamux Handle 不可序列化,維持 in-memorydatabase.md §2.7 已定)。
var redisClient *db.RedisClient
if cfg.Redis.Enabled() {
rc, rErr := db.NewRedisClient(context.Background(), cfg.Redis, log)
if rErr != nil {
// fail-fastRedis 設定了卻連不上應停機,與 Postgres 同樣不靜默 fallback。
log.Error("failed to init redis client", "error", rErr, "target", db.SafeRedisTarget(cfg.Redis))
os.Exit(1)
}
redisClient = rc
defer redisClient.Close()
} else {
log.Info("redis disabled (set VISIONA_REDIS_HOST to enable); user session uses in-memory + cleanup goroutine")
}
// ===== Pairing / Session TokenOIDC 之外的雛形 token store =====
// DB 接入塊 3 — dbPool != nil 時切到 Postgres分表pairing_tokens + session_tokens
// 否則維持 in-memorylocal dev fallback雛形行為不變
// Store interface 不變handler / 呼叫端internal/api/pairing.go一行都不需改。
// 關鍵Postgres 版以 token_hashHashToken(plaintext))當 PKDB 不存明文;
// 呼叫端統一傳 plaintextstore 內部統一 hash見 postgres_pairing_store.go 註解)。
var pairingStore auth.PairingStore
var sessionTokenStore auth.SessionTokenStore
// 保留 concrete 型別參照供塊 5.2 cascade unpair 協調者使用
// RevokeByDeviceTx / RevokeByDevice 不在 Store interface 上,需 concrete type
var pgPairingStore *auth.PostgresPairingStore
var pgSessionTokenStore *auth.PostgresSessionTokenStore
var memPairingStore *auth.InMemoryPairingStore
var memSessionTokenStore *auth.InMemorySessionTokenStore
if dbPool != nil {
pgPairingStore = auth.NewPostgresPairingStore(dbPool.Pool())
pgSessionTokenStore = auth.NewPostgresSessionTokenStore(dbPool.Pool())
pairingStore = pgPairingStore
sessionTokenStore = pgSessionTokenStore
log.Info("pairing/session token stores initialized", "backend", "postgres")
} else {
memPairingStore = auth.NewInMemoryPairingStore()
memSessionTokenStore = auth.NewInMemorySessionTokenStore()
pairingStore = memPairingStore
sessionTokenStore = memSessionTokenStore
log.Info("pairing/session token stores initialized", "backend", "in-memory")
}
// ===== OIDC + User SessionOB5唯一認證路徑 =====
// cfg.Validate() 已確保所有必填欄位存在,這裡可以放心 wire。
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
oidcProvider, err := oidc.NewProvider(ctx, oidc.ProviderConfig{
IssuerURL: cfg.OIDC.IssuerURL,
ClientID: cfg.OIDC.ClientID,
ClientSecret: cfg.OIDC.ClientSecret,
RedirectURL: cfg.OIDC.RedirectURL,
})
cancel()
if err != nil {
log.Error("failed to init OIDC provider",
"error", err,
"issuer", cfg.OIDC.IssuerURL,
"hint", "確認 IdP discovery (.well-known/openid-configuration) 可達")
os.Exit(1)
}
// userSession storeDB 接入塊 4 — redisClient != nil 時切到 RedisUserSessionStore
// (雙 TTL 取代手動 cleanup goroutine否則維持 in-memory + cleanup goroutine。
// Store interface 不變Manager / handler 一行都不需改。
var userSessionStore usersession.Store
if redisClient != nil {
userSessionStore = usersession.NewRedisUserSessionStore(
redisClient.Client(), cfg.UserSession.IdleTTL, cfg.UserSession.AbsoluteTTL)
log.Info("user session store initialized", "backend", "redis")
} else {
userSessionStore = usersession.NewInMemoryStore()
log.Info("user session store initialized", "backend", "in-memory")
}
userSessionMgr := usersession.NewManager(userSessionStore, usersession.CookieConfig{
Name: cfg.UserSession.CookieName,
Domain: cfg.UserSession.CookieDomain,
Path: "/",
Secure: cfg.UserSession.CookieSecure,
HTTPOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: int(cfg.UserSession.AbsoluteTTL.Seconds()),
SigningKey: []byte(cfg.UserSession.Secret),
})
log.Info("OIDC initialized",
"issuer", cfg.OIDC.IssuerURL,
"client_id", cfg.OIDC.ClientID,
"redirect_url", cfg.OIDC.RedirectURL,
"frontend_url", cfg.OIDC.PostLoginURL,
"cookie_secure", cfg.UserSession.CookieSecure,
"absolute_ttl", cfg.UserSession.AbsoluteTTL,
"idle_ttl", cfg.UserSession.IdleTTL,
)
// ===== Sessionapi-server 端透過 ProxyClient 走 internal HTTP =====
proxyClient := session.NewHTTPProxyClient(cfg.Session.ProxyInternalURL, log)
forwarder := session.NewForwarder(cfg.Session.ProxyInternalURL, log)
sessionStore := session.NewProxyClientStore(proxyClient, forwarder)
log.Info("session store initialized",
"backend", "proxy-client",
"proxy_internal_url", cfg.Session.ProxyInternalURL)
// ===== Repositories =====
// deviceDB 接入塊 2 — dbPool != nil 時切到 PostgresRepository否則維持 in-memory
// local dev fallback雛形行為不變。Repository interface 不變handler 一行都不需改。
var deviceRepo device.Repository
var pgDeviceRepo *device.PostgresRepository // 塊 5.2 cascadeDeleteTx 需 concrete type
if dbPool != nil {
pgDeviceRepo = device.NewPostgresRepository(dbPool.Pool())
deviceRepo = pgDeviceRepo
log.Info("device repository initialized", "backend", "postgres")
} else {
deviceRepo = device.NewInMemoryRepository()
log.Info("device repository initialized", "backend", "in-memory")
}
// ===== Device Unpair cascade 協調者DB 接入塊 5.2database.md §6 =====
// 刪 device → 同時撤銷該 device 的 pairing + session token。
// - Postgres用 db.WithTx 把三步包成單一交易device 軟刪 + 兩張 token 表撤銷),整筆原子。
// - in-memory依序執行無交易行為一致刪 device 後 token 也撤)。
// 注入 Deps.DeviceUnpairerunpair handler 偵測非 nil 即走 cascade。
var deviceUnpairer api.DeviceUnpairer
if dbPool != nil {
deviceUnpairer = api.NewPostgresDeviceUnpairer(
dbPool.Pool(), pgDeviceRepo, pgPairingStore, pgSessionTokenStore, log)
log.Info("device unpairer initialized", "backend", "postgres-tx")
} else {
deviceUnpairer = api.NewInMemoryDeviceUnpairer(
deviceRepo, memPairingStore, memSessionTokenStore)
log.Info("device unpairer initialized", "backend", "in-memory")
}
// modelDB 接入塊 1 — cfg.Database.Enabled() 且建池成功時切到 PostgresRepository
// 否則維持 in-memorylocal dev fallback雛形行為不變既有非-dbtest 測試不受影響)。
// Repository interface 不變所有呼叫端handler / conversion adapter一行都不需改。
var modelRepo model.Repository
if dbPool != nil {
modelRepo = model.NewPostgresRepository(dbPool.Pool())
log.Info("model repository initialized", "backend", "postgres")
} else {
modelRepo = model.NewInMemoryRepository()
log.Info("model repository initialized", "backend", "in-memory")
}
// ===== ConverterstubPhase 2 才實作) =====
converterClient := converter.NewStubClient()
// ===== Phase 0.8 / 0.8b Conversion轉檔功能整合 =====
// 對齊 docs/autoflow/04-architecture/conversion.md、ADR-015、ADR-016。
//
// 啟用條件cfg.Conversion.Enabled() —
// 由 ConverterBaseURL + ConverterAPIKey 決定v0.6 T4 起 visionA 端不再需要 FAA env
// 不啟用時 deps.Conversion 為 nil5 個 endpoint 自動回 501registerConversionRoutes 處理)。
//
// **Phase 0.8b T5**:完全切換至 pre-shared API key 認證 — 不再 wire MCTokenClient、
// 不再讀 OIDCConfig.ServiceClientID/Secret、不再有 tenant_id / delegated_ttl_sec
// 概念。參見 ADR-015 §6 變更影響清單。
//
// **Phase 0.8b v0.6 T3 + T4**:撤回 visionA → FAA 直接呼叫ADR-016 撤回 v0.5 設計缺口)。
// T3 砍 faa_client.go / FAAClient interface / FlowOpts.FAAT4 砍 ConversionConfig
// FAABaseURL / FAAAPIKey 兩欄位與對應 envVISIONA_FAA_BASE_URL / VISIONA_FAA_API_KEY
// `Enabled()` 簡化為只判 converter 兩欄位。download / promote 流程改走 converter.GetResult。
var conversionService conversion.Service
if cfg.Conversion.Enabled() {
// 不再檢查 ServiceClientID/Secret —— Phase 0.8b 起 conversion 不依賴 OIDC service client。
// OIDCConfig.ServiceClientID/Secret 兩欄位仍保留供 backward compat但非 conversion 必需。)
converterAPIClient := conversion.NewConverterClient(conversion.ConverterClientOpts{
BaseURL: cfg.Conversion.ConverterBaseURL,
APIKey: cfg.Conversion.ConverterAPIKey,
Logger: log,
})
ownership := conversion.NewOwnership(converterAPIClient, log)
// narrow adapter避免 conversion 直接 import internal/model / internal/storage
modelStoreAdapter := newConversionModelStoreAdapter(modelRepo)
storageAdapter := newConversionStorageAdapter(storageStore)
var convErr error
conversionService, convErr = conversion.NewService(conversion.FlowOpts{
Converter: converterAPIClient,
Ownership: ownership,
ModelStore: modelStoreAdapter,
Storage: storageAdapter,
Logger: log,
})
if convErr != nil {
log.Error("failed to init conversion service", "error", convErr)
os.Exit(1)
}
log.Info("conversion service initialized",
"converter_base_url", cfg.Conversion.ConverterBaseURL,
// 安全:絕不印 key 全文 — 對齊 ADR-015 §3.5.3 部署檢查清單 #4
"converter_api_key_set", cfg.Conversion.ConverterAPIKey != "")
} else {
log.Info("conversion service disabled (set VISIONA_CONVERTER_BASE_URL + VISIONA_CONVERTER_API_KEY to enable)")
}
// ===== Phase 0.9 模型庫 model 直連 FAA 下載ADR-017 (a) =====
// 對齊 docs/autoflow/04-architecture/adr/adr-017-model-library-access.md §10。
//
// 啟用條件cfg.FileAccess.Enabled() — 由 MC base / service client id+secret / tenant /
// FAA base 五欄位全非空決定。不啟用時 fileAccessIssuer 為 nil
// GET /api/models/:id/download 自動回 501modelsDownloadHandler 處理)。
//
// ⚠️ 技術債ADR-017 §7 R1 / Q10第一階段 PoC 共用 FAA 的 service client
// 正式上線前須換 visionA 專屬 usage=file_api client見 internal/fileaccess 套件註解 + .env.example
var fileAccessIssuer fileaccess.DownloadTokenIssuer
if cfg.FileAccess.Enabled() {
issuer, faErr := fileaccess.NewClient(fileaccess.Opts{
MCBaseURL: cfg.FileAccess.MCBaseURL,
ServiceClientID: cfg.FileAccess.ServiceClientID,
ServiceClientSecret: cfg.FileAccess.ServiceClientSecret,
TenantID: cfg.FileAccess.TenantID,
DownloadTokenTTLSeconds: cfg.FileAccess.DownloadTokenTTLSeconds,
Logger: log,
})
if faErr != nil {
log.Error("failed to init file access client", "error", faErr)
os.Exit(1)
}
fileAccessIssuer = issuer
log.Info("file access (FAA download) initialized",
"mc_base_url", cfg.FileAccess.MCBaseURL,
"faa_base_url", cfg.FileAccess.FAABaseURL,
"tenant_id", cfg.FileAccess.TenantID,
// 安全:絕不印 client secret 全文
"service_client_secret_set", cfg.FileAccess.ServiceClientSecret != "",
"download_token_ttl_sec", cfg.FileAccess.DownloadTokenTTLSeconds)
} else {
log.Info("file access (FAA download) disabled (set VISIONA_FILE_ACCESS_* to enable)")
}
// ===== Seed demo data可選 =====
if cfg.Server.SeedDemoData {
// dbPool 非 nil 時seed 的 model 走 Postgres塊 1seedDemoData 內部會先 ensure
// demo user 列並改用合法 UUID owner / idnil 時維持雛形 in-memory 行為。
var seedPool *pgxpool.Pool
if dbPool != nil {
seedPool = dbPool.Pool()
}
if err := seedDemoData(deviceRepo, modelRepo, pairingStore, cfg.Auth.StaticUserID, seedPool, log); err != nil {
log.Warn("seed demo data failed", "error", err)
}
}
// ===== /healthz 依賴 ping塊 5.4 =====
// 只在依賴非 nil 時注入;注意不能把 typed-nil*db.Pool(nil))塞進 interface
// 否則 interface != nil 但呼叫 Ping 會對 nil pool panic。故顯式分支保證 nil pool 不注入。
var healthDBPool api.HealthPinger
if dbPool != nil {
healthDBPool = dbPool
}
var healthRedis api.HealthPinger
if redisClient != nil {
healthRedis = redisClient
}
// ===== API Router =====
gin.SetMode(gin.ReleaseMode)
// Phase 0.7 security fix C1StaticUserID 不再注入 Deps見 .autoflow/05-implementation/review/phase-0.7-security-audit.md
// dev seed 仍直接讀 cfg.Auth.StaticUserIDstage/prod 不影響VISIONA_SEED_DEMO_DATA=false
router := api.NewRouter(api.Deps{
Logger: log,
PairingStore: pairingStore,
SessionTokenStore: sessionTokenStore,
SessionStore: sessionStore,
Forwarder: forwarder,
DeviceRepo: deviceRepo,
ModelRepo: modelRepo,
DeviceUnpairer: deviceUnpairer, // 塊 5.2 cascade unpairPostgres tx / in-memory 依序)
Storage: storageStore,
Converter: converterClient,
Conversion: conversionService, // Phase 0.8nil 時 /api/conversion/* 回 501
FileAccessIssuer: fileAccessIssuer, // Phase 0.9nil 時 /api/models/:id/download 回 501
FAABaseURL: cfg.FileAccess.FAABaseURL,
MaxUploadSizeMB: cfg.Model.MaxSizeMB,
CORSAllowedOrigins: cfg.CORS.AllowedOrigins,
RelayPublicURL: cfg.Server.RelayPublicURL,
// 塊 5.4/healthz 依賴 pingnil = 未啟用、略過)
HealthDBPool: healthDBPool,
HealthRedis: healthRedis,
// OIDCOB5唯一認證路徑
OIDCProvider: oidcProvider,
SessionManager: userSessionMgr,
OIDCPostLoginURL: cfg.OIDC.PostLoginURL,
})
addr := net.JoinHostPort(cfg.Server.Host, strconv.Itoa(cfg.Server.Port))
srv := &http.Server{
Addr: addr,
Handler: router,
ReadHeaderTimeout: 10 * time.Second, // 防 slow-loris對齊 security.md
}
// ===== User session cleanup goroutine =====
// DB 接入塊 4Redis 模式靠 key TTL 自動過期,不需 background 掃描,故只在 in-memory 模式啟動。
cleanupCtx, cleanupCancel := context.WithCancel(context.Background())
defer cleanupCancel()
if redisClient == nil {
go runUserSessionCleanup(cleanupCtx, userSessionStore, cfg.UserSession.IdleTTL, cfg.UserSession.AbsoluteTTL, log)
} else {
log.Info("user session cleanup goroutine skipped (redis TTL handles expiry)")
}
// ===== 啟動 server =====
errCh := make(chan error, 1)
go func() {
log.Info("api-server listening",
"addr", addr,
"proxy_internal_url", cfg.Session.ProxyInternalURL,
"seed_demo_data", cfg.Server.SeedDemoData,
"oidc_issuer", cfg.OIDC.IssuerURL,
)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()
// 等 signal 或錯誤
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
select {
case <-quit:
log.Info("shutdown signal received")
case err := <-errCh:
log.Error("api-server error, shutting down", "error", err)
}
// Graceful shutdown
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Warn("api-server shutdown error", "error", err)
}
cleanupCancel() // 停掉 user session cleanup goroutine
log.Info("api-server stopped")
}
// runUserSessionCleanup 是 OIDC user session store 的 background cleanup 迴圈。
//
// 每 sessionCleanupInterval 跑一次 store.CleanupExpired把 idle / absolute timeout
// 的 session 清掉。失敗只 log 不 paniccleanup 不應拖垮主 process
//
// ctx 取消process shutdown即退出。
func runUserSessionCleanup(ctx context.Context, store usersession.Store, idleTTL, absTTL time.Duration, log loggerLike) {
ticker := time.NewTicker(sessionCleanupInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
removed, err := store.CleanupExpired(cctx, idleTTL, absTTL)
cancel()
if err != nil {
log.Warn("user session cleanup failed", "error", err)
continue
}
if removed > 0 {
log.Info("user session cleanup", "removed", removed)
}
}
}
}
// loggerLike 是 runUserSessionCleanup 需要的最小 logger 介面,避免直接綁 *slog.Logger
// 而能在 test 中 stub。
type loggerLike interface {
Info(msg string, args ...any)
Warn(msg string, args ...any)
}