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>
165 lines
8.1 KiB
Go
165 lines
8.1 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 執行「加到模型庫」流程。
|
||
//
|
||
// 步驟(見 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=jobID(schema 已預埋)
|
||
//
|
||
// 冪等性:對同一 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 必須含 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"`
|
||
}
|
||
|
||
// 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"`
|
||
}
|