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

165 lines
8.1 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 實作 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 執行「加到模型庫」流程。
//
// 步驟(見 conversion.md §1 Stage 3a + §2.5
// 1. ownership.Check(userID, jobID)
// 2. ensurePromoted(jobID) — 冪等:若已 promote 過用 cache否則打 converter
// 3. faa.Download(promotedKey) — 用 service token (scope=files:download.read) server-to-server pull
// 4. 走既有 /api/models/init + /api/models/finalize不繞過既有 handler 邏輯)
// 5. 回填 model.Source="converted" + model.SourceJobID=jobIDschema 已預埋)
//
// 冪等性:對同一 jobID 重複呼叫;若已建過 model record回既有 modelID 而非新建。
//
// `name` 是 Design Phase 0.8 wireframe §7.1 的單一欄位(不含 description
PromoteToModels(ctx context.Context, userID, jobID, name string) (*PromoteResult, error)
// DownloadRedirectURL 產出「下載」的 server-side 302 redirect URL。
//
// Handler 拿到後直接 c.Redirect(http.StatusFound, url)token 不出現在任何 JSON response
// 也不傳給 frontend JS見 conversion.md §10.4 安全分析)。
//
// 步驟(見 conversion.md §1 Stage 3b
// 1. ownership 檢查
// 2. ensurePromoted與 PromoteToModels 共用 cache
// 3. 對 MC POST /file-access/download-tokens 換 delegated token
// scope=files:download.delegate, TTL 5 分鐘)
// 4. 組 https://<faa>/files/<key>?access_token=<token>
//
// 仿 FAA TestSite `DownloadFileDirect` pattern見 conversion.md §3.1)。
DownloadRedirectURL(ctx context.Context, userID, jobID string) (string, 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"`
}
// 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"`
}