// Package conversion 實作 Phase 0.8 轉檔功能整合。 // // 對齊文件: // - .autoflow/02-prd/features/feature-converter-integration.md(PRD) // - .autoflow/04-architecture/conversion.md(TDD 主文件) // - .autoflow/04-architecture/api/api-conversion.md(API 規格) // - .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` interface(FAANG 慣例: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,第二個固定 userID(trust 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. 成功後寫 ownership:jobID → userID(converter 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=jobID(schema 已預埋) // // 冪等性:對同一 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 proxy(Phase 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.8:visionA → MC 換 delegated token → 組 FAA URL → handler 回 302, // browser 直連 FAA。 // - Phase 0.8b:MC 認證鏈取消(ADR-015)→ 沒有 delegated token → visionA backend // 用 API key 直接拉 FAA → 中轉 stream 給 browser(server-side proxy)。 // // 安全(見 conversion.md §10.4): // - 沒有 token 結構性存在於任何 frontend response(API key 永遠在 server side) // - object_key 不對 frontend 揭露(filename 取自 promote 結果,由 visionA 命名) // - 不需 FAA CORS(visionA → FAA 是 server-side outbound HTTP call,不適用 CORS) // // Caller(handler)責任: // - 取得 stream 後**必須 defer stream.Close()**,否則 keep-alive connection 不會回 pool // - 設好 response header(Content-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=&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 必須含 boundary(multipart/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.ContentLength;converter 自己會算 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 + 7d(converter 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 限 500MB(VISIONA_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 header(Content-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。 // 規則:`_.nef`,對齊 wireframe §8.1 success card 顯示 // (例:`yolov5s_kl720.nef`);handler 應對此值再做一次 sanitize(去除控制字元 / 路徑分隔符)。 Filename string // ContentType 對應 FAA response 的 Content-Type header;NEF binary 預設為 application/octet-stream。 // 若 FAA 沒給就用此預設值(保險:browser 收到 octet-stream 必觸發 download dialog)。 ContentType string // ContentLength 對應 FAA response 的 Content-Length header。 // FAA 走 chunked transfer 時為 -1(net/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"` }