jim800121chen ce6a657df4 feat(visionA-backend): Phase 0.8b v0.6 對齊 — T1+T2 download 改走 converter.GetResult
對齊 ADR-016:visionA download 不再經 FAA delegated token、改用 converter GET /api/v1/jobs/{id}/result 中轉。

T1 — converter_client.go 加 GetResult method:
- 新增 GetResult(ctx, jobID) (io.ReadCloser, *DownloadMetadata, error)
- 新增 ErrResultExpired sentinel + ErrorCode("result_expired") + HTTPStatus 410 mapping
- 獨立 StreamHTTPClient (無 timeout / dial+response header timeout) 給 streaming 大檔
- doStreamWithRetry / doStreamOnce / mapGetResultError / resultRetryBackoff helpers
- parseFilenameFromContentDisposition (RFC 5987 quoted/unquoted/encoded)
- 9 個 GetResult test + 6 個 parseFilename sub-test
- Reviewer 0 Critical / 0 Major / 3 Minor (M-1/M-2/M-3 全部 T2 順手修)

T2 — flow.go + e2e 改造:
- DownloadStream / PromoteToModels 移除 f.faa.GetFile(...) 改 f.converter.GetResult(ctx, jobID)
- filename 仍由 defaultDownloadFilename(cj) 覆寫 (visionA source-of-truth)
- 8 個 flow_test 既有 test 改寫 + 2 個改名 (FAA → Converter) + 2 個 410 透傳 test 新增
- e2e mock converter 加 GET /api/v1/jobs/{id}/result endpoint + 3 helper + 6 斷言更新 (含 negative: FAA 0 命中 / converter /result ≥1 命中)
- T1 reviewer 3 個 Minor 全處理 (mapGetResultError 設計取捨 godoc / 指數退避→線性退避 / 401+403 mask 驗證)
- 保留 faa FAAClient 欄位 + FlowOpts.FAA 必填 (T3 才砍 faa_client.go 整檔)

T2 修補 (architect + backend 平行):
- M-1 conversion.go Service interface DownloadStream/PromoteToModels godoc 對齊 v0.6 (從 flow.go layer 搬上來)
- M-2 conversion.md v0.6 → v0.6.1 — §2.5 ensurePromoted cache 描述「sync.Map cache」改為「Phase 0.8 簡化 (不實作 cache)」+ 4 簡化理由 + 3 Phase 1+ 升級選項 (in-memory / DB / model store 推論);連動修改 line 169 / 300 / 1187 cross-reference
- 3 Minor + 2 Suggestion 順手做 (resultRetryBaseDelay godoc / fixture 註解過渡狀態 / e2e route table 4→5 / flow.go struct T3 預期清單 / e2e negative assertion 強化)

驗證:
- go build ./... exit 0
- go test -race -count=3 ./... 17 packages 全綠
- Reviewer 5 軸 (v0.6-t1-review + v0.6-t2-review + v0.6-t2-fix-review) 全  通過

對齊 ADR-016 §1 / conversion.md v0.6.1 §2.5 §4.1 / api-conversion.md v0.6 §4 / oidc-tdd.md v0.4 §13.1.3

下一步:
- T3 砍 faa_client.go + faa_client_test.go + 對應 ErrFAA* sentinel (B 層強制跑 / s-3/s-4/s-5 必補)
- T4 砍 ConversionConfig FAA* 欄位 + main.go wire 點 + .env*.example
- T5 main.go wire 點全切 + e2e regression 防護

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

