jim800121chen 9ab1a11ea4 feat(models): 補 7 個預設模型(B8)+ 詳細頁下載按鈕(B5)
# B8 預設模型(簡單版:寫死常數 + 打包 + 不簽 token)
模型庫「預設模型」原本實際是空的(舊 seed 是 demo 假資料、production 不啟用)。
補 7 個公用預設模型(kl520×4 + kl720×3,來源 local-tool models.json):
- metadata 寫死成 Go 常數(presets.go),不進 DB → 公用、不可刪、無 seed 重複
- 7 個 .nef 打包進 image(assets/preset-models/,~61MB;Dockerfile COPY)
- download 走 visionA 自己(簡單版不簽 token,preset 公用本不需授權):
  download handler 三分支 preset→visionA URL / converted→FAA / uploaded→501
- 新 GET /preset-models/*filepath 靜態 serve(無 auth、Content-Disposition
  attachment 用 mime.FormatMediaType、path-traversal 防禦)
- list/get 含 preset(對所有人可見)

# B5 詳細頁下載按鈕 + preset 可下載
- model-detail-client.tsx 補下載按鈕(行為對齊列表卡片)
- isModelDownloadable 加 preset(source==="preset" → true)
- normalizeModelSummary 對 target_chip toLowerCase(修 preset 大寫 KL520
  在小寫晶片篩選下被隱藏的 bug;收斂所有來源大小寫)

# 測試
backend:presets/preset 下載三分支/靜態 serve/path-traversal/條件對稱 全綠
frontend:詳細頁下載鈕 + preset 可下載 + target_chip 大小寫篩選回歸 38 PASS
Reviewer 兩輪通過(Major-1 + Minor-1/2 修畢、複審 0 問題)

# backlog(未做的優化)
preset 下載改 HMAC presigned / preset .nef 改放 FAA 減 image / metadata 後台可管理

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 07:38:25 +08:00

530 lines
23 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"
"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/user"
"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,
PromptLogin: cfg.OIDC.PromptLogin,
})
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,
"logout_url", cfg.OIDC.LogoutURL, // 空logout 不連動 MC
"prompt_login", cfg.OIDC.PromptLogin,
"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")
}
// userDB-on FK 收尾(問題 #1— OIDC callback provision users 列用。
// dbPool != nil 時切到 PostgresStore否則 in-memorylocal-dev fallback
// D1-BOIDC sub 直接當 users.idMember Center sub 為 UUID。callback 一行不需改地切換。
var userStore user.Store
if dbPool != nil {
userStore = user.NewPostgresStore(dbPool.Pool())
log.Info("user store initialized", "backend", "postgres")
} else {
userStore = user.NewInMemoryStore()
log.Info("user store initialized", "backend", "in-memory")
}
// pairing exchangerDB-on FK 收尾(問題 #2— exchange 時自建 device + 建 session token。
// - Postgresdb.WithTx 把「建 device + 建 session token」包成單一交易整筆原子。
// - in-memory依序執行無交易行為一致。
// 注入 Deps.PairingExchangerexchange handler 偵測非 nil 即走「自建 device」路徑。
var pairingExchanger api.PairingExchanger
if dbPool != nil {
pairingExchanger = api.NewPostgresPairingExchanger(
dbPool.Pool(), pgDeviceRepo, pgSessionTokenStore, log)
log.Info("pairing exchanger initialized", "backend", "postgres-tx")
} else {
pairingExchanger = api.NewInMemoryPairingExchanger(
deviceRepo, memSessionTokenStore)
log.Info("pairing exchanger 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/device 走 Postgres塊 1+seedDemoData 內部會先透過
// userStore.Upsert ensure demo user 列並改用合法 UUID owner / idnil 時維持雛形 in-memory 行為。
if err := seedDemoData(deviceRepo, modelRepo, pairingStore, userStore, cfg.Auth.StaticUserID, dbPool != nil, 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 依序)
PairingExchanger: pairingExchanger, // DB-on FK 收尾 #2exchange 自建 device + session token
Storage: storageStore,
PresetModelsDir: cfg.Storage.PresetModelsDir, // B87 個系統預設模型 .nef 目錄
PresetBaseURL: cfg.Storage.PresetBaseURL, // B8preset download_url 前綴
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,
OIDCLogoutURL: cfg.OIDC.LogoutURL, // 空logout 只清本地、不連動 MC向下相容
UserStore: userStore, // DB-on FK 收尾 #1OIDC callback provision users 列
})
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)
}