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

311 lines
15 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 error 定義。
//
// 對齊 conversion.md §6 錯誤碼 mapping 與 api-conversion.md §錯誤碼總覽。
//
// 設計原則:
// - 用 sentinel errorpackage-level var+ wrap 模式,不用 error code string
// 做 equality checkcaller 用 errors.Is 判斷)
// - 每個 sentinel 都對應一個對外 error code見 ErrorCode() helper
// - HTTP status mapping 與 message 在 handler 層處理(見 internal/api/conversion.go
// 避免 conversion package 依賴 gin
//
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §6)
package conversion
import "errors"
// Sentinel errors — handler 層用 errors.Is 比對。
var (
// ErrForbidden — job 不屬於當前 user。對應 HTTP 403 / code "forbidden"。
ErrForbidden = errors.New("conversion: forbidden")
// ErrJobNotFound — job_id 不存在或已過期。對應 HTTP 404 / code "not_found"。
ErrJobNotFound = errors.New("conversion: job not found")
// ErrJobNotCompleted — job 還沒 completed不能 promote / download。
// 對應 HTTP 409 / code "job_not_completed"。
ErrJobNotCompleted = errors.New("conversion: job not completed")
// ErrActiveJobExists — 同 user 已有 active job。
// 對應 HTTP 409 / code "active_job_exists"。
// caller 可用 ActiveJobError struct 取得衝突中的 job 資訊(見下方)。
ErrActiveJobExists = errors.New("conversion: user already has active job")
// ErrValidationFailed — 上傳的 multipart 內容格式錯誤converter 4xx validation_error / invalid_multipart
// 對應 HTTP 400 / code "validation_failed"。
ErrValidationFailed = errors.New("conversion: validation failed")
// ErrPayloadTooLarge — converter 端拒絕超大檔案。
// 對應 HTTP 413 / code "payload_too_large"。
ErrPayloadTooLarge = errors.New("conversion: payload too large")
// ErrConverterUnavailable — converter 5xx / network 持續失敗。
// 對應 HTTP 502 / code "converter_unavailable"。
ErrConverterUnavailable = errors.New("conversion: converter unavailable")
// ErrFAAUnavailable — FAA 5xx / network 持續失敗。
// 對應 HTTP 502 / code "faa_unavailable"。
ErrFAAUnavailable = errors.New("conversion: faa unavailable")
// ErrFAAFileNotFound — FAA 回 404指定 object_key 不存在)。
// 觸發情境promote-to-models 流程 promoted 後 FAA pull 卻找不到檔(罕見:
// converter promote 才剛寫 FAA、應立即可見— 可能 FAA 端 GC、或 object_key 命名邏輯有 bug。
// 對應 HTTP 502 / code "faa_unavailable"(對外仍視為 FAA 不可用,避免揭露內部 object key 細節)。
// callerflow.go可用 errors.Is(err, ErrFAAFileNotFound) 做精細處理log / metric
//
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.6 + §9.2)
ErrFAAFileNotFound = errors.New("conversion: faa file not found")
// ErrServiceBusy — converter 端回 503 service_busy。
// 對應 HTTP 503 / code "service_busy"。
ErrServiceBusy = errors.New("conversion: service busy")
// ErrResultExpired — converter `GET /api/v1/jobs/{id}/result` 回 410v0.6 新增)。
//
// 觸發情境ADR-016 §1.3 / conversion.md §6
// - job 已 completed但 converter MinIO 內 NEF 已被 GC超過 7 天 expires_at
// - user 隔了一週以上才回來按「下載」/「加到模型庫」
//
// 對外 HTTP 410 / code "result_expired"。frontend 顯示「轉檔結果已過期,請重新轉檔」
// 並提供重新轉檔 CTA與 502 converter_unavailable 文字明確區分,給 user 行動指引)。
//
// 設計選擇(與 ErrJobNotFound 區分):
// - ErrJobNotFound404「任務本身不存在」job_id 從未建立 / converter 端 job record 被 GC
// - ErrResultExpired410「任務存在過、有完成過但結果檔被 GC」converter 端 job
// record 仍可查 status但 MinIO 物件已被清)— user 角度語意不同(前者是 url
// 錯了後者是過期了HTTP 410 也明確語意「曾經有、現在永遠沒了」vs 404「不存在」
//
// Phase 0.8b v0.6 conversion (見 ADR-016 §1.3 / conversion.md §6 / api-conversion.md §4)
ErrResultExpired = errors.New("conversion: converter result expired")
// Phase 0.8b T3 移除5 個僅 mc_token_client 用的 sentinelMC 路徑取消後不再有觸發點):
// - ErrDownloadTokenFailed — MC delegated token 4xx
// - ErrMCTokenUnavailable — MC 5xx / network 持續失敗
// - ErrIDPMisconfigured — MC token endpoint 4xxclient_credentials grant 設定錯誤)
// - ErrIDPUnavailable — MC oauth/token 5xx / network 持續失敗
// - ErrServiceClientUnauthorized — visionA → MC 認證失敗401/403
// 取代401/403 改 mapping 到 ErrConverterAuthFailed / ErrFAAAuthFailed下方
// converter 端 503 改 mapping 到 ErrConverterUnavailableconverter_client.go mapPromoteError
//
// **不重用舊 sentinel name**Phase 1+ 注意):上述 5 個 sentinel name 已從 git history
// 中砍除,未來若需要新增 sentinel 不應重用同名(如 `ErrIDPUnavailable`),以免閱讀
// git log / blame / 既有 reference 時混淆語意(同名但對應不同層的失敗模式)。
// 若未來 MC 認證鏈以新樣態回來(例如 ADR-015 §7 選項 B 的「visionA 自簽 HMAC token」
// 採用新 sentinel name`ErrHMACTokenUnavailable` / `ErrHMACSigningFailed`)。
// 對應 T3 Reviewer Minor #M-3 / T4 補註說明(.autoflow/05-implementation/backend/logs/t4-final-*.log
// ErrConverterAuthFailed — visionA-backend → converter 帶的 API key 不對齊
// converter middleware constant-time compare 失敗 → 401 / 403
//
// 觸發情境Phase 0.8b API key 路徑):
// - VISIONA_CONVERTER_API_KEY 與 converter 端 CONVERTER_API_KEY 不同步
// rotate 後一邊還沒換 env、stage / prod env 設錯)
// - converter middleware 上線前 visionA 過早部署converter 還沒驗 key
//
// 設計選擇:對外仍 mask 成 converter_unavailable / 502 — 不洩漏「API key 對 / 不對」
// 這個內部運維狀態給 frontendSRE 從 server log 看到 auth_failed 計數異常 → 檢查 env。
// 與 ErrConverterUnavailable 分開的 sentinel 是為了 log / metric 分桶(運維事件 vs 上游不可達),
// 對外的 user-facing message 仍然一樣(避免 social engineering 利用 401 訊號做攻擊)。
//
// Phase 0.8b conversion (見 ADR-015 §6 / conversion.md §6 / api-conversion.md)
ErrConverterAuthFailed = errors.New("conversion: converter api key auth failed")
// ErrFAAAuthFailed — visionA-backend → FAA 帶的 API key 不對齊FAA middleware 401 / 403
//
// 觸發情境Phase 0.8b API key 路徑):
// - VISIONA_FAA_API_KEY 與 FAA 端 FAA_API_KEY 不同步warrenchen 跨 repo 維護)
// - FAA middleware 上線前 visionA 過早部署
//
// 設計選擇:與 ErrConverterAuthFailed 對稱、與 ErrFAAUnavailable 分開(同樣 mask 成
// faa_unavailable / 502 對外、區分只在 server log
//
// Phase 0.8b conversion (見 ADR-015 §6 / conversion.md §6 / api-conversion.md)
ErrFAAAuthFailed = errors.New("conversion: faa api key auth failed")
// ErrStorageUnavailable — visionA 自家 storagelocal FS / S3寫入或讀取失敗。
//
// 觸發情境:
// - PromoteToModels 把 NEF 寫進 visionA storage 失敗disk full / S3 5xx / 權限錯誤)
// - 與 FAA / converter 都無關,純粹是 visionA 自己的 storage 設定問題
//
// 對應 HTTP 500 / code "storage_unavailable"。
//
// 設計選擇(與 ErrFAAUnavailable 區分):
// - storage 失敗 ≠ FAA 失敗。SRE alarm 會打到不同 teami18n 訊息也不同
// FAA 對外是 "檔案存取服務暫時無法使用"storage 對外是 "伺服器內部錯誤"
// - 對外用 500 而非 502visionA 自身問題,不是 gateway / upstream 問題
//
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §6 — Reviewer M-1)
ErrStorageUnavailable = errors.New("conversion: visionA storage unavailable")
// ErrModelStoreUnavailable — visionA 自家 model storein-memory / Postgres操作失敗。
//
// 觸發情境:
// - PromoteToModels 把 model record 寫進 model store 失敗
// in-memory 永遠不會失敗;未來換 Postgres 時 connection 5xx 才會觸發)
// - 與 FAA / converter 都無關,純粹是 visionA 自己的 DB 問題
//
// 對應 HTTP 500 / code "model_store_unavailable"。
//
// 設計選擇(與 ErrConverterUnavailable 區分):理由同 ErrStorageUnavailable。
//
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §6 — Reviewer M-1)
ErrModelStoreUnavailable = errors.New("conversion: visionA model store unavailable")
)
// ActiveJobError 是 ErrActiveJobExists 的 wrapped form
// 帶上正在進行中的 job 資訊,給 handler 透傳給 frontend
// 前端可顯示「你已有進行中任務job_id=xxx」+ 跳轉到該 job 的進度頁)。
//
// 用法:
//
// if errors.Is(err, conversion.ErrActiveJobExists) {
// var ae *conversion.ActiveJobError
// if errors.As(err, &ae) {
// // ae.Job 可用details 帶給 frontend
// }
// }
type ActiveJobError struct {
Job *Job
}
// Error 實作 error interface。
func (e *ActiveJobError) Error() string {
return ErrActiveJobExists.Error()
}
// Unwrap 讓 errors.Is(err, ErrActiveJobExists) 成立。
func (e *ActiveJobError) Unwrap() error {
return ErrActiveJobExists
}
// ValidationFieldError 是 converter 4xx response 中 details.fields 陣列的單一元素。
//
// 對齊 converter openapi.yaml `validation_error` example
//
// details.fields: [{ field: "model_id", message: "model_id 範圍必須在 1 ~ 65535" }]
//
// 之所以用 array 不用 map
// - 對齊 task-scheduler openapi.yamlPOST /api/v1/jobs 400 validation_error 範例)
// - 同一個 field 可能有多個錯誤(例如 model_id 同時違反 pattern + range
//
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §6 + api-conversion.md §1)
type ValidationFieldError struct {
Field string `json:"field"`
Message string `json:"message"`
}
// ConverterValidationError 是 ErrValidationFailed 的 wrapped form
// 帶上 converter 回的欄位錯誤細節(給 frontend 顯示具體哪個欄位錯)。
//
// 用法同 ActiveJobError。
//
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §6 + api-conversion.md §1)
type ConverterValidationError struct {
// Fields 是 converter 4xx response 的 details.fields若有
// 結構對齊 converter openapi.yaml — array of {field, message}。
// converter 4xx 沒有 details.fields 時為 nil仍視為 validation 錯誤)。
Fields []ValidationFieldError
// Message 是 converter error message 原文(不過 frontend僅供 log
Message string
}
// Error 實作 error interface。
func (e *ConverterValidationError) Error() string {
if e.Message != "" {
return "conversion: validation failed: " + e.Message
}
return ErrValidationFailed.Error()
}
// Unwrap 讓 errors.Is(err, ErrValidationFailed) 成立。
func (e *ConverterValidationError) Unwrap() error {
return ErrValidationFailed
}
// ErrorCode 把 sentinel error 轉成對外的 visionA error code對齊 api-conversion.md §錯誤碼總覽)。
//
// 未匹配的 error 回 "internal_error"handler 層應 log 完整 error 後回 500
func ErrorCode(err error) string {
switch {
case errors.Is(err, ErrForbidden):
return "forbidden"
case errors.Is(err, ErrJobNotFound):
return "not_found"
case errors.Is(err, ErrJobNotCompleted):
return "job_not_completed"
case errors.Is(err, ErrActiveJobExists):
return "active_job_exists"
case errors.Is(err, ErrValidationFailed):
return "validation_failed"
case errors.Is(err, ErrPayloadTooLarge):
return "payload_too_large"
case errors.Is(err, ErrConverterUnavailable):
return "converter_unavailable"
case errors.Is(err, ErrFAAFileNotFound):
// 對外仍視為 faa_unavailable避免揭露 object_key 不存在的內部細節。
// caller 想做精細處理用 errors.Is(err, ErrFAAFileNotFound) 直接判斷。
return "faa_unavailable"
case errors.Is(err, ErrFAAUnavailable):
return "faa_unavailable"
case errors.Is(err, ErrServiceBusy):
return "service_busy"
case errors.Is(err, ErrResultExpired):
// Phase 0.8b v0.6converter `GET /result` 410對應 api-conversion.md §錯誤碼總覽
// 新增 `result_expired` code與 `converter_unavailable` 區分,給 frontend 顯示
// 「轉檔結果已過期請重新轉檔」CTA
return "result_expired"
case errors.Is(err, ErrConverterAuthFailed):
// Phase 0.8b:對外刻意 mask 成 converter_unavailable不揭露「API key 不對」內部狀態);
// caller 想做精細處理用 errors.Is(err, ErrConverterAuthFailed) 直接判斷log / metric
return "converter_unavailable"
case errors.Is(err, ErrFAAAuthFailed):
// Phase 0.8b:對外刻意 mask 成 faa_unavailable理由同上。
return "faa_unavailable"
case errors.Is(err, ErrStorageUnavailable):
return "storage_unavailable"
case errors.Is(err, ErrModelStoreUnavailable):
return "model_store_unavailable"
default:
return "internal_error"
}
}
// HTTPStatus 把 sentinel error 轉成對應的 HTTP status code。
//
// 未匹配的 error 回 500handler 層應 log 後再 WriteError。
func HTTPStatus(err error) int {
switch {
case errors.Is(err, ErrForbidden):
return 403
case errors.Is(err, ErrJobNotFound):
return 404
case errors.Is(err, ErrJobNotCompleted), errors.Is(err, ErrActiveJobExists):
return 409
case errors.Is(err, ErrValidationFailed):
return 400
case errors.Is(err, ErrPayloadTooLarge):
return 413
case errors.Is(err, ErrConverterUnavailable),
errors.Is(err, ErrFAAUnavailable),
errors.Is(err, ErrFAAFileNotFound),
errors.Is(err, ErrConverterAuthFailed),
errors.Is(err, ErrFAAAuthFailed):
// Phase 0.8bAPI key auth_failed 對外與「服務不可達」同層 502
// 內部 log / metric 才區分auth_failed = SRE alarm其他 = 自然 retry
return 502
case errors.Is(err, ErrStorageUnavailable), errors.Is(err, ErrModelStoreUnavailable):
// visionA 自身基礎設施問題 → 500不是 502 gateway因為非 upstream 失敗)
return 500
case errors.Is(err, ErrServiceBusy):
return 503
case errors.Is(err, ErrResultExpired):
// Phase 0.8b v0.6HTTP 410 Gone — converter result endpoint 過期
// ADR-016 §1.3)。語意比 404 更明確:「曾經有、現在永遠沒了、不要再 retry」。
return 410
default:
return 500
}
}