# API: `POST /api/v1/jobs/:id/promote` > **狀態**:Phase 1 完工 — Phase 0.8b 完全保留,只是對外 auth 換成 API key(converter → FAA 仍走 OAuth client_credentials)。 > > **配套**:`auth.md` §2(converter → FAA OAuth client 設計)。 --- ## 1. 用途 把 Converter Bucket 中的轉檔結果檔(onnx / bie / nef)PUT 到 FAA NAS Bucket(長期儲存)。 --- ## 2. Request ```http POST /api/v1/jobs/550e8400-.../promote Authorization: Bearer Content-Type: application/json { "targets": [ { "source": "nef", "target_object_key": "visionA/models/user-12345/model-1001/v0001/out.nef" }, { "source": "bie", "target_object_key": "visionA/models/user-12345/model-1001/v0001/out.bie" } ] } ``` ### 2.1 Body | 欄位 | 類型 | 必填 | 說明 | |------|------|------|------| | `targets` | array | ✅ | 至少 1 個,最多 10 個 | | `targets[].source` | string | ✅ | enum: `onnx`, `bie`, `nef` | | `targets[].target_object_key` | string | ✅ | FAA 的目標 key(visionA 決定命名);長度 ≤ 1024、不可含 `..` / `\` / 控制字元 / 開頭 `/` / `?` / `#` / `%` | --- ## 3. Response 200 ```json { "job_id": "550e8400-...", "promoted": [ { "source": "nef", "target_object_key": "visionA/models/user-12345/model-1001/v0001/out.nef", "size_bytes": 10485760, "file_access_agent_etag": "abc123", "promoted_at": "2026-05-16T12:30:00Z" }, { "source": "bie", "target_object_key": "...", "size_bytes": 5242880, "file_access_agent_etag": "def456", "promoted_at": "..." } ] } ``` --- ## 4. Error Responses | HTTP | error.code | 情境 | |------|-----------|------| | 400 | `validation_error` | targets 格式錯、source 非合法 stage、duplicate source | | 401 | `invalid_token` | API key 缺 / 不符 | | 404 | `job_not_found` | job 不存在 | | 409 | `job_not_ready_for_promote` | status != COMPLETED(`details.current_status`)| | 409 | `source_not_available` | job 沒產這個 stage 的結果 | | 422 | `invalid_object_key` | target_object_key 格式不合法(含 reason)| | 502 | `file_gateway_unavailable` | FAA PUT 失敗(4xx / 5xx / timeout 已重試 3 次)| | 502 | `storage_unavailable` | MinIO HEAD / GET 失敗 | | 503 | `auth_service_unavailable` | 取 FAA token 失敗(401 已 invalidate + retry 仍失敗)| --- ## 5. 冪等性 `promote` 對同樣 `target_object_key` PUT 兩次結果一樣(FAA 會覆蓋)。 **Two-layer 冪等性**(保留 Phase 1 實作): 1. **Job-level**:`job.promoted === true` → 直接回 200 + 既有 `promoted_object_keys`,不重打 FAA 2. **FAA-level**:FAA PUT 本身冪等,重試安全 --- ## 6. 實作流程 ``` 1. requireApiKey() → 401 2. perClientLimiter → 429 3. validate body → 400 / 422 4. jobService.getJob(id) + client 隔離 → 404 5. 冪等性 check(job.promoted === true → return 200) 6. status === 'COMPLETED' check → 409 7. for each target (序列): a. getJobOutputKey(job, target.source) → 409 source_not_available b. minio.headObject(sourceKey) → 502 storage_unavailable c. oauthClient.getServiceToken('files:upload.write') ← OAuth client(保留) d. faaClient.putFile(targetKey, streamFactory, ...) → 502 / 503 e. 收集 promoted result 8. jobService.markPromoted(jobId, ...) → log ERROR if 失敗(但 client 仍回 200,因為檔案實際已搬完) 9. return 200 + { job_id, promoted: [...] } ``` --- ## 7. 重要決策(保留 Phase 1) ### 7.1 序列 promote 各 target **為什麼序列**: - FAA 端對單一 client 並發可能有限制 - 失敗時容易判斷哪個 target 已成功 - 大檔串流並發會放大記憶體 / CPU 壓力 ### 7.2 Stream factory pattern `faaClient.putFile` 接受 `streamFactory: () => Promise`,每次 attempt 才呼叫 `minio.getObjectStream` 拿新 stream。 **為什麼**:HTTP body 不可 replay;attempt #1 5xx 失敗,attempt #2 必須拿新 stream。 ### 7.3 Target_object_key 安全檢查 拒絕: - 空字串、超長(> 1024) - 開頭 `/`(避免被 FAA 解讀為絕對路徑) - 含 `..`(路徑穿越) - 含 `\`(Windows 路徑 / URL 注入) - 含 `\0` / 控制字元(`\x00-\x1F`、`\x7F`) - 含 `?` / `#`(URL query / fragment 注入) - 含 `%`(雙重編碼攻擊,避免 `%2E%2E` 解碼為 `..`) ### 7.4 FAA 錯誤分類 | FAA 錯誤 | 轉換成 v1 ApiError | |---------|-------------------| | `FAAUnauthorizedError`(已 retry 仍 401)| 503 `auth_service_unavailable` | | `FAAClientError`(4xx 非 401)| 502 `file_gateway_unavailable`(拒絕細節,避免洩漏 FAA 內部訊息)| | `FAAServerError`(5xx)/ `FAATimeoutError` | 502 `file_gateway_unavailable` | | 其他 | 500 `internal_error` | ### 7.5 FAA 重試策略 - 4xx 非 401:不重試(client error,重試無益) - 401:`oauthClient.invalidate(scope)` + retry 1 次;仍 401 → 503 - 5xx / timeout / network:重試 2 次(exponential backoff 500ms / 2000ms);全失敗 → 502 ### 7.6 markPromoted 失敗的處理 FAA 已成功(檔案在 NAS 上)但 Redis `markPromoted` 失敗: - Log ERROR - 仍回 200 給 client(檔案實際已搬完) - 下次 promote 同 job 時 `markPromoted` 會再嘗試(FAA PUT 冪等) - 副作用:client 後續呼叫不會走 idempotent path、會再 PUT 一次(無害) --- ## 8. Curl 範例 ```bash curl -X POST https://converter.innovedus.com/api/v1/jobs/550e8400-.../promote \ -H "Authorization: Bearer $CONVERTER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "targets": [ {"source": "nef", "target_object_key": "visionA/models/u-12345/m-1001/v0001/out.nef"} ] }' ```