jim800121chen 1231bf0ed2 feat(visionA-backend): Phase 0.8 conversion package — 5 endpoint + 8 個內部模組
Phase 0.8 把 kneron_model_converter 的轉檔功能整合進 visionA Cloud。
visionA backend 當 streaming proxy(upload)+ delegated download token broker(download)+
ownership trust boundary,converter / FAA / MC 三方零修改。

新增 internal/conversion/ 套件(8 個檔,~10,000 行 prod+test,117+ test cases,race -count=3 全綠):

- conversion.go:Service interface 5 method、Job/PromoteResult/InitJobInput types
- errors.go:13+ sentinel errors + ErrorCode/HTTPStatus mapping,對齊 conversion.md §6
- mc_token_client.go:service-to-service token (client_credentials grant) + DCL cache
  (exp - 15s 重取,per-scope cache),IssueDelegatedDownload(MC delegated download token)
  錯誤分 idp_misconfigured (4xx) / idp_unavailable (5xx) / download_token_failed / mc_token_unavailable
- converter_client.go:對 converter scheduler 4 method(InitJob multipart streaming /
  GetJob / Promote / ListInProgressJobs),InitJob 不 retry 5xx(streaming body 無法 replay)
- faa_client.go:對 FAA GET /files/{key} server-to-server pull,Phase A retry(GET 無 body
  可 replay)對齊 §9.1 retry 矩陣,streaming io.ReadCloser 透傳避 OOM
- ownership.go:in-memory job_id → user_id map + per-user mutex 防 thundering herd lazy rebuild
  (不同 user 平行 fetch,同 user 100 caller 收斂成 1 次),visionA 重啟靠 converter
  ListInProgressJobs(user) 重建
- flow.go:Service interface 整合層(5 method 串接 converter/FAA/MC/ownership)
  - InitJob 用 io.Pipe + multipart.Reader/Writer 重組 streaming proxy(黑名單 client user_id
    + 灌入 OIDC sub)
  - DownloadRedirectURL 自動觸發 promote(spec §1 Stage 3b),用 ensurePromoted helper
  - PromoteToModels 冪等(modelStore.FindBySourceJobID 為 source-of-truth)
  - OwnershipMismatch → ErrJobNotFound 不 forbidden(§7.2 防枚舉)
  - storage / modelStore 失敗包 ErrStorageUnavailable / ErrModelStoreUnavailable
    (視為 visionA 自身 500 而非 502 gateway,SRE alarm 才打對 team)

新增 internal/api/conversion.go(5 endpoint handler + main.go wire):
- POST /api/conversion/init(multipart streaming proxy,不呼叫 c.MultipartForm())
- GET  /api/conversion/active(lazy rebuild ownership)
- GET  /api/conversion/{job_id}(poll status)
- POST /api/conversion/{job_id}/promote-to-models(FAA pull → models 三段式)
- GET  /api/conversion/{job_id}/download(server-side HTTP 302 → FAA,token 不過 frontend
  JS,仿 FAA TestSite DownloadFileDirect pattern;Cache-Control: no-store)

5 個 endpoint 全部走 OIDC AuthMiddleware;user_id 從 cookie session 灌(trust boundary),
從不接受 client multipart form / JSON / query 的 user_id。
TestAllAPIEndpointsRequire401WithoutCookie 自動覆蓋新 5 endpoint regression 防呆。

新增 cmd/api-server/conversion_e2e_test.go(4 個 e2e 場景):
- TestConversionE2E_StreamingProxy(10MB body + trust boundary regression)
- TestConversionE2E_LazyRebuildAfterRestart(visionA 重啟仍能 /active)
- TestConversionE2E_Download302Redirect(驗 302 + Location header + token 不在 body)
- TestConversionE2E_ActiveJobConflict(409 + active_job 詳情)

