jim800121chen 6024c294d3 feat(visionA-backend): Phase 0.8b v0.6 T3 — 砍 faa_client + ErrFAA* + s-3/s-4/s-5
對齊 ADR-016:visionA backend 不再直連 FAA、download 改走 converter GetResult。T3 砍除 v0.5 階段為 FAA delegated token 路線留的 faa_client.go 整檔 + 對應 sentinel + flow / e2e 殘留。

砍除:
- internal/conversion/faa_client.go(整檔)
- internal/conversion/faa_client_test.go(整檔)
- errors.go: ErrFAAFileNotFound + ErrFAAAuthFailed 2 sentinel(+ ErrorCode/HTTPStatus mapping)
- flow.go: faa FAAClient 欄位 + FlowOpts.FAA 必填 + a-h T3 預期清單 godoc
- flow_test.go: flowStubFAA struct + newFlowStubFAA helper + fixture.faa
- internal/api/conversion_test.go: TestConversion_Download_FAAAuthFailed
- cmd/api-server/main.go: NewFAAClient wire + FAA: faaAPIClient field

保留:
- ErrFAAUnavailable(converter promote 仍 PUT FAA、502 透傳路徑需要)
- hashObjectKey helper 搬到 util.go(ownership 仍用)
- e2e mockFAA 精簡為 regression-only(保留 negative assertion: FAA 0 命中)— reviewer 推薦雙層防護

新增(T3 必補,T1/T2 reviewer 累積):
- s-3 TestDownloadStream_ConverterValidationFailed_Propagation(converter 4xx fallback → ErrValidationFailed 透傳)
- s-4 TestPromoteToModels_StorageError_StreamClosed(instrumented stream wrapper 驗 fd leak 防護)
- s-5 TestParseFilenameFromContentDisposition 9 個 sub-case(3 RFC 5987 + 5 hostile-input + 1 empty quoted)
  發現:Go stdlib 自動 percent-decode RFC 5987 並寫入 params["filename"]、RFC 5987 優先於 ASCII filename

T3 review M-1 修補(commit 內含):
- internal/api/conversion.go:51,56 godoc + 501 user-facing message 從「FAA_BASE_URL」改為「VISIONA_CONVERTER_BASE_URL + VISIONA_CONVERTER_API_KEY」
- 對齊 ADR-016 visionA 端不再有 FAA 直連設計

驗證:
- B 層 verification 強制跑(reviewer 規定 T3 不接受暫緩):
  * 跨檔 grep: MC chain 0 / FAA functional refs 0 / TenantID 0
  * API contract test: TestConversionE2E_DownloadStream 6 斷言含 FAA negative
  * 安全 manual review: path traversal / unbounded read / secret in log / error mask 4 項
- go build ./... exit 0
- go test -race -count=3 ./... 17 packages 全綠
- Reviewer 5 軸(v0.6-t3-review)⚠️ 通過(M-1 已修)

a-h 8 條清單 100% 達成(逐條 grep 驗收);mockFAA 選方案 1(保留 + negative assertion)— 雙層防護。

下一步:
- T4 砍 ConversionConfig.FAAAPIKey/FAABaseURL + load.go env 讀取 + .env*.example + m-2 i18n dead case 一併
- T5 main.go startup log 整理 + e2e regression 防護

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:56:01 +08:00

302 lines
11 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/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 決定FAABaseURL / FAAAPIKey 由 T4 砍除 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**:撤回 visionA → FAA 直接呼叫ADR-016 撤回 v0.5 設計缺口)。
// faa_client.go / FAAClient interface / FlowOpts.FAA 全部砍除download / promote 流程
// 改走 converter.GetResult。FAABaseURL / FAAAPIKey env 仍保留在 config 直到 T4 一併砍。
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)")
}
// ===== 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
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)
}