// 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/device" "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) // ===== Pairing / Session Token(OIDC 之外的雛形 token store) ===== pairingStore := auth.NewInMemoryPairingStore() sessionTokenStore := auth.NewInMemorySessionTokenStore() // ===== 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) } userSessionStore := usersession.NewInMemoryStore() 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(in-memory,雛形) ===== deviceRepo := device.NewInMemoryRepository() modelRepo := model.NewInMemoryRepository() // ===== Converter(stub,Phase 2 才實作) ===== converterClient := converter.NewStubClient() // ===== Phase 0.8 / 0.8b Conversion(轉檔功能整合) ===== // 對齊 .autoflow/04-architecture/conversion.md、ADR-015。 // // 啟用條件:cfg.Conversion.Enabled() — // ConverterBaseURL + FAABaseURL + ConverterAPIKey + FAAAPIKey 全部非空。 // 不啟用時 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 變更影響清單。 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, }) faaAPIClient := conversion.NewFAAClient(conversion.FAAClientOpts{ BaseURL: cfg.Conversion.FAABaseURL, APIKey: cfg.Conversion.FAAAPIKey, 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, FAA: faaAPIClient, 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, "faa_base_url", cfg.Conversion.FAABaseURL, // 安全:絕不印 key 全文 — 對齊 ADR-015 §3.5.3 部署檢查清單 #4 "converter_api_key_set", cfg.Conversion.ConverterAPIKey != "", "faa_api_key_set", cfg.Conversion.FAAAPIKey != "") } else { log.Info("conversion service disabled (set VISIONA_CONVERTER_BASE_URL + VISIONA_FAA_BASE_URL + VISIONA_CONVERTER_API_KEY + VISIONA_FAA_API_KEY to enable)") } // ===== Seed demo data(可選) ===== if cfg.Server.SeedDemoData { if err := seedDemoData(deviceRepo, modelRepo, pairingStore, cfg.Auth.StaticUserID, log); err != nil { log.Warn("seed demo data failed", "error", err) } } // ===== 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, Storage: storageStore, Converter: converterClient, Conversion: conversionService, // Phase 0.8(nil 時 /api/conversion/* 回 501) MaxUploadSizeMB: cfg.Model.MaxSizeMB, CORSAllowedOrigins: cfg.CORS.AllowedOrigins, RelayPublicURL: cfg.Server.RelayPublicURL, // 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 ===== cleanupCtx, cleanupCancel := context.WithCancel(context.Background()) defer cleanupCancel() go runUserSessionCleanup(cleanupCtx, userSessionStore, cfg.UserSession.IdleTTL, cfg.UserSession.AbsoluteTTL, log) // ===== 啟動 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) }