修改 internal/config/{config,load}.go:新增 ConversionConfig 5 欄位
(ConverterBaseURL / FAABaseURL / TenantID / ServiceClientID / ServiceClientSecret)+
Enabled() helper(雙非空判定)。
修改 cmd/api-server/main.go:條件 wire(cfg.Conversion.Enabled() 為 true 才建 client + Service;
否則 Deps.Conversion=nil,handler 自動回 501)。
修改 .env.example:新增 Phase 0.8 區塊註解。
新增 cmd/api-server/conversion_adapters.go:narrow interface adapter(接既有
internal/model.Repository / internal/storage.Store → conversion.ModelStore / Storage,避免 import cycle)。

驗證:go test -race -count=3 ./... 17 packages 全綠 / go vet 0 warning / go build 成功。

對齊文件:
- .autoflow/04-architecture/adr/adr-014-conversion-integration.md
- .autoflow/04-architecture/conversion.md (TDD)
- .autoflow/04-architecture/api/api-conversion.md
- .autoflow/02-prd/features/feature-converter-integration.md
- .autoflow/03-design/wireframes/wireframe-conversion.md
- .autoflow/03-design/flows/flow-conversion.md

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

275 lines
12 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")
// ErrDownloadTokenFailed — MC 換 delegated token 4xx 失敗(設定問題)。
// 對應 HTTP 502 / code "download_token_failed"。
ErrDownloadTokenFailed = errors.New("conversion: download token failed")
// ErrMCTokenUnavailable — MC 5xx / network 持續失敗。
// 對應 HTTP 502 / code "mc_token_unavailable"。
ErrMCTokenUnavailable = errors.New("conversion: mc token unavailable")
// ErrIDPMisconfigured — MC token endpoint 4xxclient_credentials grant 設定錯誤)。
// 對應 HTTP 500 / code "idp_misconfigured"。
ErrIDPMisconfigured = errors.New("conversion: idp misconfigured")
// ErrIDPUnavailable — MC oauth/token 5xx / network 持續失敗。
// 對應 HTTP 503 / code "idp_unavailable"。
ErrIDPUnavailable = errors.New("conversion: idp unavailable")
// ErrServiceBusy — converter 端回 503 service_busy。
// 對應 HTTP 503 / code "service_busy"。
ErrServiceBusy = errors.New("conversion: service busy")
// ErrServiceClientUnauthorized — visionA-backend 對 MC 認證失敗401 / 403
//
// 觸發情境:
// - VISIONA_OIDC_SERVICE_CLIENT_ID / SECRET 設定錯誤(典型)
// - MC 端 client 被 revoke / 停用
// - client 沒有對應 scope 的權限
//
// 設計選擇:與 ErrIDPMisconfigured 分開的 sentinel給 mc_token_client 內部 caller
// 可以做更精細的處理(例如 401 時主動 invalidate cache但對外 ErrorCode/HTTPStatus
// 都對應到 idp_misconfigured / 500fail-fast避免半設定狀態跑進 production
//
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §5.2)
ErrServiceClientUnauthorized = errors.New("conversion: service client unauthorized")
// 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, ErrDownloadTokenFailed):
return "download_token_failed"
case errors.Is(err, ErrMCTokenUnavailable):
return "mc_token_unavailable"
case errors.Is(err, ErrIDPMisconfigured):
return "idp_misconfigured"
case errors.Is(err, ErrIDPUnavailable):
return "idp_unavailable"
case errors.Is(err, ErrServiceBusy):
return "service_busy"
case errors.Is(err, ErrServiceClientUnauthorized):
// 對外仍透過 idp_misconfigured 呈現(避免 leak「我們的 client_secret 過期」這種內部狀態);
// caller 想做精細處理用 errors.Is(err, ErrServiceClientUnauthorized) 直接判斷。
return "idp_misconfigured"
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, ErrDownloadTokenFailed),
errors.Is(err, ErrMCTokenUnavailable):
return 502
case errors.Is(err, ErrIDPMisconfigured), errors.Is(err, ErrServiceClientUnauthorized):
return 500
case errors.Is(err, ErrStorageUnavailable), errors.Is(err, ErrModelStoreUnavailable):
// visionA 自身基礎設施問題 → 500不是 502 gateway因為非 upstream 失敗)
return 500
case errors.Is(err, ErrIDPUnavailable), errors.Is(err, ErrServiceBusy):
return 503
default:
return 500
}
}