# 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>
530 lines
23 KiB
Go
530 lines
23 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"
|
||
|
||
"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 =====
|
||
// 用 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,
|
||
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 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,
|
||
"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,
|
||
)
|
||
|
||
// ===== 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")
|
||
}
|
||
|
||
// user:DB-on FK 收尾(問題 #1)— OIDC callback provision users 列用。
|
||
// dbPool != nil 時切到 PostgresStore;否則 in-memory(local-dev fallback)。
|
||
// D1-B:OIDC sub 直接當 users.id(Member 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 exchanger:DB-on FK 收尾(問題 #2)— exchange 時自建 device + 建 session token。
|
||
// - Postgres:db.WithTx 把「建 device + 建 session token」包成單一交易,整筆原子。
|
||
// - in-memory:依序執行(無交易),行為一致。
|
||
// 注入 Deps.PairingExchanger;exchange 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")
|
||
}
|
||
|
||
// ===== 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/device 走 Postgres(塊 1+):seedDemoData 內部會先透過
|
||
// userStore.Upsert ensure demo user 列並改用合法 UUID owner / id;nil 時維持雛形 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 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 依序)
|
||
PairingExchanger: pairingExchanger, // DB-on FK 收尾 #2:exchange 自建 device + session token
|
||
Storage: storageStore,
|
||
PresetModelsDir: cfg.Storage.PresetModelsDir, // B8:7 個系統預設模型 .nef 目錄
|
||
PresetBaseURL: cfg.Storage.PresetBaseURL, // B8:preset download_url 前綴
|
||
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,
|
||
OIDCLogoutURL: cfg.OIDC.LogoutURL, // 空=logout 只清本地、不連動 MC(向下相容)
|
||
UserStore: userStore, // DB-on FK 收尾 #1:OIDC 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 接入塊 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)
|
||
}
|