jim800121chen c63886a194 feat(visionA-backend): Phase 0.9 模型庫存取 — FAA delegated download token(B1+B2)
對齊 ADR-017 v1.2:模型庫下載走 visionA 簽 MC delegated token → Client 直連 FAA。

B2 — MC download token client(internal/fileaccess):
- DownloadTokenIssuer: GetServiceToken(打 MC /oauth/token,client_credentials +
  scope files:download.delegate,含 token cache)+ IssueDownloadToken(打 MC Issue 簽 fdt_)
- secret / service token / fdt token 三層全程用 hashShort 遮罩不 log
- FileAccessConfig + VISIONA_FILE_ACCESS_* env + main.go wire(Enabled() 才接)

B1 — object_key 斷層:
- model.Model 加 FAAObjectKey(json:"-" 不揭露前端)
- PromoteToModels 寫入(用 promote response TargetObjectKey = models/{userID}/{jobID}.nef)
- 三方對映天然一致(visionA Issue / FAA path / MC validate)
- 第一階段框死只 Source=converted 類 model,上傳類 download 回 501

download endpoint:
- GET /api/models/:id/download(owner-only)→ {download_url, token, expires_at}
- 前端帶 Authorization: Bearer 直連 FAA(不經 visionA、不經 AWS)
- 401/403/404/501/502 分明,502 對外 mask 不洩漏 MC 內部狀態

測試: 13 + 8 unit test(mock MC + fake issuer,httptest 驗真 HTTP);go build/vet/test 全綠。
Reviewer: 0 Critical / 0 Major / 3 Minor / 4 Suggestion,通過。

技術債(正式上線前): 第一階段 PoC 共用 FAA service client,MC 規範禁止 client 混用
usage、secret 不共用,須 MC 配發 visionA 專屬 usage=file_api client。

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

341 lines
13 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/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 =====
// 用 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)
// ===== Pairing / Session TokenOIDC 之外的雛形 token store =====
pairingStore := auth.NewInMemoryPairingStore()
sessionTokenStore := auth.NewInMemorySessionTokenStore()
// ===== 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,
})
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,
)
// ===== 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)
// ===== Repositoriesin-memory雛形 =====
deviceRepo := device.NewInMemoryRepository()
modelRepo := model.NewInMemoryRepository()
// ===== 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 {
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 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,
Storage: storageStore,
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,
// OIDCOB5唯一認證路徑
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 不 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)
}