對齊 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>
569 lines
21 KiB
Go
569 lines
21 KiB
Go
// 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":
|
||
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.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
|
||
}
|