jim800121chen 86b7175649 feat(visionA-backend): Phase 0.8b 步驟 2 — visionA → converter / FAA 改 API key 認證
對齊 ADR-015:visionA backend 從 OAuth client_credentials 改 pre-shared API key 服務間認證。Phase 0.8 stage e2e 撞 4 個 blocker(MC scope 沒註冊 / converter image 舊版 / converter 缺 env / FAA 不確定)後,使用者拍板 1:1 internal trust 用 OAuth 過度設計,改 API key。

實作(5 個增量 task,T1-T5 全綠 + Reviewer 5 輪 + final cross-task review):

T1 config + env:
- ConversionConfig 新增 ConverterAPIKey / FAAAPIKey 欄位
- Enabled() 改判定 4 欄位齊全(含兩個 API key)
- .env*.example 移除 OIDC service client / OIDC tenant / FAA delegated TTL env、新增 API key env
- TenantID / DelegatedTTLSeconds T1 暫留、T5 整批清

T2 client 改造:
- converter_client / faa_client 移除 MCTokenClient 依賴
- 直接讀 cfg.Conversion.{Converter,FAA}APIKey、set Authorization: Bearer <key>
- NewConverterClient / NewFAAClient APIKey 為空時 panic(fail-fast,對齊 ADR-015 §3.5.3 #1)
- 新增 ErrConverterAuthFailed / ErrFAAAuthFailed sentinel
- 對外 mask 成 converter_unavailable / faa_unavailable(不洩漏 401 細節,對齊 ADR-015 §3.5.3 #3)

T3 砍 mc_token_client:
- mc_token_client.go (624 行) + mc_token_client_test.go (864 行) 整檔砍
- 砍 5 個僅 mc_token_client 用的 sentinel(ErrServiceClientUnauthorized / ErrMCTokenUnavailable / ErrIDPMisconfigured / ErrIDPUnavailable / ErrDownloadTokenFailed)
- helper(truncate / silentLogger)搬到 util.go / testing_helpers_test.go

T4 flow + handler stream proxy:
- Service interface DownloadRedirectURL → DownloadStream(ctx) (io.ReadCloser, *DownloadMetadata, error)
- flow.DownloadStream 用 faa.GetFile 直接 stream NEF binary(取代 MC delegated token + 302)
- handler conversionDownloadHandler 改 io.Copy + Content-Type/Disposition/Cache-Control header
- 新增 sanitizeDownloadFilename helper 防 HTTP header injection
- 跨 package handler test (conversion_test.go) 改測 ErrFAAUnavailable + 補 *_AuthFailed 對稱 test

T5 wire 切換 + cleanup:
- main.go 砍 mcTokenClient wire、改 APIKey 注入、startup log 用 *_api_key_set boolean(不印 key)
- ConverterClient/FAAClient struct Tokens 欄位移除
- mc_token_stub.go (T4 過渡期) 整檔砍
- ConversionConfig TenantID / DelegatedTTLSeconds 欄位移除
- e2e_test.go TestConversionE2E_Download302Redirect 改寫為 TestConversionE2E_DownloadStream
- 補 MaxDownloadStreamBytes = 1 GiB size cap(io.CopyN,T4 reviewer Minor M-1)
- escapeObjectKeyPath Phase 1+ 預留 godoc + //nolint:unused(T4 reviewer Minor M-2)
- conversion.md §4.1 line 502 filename 來源描述歧義修訂(T4 reviewer Minor M-3,由 architect 處理)
- conversion_e2e_test.go 檔頭 docstring 更新(final reviewer Minor #1)

驗證:
- go build ./... exit 0
- go test -race -count=3 ./... 17 packages 全綠
- ADR-015 §6 砍除清單 100% cover(reviewer 獨立 grep 確認 MC chain / TenantID / DelegatedTTLSeconds 全清)
- ADR-015 §3.5.3 部署檢查清單 visionA 範圍 4/4 達成(fail-fast / mask / 不印 token / placeholder env)

不動:
- OIDCConfig.ServiceClientID/Secret 欄位保留(使用者拍板 backward compat)
- user login OIDC 完全不動

下一步:
- 步驟 4 — converter scheduler middleware 改 API key(jimchen 跨 repo,ADR-015 §3.5.1 Go snippet)
- 步驟 5 — FAA middleware 改 API key(warrenchen 跨 repo,ADR-015 §3.5.2 C# snippet)
- 步驟 6 — stage redeploy + e2e 完整測試

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:45:45 +08:00

223 lines
12 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.

// Package conversion 實作 Phase 0.8 轉檔功能整合。
//
// 對齊文件:
// - .autoflow/02-prd/features/feature-converter-integration.mdPRD
// - .autoflow/04-architecture/conversion.mdTDD 主文件)
// - .autoflow/04-architecture/api/api-conversion.mdAPI 規格)
// - .autoflow/04-architecture/adr/adr-014-conversion-integration.md架構決策
//
// 與 internal/converter/ 的關係:
//
// internal/converter/ 是 Phase 0 / Phase 2 規劃時 PM 寫的 stub interface
// scope 與 Phase 0.8 不同Phase 0 規劃的是「自動推入模型庫」端到端 flow
// Phase 0.8 改為半自動 + streaming proxy + 三方 token 機制,重新設計 internal/conversion/
// 實作;舊的 internal/converter/ 套件保留在 codebase 中(對 frontend / 其他模組無依賴),
// 等 Phase 0.8 整合完成後可由 Architect 評估是否清除。
//
// 套件邊界:
// - 對 handler 層只暴露 `Service` interfaceFAANG 慣例DI-friendly、unit test 友善)
// - 內部模組converter_client / faa_client / mc_token_client / ownership / flow對 handler 不可見
// - 所有 Phase 0.8 流程的協調點在 flow.go 的 `Flow` struct
//
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2)
package conversion
import (
"context"
"io"
"time"
)
// Service 是 handler 層的單一進入點,匹配 5 個對外 endpoint 的能力。
//
// 實作:`Flow`flow.go
//
// 設計原則:
// - 所有 method 第一個參數固定 ctx第二個固定 userIDtrust boundary
// - 任何 client 帶來的 user_id 都不可信userID 來源永遠是 OIDC AuthMiddleware
// 從 cookie session 解出的 sub見 conversion.md §7.1)。
// - 所有 method 都不接受 user_id 從 body / query / header 傳入。
type Service interface {
// InitJob 把 client 的 multipart stream 透傳給 converter建立轉檔 job。
//
// 行為:
// 1. 內部用 io.Pipe + multipart.Reader/Writer 重組 multipart body
// streaming proxy避免 buffer 全 RAM見 conversion.md §4.2
// 2. 黑名單 client 帶來的 user_id field永遠以 InitJobInput.UserID 為準
// 3. 等到 converter 回 201 才 return見 conversion.md §4.3.1
// 不採用 early-return 模式以避免進度條假象)
// 4. 成功後寫 ownershipjobID → userIDconverter 7d 過期對齊)
//
// 失敗處理:
// - converter 4xx → 透傳 error code見 conversion.md §6 mapping
// - converter 5xx / network → retry見 §9.1
// - client 中斷 / ctx cancel → goroutine cleanup + best-effort 對 converter 發 cancel
// (見 §4.3.2 cleanup 鏈)
InitJob(ctx context.Context, in InitJobInput) (*Job, error)
// GetJob 查 converter 的 job 狀態,先做 ownership 檢查。
//
// Frontend polling 場景;內部對 converter response cache 1-2s 避免 polling 直接打爆 converter。
//
// 失敗處理:
// - ownership 不符 → ErrForbidden
// - job 不存在 → ErrJobNotFound
// - converter 5xx / network → 重試後仍失敗回 ErrConverterUnavailable
GetJob(ctx context.Context, userID, jobID string) (*Job, error)
// PromoteToModels 執行「加到模型庫」流程。
//
// 步驟(見 conversion.md §1 Stage 3a + §2.5
// 1. ownership.Check(userID, jobID)
// 2. ensurePromoted(jobID) — 冪等:若已 promote 過用 cache否則打 converter
// 3. faa.Download(promotedKey) — 用 service token (scope=files:download.read) server-to-server pull
// 4. 走既有 /api/models/init + /api/models/finalize不繞過既有 handler 邏輯)
// 5. 回填 model.Source="converted" + model.SourceJobID=jobIDschema 已預埋)
//
// 冪等性:對同一 jobID 重複呼叫;若已建過 model record回既有 modelID 而非新建。
//
// `name` 是 Design Phase 0.8 wireframe §7.1 的單一欄位(不含 description
PromoteToModels(ctx context.Context, userID, jobID, name string) (*PromoteResult, error)
// DownloadStream 產出「下載」的 server-side stream proxyPhase 0.8b 變更,對應 ADR-015 §7
//
// 流程(見 conversion.md §1 Stage 3b + §4.1
// 1. ownership 檢查(不符 → ErrJobNotFound§7.2 防枚舉)
// 2. converter.GetJob 確認 status=completed否則 ErrJobNotCompleted
// 3. ensurePromoted與 PromoteToModels 共用同一個 converter promote endpoint冪等
// 4. faa.GetFile(targetObjectKey) — 用 pre-shared API key 直接拉 NEF stream
//
// Phase 0.8 → 0.8b 差異:
// - Phase 0.8visionA → MC 換 delegated token → 組 FAA URL → handler 回 302
// browser 直連 FAA。
// - Phase 0.8bMC 認證鏈取消ADR-015→ 沒有 delegated token → visionA backend
// 用 API key 直接拉 FAA → 中轉 stream 給 browserserver-side proxy
//
// 安全(見 conversion.md §10.4
// - 沒有 token 結構性存在於任何 frontend responseAPI key 永遠在 server side
// - object_key 不對 frontend 揭露filename 取自 promote 結果,由 visionA 命名)
// - 不需 FAA CORSvisionA → FAA 是 server-side outbound HTTP call不適用 CORS
//
// Callerhandler責任
// - 取得 stream 後**必須 defer stream.Close()**,否則 keep-alive connection 不會回 pool
// - 設好 response headerContent-Type / Content-Disposition / Cache-Control / Content-Length
// 用 io.Copy(w, stream) streaming 寫到 client
// - 中途錯誤無法再改 status已 200 + part of body由 ctx 控制 caller 端 cleanup
DownloadStream(ctx context.Context, userID, jobID string) (stream io.ReadCloser, meta *DownloadMetadata, err error)
// ActiveJob 查 user 當前是否有 active job給 frontend `/conversion` 頁載入時 pre-check。
//
// 重啟恢復行為A4 lazy rebuild見 conversion.md §2.6.1
// 1. 先查 in-memory ownership
// 2. miss 時 fallback 對 converter 打 GET /api/v1/jobs?user_id=<sub>&status=in_progress
// 3. 若 converter 有回覆 active job重建 ownership 後 return
//
// 對 frontend 完全透明(同樣 endpoint、同樣 response shape
//
// 沒有 active job 時回 (nil, nil),不視為 error。
ActiveJob(ctx context.Context, userID string) (*Job, error)
}
// ==========================================================================
// I/O types
// ==========================================================================
// InitJobInput 是 handler 傳給 Service.InitJob 的 streaming proxy 輸入。
//
// 設計原則:
// - Service 不關心 multipart 解析細節handler 把 raw body 傳進來,
// 由 Service 內部處理 io.Pipe + multipart.Reader/Writer 的重組(見 conversion.md §4.2
// - UserID 是唯一可信任的 user 身份來源OIDC sub
// - ContentType 必須含 boundarymultipart/form-data; boundary=...
// handler 直接從 c.GetHeader("Content-Type") 取
type InitJobInput struct {
UserID string // 由 AuthMiddleware UserContext.UserID 注入;唯一可信來源
ContentType string // 含 boundary 的原始 Content-Type header 值
Body io.Reader // request.Body
ContentLength int64 // request.ContentLengthconverter 自己會算 multer這裡僅供 log
}
// Job 是轉檔任務的對外 response shape。
//
// 對齊 api-conversion.md §1-2 的 response 欄位 + 三方 review 議題 #7
// (補 expires_at / source_filename / target_chip
//
// 注意Job.Status / Job.Stage 用 converter 端的字面值converted from openapi.yaml
// 直接透傳給 frontend不另做 mapping避免 enum 同步成本:
//
// status: "created" / "running" / "completed" / "failed"
// stage: "onnx" / "bie" / "nef"
type Job struct {
JobID string `json:"job_id"`
Status string `json:"status"`
Stage string `json:"stage"`
Progress int `json:"progress"` // 0-100整體
StageProgress int `json:"stage_progress"` // 0-100當前 stage
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ExpiresAt time.Time `json:"expires_at"` // created_at + 7dconverter GC 期限)
SourceFilename string `json:"source_filename,omitempty"` // 原始檔名(顯示用)
TargetChip string `json:"target_chip,omitempty"` // "520" / "720" / "630" / "730"
ErrorCode string `json:"error_code,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
}
// MaxDownloadStreamBytes 是 Service.DownloadStream → handler io.Copy 的 sanity cap。
//
// 用途:對 buggy / malicious FAA 回傳超大 body 的防禦性深化T4 Reviewer Minor #1
// io.Copy 本身是 streaming不 buffer 全 RAM每次 32KB但無上限會
// - 浪費 visionA → browser 的 egress bandwidth
// - goroutine 持續開著slow loris-like 行為)
//
// 1GB 的選擇邏輯:
// - ADR-015 §7「單檔 NEF 通常 < 50MB」是現況觀測但 NEF 沒有結構性上限
// - visionA model upload 限 500MBVISIONA_CONVERTER_MAX_MODEL_SIZE_MB 預設)
// - 1GB = 「正常 NEF × 20」的 sanity cap足以涵蓋極端但合理的轉檔結果
// - 超過此值幾乎必為 FAA bug 或攻擊;中斷比繼續 stream 安全
//
// 對 < 50MB 的正常 NEF 零影響;超過 1GB 時 handler log warn + 中斷 stream
// (已 200 + 部分 body無法回頭改 status — 對齊 conversion.go:325-336 既有錯誤分支處理)。
//
// Phase 1 量大評估升級時ADR-015 §7 選項 B可一併重新評估此值或改 per-user quota。
//
// **設計選擇var 而非 const**handler 端讀取此值;測試需要 override 為小數值
// e.g. 1024 bytes以驗 cap 行為而不需 read 真實 1GB stream。Production 程式碼
// **絕不**修改此值runtime mutation 會造成 race只有 test 在初始化階段覆寫。
var MaxDownloadStreamBytes int64 = 1 * 1024 * 1024 * 1024 // 1 GiB
// DownloadMetadata 是 Service.DownloadStream 回傳的中介資料Phase 0.8b 新增)。
//
// 對應 api-conversion.md §4 Phase 0.8b response 規格 — handler 把這些值寫進對 browser 的
// HTTP response headerContent-Type / Content-Length / Content-Disposition
//
// 設計選擇:與 faa_client.FAAFile.ContentLength / ContentType 對齊;多一個 Filename 是
// 因為 download 走 `Content-Disposition: attachment; filename=...`,需要 visionA 自行命名
// API key 模式下沒有 FAA delegated URL 含原檔名了)。
type DownloadMetadata struct {
// Filename 對應 `Content-Disposition: attachment; filename=...` 的 value。
// 規則:`<source_filename_stem>_<target_chip_lower>.nef`,對齊 wireframe §8.1 success card 顯示
// (例:`yolov5s_kl720.nef`handler 應對此值再做一次 sanitize去除控制字元 / 路徑分隔符)。
Filename string
// ContentType 對應 FAA response 的 Content-Type headerNEF binary 預設為 application/octet-stream。
// 若 FAA 沒給就用此預設值保險browser 收到 octet-stream 必觸發 download dialog
ContentType string
// ContentLength 對應 FAA response 的 Content-Length header。
// FAA 走 chunked transfer 時為 -1net/http 慣例handler 此時不要 set Content-Length header
// (讓 browser 用 chunked decoding
ContentLength int64
}
// PromoteResult 是 PromoteToModels 的 response shape對齊 api-conversion.md §3。
type PromoteResult struct {
ModelID string `json:"model_id"`
Source string `json:"source"` // 永遠是 "converted"
SourceJobID string `json:"source_job_id"` // converter job id
Name string `json:"name"`
TargetChip string `json:"target_chip,omitempty"` // 對齊 api-conversion.md §3 response
FileSize int64 `json:"file_size"`
Status string `json:"status"` // 沿用 model 既有 status"ready" 等)
CreatedAt time.Time `json:"created_at"`
}