# Conversion — 轉檔功能整合(Phase 0.8 / Phase 0.8b) > **角色**:visionA-backend 端的「轉檔」實作 spec(內部模組設計 + API + flow)。 > **上位文件**:[`adr/adr-015-server-to-server-api-key.md`](./adr/adr-015-server-to-server-api-key.md)(Phase 0.8b 認證機制 — 部分 supersede ADR-014)、[`adr/adr-014-conversion-integration.md`](./adr/adr-014-conversion-integration.md)(仍有效:upload streaming、download 302 設計、模組劃分等)、`TDD.md`、`security.md` > **同層文件**:`api/api-conversion.md`(對 frontend 的 API 規格細節) > **作者**:Architect Agent > **狀態**:Phase 0.8b 修訂(v0.4)— OAuth client_credentials 改 pre-shared API key > **最後更新**:2026-05-11 --- ## 索引 1. [整體 flow(端對端)](#1-整體-flow端對端) 2. [模組設計 — `internal/conversion/`](#2-模組設計--internalconversion) 3. [服務間認證(API key)— 取代 OAuth client_credentials](#3-服務間認證api-key--取代-oauth-client_credentials) 4. [新增 visionA-backend API](#4-新增-visiona-backend-api) 5. [Streaming proxy 設計(upload)](#5-streaming-proxy-設計upload) 6. [錯誤碼 mapping + i18n key](#6-錯誤碼-mapping--i18n-key) 7. [user_id 注入與 trust boundary](#7-user_id-注入與-trust-boundary) 8. [Non-Goals(Phase 0.8 不做)](#8-non-goalsphase-08-不做) 9. [失敗模式 & retry 矩陣](#9-失敗模式--retry-矩陣) 10. [安全考量](#10-安全考量) --- ## 1. 整體 flow(端對端) > **Phase 0.8b 變更**:服務間認證從「打 MC 換 OAuth service token + JWKS 驗簽 + scope」改為「visionA 帶 `Authorization: Bearer ` 直接打 converter / FAA」。詳見 §3 與 ADR-015。 ```mermaid sequenceDiagram participant B as Browser participant V as visionA-backend participant C as Converter participant F as FAA Note over B,F: Stage 1 — Init job(streaming upload) B->>V: POST /api/conversion/init (multipart) V->>V: AuthMiddleware → user_id (OIDC sub) V->>V: 檢查同 user active job V->>C: POST /api/v1/jobs
Authorization: Bearer
(streamed multipart, user_id 注入) C->>C: middleware: ConstantTimeCompare(key, CONVERTER_API_KEY) C-->>V: 201 {job_id, status:created, stage:onnx} V->>V: 記錄 job_id ↔ user_id mapping V-->>B: 200 {job_id, status:running, stage:onnx} Note over B,F: Stage 2 — Poll status loop 直到 completed / failed B->>V: GET /api/conversion/{job_id} V->>V: ownership 檢查 V->>C: GET /api/v1/jobs/{id}
Authorization: Bearer C-->>V: {status, stage, progress, ...} V-->>B: 整形後 status end Note over B,F: Stage 3a — User 選「加到模型庫」 B->>V: POST /api/conversion/{job_id}/promote-to-models V->>C: POST /api/v1/jobs/{id}/promote
Authorization: Bearer C->>F: PUT /files/{key} (NEF — converter 內部認證,與 visionA 無關) C-->>V: {target_object_key} V->>F: GET /files/{key}
Authorization: Bearer F->>F: middleware: ConstantTimeCompare(key, FAA_API_KEY) F-->>V: NEF stream V->>V: /api/models/init → /api/models/finalize
(Source=converted, SourceJobID=job_id) V-->>B: 201 {model_id} Note over B,F: Stage 3b — User 選「下載」(Phase 0.8b: server-side proxy;非 302 redirect) B->>V: GET /api/conversion/{job_id}/download V->>V: AuthMiddleware → user_id + ownership 檢查 V->>C: POST /api/v1/jobs/{id}/promote (若還沒 promote)
Authorization: Bearer C-->>V: {target_object_key} V->>F: GET /files/{key}
Authorization: Bearer F-->>V: NEF stream V-->>B: stream NEF(visionA backend 中轉,token 結構性不過 browser) ``` **critical path 說明**: - visionA-backend 在 upload / download / promote 任一階段都先做 OIDC AuthMiddleware(既有)+ ownership 檢查 - promote 動作是冪等的(converter 端對同一 job 重複 promote 接受),visionA-backend 內部以 `job_id ↔ promoted_object_key` 記錄避免重複呼叫 - 加到模型庫流程:promote → FAA pull → `/api/models/init` 取得 model_id + presigned PUT URL → visionA-backend 自己 PUT 到 storage → `/api/models/finalize`(這條路徑要重用既有 handler 邏輯,不繞過) - **Phase 0.8b 認證鏈簡化**:不再有 visionA ↔ MC 鏈路;不再有「token cache miss / scope mismatch / JWKS 不可達」失敗模式。converter / FAA 端 middleware 各自只比對單一字串。 **Phase 0.8b 與 ADR-014 的差異說明**: | 面向 | ADR-014(OAuth client_credentials) | Phase 0.8b(API key)| |------|--------------------------------|------------------| | visionA → converter 認證 | `Authorization: Bearer ` | `Authorization: Bearer ` | | visionA → FAA 認證 | `Authorization: Bearer ` | `Authorization: Bearer ` | | download 流程 | server-side 302 redirect → browser 直連 FAA(拿 MC delegated token) | server-side proxy(visionA backend 中轉 stream)| | visionA → MC service token 路徑 | 啟動 lazy init MCTokenClient + cache | **完全移除** | | converter / FAA middleware | 驗 JWKS 簽章 + 驗 scope + 驗 tenant | 比對 env 字串(constant-time)| > 為什麼 download 不繼續走 302 redirect:API key 模式下沒有 MC 簽 short-TTL delegated token;visionA 自己簽 HMAC token 給 browser 的方案留給 Phase 1+(見 ADR-015 §7 選項 B)。 --- ## 2. 模組設計 — `internal/conversion/` > **Phase 0.8b 變更**:移除 `mc_token_client.go` 整個檔案。converter / FAA client 直接從 config 讀預設 API key。 ``` internal/conversion/ ├── conversion.go # Service interface + 對外暴露的 type ├── converter_client.go # converter scheduler API client(直接帶 VISIONA_CONVERTER_API_KEY) ├── faa_client.go # FAA API client(pull NEF;直接帶 VISIONA_FAA_API_KEY) ├── flow.go # 整體 flow 協調 ├── types.go # request / response struct └── errors.go # error code 定義 ``` **Phase 0.8b 移除**: - ❌ `mc_token_client.go`(~440 行 — 整檔砍)— 不再需要與 MC 換 service token / delegated download token - ❌ `MCTokenClient` 在 flow / converter_client / faa_client 上的所有 reference ### 2.1 `conversion.go` — 對外 interface ```go package conversion import ( "context" "io" "time" ) // Service 是 handler 層的單一進入點。 type Service interface { // InitJob 把 client 的 multipart stream 透傳給 converter,建立 job。 // bodyReader 必須是「上層 handler 已 wrap 好的 multipart.Reader」— 由 handler 解多 part // 後重新組裝(見 §4),避免 service 層關心 multipart.NewReader。 // 實際實作:handler 直接拿 raw request body + content-type,由 service 內部處理 streaming。 InitJob(ctx context.Context, in InitJobInput) (*Job, error) // GetJob 查 converter status;ownership 檢查後 cache 1-2s。 GetJob(ctx context.Context, userID, jobID string) (*Job, error) // PromoteToModels — 「加到模型庫」流程:promote → FAA pull → models repo finalize。 // 回傳新建的 model_id。 PromoteToModels(ctx context.Context, userID, jobID string) (modelID string, err error) // DownloadStream — 「下載」流程(Phase 0.8b:server-side proxy): // 1. ownership 檢查 // 2. promote (若需要) // 3. 從 FAA pull NEF stream // 4. handler 直接 io.Copy stream 給 client // 不再產生 302 redirect URL(API key 模式下無 MC delegated token)。 // 詳見 ADR-015 §7 — Phase 1+ 量大時再評估「visionA 自簽 HMAC token + 302」升級路徑。 DownloadStream(ctx context.Context, userID, jobID string) (stream io.ReadCloser, meta *DownloadMetadata, err error) // ActiveJob 查 user 當前是否有 active job(給 frontend pre-check 用)。 ActiveJob(ctx context.Context, userID string) (*Job, error) } // InitJobInput 是 handler 傳給 service 的所有資料。 // MultipartBody 由 handler 從 request.Body 取得(已驗 content-type),service 內部處理 streaming。 type InitJobInput struct { UserID string ContentType string // 含 boundary 的原始值 Body io.Reader // request.Body ContentLength int64 TargetChip string // "520" / "720" // 其他 form fields(model_id, version, enable_*)由 handler 解多 part 後傳入 // — 實作上在 §4 streaming 處理時把這些 field 也透傳給 converter } type Job struct { JobID string `json:"job_id"` Status string `json:"status"` // created / running / completed / failed Stage string `json:"stage"` // onnx / bie / nef Progress int `json:"progress"` // 0-100 StageProgress int `json:"stage_progress"`// 0-100 CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` ErrorCode string `json:"error_code,omitempty"` ErrorMessage string `json:"error_message,omitempty"` } // Phase 0.8b:DownloadGrant 移除(不再有 MC delegated token 換取流程)。 // Download 走 server-side proxy;token 結構性不過 frontend。 // DownloadMetadata — DownloadStream 回傳的中介資料(沿用 §2.3 既定的型別)。 // (定義在 faa_client.go,避免重複) ``` ### 2.2 `converter_client.go` ```go type ConverterClient struct { baseURL string apiKey string // Phase 0.8b:pre-shared API key(VISIONA_CONVERTER_API_KEY) httpClient *http.Client } // CreateJobStream 把 io.Reader 當作 multipart body(content-type 含 boundary)透傳給 converter。 // caller 必須: // 1. 已經把 user_id 透過 multipart.Writer 注入 body(在 streaming 過程中) // 2. content-type 是合法的 multipart/form-data; boundary=... func (c *ConverterClient) CreateJobStream(ctx context.Context, contentType string, body io.Reader, contentLength int64) (*Job, error) func (c *ConverterClient) GetJob(ctx context.Context, jobID string) (*Job, error) func (c *ConverterClient) PromoteJob(ctx context.Context, jobID string) (targetObjectKey string, err error) // ListActiveJobsByUser — lazy rebuild ownership 用(§2.6.1) func (c *ConverterClient) ListActiveJobsByUser(ctx context.Context, userID string) (*Job, error) ``` 每個方法內部(Phase 0.8b 簡化): 1. `req.Header.Set("Authorization", "Bearer "+c.apiKey)` — 直接帶 pre-shared API key,**不查 cache、不打 MC、不重簽** 2. 帶 `X-Request-Id`(從 ctx 取,沿用 visionA-backend 既有 request_id 中介層) 3. response 5xx / network error 走 retry(§9);401/403 不 retry(API key 錯不會自己變對) ### 2.3 `faa_client.go` ```go type FAAClient struct { baseURL string apiKey string // Phase 0.8b:pre-shared API key(VISIONA_FAA_API_KEY) httpClient *http.Client } // Download server-to-server 拉檔(給「加到模型庫」+「下載」兩個流程共用)。 // 帶 Authorization: Bearer 。 func (c *FAAClient) Download(ctx context.Context, objectKey string) (io.ReadCloser, *DownloadMetadata, error) type DownloadMetadata struct { SizeBytes int64 ContentType string Checksum string // optional } ``` **Phase 0.8b**:不再需要 `DownloadGrant`(無 MC delegated token);download flow 用同一個 `FAAClient.Download()` 拉 stream 後由 handler 中轉給 client。 ### 2.4 ~~`mc_token_client.go`~~(Phase 0.8b 移除) > **整個檔案在 Phase 0.8b 移除**。詳見 [ADR-015](./adr/adr-015-server-to-server-api-key.md)。 > > 原本職責:跟 MC 換 service token(client_credentials grant)+ 換 delegated download token。 > > Phase 0.8b 後: > - 服務間認證直接帶 `Authorization: Bearer ` — 不需 cache、不需 refresh、不需 retry MC > - download flow 退回 server-side proxy(visionA backend 中轉 stream),不再有 delegated token 概念 > > **Tenant 概念取消**:visionA → converter / FAA 不再帶 tenant_id;converter 端的 user_id 仍由 visionA 灌入(§7 trust boundary 不變)。 ### 2.5 `flow.go` — 流程協調 ```go type Flow struct { converter *ConverterClient faa *FAAClient // Phase 0.8b:不再需要 tokens *MCTokenClient models model.Repository // 沿用既有 model store storage storage.Store // 沿用既有 LocalFS / S3 ownership ownershipStore // job_id → user_id mapping (in-memory map) statusCache *jobStatusCache // 1-2s short cache,避免 frontend polling 直接打爆 converter } // 主要 method 對應 Service interface。 // PromoteToModels 內部: // 1. ownership.Check(userID, jobID) // 2. promotedKey, err := flow.ensurePromoted(ctx, jobID) // 冪等:若已 promote 過用 cache,否則打 converter // 3. reader, meta, err := faa.Download(ctx, promotedKey) // 4. modelID, putURL, _ := callModelsInit(...) // 直接呼叫 internal/api 同 package 既有 helper(不走 HTTP) // 5. PUT 到 storage(或直接 io.Copy 到 storage.Put) // 6. callModelsFinalize(...) // 7. 在 model record 補 Source="converted" + SourceJobID=jobID // 8. 回 modelID ``` **冪等性**:`flow.ensurePromoted(jobID)` 內部用 `sync.Map` 記 `job_id → target_object_key`;同 job 第二次 promote 直接回 cache,不打 converter。 ### 2.6 `ownership` store(in-memory) ```go type ownershipStore interface { Set(jobID, userID string, expiresAt time.Time) // 對齊 converter 7d 過期 Get(jobID string) (userID string, ok bool) CleanupExpired() // background goroutine 每 60s } ``` 雛形 in-memory;visionA-backend 重啟 → 所有「我的 job 列表」消失,user 等同失去對未完成 job 的後續操作能力(接受的取捨 — converter 端用 user_id 仍可查到,但 visionA UX 上看不到)。Phase 0.9 之後可改 DB persist。 #### 2.6.1 visionA-backend 重啟後的 cold start 恢復(Phase 0.8 MVP 行為) **問題**:使用者 A 上傳了一個 job,正在 `processing`;visionA-backend 重啟(部署新版、crash recovery)→ in-memory ownership store 全空;使用者 A 重新打開 `/conversion` 頁面,前端打 `GET /api/conversion/active` → backend 找不到任何 ownership → 回 `has_active=false` → 前端顯示「沒有進行中的轉檔」。 **結果**:使用者 A 看到一個假的「乾淨」狀態,認為什麼都沒發生;但 converter 端那個 job 仍在跑(且 converter 端「同 user 1 active job」邏輯仍生效),使用者 A 重新 submit 會撞 409。 **Phase 0.8 MVP 的決策(接受的取捨)**: | 選項 | 描述 | 採用? | |------|------|--------| | A1 | 維持現狀:重啟即遺失。靠 converter 7 天 expires_at 自然兜底 | ✓ Phase 0.8 採用 | | A2 | 啟動時對 converter 打 `GET /api/v1/jobs?status=in_progress` 重建所有 ownership | ✗ Phase 0.8 不做 | | A3 | 把 ownership 寫進 DB / Redis | ✗ Phase 0.9+ 評估 | | A4 | 啟動時對特定 user 才 lazy 重建(`GET /active` 時若 in-memory 沒有,去 converter 查該 user 的 active job) | ✓ Phase 0.8 補上(**新增**) | **A4 實作(Phase 0.8 補強)**: ```go // flow.go ActiveJob 內部: func (f *Flow) ActiveJob(ctx context.Context, userID string) (*conversion.Job, error) { // 1. 先查 in-memory ownership if jobID, ok := f.ownership.GetByUser(userID); ok { return f.GetJob(ctx, userID, jobID) } // 2. in-memory miss → fallback 對 converter 查(lazy rebuild ownership) job, err := f.converter.ListActiveJobsByUser(ctx, userID) if err != nil { return nil, err } if job == nil { return nil, nil } // 重建 ownership(用 converter 回的 created_at + 7d 推算 expires_at) f.ownership.Set(job.JobID, userID, job.CreatedAt.Add(7*24*time.Hour)) return job, nil } ``` **前提**:converter Phase 1 的 `GET /api/v1/jobs?user_id=&status=in_progress` 必須可用。見 §11 跨團隊依賴。 **為什麼選 A4 不選 A2**: - A2 啟動時批次掃所有 in_progress jobs:對 converter 是 hammer(重啟頻繁時尤甚),且大部分 jobs 重啟期間使用者根本沒在等 - A4 是 lazy(只有使用者主動進 `/conversion` 才查),cost 對應 user 行為,不會打爆 converter - 取捨:使用者進 `/conversion` 時多 1 次 round-trip(< 200ms),對 UX 可接受 **Wireframe / UX 對齊**:Design wireframe §3.3 已 cover「進入頁面打 `/active`、有 active 直接落 processing」,A4 行為對 frontend 完全透明(同樣 endpoint、同樣 response shape)。 #### 2.6.2 expires_at 的來源 | 屬性 | 規格 | |------|------| | 定義 | converter 端對 job 做 7 天 GC 的截止時間 | | 來源 | converter 的 job record `created_at + 7 days`(converter Phase 1 的 GC 邏輯) | | 是否回傳給 visionA-frontend | ✓ 是 — `GET /api/conversion/{job_id}` response 與 `GET /api/conversion/active` response 都帶 `expires_at` | | visionA-backend 怎麼知道 | 優先從 converter response 直接讀;若 converter 沒給(Phase 1 的 OpenAPI spec 待確認),visionA-backend 自行 `created_at + 7d` 推算 | **待確認(given to DevOps / Backend Agent)**: - 確認 converter Phase 1 的 `GET /api/v1/jobs/{id}` 是否在 response 含 `expires_at` 欄位 - 若有 → `internal/conversion/converter_client.go` 的 `Job` struct 加 `ExpiresAt time.Time`,直接透傳 - 若無 → backend 在 `Job` 上補 `ExpiresAt = CreatedAt.Add(7 * 24 * time.Hour)`,**前端永遠拿到 `expires_at`**(無論來源) **Frontend 用途**: - `completed.success` 畫面顯示「6 天 21 小時後自動清除」倒數提示 - `expires_at - now() ≤ 0` 時切「已過期」狀態(wireframe §8.2) --- ## 3. 服務間認證(API key)— 取代 OAuth client_credentials > **Phase 0.8b 變更**:本節為新增;對應 [ADR-015](./adr/adr-015-server-to-server-api-key.md)。 > > **歷史**:ADR-014 §5 原本設計為「visionA → MC `POST /oauth/token` 換 service token + cache + 帶 JWT 給 converter / FAA」。Phase 0.8 stage e2e 卡關(MC scope 沒註冊、converter image 舊、跨 repo 配合複雜)後,使用者決策改用 pre-shared API key。 ### 3.1 取得流程 ``` visionA-backend 啟動 ↓ 讀 cfg.Conversion.ConverterAPIKey(env VISIONA_CONVERTER_API_KEY) 讀 cfg.Conversion.FAAAPIKey(env VISIONA_FAA_API_KEY) ↓ [轉檔請求進來] ↓ converter_client / faa_client 發 request 時: req.Header.Set("Authorization", "Bearer "+apiKey) ↓ converter / FAA middleware: - parse Authorization header → 取 token - subtle.ConstantTimeCompare(token, envKey) - match → 放行;mismatch → 401 + log(不附原因) ``` **沒有 token cache、沒有 refresh、沒有 retry MC、沒有 scope 驗證**。整條鏈路是「visionA → 下游」一步。 ### 3.2 Config 對齊 `visionA-backend/internal/config/config.go` 變更: ```go type ConversionConfig struct { ConverterBaseURL string `env:"VISIONA_CONVERTER_BASE_URL"` FAABaseURL string `env:"VISIONA_FAA_BASE_URL"` ConverterAPIKey string `env:"VISIONA_CONVERTER_API_KEY"` // Phase 0.8b 新增 FAAAPIKey string `env:"VISIONA_FAA_API_KEY"` // Phase 0.8b 新增 // Phase 0.8b 廢棄欄位(為 backward compat 保留 struct field 但 conversion 不再使用): // TenantID string `env:"VISIONA_OIDC_TENANT_ID"` } func (c ConversionConfig) Enabled() bool { return c.ConverterBaseURL != "" && c.FAABaseURL != "" && c.ConverterAPIKey != "" && c.FAAAPIKey != "" } ``` `OIDCConfig.ServiceClientID` / `ServiceClientSecret` 兩個欄位 Phase 0.8b **conversion 不再依賴**;如其他模組未使用即可從 struct 移除。env 端從 `.env*.example` 移除以免誤導。 新增的 stage env: ```bash # .env.stage VISIONA_CONVERTER_BASE_URL=http://192.168.0.130:9501 VISIONA_FAA_BASE_URL=http://192.168.0.130:5081 VISIONA_CONVERTER_API_KEY= VISIONA_FAA_API_KEY= # Phase 0.8b 移除: # VISIONA_OIDC_SERVICE_CLIENT_ID # VISIONA_OIDC_SERVICE_CLIENT_SECRET # VISIONA_OIDC_TENANT_ID(conversion 不依賴;其他模組未發現使用) ``` ### 3.3 啟動時驗證 api-server 啟動時 log 一行(**不可 log key 本身**): ``` [INFO] conversion config: converter=http://192.168.0.130:9501 (api_key_set=true) faa=http://192.168.0.130:5081 (api_key_set=true) ``` 若 `Enabled() == false` → conversion 模組整個 disabled(與 Phase 0.8 「partial deploy」相容,sidebar tab 仍會顯示但會回 503 `service_busy`)。 ### 3.4 Key 產生 / 部署 / Rotate | 項目 | 規格 | |------|------| | 長度 | 64 字元 hex(256 bit 熵) — `openssl rand -hex 32` | | 環境隔離 | dev / stage / prod 各自獨立的 key,**不重用** | | 兩個下游 | converter / FAA 各自一把,**不共用** | | 儲存(dev)| `.env.dev`(gitignore) | | 儲存(stage)| stage host `.env.stage`(不進 git) | | 儲存(prod)| AWS Secrets Manager / Vault | | Rotate | runbook Phase 0.9 補;流程:產新 key → 雙方同步 env → restart → 驗證 → 拔舊 key | | Log policy | 永遠不印 key 全文;可印 `api_key_set=true/false` 或前 8 字元 prefix | ### 3.5 Trust boundary 對齊 API key 證明的是「caller 是 visionA」(machine 身份);user_id 的真實性由 visionA 內部的 OIDC cookie session 保證(user 身份)。兩條獨立鏈: - **machine auth**:visionA → converter / FAA 用 API key - **user auth**:browser → visionA 用 OIDC cookie session(既有,未變) - visionA 是橋樑:從 OIDC sub 解出 user_id → 透過 multipart body / API path 灌進對下游的請求 詳見 §7。 --- ## 4. 新增 visionA-backend API 詳細請求 / 回應 schema 見 `api/api-conversion.md`;這裡列總覽。 | Method | Path | Auth | 用途 | |--------|------|------|------| | `POST` | `/api/conversion/init` | OIDC cookie | 上傳 + 建 job(multipart streaming) | | `GET` | `/api/conversion/{job_id}` | OIDC cookie | 查 job 狀態 | | `POST` | `/api/conversion/{job_id}/promote-to-models` | OIDC cookie | 「加到模型庫」 | | `GET` | `/api/conversion/{job_id}/download` | OIDC cookie | 「下載」— server-side HTTP 302 redirect 到 FAA delegated URL | | `GET` | `/api/conversion/active` | OIDC cookie | 查當前 user 有無 active job(frontend pre-check);in-memory miss 時 fallback 對 converter lazy rebuild(§2.6.1) | > **不對外暴露但內部使用的 converter endpoint**:`GET /api/v1/jobs?user_id=&status=in_progress`(§2.6.1 lazy rebuild)。Phase 0.8 frontend 看不到「歷史列表」UI,但後端會用此內部 endpoint 做韌性處理。 > > **`POST /api/v1/jobs/{id}/cancel`**:converter Phase 1 **尚未實作**(已驗證 routes/v1/jobs.js 與 openapi.yaml)。Phase 0.8 失敗 cleanup 採「socket close 自然 abort」(§5.3.2);Phase 1 待 converter 補上 endpoint 後再升級為 best-effort 主動 cancel。 所有 endpoint 通用: - 走既有 `AuthMiddleware`(`internal/api/middleware/auth.go`) - 從 `UserContextFrom(c)` 拿 `uc.UserID`(OIDC sub) - response 用既有 `WriteSuccess` / `WriteError` helper - request_id 透傳給 converter(`X-Request-Id` header) ### 4.1 `GET /api/conversion/{job_id}/download` — Phase 0.8b:server-side stream proxy handler > **變更**:Phase 0.8(ADR-014 v1.1)原本是 `c.Redirect(302, FAA_URL_with_delegated_token)`;Phase 0.8b API key 模式下無 MC delegated token,改為 visionA backend 中轉 stream。 ```go // GET /api/conversion/{job_id}/download func conversionDownloadHandler(deps Deps) gin.HandlerFunc { return func(c *gin.Context) { uc, _ := UserContextFrom(c) // AuthMiddleware 已驗 jobID := c.Param("job_id") // service 內部完成:ownership 檢查 → ensurePromoted → 從 FAA pull stream stream, meta, err := deps.Conversion.DownloadStream(c.Request.Context(), uc.UserID, jobID) if err != nil { writeConversionError(c, err) // §6 錯誤碼分類 return } defer stream.Close() // streaming proxy 給 client(io.Copy;不暫存 disk / RAM 全 buffer) c.Header("Content-Type", meta.ContentType) if meta.SizeBytes > 0 { c.Header("Content-Length", strconv.FormatInt(meta.SizeBytes, 10)) } // 鼓勵 browser 觸發 save dialog // 注意:meta.Filename **不是** FAA metadata 直接給的(FAA 端的 object_key 是 // `models//.nef` 對 user 不友善),而是 visionA backend 在 service 層 // 由 `defaultDownloadFilename(cj)` 從 conversion job metadata 構造,規則: // `_.nef`(例:`yolov5s_kl720.nef`), // 對齊 wireframe success card 顯示範例(`yolov5s.onnx → yolov5s_kl720.nef`)。 c.Header("Content-Disposition", `attachment; filename="`+sanitizeFilename(meta.Filename)+`"`) c.Header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") c.Status(http.StatusOK) io.Copy(c.Writer, stream) } } ``` **為什麼仍用 GET**: - frontend 用 `` 觸發 — anchor tag 只能發 GET - GET semantically 對應「拿一個資源」,符合「下載這個 job 的結果」語意 - 既有 OIDC AuthMiddleware 會自然處理 GET 上的 cookie session,無 CSRF 風險(沒有狀態變更,promote 是冪等的) **Frontend 使用範例**(與 Phase 0.8 一致,無需改動): ```html 下載 ``` 或: ```ts // 程式化觸發 window.location.href = `/api/conversion/${jobId}/download`; ``` **安全性面比較(Phase 0.8 → Phase 0.8b)**: | 面向 | Phase 0.8(302 redirect + MC delegated token)| Phase 0.8b(server-side stream proxy)| |------|----|----| | Token 在 frontend JS / URL bar | △ 短暫(Location header 流經 browser,nav 完即消失) | ✓ 結構性不存在(無 token 概念)| | 要 FAA CORS | ✓ 不需要(navigation 不適用 CORS)| ✓ 同 — visionA 為 same-origin,FAA 直連在 server-side | | 跨 internet 流量(同 NEF 多次下載)| ✓ 直連 FAA、N× 流量算 FAA 出 | ✗ 每次都繞 visionA backend,N× 流量算 visionA 出 | | visionA backend 是否變 streaming bottleneck | ✓ 不是 | ✗ 是 — Phase 0.8 MVP user 量小可接受;Phase 1 量大需改 ADR-015 §7 選項 B | | 認證鏈簡化 | ✗ 需要 MC scope `files:download.delegate` | ✓ 一把 API key 解決 | **Phase 1 升級路徑**:如量大需回 302 redirect 模式,採 ADR-015 §7 選項 B(visionA 自己簽 short-TTL HMAC token,FAA middleware 多支援「visionA HMAC」路徑)。 --- ## 5. Streaming proxy 設計(upload) ### 5.1 為什麼要 streaming - 模型上限 500MB;ref_images 100×10MB = 1GB 上限 - 全 buffer 在 RAM → 同時 N 個 user upload 直接 OOM - 暫存 disk → 增加 IO 與磁碟空間需求;壞掉的 cleanup 麻煩 ### 5.2 實作 pattern ```go // handler 收到 request: func conversionInitHandler(deps Deps) gin.HandlerFunc { return func(c *gin.Context) { uc, _ := UserContextFrom(c) userID := uc.UserID ct := c.GetHeader("Content-Type") if !strings.HasPrefix(ct, "multipart/form-data") { WriteError(c, 400, ErrCodeValidationFailed, "expect multipart/form-data", nil) return } // 同 user active job pre-check if active, _ := deps.Conversion.ActiveJob(c.Request.Context(), userID); active != nil { WriteError(c, 409, ErrCodeActiveJobExists, "user has active job", map[string]any{"job": active}) return } // service 內部做 streaming job, err := deps.Conversion.InitJob(c.Request.Context(), conversion.InitJobInput{ UserID: userID, ContentType: ct, Body: c.Request.Body, ContentLength: c.Request.ContentLength, }) // ... error handling } } ``` ```go // service 內部 (flow.go InitJob): func (f *Flow) InitJob(ctx context.Context, in conversion.InitJobInput) (*conversion.Job, error) { pr, pw := io.Pipe() mw := multipart.NewWriter(pw) // goroutine:解 client 的 multipart,重新寫到 mw errCh := make(chan error, 1) go func() { defer pw.Close() defer mw.Close() mr, err := readerFromContentType(in.Body, in.ContentType) if err != nil { errCh <- err; return } // 先寫 user_id(重點:visionA backend 灌的,不是 client 灌的) if err := mw.WriteField("user_id", in.UserID); err != nil { errCh <- err; return } for { part, err := mr.NextPart() if err == io.EOF { break } if err != nil { errCh <- err; return } name := part.FormName() // 黑名單:client 不允許自己塞 user_id if name == "user_id" { continue // 忽略,用我們自己灌的 } if part.FileName() == "" { // form field:直接複製 fw, err := mw.CreateFormField(name) if err != nil { errCh <- err; return } if _, err := io.Copy(fw, part); err != nil { errCh <- err; return } } else { // file part:streaming copy fw, err := mw.CreateFormFile(name, part.FileName()) if err != nil { errCh <- err; return } if _, err := io.Copy(fw, part); err != nil { errCh <- err; return } } } errCh <- nil }() // 同步 POST 到 converter job, err := f.converter.CreateJobStream(ctx, mw.FormDataContentType(), pr, -1) // 等 goroutine 結束 if goErr := <-errCh; goErr != nil && err == nil { err = goErr } if err != nil { return nil, mapConverterError(err) } f.ownership.Set(job.JobID, in.UserID, time.Now().Add(7*24*time.Hour)) return job, nil } ``` **關鍵點**: 1. `io.Pipe` 把「client 端 reader → converter 端 writer」串接,期間記憶體只有 multipart.Reader 的 buffer(≈ 64KB 預設) 2. 必須先寫 `user_id` field(**順序**:user_id 在 model file 之前,避免 converter multer 解析時 user_id 還沒到就拒絕) 3. **黑名單 user_id**:忽略 client 帶的 user_id,永遠用 visionA-backend 自己灌的 4. context cancellation:handler 收到 client disconnect → ctx.Done() → goroutine 自動結束(pw.Close 觸發 reader EOF) 5. 不做 ContentLength forward(converter 自己 multer 算) ### 5.3 進度 / 取消 #### 5.3.1 進度語意(重要:給 Frontend / Design 對齊) XHR `upload.onprogress` 計算的是 **browser → visionA-backend** 的進度,**不是** browser → backend → converter 的端到端進度。在 streaming proxy 模式下,這兩者有時間差: ``` T0: browser 開始上傳 └─ XHR onprogress 持續更新(loaded / total) T1: browser 已 send 完全部 bytes(XHR 進度 100%) └─ 但 backend → converter 的 io.Pipe 可能還在繼續流(buffer 內未消化) T2: backend 把全部 bytes forward 完給 converter └─ 這時候才拿到 converter 的 201 + job_id T3: backend 200 回 frontend ``` **設計選擇(Phase 0.8 MVP)**:visionA-backend 等到 T2(converter 回 201)才回 200,**不 early-return**。 | 屬性 | 選項 A:等 converter 201 才 200 ✓ 採用 | 選項 B:browser send 完就 200,背景 forward | |------|----------------------------------|------------------------------------------| | Frontend 進度條精確度 | 100% 接近端到端真實狀態 | 進度 100% 後還有未知延遲 → 假象 | | UX 延遲感 | 多等 1-3 秒(io.Pipe drain)| 立即切 processing 畫面 | | backend 實作複雜度 | 低(直接同步等)| 高(需要 background goroutine + ownership 標 `upload_in_progress` + 額外狀態管理) | | 失敗處理複雜度 | 低(同步錯誤直接回 frontend)| 高(背景 forward 失敗時 frontend 已切 processing,要額外 push 錯誤通知)| **選項 A 的 UX 補償**:當 XHR `loaded === total` 但 backend 還沒回 200 時,frontend 顯示 `即將完成…` / `伺服器處理中…`(對齊 flow-conversion.md §5.3,本文件 §5.3.1)。這明確告訴使用者「browser 端已送完,正在等 server 收尾」,不是欺騙性的進度條。 > **Phase 1 升級路徑**:若 Phase 1 量大需要更精準的端到端進度,可改成 SSE 推送 `upload_progress` 事件(backend 主動報「已 forward N bytes 給 converter」),但 Phase 0.8 MVP 不做。 #### 5.3.2 Cancel 與 Cleanup 鏈(重要) **情境分類**: | 情境 | 觸發 | backend 行為 | |------|------|-------------| | C1:使用者按「取消上傳」 | frontend `xhr.abort()` | TCP RST → backend `c.Request.Context().Done()` → goroutine cleanup(見下) | | C2:使用者重新整理 / 關分頁 | browser 中斷 connection | 同 C1 | | C3:網路斷線 | TCP timeout | 同 C1 | | C4:backend 偵測 converter 拒絕(4xx/5xx)| converter response | 立即回 frontend,不需特別 cleanup(converter 自己沒建 job) | **C1-C3 的 cleanup 鏈**: ``` client disconnect ↓ gin handler `c.Request.Context().Done()` 觸發 ↓ streaming goroutine 的 `pw.Close()` defer 執行 → io.Pipe reader 收到 EOF/error ↓ converter HTTP request 的 body read 端拿到 EOF ↓ converter multer 偵測 incomplete multipart → 拒絕收 job(不會建 job_id) ``` **但**:上面的鏈在「backend 已經把 multipart header 寫進去、converter 已建 job_id、stream 還在 forward 中」這個區間斷線時,**converter 端可能已經建立 job 但收不完 body**。實測上 converter(Phase 1)的行為是: - 收不完 body → multer 拋錯 → 該 job 留在 `failed` 狀態 + error_code=`invalid_multipart` - 該 user 的 active_job 邏輯:converter 把 `failed` 視為 active job 結束,下次 init 不會撞 409 **為了避免「converter 視為 active 但 visionA 不知道」的孤立 job 風險**,依 converter 是否提供 `/cancel` endpoint 採不同策略: > ### Phase 0.8 限制(重要 — 已驗證實作狀態) > > **converter Phase 1 並未實作 `POST /api/v1/jobs/{id}/cancel` endpoint**。 > > 已驗證範圍:`apps/task-scheduler/src/routes/v1/jobs.js` 只有以下路由: > - `POST /api/v1/jobs/`(建立 job) > - `GET /api/v1/jobs/`(list) > - `GET /api/v1/jobs/:id`(單一狀態) > - `POST /api/v1/jobs/:id/download-tokens`(issue download token) > - `DELETE /api/v1/jobs/:id`(刪 job — 是 hard delete 而不是 cancel running job) > > openapi.yaml 也沒有 cancel 路徑或 example。 > > 因此 **Phase 0.8 採「socket close 自然 abort」策略**: > > ``` > client disconnect / streaming body 中斷 > ↓ > converter multer 拋 invalid_multipart > ↓ > 該 job 留 failed + error_code=invalid_multipart > ↓ > converter 對 active_job 邏輯視為已結束(failed 不算 active) > ↓ > 下次 init 不會撞 409 > ``` > > visionA-backend 在 InitJob 失敗時不主動發 cancel(沒有對應 endpoint 可發);只在 > log 紀錄失敗事件,依靠 converter 自然 abort 收尾。 > ### Phase 1+ 升級路徑(converter 補上 /cancel 之後) > > 當 converter 上線 `POST /api/v1/jobs/{id}/cancel` 後,visionA-backend 升級為 > best-effort 主動 cancel: > > ```go > // flow.go InitJob 內部,goroutine 結束後若 err != nil 且我們已拿到 job_id: > if goErr != nil && job != nil && job.JobID != "" { > // 用獨立 timeout(不繼承已 cancel 的 ctx),失敗只 log > cancelCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) > defer cancel() > if cancelErr := f.converter.CancelJob(cancelCtx, job.JobID); cancelErr != nil { > logger.Warn("best-effort cancel failed", "job_id", job.JobID, "err", cancelErr) > } > } > ``` > > 動工項: > 1. T3 ConverterClient interface 新增 `CancelJob(ctx context.Context, jobID string) error` > 2. flow.go InitJob 失敗路徑加上面的 best-effort cancel block > 3. 補對應 unit test(mock converter 收到 cancel call) > 4. 對齊 §9.1 retry 矩陣的「Converter `POST /jobs/{id}/cancel`(內部 cleanup)」row **C1 特別處理(使用者按「取消上傳」)**:frontend 在 `xhr.abort()` 之前**不應**先打 cancel API(多此一舉,TCP RST 即已觸發 cleanup);後端會自動處理。 #### 5.3.3 既有 4.3 內容(保留) - **進度**:visionA-backend 不做進度回報;frontend 用 XHR `upload.onprogress` 自己顯示(既有前端模式) - **取消**:context.Cancel(client 斷線)→ 連帶 cancel converter request(如上 cleanup 鏈);converter 端 multer 收到 socket close 會自動 abort multipart parsing ### 5.4 Timeout - handler 整體不設總 timeout(500MB upload 可能 5-10 分鐘) - 但每個 io.Copy 之間用 `http.Server.WriteTimeout`/`IdleTimeout` 控制 keep-alive;具體值由 DevOps 在 Nginx / ingress 設定(建議 600s) --- ## 6. 錯誤碼 mapping + i18n key > **Phase 0.8b 變更**:移除所有「MC token」相關錯誤碼(`idp_misconfigured` / `idp_unavailable` / `download_token_failed` / `mc_token_unavailable`)— 服務間認證已不再經 MC。新增「API key 驗證失敗」錯誤碼(visionA 端不直接面對,但下游若回 401 要處理)。 | Converter / FAA error | visionA error code | HTTP | i18n key | user-friendly 訊息(zh-TW) | |----------|--------------------|------|----------|--------------------------| | converter `validation_error` | `validation_failed` | 400 | `conversion.error.validation` | 上傳的檔案不符合要求(請查看詳細欄位錯誤) | | converter `invalid_multipart` | `validation_failed` | 400 | `conversion.error.invalid_multipart` | 上傳格式錯誤,請重新嘗試 | | converter `user_has_active_job` | `active_job_exists` | 409 | `conversion.error.active_job` | 你目前已有進行中的轉檔任務 | | converter `file_too_large` | `payload_too_large` | 413 | `conversion.error.too_large` | 檔案超過大小限制 | | converter `service_busy` | `service_busy` | 503 | `conversion.error.busy` | 系統繁忙,請稍後再試 | | converter `storage_unavailable` | `converter_unavailable` | 502 | `conversion.error.converter_down` | 轉檔服務暫時無法使用 | | converter 5xx / network | `converter_unavailable` | 502 | `conversion.error.converter_down` | 同上 | | **converter 401(API key 不對 / 過期 / rotate 未同步)** | `converter_auth_failed` | 502 | `conversion.error.converter_down` | 轉檔服務暫時無法使用(內部 log 區分 auth_failed vs 5xx)| | FAA 5xx / network | `faa_unavailable` | 502 | `conversion.error.faa_down` | 檔案存取服務暫時無法使用 | | **FAA 401(API key 不對 / 過期 / rotate 未同步)** | `faa_auth_failed` | 502 | `conversion.error.faa_down` | 檔案存取服務暫時無法使用(內部 log 區分 auth_failed vs 5xx)| | job 不屬於當前 user | `forbidden` | 403 | `conversion.error.forbidden` | 你無權存取此轉檔任務 | | job_id 不存在 | `not_found` | 404 | `conversion.error.not_found` | 轉檔任務不存在 | | job 還沒 completed | `job_not_completed` | 409 | `conversion.error.not_completed` | 任務尚未完成,請等轉檔完成再下載 | **Phase 0.8b 移除的錯誤碼**(與 MC token 相關,認證路徑取消後不會發生): | 已移除 | 原來語意 | Phase 0.8b 替代 | |------|----------|-----| | `idp_misconfigured`(500) | MC token endpoint 回 4xx(scope 沒註冊 / client 設錯)| — | | `idp_unavailable`(503) | MC token endpoint 5xx | — | | `download_token_failed`(502) | MC delegated token 4xx | — | | `mc_token_unavailable`(502) | MC 持續失敗 | — | 下游 401 對待原則: - 對 visionA 端而言,下游 401 是「**部署設定錯誤**」(API key 不對齊)— 跟「使用者沒登入」(visionA → frontend 401)完全不同層次 - visionA 從 `converter_client` / `faa_client` 收到 401 → log error(含 request_id,方便 SRE 排查)→ 回 frontend `502 converter_auth_failed` / `faa_auth_failed`,不要對 frontend 暴露「API key 不對」這個內部細節 - 401 不 retry(同 §9)— rotate 流程不同步是運維事件,需人工介入 i18n key 命名:`conversion.error.`,前端 i18n 字典在 `visionA-frontend/messages/{zh-TW,en}.json` 補。 **`/download` endpoint 錯誤回應策略**(GET + server-side stream proxy 場景): 由於 `GET /api/conversion/{job_id}/download` 採 server-side stream proxy(Phase 0.8b 變更),錯誤情況直接走既有 `WriteError` helper 依 `Accept` header 回應: - `Accept: application/json` → 回標準 visionA error JSON `{success:false, error:{code, message}}` - `Accept: text/html`(一般 anchor tag / window.location.href 觸發)→ 回 HTML 錯誤頁;browser 直接顯示 - 其他 → 預設 JSON frontend 用 `` 觸發時,若失敗 browser 會把錯誤頁顯示在頁面上。Phase 0.8 不要求 inline 錯誤 UX。 --- ## 7. user_id 注入與 trust boundary ### 7.1 唯一可信任點 ``` ┌──────────────────────────────────────────────────────────────┐ │ visionA-backend conversion handler │ │ │ │ AuthMiddleware → UserContext (OIDC sub from cookie) │ │ ↓ │ │ conversion.Service.InitJob(InitJobInput{UserID: }) │ │ ↓ │ │ flow.go InitJob │ │ ├─ multipart streaming 重組(黑名單 client 帶的 user_id)│ │ ├─ mw.WriteField("user_id", ) ← 唯一灌入點 │ │ └─ POST converter /api/v1/jobs │ └──────────────────────────────────────────────────────────────┘ ``` ### 7.2 Ownership 檢查 每個 GET / promote / download / models 操作都先檢查 `ownership.Get(jobID) == userCtx.UserID`,不符 → 403 `forbidden`。 ### 7.3 客戶端不可信原則 - frontend / browser 帶來的 user_id 永遠忽略(streaming 重組時黑名單) - frontend / browser 帶來的 object_key 永遠忽略(GET /download 不接受 client 指定 object_key,從 visionA 內部 promote 結果反查) - frontend 只能告訴我們 `job_id`,其他都從 server side 推 --- ## 8. Non-Goals(Phase 0.8 不做) 對齊 PRD Phase 0.8 邊界: | 項目 | Phase 0.8 行為 | Phase 1+ 計畫 | |------|--------------|--------------| | SSE / WebSocket 進度推送 | frontend HTTP polling,間隔 2s | SSE endpoint `/api/conversion/{id}/events` | | 取消 job | 不提供;user 等 converter 自己跑完或 7 日後 expires | `POST /api/conversion/{id}/cancel` | | Job 歷史列表 | 不提供;in-memory map 重啟即清 | DB persist + `GET /api/conversion/history` | | 同 user 多個 active job | 強制 1 個(pre-check + converter 409 透傳) | 沿用 converter 限制(短期內無計畫放寬) | | 多 chip 同時轉 | 一次只能選一個 target_chip | Phase 1 後評估 | | Webhook(converter 完成 push) | 不接收 | converter Phase 2 才提供 | | 大量批次 upload | 不支援 | 不在路線圖 | --- ## 9. 失敗模式 & retry 矩陣 ### 9.1 retry 規則 > **Phase 0.8b 變更**:移除 MC 兩 row;下游 401/403(API key 不對)一律不 retry。 | 操作 | 401 / 403 | 4xx 其他 | 5xx | network / timeout | max retry | 退避 | |------|-----------|---------|-----|------------------|-----------|------| | Converter `POST /jobs` | **不重試**(auth_failed)| 透傳 | retry | retry | 2 | 1s, 2s | | Converter `GET /jobs/{id}` | **不重試** | 透傳 | retry | retry | 3 | 0.5s, 1s, 2s | | Converter `POST /jobs/{id}/cancel`(內部 cleanup,Phase 1+)| 不重試 | 不重試 | 不重試 | 不重試 | 0 | best-effort(失敗只 log);**Phase 0.8 converter 未實作此 endpoint,靠 socket close 兜底**(§5.3.2)| | Converter `GET /jobs?user_id=&status=in_progress`(lazy rebuild)| **不重試** | 透傳 | retry | retry | 1 | 0.5s | | Converter `POST /promote` | **不重試** | 透傳 | retry | retry | 2 | 1s, 2s | | FAA `GET /files/{key}`(s2s download / s2s pull) | **不重試** | 透傳 | retry | retry | 2 | 1s, 2s | 每次 retry 之間檢查 `ctx.Done()`;ctx cancel → 立即 return ctx.Err()。 **401 不重試的理由**:API key 是 long-lived secret,rotate 同步是運維事件、不是瞬時抖動。401 通常意味「visionA env 與下游 env 不同步」,retry 100 次也不會自己變對,反而浪費 latency 並掩蓋運維事件。直接回 502 `*_auth_failed` 讓 SRE 看到。 ### 9.2 graceful degradation | 場景 | 處理 | |------|------| | converter 完全不可達(持續 5xx) | `502 converter_unavailable`,UI 提示「轉檔服務暫時無法使用,請稍後再試」 | | converter 回 401(API key 不同步)| `502 converter_auth_failed`,UI 同上文字;SRE 從 log 看到 auth_failed 計數異常 → 檢查 env | | 完成後 promote 失敗(converter 5xx) | job 留在 completed 狀態(FAA 上沒檔但 visionA 知道),UI 給 user 「重試 promote」按鈕(重打 promote-to-models / download) | | FAA pull 失敗 — 加到模型庫流程(5xx)| model record 不寫入;UI 提示重試 | | FAA pull 失敗 — 下載流程(5xx)| visionA backend 中轉時 503 / 502,UI 提示重試(Phase 0.8b 兩條 download path 都共用 visionA → FAA pull)| | FAA 回 401(API key 不同步)| `502 faa_auth_failed`,UI 文字「檔案存取服務暫時無法使用」;SRE 從 log 排查 | | visionA-backend 重啟 | in-memory ownership 與 promoted_key cache 全失,frontend 進 /conversion 時 `/active` lazy rebuild(§2.6.1);rebuild 不到的 job 由 converter 7 天 expire 自然兜底 | ### 9.3 同 user active job 衝突 ``` [Frontend init] → [visionA POST /api/conversion/init] ↓ visionA pre-check (ownership store) ├── 有 active job → 409 active_job_exists(不打 converter) └── 沒 → 透傳給 converter ├── converter 200 → 寫 ownership → return └── converter 409 user_has_active_job → 透傳 frontend (罕見:visionA 的 ownership 與 converter 不同步, 例如 visionA 重啟後遺失 mapping;以 converter 為準) ``` --- ## 10. 安全考量 ### 10.1 visionA-backend 是 user_id 灌入唯一點 詳見 §7。任何繞過此原則的設計都必須先過 ADR review。 ### 10.2 ~~Delegated download token TTL~~(Phase 0.8b 移除) > Phase 0.8b 不再有 delegated download token;download 走 server-side stream proxy。原段落(5 分鐘 TTL、`VISIONA_FAA_DELEGATED_TTL_SECONDS` env)刪除。 > > Phase 1+ 若量大改 ADR-015 §7 選項 B(visionA 自簽 HMAC token),那時再回設 TTL 規格。 ### 10.3 Pre-shared API key 保護(取代 Service token 保護) - `VISIONA_CONVERTER_API_KEY` / `VISIONA_FAA_API_KEY` 不可進 git(既有 `.gitignore` 含 `.env*`,配合 `!.env*.example`) - 部署用 AWS Secrets Manager / k8s Secret 注入 - log 永遠不印 key 全文;可印 `api_key_set=true` 或前 8 字元 prefix(debug 用) - 若 key 洩漏:產新 key → 雙方同步 env → restart visionA / converter(或 FAA)→ 驗證 → 拔舊 key(runbook Phase 0.9 補) - 已洩漏的 stage service client secret `RciRUyi...` 改 API key 後直接作廢,無 rotate 動作 ### 10.4 Object key 不暴露給 frontend JS - Phase 0.8b:visionA-backend 透過 server-side stream proxy 把 NEF stream 中轉回 browser,**FAA URL / object_key 都不出現在任何 frontend response** - frontend JS 對 object_key / 內部 FAA 路徑完全沒有 reference - 防快取:handler 設 `Cache-Control: no-store, no-cache, must-revalidate`,避免 browser cache NEF stream - **不需 FAA CORS**:visionA → FAA 是 server-side 同進程內 outbound HTTP call,不適用 CORS(CORS 只管 browser JS fetch / XHR) - visionA backend 是 attack surface:任何能拿到 visionA cookie session 的 attacker 都能下載自己 user_id 的 NEF — 但這本來就是 user 自己的檔,無 escalation ### 10.4b Phase 0.8 → Phase 0.8b 安全面遷移摘要 | 面向 | Phase 0.8(302 + delegated token)| Phase 0.8b(server-side proxy)| |------|----|----| | Token 結構是否存在 | 是(MC issue,5 分鐘 TTL)| 否 | | 攻擊者攔截 visionA → browser response 拿到 token | 短期可用 5 分鐘 | 結構性無 token | | Frontend XSS 影響範圍 | 短 TTL token | 無 token 可竊 | | Server compromise(visionA backend 被攻破)| 攻擊者可簽任意 MC delegated token | 攻擊者拿到 API key 後可任意打 converter / FAA | | Defense in depth | Token TTL + scope 限制 | API key + visionA OIDC 上游 user auth | | 結論 | 兩者都安全可接受;Phase 0.8b 取捨「實作簡化 + bottleneck」換「無 token in browser 的更乾淨模型」| ### 10.5 Race condition - 同 user 同時兩 tab init → 第一個成功寫 ownership / converter 接受;第二個 pre-check 通過但 converter 409 - 兩 tab 同時 promote-to-models → 第一個寫 model record 成功;第二個重複呼叫 ensurePromoted(cache hit)→ FAA pull 兩次(接受的取捨;FAA 端冪等)→ models repo 寫入時可能撞 model_id 衝突 — 改用 model_id 在 finalize 前 SELECT 檢查 - 兩 tab 同時 download → visionA backend 各自獨立 FAA pull(無 cache);兩條 stream 同時跑、兩條都成功(FAA 端冪等讀)— Phase 0.8b 可接受,量大時再加 server-side stream cache ### 10.6 DoS 防護(最小集,Phase 1 強化) - 同 user 1 個 active job 的限制本身就是 DoS 防護(user 不能 init 1000 個 job) - visionA-backend conversion endpoint 不額外 rate limit(Phase 1 補;對齊 `security.md` §4) - converter 端有 process semaphore(max 5 concurrent upload)保底 --- ## 變更影響清單 實作此 spec 會動到的檔案(給 Backend Agent 參考;Backend 自己拆任務): **Phase 0.8b 變更(在 Phase 0.8 已上的 code 上面動)**: - 砍:`visionA-backend/internal/conversion/mc_token_client.go`(~440 行整檔刪除) - 砍:`visionA-backend/internal/conversion/mc_token_client_test.go`(對應 test) - 改:`visionA-backend/internal/conversion/converter_client.go` — 移除 `tokens *MCTokenClient` 欄位,改 `apiKey string`;每個 method 內 `Authorization: Bearer ` 直接 set - 改:`visionA-backend/internal/conversion/faa_client.go` — 同上模式 - 改:`visionA-backend/internal/conversion/flow.go` — 移除 `tokens` 欄位;download path 從 `DownloadRedirectURL` 改為 `DownloadStream`(從 FAA pull stream 回給 caller) - 改:`visionA-backend/internal/conversion/conversion.go` — `Service` interface `DownloadRedirectURL` 改為 `DownloadStream(...) (io.ReadCloser, *DownloadMetadata, error)` - 改:`visionA-backend/internal/api/conversion.go` — `conversionDownloadHandler` 從 `c.Redirect(302, ...)` 改為 `io.Copy(c.Writer, stream)` + 設好 Content-Type / Content-Disposition / Cache-Control - 改:`visionA-backend/internal/config/config.go` — - `ConversionConfig`:新增 `ConverterAPIKey` / `FAAAPIKey` 兩欄位 - `ConversionConfig.Enabled()` 加入兩個 API key 非空檢查 - `OIDCConfig.ServiceClientID` / `ServiceClientSecret`:conversion 不再依賴;如其他模組未使用即從 struct 移除(檢查 grep) - `ConversionConfig.TenantID`:conversion 不再依賴;如其他模組未使用即移除 - 改:`visionA-backend/cmd/api-server/main.go` — wire conversion.Flow 時不再傳 MCTokenClient;改傳兩個 API key - 改:`.env.stage.example` / `.env.dev.example` — 移除 `VISIONA_OIDC_SERVICE_CLIENT_ID` / `_SECRET` / `VISIONA_OIDC_TENANT_ID`;新增 `VISIONA_CONVERTER_API_KEY` / `VISIONA_FAA_API_KEY` - 改:對應的 unit test / integration test — 移除 MC mock;改用 fake converter / FAA server,驗 `Authorization: Bearer ` header 正確帶上 - 不動:`internal/model/*`(schema 不變) - 不動:`internal/api/models.go`(既有 init/finalize 不動,flow.PromoteToModels 內部呼叫 helper) - 不動:OIDC user login 相關全部(`internal/oidc/`、`internal/usersession/`、`/api/auth/*` handlers) --- ## 版本記錄 | 日期 | 版本 | 變更 | |------|------|------| | 2026-04-30 | 0.1 | 初稿 — Phase 0.8 轉檔整合 TDD | | 2026-04-30 | 0.2 | Download flow 改為 server-side HTTP 302 redirect:endpoint 從 `POST /{job}/download-token` 改為 `GET /{job}/download`、Service interface `DownloadToken` → `DownloadRedirectURL`、`DownloadGrant` 改為 mc_token_client 內部 struct(不對外 JSON)、補 §3.1 handler 範例、補 §10.4 token 不過 frontend JS 的安全分析、§6 補 `/download` 錯誤回應策略 | | 2026-04-30 | 0.3 | Phase 0.8 三方交叉審閱回饋整合:§2.6.1 補「visionA-backend 重啟後 lazy rebuild ownership」(議題 #2,A4 方案);§2.6.2 補 expires_at 來源(議題 #7);§4.3.1 streaming proxy 進度語意明確化(議題 #6,採選項 A:等 converter 201 才回 200);§4.3.2 補 cancel cleanup 鏈與 best-effort cancel converter(議題 #5) | | 2026-05-11 | 0.4 | **Phase 0.8b**:服務間認證從 OAuth `client_credentials` 改為 pre-shared API key(對應 [ADR-015](./adr/adr-015-server-to-server-api-key.md))。主要變更:(1) §1 端對端 sequence 拿掉 MC node;(2) §2 砍 `mc_token_client.go` 整個檔;(3) §3 新增「服務間認證(API key)」章節(原 §5 OAuth 章節整段刪除,章節編號 4→5);(4) §4.1 `/download` handler 從 `c.Redirect(302)` 改 server-side stream proxy(Service interface `DownloadRedirectURL` → `DownloadStream`);(5) §6 錯誤碼 mapping 移除 MC 4 個 code、新增 `converter_auth_failed` / `faa_auth_failed`;(6) §9.1 retry 矩陣移除 MC 2 row、所有下游 401/403 不重試;(7) §10.2 刪除 delegated token TTL、§10.3 改為 pre-shared API key 保護、§10.4 改為 server-side stream proxy 安全模型;(8) 變更影響清單列出 backend agent 後續實作要動的 .go 檔。OIDC user login 完全不動。 | | 2026-05-15 | 0.4.1 | 修 §4.1 `/download` handler `Content-Disposition` filename 來源描述歧義(T4 Reviewer M-3)— 原註釋「filename 來自 promote 結果」可被誤讀為「FAA promote response 直接給 filename」;改為明確標示「visionA backend 在 service 層由 `defaultDownloadFilename(cj)` 從 conversion job metadata 構造(規則 `_.nef`),對齊 wireframe success card 顯示範例」、並補充「FAA 端的 object_key 是 `models//.nef` 對 user 不友善」的對比說明。純文字釐清、無實作行為變更。 |