252 lines
14 KiB
Go
Raw Permalink 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 執行「加到模型庫」流程。
//
// 步驟Phase 0.8b v0.6,對齊 ADR-016 / conversion.md §1 Stage 3a + §2.5
// 實作見 internal/conversion/flow.go PromoteToModels
// 1. ownership.Check(userID, jobID)
// 2. converter.GetJob — 確認 status=completed否則 ErrJobNotCompleted
// 3. 冪等檢查modelStore.FindBySourceJobID — 已有 model 直接回(避免重複 promote
// 4. converter.Promote — 觸發 NEF 推到 FAA 並保留在 converter MinIO拿到 target_object_key
// / size / checksum
// 5. converter.GetResult(jobID) — streaming pull NEF binary from converter MinIO
// v0.6:取代原 faa.Download(promotedKey)visionA 端不再直接打 FAA
// 6. storage.Put — streaming 寫進 visionA storage不 ReadAllsize 用 promoteRes.Size 為權威值,
// 避免 converter response chunked transfer 時 Content-Length = -1 的邊界)
// 7. modelStore.Save — 建 model recordSource="converted"、SourceJobID=jobIDschema 已預埋)
//
// 冪等性:對同一 jobID 重複呼叫;若已建過 model record回既有 modelID 而非新建。
//
// 安全模型演進(見 conversion.md §10.6 + ADR-016 後果):
// - Phase 0.8visionA backend 用 service token (scope=files:download.read) server-to-server
// pull from FAA
// - Phase 0.8b v0.4visionA backend 中轉 stream from FAAAPI key
// - **Phase 0.8b v0.6**visionA backend 中轉 stream from **converter MinIO**visionA 端
// 完全不接觸 MC / FAA認證鏈只剩單條 visionA → converter API key
//
// `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 v0.6 變更,
// 對應 ADR-016 §1 + conversion.md §2.5 / §4.1)。
//
// 流程Phase 0.8b v0.6ADR-016 §1 + conversion.md §2.5 / §4.1
// 實作見 internal/conversion/flow.go DownloadStream
// 1. ownership 檢查(不符 → ErrJobNotFound§7.2 防枚舉)
// 2. converter.GetJob — 確認 status=completed否則 ErrJobNotCompleted
// 3. ensurePromoted — 自動觸發 promote 確保 converter MinIO 內有 NEFconverter 端冪等)
// 4. converter.GetResult(jobID) — 從 converter MinIO streaming pull NEF binary
// v0.6:取代原 faa.GetFile(targetObjectKey)visionA 端不再直接打 FAA
// 5. 回傳 (io.ReadCloser, *DownloadMetadata, nil)callerhandler負責 io.CopyN 到 client + Close
//
// filename 來源(對齊 conversion.md §4.1DownloadMetadata.Filename **由 visionA backend
// 覆寫**為 `<source_filename_stem>_<target_chip_lower>.nef`(如 `yolov5s_kl720.nef`
// 不沿用 converter response 的 Content-Disposition filenameconverter 給的 filename 可能是
// object_key 派生、對 user 不直觀conversion.md §4.1 註明 visionA 為 source-of-truth
//
// 安全模型演進(見 conversion.md §10.6 + ADR-016 後果):
// - Phase 0.8MC simple delegated token + 302 redirect → browser 直連 FAAtoken 短暫流經 browser
// - Phase 0.8b v0.4visionA backend 中轉 stream from FAAAPI key
// - Phase 0.8b v0.5:同 v0.4 設計但 token 來源改 MC delegatedfictional、從未跑通
// - **Phase 0.8b v0.6**visionA backend 中轉 stream from **converter MinIO**visionA 端
// 完全不接觸 MC / FAA認證鏈只剩單條 visionA → converter API key
//
// 安全(見 conversion.md §10.4 + §10.6
// - 沒有 token 結構性存在於任何 frontend responseAPI key 永遠在 server side
// - object_key 不對 frontend 揭露filename 由 visionA 從 conversion job metadata 重新構造)
// - 不需 FAA CORSvisionA → converter不需 FAA CORSvisionA 端不再接觸 FAA
//
// 錯誤透傳(對齊 ADR-016 §1.3 / conversion.md §6
// - converter 401 / 403 → ErrConverterAuthFailedhandler 透 ErrorCode mask 成 converter_unavailable / 502
// - converter 404 → ErrJobNotFound與 ownership 找不到共用文字 / 404
// - converter 410 → ErrResultExpiredv0.6 新增frontend 顯示「請重新轉檔」CTA / 410
// - converter 5xx → ErrConverterUnavailableconverter / MinIO 暫時失常 / 502
//
// Callerhandler責任
// - 取得 stream 後**必須 defer stream.Close()**,否則 keep-alive connection 不會回 pool
// - 設好 response headerContent-Type / Content-Disposition / Cache-Control / Content-Length
// 用 io.CopyN(w, stream, MaxDownloadStreamBytes) streaming 寫到 client含 size cap
// - 中途錯誤無法再改 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"`
}