// 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) }