jim800121chen 6024c294d3 feat(visionA-backend): Phase 0.8b v0.6 T3 — 砍 faa_client + ErrFAA* + s-3/s-4/s-5
對齊 ADR-016:visionA backend 不再直連 FAA、download 改走 converter GetResult。T3 砍除 v0.5 階段為 FAA delegated token 路線留的 faa_client.go 整檔 + 對應 sentinel + flow / e2e 殘留。

砍除:
- internal/conversion/faa_client.go(整檔)
- internal/conversion/faa_client_test.go(整檔)
- errors.go: ErrFAAFileNotFound + ErrFAAAuthFailed 2 sentinel(+ ErrorCode/HTTPStatus mapping)
- flow.go: faa FAAClient 欄位 + FlowOpts.FAA 必填 + a-h T3 預期清單 godoc
- flow_test.go: flowStubFAA struct + newFlowStubFAA helper + fixture.faa
- internal/api/conversion_test.go: TestConversion_Download_FAAAuthFailed
- cmd/api-server/main.go: NewFAAClient wire + FAA: faaAPIClient field

保留:
- ErrFAAUnavailable(converter promote 仍 PUT FAA、502 透傳路徑需要)
- hashObjectKey helper 搬到 util.go(ownership 仍用)
- e2e mockFAA 精簡為 regression-only(保留 negative assertion: FAA 0 命中)— reviewer 推薦雙層防護

新增(T3 必補,T1/T2 reviewer 累積):
- s-3 TestDownloadStream_ConverterValidationFailed_Propagation(converter 4xx fallback → ErrValidationFailed 透傳)
- s-4 TestPromoteToModels_StorageError_StreamClosed(instrumented stream wrapper 驗 fd leak 防護)
- s-5 TestParseFilenameFromContentDisposition 9 個 sub-case(3 RFC 5987 + 5 hostile-input + 1 empty quoted)
  發現:Go stdlib 自動 percent-decode RFC 5987 並寫入 params["filename"]、RFC 5987 優先於 ASCII filename

T3 review M-1 修補(commit 內含):
- internal/api/conversion.go:51,56 godoc + 501 user-facing message 從「FAA_BASE_URL」改為「VISIONA_CONVERTER_BASE_URL + VISIONA_CONVERTER_API_KEY」
- 對齊 ADR-016 visionA 端不再有 FAA 直連設計

驗證:
- B 層 verification 強制跑(reviewer 規定 T3 不接受暫緩):
  * 跨檔 grep: MC chain 0 / FAA functional refs 0 / TenantID 0
  * API contract test: TestConversionE2E_DownloadStream 6 斷言含 FAA negative
  * 安全 manual review: path traversal / unbounded read / secret in log / error mask 4 項
- go build ./... exit 0
- go test -race -count=3 ./... 17 packages 全綠
- Reviewer 5 軸(v0.6-t3-review)⚠️ 通過(M-1 已修)

a-h 8 條清單 100% 達成(逐條 grep 驗收);mockFAA 選方案 1(保留 + negative assertion)— 雙層防護。

下一步:
- T4 砍 ConversionConfig.FAAAPIKey/FAABaseURL + load.go env 讀取 + .env*.example + m-2 i18n dead case 一併
- T5 main.go startup log 整理 + e2e regression 防護

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

306 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 — converter 端對 FAA 推送 NEF 失敗converter 回 502 file_gateway_unavailable
//
// Phase 0.8b v0.6T3後語意調整visionA 端不再直接呼叫 FAAADR-016 撤回),
// 此 sentinel 改由 `converter_client.go` 的 promote response mapping 使用 ——
// 當 converter promote 內部 PUT FAA 失敗時converter 回 502 `file_gateway_unavailable`
// visionA-backend 透傳成 `ErrFAAUnavailable` 給 handler 對外 502 + `faa_unavailable`。
//
// 與 ErrConverterUnavailable 區分:
// - ErrConverterUnavailableconverter scheduler 本身不可達 / 5xxvisionA → converter 失敗)
// - ErrFAAUnavailableconverter 可達、但 converter → FAA push 失敗(運維告警打 FAA team
//
// 對應 HTTP 502 / code "faa_unavailable"。
ErrFAAUnavailable = errors.New("conversion: faa unavailable")
// Phase 0.8b v0.6 T3 移除visionA 端不再直接打 FAA、相關 sentinel 不再被觸發):
// - ErrFAAFileNotFound — FAA `GET /files/{key}` 404visionA 端已無此 call path
// - ErrFAAAuthFailed — visionA → FAA API key 401/403visionA 端已無此 call path
// 取代FAA 相關失敗模式收斂到 converter 端透傳converter promote 失敗 → ErrFAAUnavailable
// **不重用舊 sentinel name**(同 T3 ErrIDPUnavailable 規範Phase 1+ 若未來再加 FAA 直連
// 路徑,採新 sentinel name 避免閱讀 git log 時混淆語意)。
// 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下方
// converter 端 503 改 mapping 到 ErrConverterUnavailableconverter_client.go mapPromoteError
// Phase 0.8b v0.6 T3 加註:原 v0.4/v0.5 引入的 ErrFAAAuthFailed 也已砍除,見上方說明。)
//
// **不重用舊 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")
// Phase 0.8b v0.6 T3 移除ErrFAAAuthFailed同上方說明
// 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, ErrFAAUnavailable):
// Phase 0.8b v0.6 T3 起:此 sentinel 改由 converter promote 502 file_gateway_unavailable
// 透傳visionA 端不再直接打 FAA對外 code 仍為 faa_unavailable給 SRE 區分
// converter 不可達 vs converter 端 push FAA 失敗。
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, 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, ErrConverterAuthFailed):
// Phase 0.8bAPI key auth_failed 對外與「服務不可達」同層 502
// 內部 log / metric 才區分auth_failed = SRE alarm其他 = 自然 retry
// v0.6 T3 後ErrFAAFileNotFound / ErrFAAAuthFailed 已砍visionA 端不再直接打 FAA
// ErrFAAUnavailable 沿用、改由 converter promote 502 file_gateway_unavailable 透傳
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
}
}