// conversion.go — /api/conversion/* 的 handler 實作(Phase 0.8)。 // // 對齊: // - .autoflow/04-architecture/api/api-conversion.md(5 個 endpoint API spec) // - .autoflow/04-architecture/conversion.md §3 endpoint 表 + §6 錯誤碼 + §10 安全考量 // - internal/conversion/conversion.go(Service interface) // // 5 個 endpoint: // // POST /api/conversion/init — 啟動轉檔(multipart streaming) // GET /api/conversion/active — 查當前 active job // GET /api/conversion/{job_id} — poll 狀態 // POST /api/conversion/{job_id}/promote-to-models — 加到模型庫 // GET /api/conversion/{job_id}/download — server-side stream proxy(Phase 0.8b) // // 安全要點(對齊 conversion.md §7 / §10): // - 全部 5 個 endpoint 都註冊在 apiGroup(OIDC AuthMiddleware 之後) // - userID 一律來自 UserContextFrom(c).UserID(從 cookie session 解出 OIDC sub) // - 任何 client 帶來的 user_id(multipart form / JSON / query)一律忽略 // - /init 不呼叫 c.MultipartForm() — 會 buffer 全 body 進 RAM / disk,破壞 streaming // - /download Phase 0.8b 改 server-side stream proxy(visionA backend 中轉 NEF stream); // 沒有 delegated token 結構性流經 frontend(ADR-015 §7 / conversion.md §4.1 / §10.4) // // Phase 0.8 conversion (見 .autoflow/04-architecture/api/api-conversion.md) // Phase 0.8b /download proxy 改造 (見 ADR-015 + conversion.md §4.1) package api import ( "context" "encoding/json" "errors" "io" "net/http" "strconv" "strings" "github.com/gin-gonic/gin" "visiona-backend/internal/conversion" ) // ========================================================================== // Route 註冊 // ========================================================================== // registerConversionRoutes 註冊 /api/conversion/* 的 routes。 // // 由 NewRouter 在 apiGroup(OIDC AuthMiddleware 已套)下呼叫; // 若 deps.Conversion 為 nil(Phase 0.8 conversion 未啟用,例如 dev 環境沒設 // VISIONA_CONVERTER_BASE_URL + VISIONA_CONVERTER_API_KEY)→ 5 個 endpoint 一律回 501。 // Phase 0.8b v0.6(ADR-016):visionA 端不再直連 FAA、download 改走 converter // GET /api/v1/jobs/{id}/result,因此不再需要 FAA env。 func registerConversionRoutes(g *gin.RouterGroup, deps Deps) { if deps.Conversion == nil { // 未啟用 — 註冊 501 stub,避免 404(讓 frontend 拿到明確 NOT_IMPLEMENTED) notImpl := func(c *gin.Context) { WriteNotImplemented(c, "conversion service is not configured (set VISIONA_CONVERTER_BASE_URL + VISIONA_CONVERTER_API_KEY)") } conv := g.Group("/conversion") conv.POST("/init", notImpl) conv.GET("/active", notImpl) conv.GET("/:job_id", notImpl) conv.POST("/:job_id/promote-to-models", notImpl) conv.GET("/:job_id/download", notImpl) return } conv := g.Group("/conversion") conv.POST("/init", conversionInitHandler(deps)) conv.GET("/active", conversionActiveHandler(deps)) conv.GET("/:job_id", conversionGetHandler(deps)) conv.POST("/:job_id/promote-to-models", conversionPromoteHandler(deps)) conv.GET("/:job_id/download", conversionDownloadHandler(deps)) } // ========================================================================== // 1. POST /api/conversion/init // ========================================================================== // conversionInitHandler 處理「啟動轉檔」請求。 // // 流程: // 1. UserContextFrom 拿 OIDC sub(AuthMiddleware 已驗) // 2. 驗 Content-Type 必須是 multipart/form-data(含 boundary) // 3. 直接把 c.Request.Body + Content-Type 傳給 Service.InitJob // (**不**呼叫 c.MultipartForm() — 會破壞 streaming) // 4. 成功 → 201 + Job // 5. 失敗 → 透過 handleConversionError 對應 sentinel mapping // // 對齊 api-conversion.md §1 + conversion.md §4.2 streaming proxy。 func conversionInitHandler(deps Deps) gin.HandlerFunc { return func(c *gin.Context) { uc, ok := UserContextFrom(c) if !ok || uc.UserID == "" { // AuthMiddleware 已通過卻拿不到 UserContext — 設定錯誤 WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "missing user context (auth middleware misconfigured?)", nil) return } ct := c.GetHeader("Content-Type") if !strings.HasPrefix(strings.ToLower(ct), "multipart/form-data") { WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "Content-Type must be multipart/form-data with boundary", nil) return } // 把 raw body + Content-Type 傳給 Service;Service 內部處理 multipart streaming // 重組(注入 user_id、黑名單 client 帶的 user_id)。見 conversion.md §4.2。 in := conversion.InitJobInput{ UserID: uc.UserID, ContentType: ct, Body: c.Request.Body, ContentLength: c.Request.ContentLength, } job, err := deps.Conversion.InitJob(c.Request.Context(), in) if err != nil { handleConversionError(c, err) return } // 成功 — 201 Created(對齊 RESTful 慣例:POST 建立資源用 201) WriteSuccess(c, http.StatusCreated, jobToResponse(job)) } } // ========================================================================== // 2. GET /api/conversion/active // ========================================================================== // conversionActiveHandler 處理「查當前 active job」請求。 // // 對齊 api-conversion.md §5: // - 有 active → 200 + {has_active: true, job: {...}} // - 無 active → 200 + {has_active: false, job: null} // // 重啟恢復場景由 Service 內部 EnsureRebuilt 處理(lazy rebuild from converter); // handler 對 frontend 完全透明。 func conversionActiveHandler(deps Deps) gin.HandlerFunc { return func(c *gin.Context) { uc, ok := UserContextFrom(c) if !ok || uc.UserID == "" { WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "missing user context (auth middleware misconfigured?)", nil) return } job, err := deps.Conversion.ActiveJob(c.Request.Context(), uc.UserID) if err != nil { handleConversionError(c, err) return } if job == nil { WriteSuccess(c, http.StatusOK, gin.H{ "has_active": false, "job": nil, }) return } WriteSuccess(c, http.StatusOK, gin.H{ "has_active": true, "job": jobToResponse(job), }) } } // ========================================================================== // 3. GET /api/conversion/{job_id} // ========================================================================== // conversionGetHandler 處理「poll job 狀態」請求。 // // 對齊 api-conversion.md §2。 // 設計選擇:ownership 不符 / job 不存在都對應到 ErrJobNotFound(404)— // 由 Service 層做安全 mapping(見 flow.go GetJob 註解:避免「forbidden vs not_found」 // 差異枚舉合法 job_id)。 func conversionGetHandler(deps Deps) gin.HandlerFunc { return func(c *gin.Context) { uc, ok := UserContextFrom(c) if !ok || uc.UserID == "" { WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "missing user context (auth middleware misconfigured?)", nil) return } jobID := c.Param("job_id") if jobID == "" { WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "job_id is required", nil) return } job, err := deps.Conversion.GetJob(c.Request.Context(), uc.UserID, jobID) if err != nil { handleConversionError(c, err) return } WriteSuccess(c, http.StatusOK, jobToResponse(job)) } } // ========================================================================== // 4. POST /api/conversion/{job_id}/promote-to-models // ========================================================================== // promoteRequest 是 promote-to-models 的 request body(對齊 api-conversion.md §3)。 // // `name` 是 Phase 0.8 wireframe §7.1 的單一欄位;可為空(Service 用 // `{source_filename_stem}_{target_chip_lower}` fallback)。 // `description` 雖在 schema 內但 Phase 0.8 不顯示給使用者,backend 接受但忽略。 type promoteRequest struct { Name string `json:"name"` Description string `json:"description,omitempty"` // Phase 0.8 ignored, Phase 1 reserved } // conversionPromoteHandler 處理「加到模型庫」請求。 // // 流程: // 1. 驗 user / job_id // 2. 解析 body(name 可空;body 整個可空) // 3. Service.PromoteToModels:promote → FAA pull → models repo finalize // 4. 成功 → 201 + PromoteResult // 5. 冪等:同 jobID 重複 promote 由 Service 層處理(回既有 model record,也是 201) func conversionPromoteHandler(deps Deps) gin.HandlerFunc { return func(c *gin.Context) { uc, ok := UserContextFrom(c) if !ok || uc.UserID == "" { WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "missing user context (auth middleware misconfigured?)", nil) return } jobID := c.Param("job_id") if jobID == "" { WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "job_id is required", nil) return } // body optional — 沒帶或解析失敗都不擋(name 可由 Service fallback) var body promoteRequest if c.Request.Body != nil && c.Request.ContentLength != 0 { // 寬鬆解析:JSON 解失敗只 log(不算 hard error,因為 name 可選) if err := json.NewDecoder(c.Request.Body).Decode(&body); err != nil { // 嚴格一點:JSON 格式錯誤回 400(避免 silent ignore 讓使用者困惑) WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "invalid JSON body: "+err.Error(), nil) return } } result, err := deps.Conversion.PromoteToModels(c.Request.Context(), uc.UserID, jobID, body.Name) if err != nil { handleConversionError(c, err) return } WriteSuccess(c, http.StatusCreated, result) } } // ========================================================================== // 5. GET /api/conversion/{job_id}/download // ========================================================================== // conversionDownloadHandler 處理「下載」請求 — Phase 0.8b:server-side stream proxy。 // // 對齊 api-conversion.md §4 (Phase 0.8b 變更) + conversion.md §4.1 / §10.4 + ADR-015 §7: // - 成功:200 OK + Content-Type/Length/Disposition + NEF binary streaming body // - 失敗:不寫 200,依 sentinel 走 handleConversionError 回 JSON // - Cache-Control: no-store — 避免 browser 對私有檔案 cache // // Phase 0.8 → 0.8b → v0.6 演進: // - Phase 0.8:visionA → MC 換 delegated token → c.Redirect(302, FAA_URL_with_token) // - Phase 0.8b v0.4/v0.5:visionA backend 用 API key 直接拉 FAA → io.Copy(c.Writer, stream) // - **Phase 0.8b v0.6**(ADR-016):visionA backend 改走 `converter.GetResult` 從 converter // MinIO 拉 NEF stream(visionA 端不再直接打 FAA、撤回 v0.5 設計缺口);handler 端 // io.Copy(c.Writer, stream) 路徑不變、只是 stream 來源換成 converter // // 中途錯誤處理(已 200 / 已寫 part of body 後失敗): // - 一旦 status 200 已寫,無法再改 status 給 client(HTTP 規範) // - io.Copy 中斷只能 log 錯誤;client 端 browser 會看到截斷檔 // - ctx cancel(client 斷線)由 ConverterClient 內部 ctx-aware 透傳,goroutine 自動結束 func conversionDownloadHandler(deps Deps) gin.HandlerFunc { return func(c *gin.Context) { uc, ok := UserContextFrom(c) if !ok || uc.UserID == "" { WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "missing user context (auth middleware misconfigured?)", nil) return } jobID := c.Param("job_id") if jobID == "" { WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "job_id is required", nil) return } stream, meta, err := deps.Conversion.DownloadStream(c.Request.Context(), uc.UserID, jobID) if err != nil { // 200 還沒寫,可以正常回 JSON error(依 Accept header) handleConversionError(c, err) return } // 必須 close — 否則底層 HTTP keep-alive connection 不會回 pool(fd leak) defer stream.Close() // 設 response header 後才能 io.Copy;一旦 io.Copy 開始就無法再改 status // (Phase 0.8b: 對齊 api-conversion.md §4 「Response 200(成功 — Phase 0.8b 變更)」) c.Header("Content-Type", meta.ContentType) // Content-Length:FAA 走 chunked 時 ContentLength = -1,此時不要 set header // (讓 net/http 用 chunked transfer encoding,避免 browser 依 -1 解析錯誤) if meta.ContentLength > 0 { c.Header("Content-Length", strconv.FormatInt(meta.ContentLength, 10)) } // 對 browser 觸發 download dialog;filename 由 Service 命名(已 stem + chip + .nef 規則化) // sanitizeDownloadFilename 額外擋特殊字元(即使 Service 已給乾淨值也防呆) c.Header("Content-Disposition", `attachment; filename="`+sanitizeDownloadFilename(meta.Filename)+`"`) // 防快取:private NEF 不該被 browser cache(§10.4) c.Header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") c.Header("Pragma", "no-cache") c.Status(http.StatusOK) // streaming proxy — 不 ReadAll、不暫存 disk // 中斷錯誤只能 log(已 200 + part of body,無法回頭改 status) // // Phase 0.8b T5(T4 Reviewer Minor #1 修補):用 io.CopyN 上 size cap // 防 buggy / malicious FAA 回傳超大 body 把 visionA backend 變 unbounded relay。 // 上限值 conversion.MaxDownloadStreamBytes(1GB)— 對 < 50MB 正常 NEF 零影響、 // 對 > 1GB 視為異常並中斷 stream。 written, copyErr := io.CopyN(c.Writer, stream, conversion.MaxDownloadStreamBytes) switch { case errors.Is(copyErr, io.EOF): // 正常 EOF — stream 在 cap 之內結束;written < cap,無事 case copyErr == nil && written == conversion.MaxDownloadStreamBytes: // 命中 cap — 中斷 stream(接下來的 bytes 不再 copy 給 client) // 已 200 + 部分 body,無法回頭改 status,client 會收到截斷檔 if deps.Logger != nil { deps.Logger.Warn("conversion.download.size_cap_exceeded", "user_id", uc.UserID, "job_id", jobID, "written_bytes", written, "cap_bytes", conversion.MaxDownloadStreamBytes, "hint", "FAA returned body >= cap; truncated to protect visionA bandwidth", ) } case copyErr != nil: // 其他錯誤(client 斷線 / 網路中斷 / FAA stream error 等) // 此時 client 端可能已收到部分 bytes 但 connection 中斷; // 用 deps.Logger 記下、由 SRE alarm 看「download_stream_copy_failed」率 if deps.Logger != nil { deps.Logger.Warn("conversion.download.stream_copy_failed", "user_id", uc.UserID, "job_id", jobID, "written_bytes", written, "err", copyErr.Error(), ) } } } } // sanitizeDownloadFilename 對 Content-Disposition 的 filename 做最低限度的安全處理。 // // 規則: // - 移除控制字元(包含 \r \n \t)— 防 HTTP header injection // - 移除 path separator(/ 與 \)— 防 directory traversal 暗示 // - 移除 quote / backslash — 避免破壞 `filename="..."` 結構 // - 空字串兜底為 "download.nef" // // 注意:Service 已給乾淨 filename(defaultDownloadFilename 從 stem + chip 組), // 這個 sanitize 只是防呆 — 即使 Service 漏字元也擋一次。 func sanitizeDownloadFilename(name string) string { if name == "" { return "download.nef" } // 黑名單:控制字元 + path sep + quote + backslash var sb strings.Builder sb.Grow(len(name)) for _, r := range name { switch { case r < 0x20: // 控制字元(含 \r \n \t) continue case r == '/' || r == '\\': continue case r == '"': continue default: sb.WriteRune(r) } } out := sb.String() if out == "" { return "download.nef" } return out } // ========================================================================== // 錯誤處理 helper // ========================================================================== // handleConversionError 把 conversion package 的 sentinel error 轉成統一 JSON 錯誤回應。 // // 對齊 conversion.md §6 mapping + api-conversion.md 錯誤碼總覽。 // // 特殊情況: // - ActiveJobError:附帶 `extra.active_job` 給 frontend 顯示「你已有進行中任務」 // - ConverterValidationError:附帶 details(fields)給 frontend 顯示具體欄位錯 // - 其他:用 errorMessageFor 拿 user-friendly 訊息 // // HTTP status / error code 由 conversion.HTTPStatus / conversion.ErrorCode 決定, // handler 不做二次 mapping。 func handleConversionError(c *gin.Context, err error) { if err == nil { // defensive — caller bug WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "unknown error (handleConversionError called with nil)", nil) return } // ctx cancel / deadline — handler 不主動回(client 已斷線;gin 收到時通常已 abort) if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { // gin context aborted 時 c.Writer 仍可寫(但 client 看不到),保持簡單寫入 WriteError(c, http.StatusServiceUnavailable, "request_cancelled", "request cancelled or timed out", nil) return } status := conversion.HTTPStatus(err) code := conversion.ErrorCode(err) message := errorMessageFor(code) // ActiveJobError — 帶 active_job detail(前端可顯示「跳到該 job 進度頁」) var aje *conversion.ActiveJobError if errors.As(err, &aje) && aje != nil { var jobJSON any if aje.Job != nil { jobJSON = jobToResponse(aje.Job) } writeConversionErrorWithExtra(c, status, code, message, nil, gin.H{ "active_job": jobJSON, }) return } // ConverterValidationError — 帶 details.fields var cve *conversion.ConverterValidationError if errors.As(err, &cve) && cve != nil { details := make([]FieldError, 0, len(cve.Fields)) for _, f := range cve.Fields { details = append(details, FieldError{Field: f.Field, Message: f.Message}) } WriteError(c, status, code, message, details) return } // 一般 sentinel WriteError(c, status, code, message, nil) } // writeConversionErrorWithExtra 是 WriteError 的擴充版本 — 額外帶 extra 結構化資料。 // // 用於 ActiveJobError 等需要在 error body 內帶結構化 detail 的場景。 // // 為什麼不直接複用 errors.go 的 WriteError: // WriteError 簽章是 (status, code, message, details []FieldError) — details 為陣列; // ActiveJobError 要帶的是 object(active_job)。errors.go 的 ErrorDetail 已預留 Extra // 欄位給此用途。 func writeConversionErrorWithExtra(c *gin.Context, status int, code, message string, details []FieldError, extra map[string]any, ) { c.JSON(status, ErrorBody{ Success: false, Error: &ErrorDetail{ Code: code, Message: message, Details: details, RequestID: RequestIDFrom(c), Extra: extra, }, }) } // errorMessageFor 把 conversion error code 對應到 zh-TW user-friendly 訊息。 // // 對齊 api-conversion.md 錯誤碼總覽 i18n 預設訊息。 // 真正的 i18n 切換在 frontend 處理(用 code 當 i18n key),backend 只回預設 zh-TW。 func errorMessageFor(code string) string { switch code { case "validation_failed": return "上傳的內容不符合要求" case "unauthorized": return "請先登入" case "forbidden": return "你無權存取此任務" case "not_found": return "任務不存在" case "active_job_exists": return "你目前已有進行中的轉檔任務" case "job_not_completed": return "任務尚未完成" case "payload_too_large": return "檔案超過大小限制" case "converter_unavailable": return "轉檔服務暫時無法使用" case "faa_unavailable": // converter promote 內部 PUT FAA 失敗時透傳,由 SRE 區分 converter 不可達 vs // converter→FAA push 失敗(ADR-016:visionA 端不再直接呼叫 FAA)。 return "檔案存取服務暫時無法使用" case "service_busy": return "系統繁忙,請稍後再試" // Phase 0.8b v0.6 T3 / T4:以下 i18n key 對應的 sentinel 已砍除,不再會被產生: // - download_token_failed / mc_token_unavailable(v0.5 mc_token_client 撤回) // - idp_misconfigured / idp_unavailable(同上) // 故 case 直接省略;errorMessageFor 落入 default。 default: return "內部錯誤" } } // ========================================================================== // Response shape helper // ========================================================================== // jobToResponse 把 internal *conversion.Job 轉成 api-conversion.md §1-2 規定的 JSON shape。 // // 直接用 gin.H(map)而非 struct — 為了讓 stage / progress / error_* 等選填欄位 // 在「沒值」時可以直接省略(不出現在 JSON),符合 api-conversion.md §2 範例 // (error_code: null vs 缺欄位 — 我們選缺欄位,frontend 用 nullable 邏輯處理)。 // // 時間欄位用 RFC3339(Go time.Time 預設 marshal 即 RFC3339)。 func jobToResponse(j *conversion.Job) gin.H { if j == nil { return nil } out := gin.H{ "job_id": j.JobID, "status": j.Status, "created_at": j.CreatedAt, "updated_at": j.UpdatedAt, "expires_at": j.ExpiresAt, "progress": j.Progress, "stage_progress": j.StageProgress, // T7 review M-2: 對齊 api-conversion.md §1 範例顯式列出 stage_progress(即使為 0) } // 選填欄位 — 有值才寫 if j.Stage != "" { out["stage"] = j.Stage } if j.SourceFilename != "" { out["source_filename"] = j.SourceFilename } if j.TargetChip != "" { out["target_chip"] = j.TargetChip } if j.ErrorCode != "" { out["error_code"] = j.ErrorCode } if j.ErrorMessage != "" { out["error_message"] = j.ErrorMessage } return out }