把 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>
499 lines
21 KiB
Go
499 lines
21 KiB
Go
// 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 =====
|
||
// 用 LocalFS(Phase 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 接入塊 0:DB 基礎建設) =====
|
||
// 對齊 docs/autoflow/04-architecture/database.md §5、§5.5.1。
|
||
//
|
||
// 啟用條件:cfg.Database.Enabled()(Host/User/DBName 全非空)。
|
||
// 未啟用 → dbPool 為 nil,所有 repository 維持 in-memory(local dev fallback,雛形行為不變)。
|
||
//
|
||
// ⚠️ 塊 0 範圍:只建池 + 跑 migration,**尚未**把 model/device/token repository 切到
|
||
// Postgres(那是塊 1–3)。因此即使 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-fast:DB 設定了卻連不上應停機,而非靜默 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 為 nil,userSession 維持 in-memory + cleanup goroutine(雛形行為不變)。
|
||
//
|
||
// ⚠️ 範圍:只接 internal/usersession(cookie session)。tunnel session(internal/session)
|
||
// value 是活的 yamux Handle 不可序列化,維持 in-memory(database.md §2.7 已定)。
|
||
var redisClient *db.RedisClient
|
||
if cfg.Redis.Enabled() {
|
||
rc, rErr := db.NewRedisClient(context.Background(), cfg.Redis, log)
|
||
if rErr != nil {
|
||
// fail-fast:Redis 設定了卻連不上應停機,與 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 Token(OIDC 之外的雛形 token store) =====
|
||
// DB 接入塊 3 — dbPool != nil 時切到 Postgres(分表:pairing_tokens + session_tokens);
|
||
// 否則維持 in-memory(local dev fallback,雛形行為不變)。
|
||
// Store interface 不變,handler / 呼叫端(internal/api/pairing.go)一行都不需改。
|
||
// 關鍵:Postgres 版以 token_hash(HashToken(plaintext))當 PK,DB 不存明文;
|
||
// 呼叫端統一傳 plaintext,store 內部統一 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 Session(OB5:唯一認證路徑) =====
|
||
// 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 store:DB 接入塊 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,
|
||
)
|
||
|
||
// ===== Session(api-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 =====
|
||
// device:DB 接入塊 2 — dbPool != nil 時切到 PostgresRepository;否則維持 in-memory
|
||
// (local dev fallback,雛形行為不變)。Repository interface 不變,handler 一行都不需改。
|
||
var deviceRepo device.Repository
|
||
var pgDeviceRepo *device.PostgresRepository // 塊 5.2 cascade:DeleteTx 需 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.2,database.md §6) =====
|
||
// 刪 device → 同時撤銷該 device 的 pairing + session token。
|
||
// - Postgres:用 db.WithTx 把三步包成單一交易(device 軟刪 + 兩張 token 表撤銷),整筆原子。
|
||
// - in-memory:依序執行(無交易),行為一致(刪 device 後 token 也撤)。
|
||
// 注入 Deps.DeviceUnpairer;unpair 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")
|
||
}
|
||
|
||
// model:DB 接入塊 1 — cfg.Database.Enabled() 且建池成功時切到 PostgresRepository;
|
||
// 否則維持 in-memory(local 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")
|
||
}
|
||
|
||
// ===== Converter(stub,Phase 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 為 nil,5 個 endpoint 自動回 501(registerConversionRoutes 處理)。
|
||
//
|
||
// **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.FAA;T4 砍 ConversionConfig
|
||
// FAABaseURL / FAAAPIKey 兩欄位與對應 env(VISIONA_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 自動回 501(modelsDownloadHandler 處理)。
|
||
//
|
||
// ⚠️ 技術債(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(塊 1):seedDemoData 內部會先 ensure
|
||
// demo user 列並改用合法 UUID owner / id;nil 時維持雛形 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 C1:StaticUserID 不再注入 Deps(見 .autoflow/05-implementation/review/phase-0.7-security-audit.md)
|
||
// dev seed 仍直接讀 cfg.Auth.StaticUserID;stage/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 unpair(Postgres tx / in-memory 依序)
|
||
Storage: storageStore,
|
||
Converter: converterClient,
|
||
Conversion: conversionService, // Phase 0.8(nil 時 /api/conversion/* 回 501)
|
||
FileAccessIssuer: fileAccessIssuer, // Phase 0.9(nil 時 /api/models/:id/download 回 501)
|
||
FAABaseURL: cfg.FileAccess.FAABaseURL,
|
||
MaxUploadSizeMB: cfg.Model.MaxSizeMB,
|
||
CORSAllowedOrigins: cfg.CORS.AllowedOrigins,
|
||
RelayPublicURL: cfg.Server.RelayPublicURL,
|
||
|
||
// 塊 5.4:/healthz 依賴 ping(nil = 未啟用、略過)
|
||
HealthDBPool: healthDBPool,
|
||
HealthRedis: healthRedis,
|
||
|
||
// OIDC(OB5:唯一認證路徑)
|
||
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 接入塊 4:Redis 模式靠 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 不 panic(cleanup 不應拖垮主 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)
|
||
}
|