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

569 lines
21 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.

// conversion.go — /api/conversion/* 的 handler 實作Phase 0.8)。
//
// 對齊:
// - .autoflow/04-architecture/api/api-conversion.md5 個 endpoint API spec
// - .autoflow/04-architecture/conversion.md §3 endpoint 表 + §6 錯誤碼 + §10 安全考量
// - internal/conversion/conversion.goService 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 proxyPhase 0.8b
//
// 安全要點(對齊 conversion.md §7 / §10
// - 全部 5 個 endpoint 都註冊在 apiGroupOIDC AuthMiddleware 之後)
// - userID 一律來自 UserContextFrom(c).UserID從 cookie session 解出 OIDC sub
// - 任何 client 帶來的 user_idmultipart form / JSON / query一律忽略
// - /init 不呼叫 c.MultipartForm() — 會 buffer 全 body 進 RAM / disk破壞 streaming
// - /download Phase 0.8b 改 server-side stream proxyvisionA backend 中轉 NEF stream
// 沒有 delegated token 結構性流經 frontendADR-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 在 apiGroupOIDC AuthMiddleware 已套)下呼叫;
// 若 deps.Conversion 為 nilPhase 0.8 conversion 未啟用,例如 dev 環境沒設
// VISIONA_CONVERTER_BASE_URL + VISIONA_CONVERTER_API_KEY→ 5 個 endpoint 一律回 501。
// Phase 0.8b v0.6ADR-016visionA 端不再直連 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 subAuthMiddleware 已驗)
// 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 傳給 ServiceService 內部處理 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 不存在都對應到 ErrJobNotFound404
// 由 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. 解析 bodyname 可空body 整個可空)
// 3. Service.PromoteToModelspromote → 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.8bserver-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.8visionA → MC 換 delegated token → c.Redirect(302, FAA_URL_with_token)
// - Phase 0.8b v0.4/v0.5visionA backend 用 API key 直接拉 FAA → io.Copy(c.Writer, stream)
// - **Phase 0.8b v0.6**ADR-016visionA backend 改走 `converter.GetResult` 從 converter
// MinIO 拉 NEF streamvisionA 端不再直接打 FAA、撤回 v0.5 設計缺口handler 端
// io.Copy(c.Writer, stream) 路徑不變、只是 stream 來源換成 converter
//
// 中途錯誤處理(已 200 / 已寫 part of body 後失敗):
// - 一旦 status 200 已寫,無法再改 status 給 clientHTTP 規範)
// - io.Copy 中斷只能 log 錯誤client 端 browser 會看到截斷檔
// - ctx cancelclient 斷線)由 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 不會回 poolfd 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-LengthFAA 走 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 dialogfilename 由 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 T5T4 Reviewer Minor #1 修補):用 io.CopyN 上 size cap
// 防 buggy / malicious FAA 回傳超大 body 把 visionA backend 變 unbounded relay。
// 上限值 conversion.MaxDownloadStreamBytes1GB— 對 < 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無法回頭改 statusclient 會收到截斷檔
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 已給乾淨 filenamedefaultDownloadFilename 從 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附帶 detailsfields給 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 要帶的是 objectactive_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 keybackend 只回預設 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":
return "檔案存取服務暫時無法使用"
case "download_token_failed":
return "無法取得下載授權"
case "mc_token_unavailable":
return "無法取得下載授權,請重試"
case "idp_misconfigured":
return "系統設定錯誤,請聯絡支援"
case "idp_unavailable":
return "認證服務暫時無法使用"
case "service_busy":
return "系統繁忙,請稍後再試"
default:
return "內部錯誤"
}
}
// ==========================================================================
// Response shape helper
// ==========================================================================
// jobToResponse 把 internal *conversion.Job 轉成 api-conversion.md §1-2 規定的 JSON shape。
//
// 直接用 gin.Hmap而非 struct — 為了讓 stage / progress / error_* 等選填欄位
// 在「沒值」時可以直接省略(不出現在 JSON符合 api-conversion.md §2 範例
// error_code: null vs 缺欄位 — 我們選缺欄位frontend 用 nullable 邏輯處理)。
//
// 時間欄位用 RFC3339Go 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
}