對齊 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>
252 lines
14 KiB
Go
252 lines
14 KiB
Go
// 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 執行「加到模型庫」流程。
|
||
//
|
||
// 步驟(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(不 ReadAll;size 用 promoteRes.Size 為權威值,
|
||
// 避免 converter response chunked transfer 時 Content-Length = -1 的邊界)
|
||
// 7. modelStore.Save — 建 model record(Source="converted"、SourceJobID=jobID;schema 已預埋)
|
||
//
|
||
// 冪等性:對同一 jobID 重複呼叫;若已建過 model record,回既有 modelID 而非新建。
|
||
//
|
||
// 安全模型演進(見 conversion.md §10.6 + ADR-016 後果):
|
||
// - Phase 0.8:visionA backend 用 service token (scope=files:download.read) server-to-server
|
||
// pull from FAA
|
||
// - Phase 0.8b v0.4:visionA backend 中轉 stream from FAA(API 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 proxy(Phase 0.8b v0.6 變更,
|
||
// 對應 ADR-016 §1 + conversion.md §2.5 / §4.1)。
|
||
//
|
||
// 流程(Phase 0.8b v0.6,ADR-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 內有 NEF(converter 端冪等)
|
||
// 4. converter.GetResult(jobID) — 從 converter MinIO streaming pull NEF binary
|
||
// (v0.6:取代原 faa.GetFile(targetObjectKey);visionA 端不再直接打 FAA)
|
||
// 5. 回傳 (io.ReadCloser, *DownloadMetadata, nil);caller(handler)負責 io.CopyN 到 client + Close
|
||
//
|
||
// filename 來源(對齊 conversion.md §4.1):DownloadMetadata.Filename **由 visionA backend
|
||
// 覆寫**為 `<source_filename_stem>_<target_chip_lower>.nef`(如 `yolov5s_kl720.nef`);
|
||
// 不沿用 converter response 的 Content-Disposition filename(converter 給的 filename 可能是
|
||
// object_key 派生、對 user 不直觀;conversion.md §4.1 註明 visionA 為 source-of-truth)。
|
||
//
|
||
// 安全模型演進(見 conversion.md §10.6 + ADR-016 後果):
|
||
// - Phase 0.8:MC simple delegated token + 302 redirect → browser 直連 FAA(token 短暫流經 browser)
|
||
// - Phase 0.8b v0.4:visionA backend 中轉 stream from FAA(API key)
|
||
// - Phase 0.8b v0.5:同 v0.4 設計但 token 來源改 MC delegated(fictional、從未跑通)
|
||
// - **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 response(API key 永遠在 server side)
|
||
// - object_key 不對 frontend 揭露(filename 由 visionA 從 conversion job metadata 重新構造)
|
||
// - 不需 FAA CORS(visionA → converter,不需 FAA CORS;visionA 端不再接觸 FAA)
|
||
//
|
||
// 錯誤透傳(對齊 ADR-016 §1.3 / conversion.md §6):
|
||
// - converter 401 / 403 → ErrConverterAuthFailed(handler 透 ErrorCode mask 成 converter_unavailable / 502)
|
||
// - converter 404 → ErrJobNotFound(與 ownership 找不到共用文字 / 404)
|
||
// - converter 410 → ErrResultExpired(v0.6 新增;frontend 顯示「請重新轉檔」CTA / 410)
|
||
// - converter 5xx → ErrConverterUnavailable(converter / MinIO 暫時失常 / 502)
|
||
//
|
||
// Caller(handler)責任:
|
||
// - 取得 stream 後**必須 defer stream.Close()**,否則 keep-alive connection 不會回 pool
|
||
// - 設好 response header(Content-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 必須含 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。
|
||
// 規則:`<source_filename_stem>_<target_chip_lower>.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"`
|
||
}
|