jim800121chen 86b7175649 feat(visionA-backend): Phase 0.8b 步驟 2 — visionA → converter / FAA 改 API key 認證
對齊 ADR-015:visionA backend 從 OAuth client_credentials 改 pre-shared API key 服務間認證。Phase 0.8 stage e2e 撞 4 個 blocker(MC scope 沒註冊 / converter image 舊版 / converter 缺 env / FAA 不確定)後,使用者拍板 1:1 internal trust 用 OAuth 過度設計,改 API key。

實作(5 個增量 task,T1-T5 全綠 + Reviewer 5 輪 + final cross-task review):

T1 config + env:
- ConversionConfig 新增 ConverterAPIKey / FAAAPIKey 欄位
- Enabled() 改判定 4 欄位齊全(含兩個 API key)
- .env*.example 移除 OIDC service client / OIDC tenant / FAA delegated TTL env、新增 API key env
- TenantID / DelegatedTTLSeconds T1 暫留、T5 整批清

T2 client 改造:
- converter_client / faa_client 移除 MCTokenClient 依賴
- 直接讀 cfg.Conversion.{Converter,FAA}APIKey、set Authorization: Bearer <key>
- NewConverterClient / NewFAAClient APIKey 為空時 panic(fail-fast,對齊 ADR-015 §3.5.3 #1)
- 新增 ErrConverterAuthFailed / ErrFAAAuthFailed sentinel
- 對外 mask 成 converter_unavailable / faa_unavailable(不洩漏 401 細節,對齊 ADR-015 §3.5.3 #3)

T3 砍 mc_token_client:
- mc_token_client.go (624 行) + mc_token_client_test.go (864 行) 整檔砍
- 砍 5 個僅 mc_token_client 用的 sentinel(ErrServiceClientUnauthorized / ErrMCTokenUnavailable / ErrIDPMisconfigured / ErrIDPUnavailable / ErrDownloadTokenFailed)
- helper(truncate / silentLogger)搬到 util.go / testing_helpers_test.go

T4 flow + handler stream proxy:
- Service interface DownloadRedirectURL → DownloadStream(ctx) (io.ReadCloser, *DownloadMetadata, error)
- flow.DownloadStream 用 faa.GetFile 直接 stream NEF binary(取代 MC delegated token + 302)
- handler conversionDownloadHandler 改 io.Copy + Content-Type/Disposition/Cache-Control header
- 新增 sanitizeDownloadFilename helper 防 HTTP header injection
- 跨 package handler test (conversion_test.go) 改測 ErrFAAUnavailable + 補 *_AuthFailed 對稱 test

T5 wire 切換 + cleanup:
- main.go 砍 mcTokenClient wire、改 APIKey 注入、startup log 用 *_api_key_set boolean(不印 key)
- ConverterClient/FAAClient struct Tokens 欄位移除
- mc_token_stub.go (T4 過渡期) 整檔砍
- ConversionConfig TenantID / DelegatedTTLSeconds 欄位移除
- e2e_test.go TestConversionE2E_Download302Redirect 改寫為 TestConversionE2E_DownloadStream
- 補 MaxDownloadStreamBytes = 1 GiB size cap(io.CopyN,T4 reviewer Minor M-1)
- escapeObjectKeyPath Phase 1+ 預留 godoc + //nolint:unused(T4 reviewer Minor M-2)
- conversion.md §4.1 line 502 filename 來源描述歧義修訂(T4 reviewer Minor M-3,由 architect 處理)
- conversion_e2e_test.go 檔頭 docstring 更新(final reviewer Minor #1)

驗證:
- go build ./... exit 0
- go test -race -count=3 ./... 17 packages 全綠
- ADR-015 §6 砍除清單 100% cover(reviewer 獨立 grep 確認 MC chain / TenantID / DelegatedTTLSeconds 全清)
- ADR-015 §3.5.3 部署檢查清單 visionA 範圍 4/4 達成(fail-fast / mask / 不印 token / placeholder env)

不動:
- OIDCConfig.ServiceClientID/Secret 欄位保留(使用者拍板 backward compat)
- user login OIDC 完全不動

下一步:
- 步驟 4 — converter scheduler middleware 改 API key(jimchen 跨 repo,ADR-015 §3.5.1 Go snippet)
- 步驟 5 — FAA middleware 改 API key(warrenchen 跨 repo,ADR-015 §3.5.2 C# snippet)
- 步驟 6 — stage redeploy + e2e 完整測試

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:45:45 +08:00

1018 lines
55 KiB
Markdown
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 — 轉檔功能整合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-GoalsPhase 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 <pre-shared-api-key>` 直接打 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 jobstreaming 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<br/>Authorization: Bearer <VISIONA_CONVERTER_API_KEY><br/>(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}<br/>Authorization: Bearer <VISIONA_CONVERTER_API_KEY>
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<br/>Authorization: Bearer <VISIONA_CONVERTER_API_KEY>
C->>F: PUT /files/{key} (NEF — converter 內部認證,與 visionA 無關)
C-->>V: {target_object_key}
V->>F: GET /files/{key}<br/>Authorization: Bearer <VISIONA_FAA_API_KEY>
F->>F: middleware: ConstantTimeCompare(key, FAA_API_KEY)
F-->>V: NEF stream
V->>V: /api/models/init → /api/models/finalize<br/>(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)<br/>Authorization: Bearer <VISIONA_CONVERTER_API_KEY>
C-->>V: {target_object_key}
V->>F: GET /files/{key}<br/>Authorization: Bearer <VISIONA_FAA_API_KEY>
F-->>V: NEF stream
V-->>B: stream NEFvisionA 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-014OAuth client_credentials | Phase 0.8bAPI key|
|------|--------------------------------|------------------|
| visionA → converter 認證 | `Authorization: Bearer <MC-issued service token>` | `Authorization: Bearer <VISIONA_CONVERTER_API_KEY>` |
| visionA → FAA 認證 | `Authorization: Bearer <MC-issued service token>` | `Authorization: Bearer <VISIONA_FAA_API_KEY>` |
| download 流程 | server-side 302 redirect → browser 直連 FAA拿 MC delegated token | server-side proxyvisionA backend 中轉 stream|
| visionA → MC service token 路徑 | 啟動 lazy init MCTokenClient + cache | **完全移除** |
| converter / FAA middleware | 驗 JWKS 簽章 + 驗 scope + 驗 tenant | 比對 env 字串constant-time|
> 為什麼 download 不繼續走 302 redirectAPI key 模式下沒有 MC 簽 short-TTL delegated tokenvisionA 自己簽 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 clientpull 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 statusownership 檢查後 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.8bserver-side proxy
// 1. ownership 檢查
// 2. promote (若需要)
// 3. 從 FAA pull NEF stream
// 4. handler 直接 io.Copy stream 給 client
// 不再產生 302 redirect URLAPI 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-typeservice 內部處理 streaming。
type InitJobInput struct {
UserID string
ContentType string // 含 boundary 的原始值
Body io.Reader // request.Body
ContentLength int64
TargetChip string // "520" / "720"
// 其他 form fieldsmodel_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.8bDownloadGrant 移除(不再有 MC delegated token 換取流程)。
// Download 走 server-side proxytoken 結構性不過 frontend。
// DownloadMetadata — DownloadStream 回傳的中介資料(沿用 §2.3 既定的型別)。
// (定義在 faa_client.go避免重複
```
### 2.2 `converter_client.go`
```go
type ConverterClient struct {
baseURL string
apiKey string // Phase 0.8bpre-shared API keyVISIONA_CONVERTER_API_KEY
httpClient *http.Client
}
// CreateJobStream 把 io.Reader 當作 multipart bodycontent-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§9401/403 不 retryAPI key 錯不會自己變對)
### 2.3 `faa_client.go`
```go
type FAAClient struct {
baseURL string
apiKey string // Phase 0.8bpre-shared API keyVISIONA_FAA_API_KEY
httpClient *http.Client
}
// Download server-to-server 拉檔(給「加到模型庫」+「下載」兩個流程共用)。
// 帶 Authorization: Bearer <VISIONA_FAA_API_KEY>。
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 tokendownload 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 tokenclient_credentials grant+ 換 delegated download token。
>
> Phase 0.8b 後:
> - 服務間認證直接帶 `Authorization: Bearer <pre-shared API key>` — 不需 cache、不需 refresh、不需 retry MC
> - download flow 退回 server-side proxyvisionA backend 中轉 stream不再有 delegated token 概念
>
> **Tenant 概念取消**visionA → converter / FAA 不再帶 tenant_idconverter 端的 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` storein-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-memoryvisionA-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=<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.ConverterAPIKeyenv VISIONA_CONVERTER_API_KEY
讀 cfg.Conversion.FAAAPIKeyenv 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=<openssl rand -hex 32 產的值,與 converter 端 CONVERTER_API_KEY 對齊>
VISIONA_FAA_API_KEY=<openssl rand -hex 32 產的值,與 FAA 端 FAA_API_KEY 對齊>
# Phase 0.8b 移除:
# VISIONA_OIDC_SERVICE_CLIENT_ID
# VISIONA_OIDC_SERVICE_CLIENT_SECRET
# VISIONA_OIDC_TENANT_IDconversion 不依賴;其他模組未發現使用)
```
### 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 字元 hex256 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 | 上傳 + jobmultipart 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 jobfrontend pre-checkin-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.2Phase 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.8bserver-side stream proxy handler
> **變更**Phase 0.8ADR-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 給 clientio.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/<user>/<job>.nef` 對 user 不友善),而是 visionA backend 在 service 層
// 由 `defaultDownloadFilename(cj)` 從 conversion job metadata 構造,規則:
// `<source_filename_stem>_<target_chip_lower>.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 `<a href="..." download>` 觸發 anchor tag 只能發 GET
- GET semantically 對應拿一個資源」,符合下載這個 job 的結果語意
- 既有 OIDC AuthMiddleware 會自然處理 GET 上的 cookie session CSRF 風險沒有狀態變更promote 是冪等的
**Frontend 使用範例** Phase 0.8 一致無需改動
```html
<!-- 推薦anchor tagbrowser 自動處理 navigation + 收 attachment -->
<a href={`/api/conversion/${jobId}/download`} download>下載</a>
```
```ts
// 程式化觸發
window.location.href = `/api/conversion/${jobId}/download`;
```
**安全性面比較Phase 0.8 → Phase 0.8b**
| 面向 | Phase 0.8302 redirect + MC delegated token| Phase 0.8bserver-side stream proxy|
|------|----|----|
| Token frontend JS / URL bar | 短暫Location header 流經 browsernav 完即消失 | 結構性不存在 token 概念|
| FAA CORS | 不需要navigation 不適用 CORS| visionA same-originFAA 直連在 server-side |
| internet 流量 NEF 多次下載| 直連 FAAN× 流量算 FAA | 每次都繞 visionA backendN× 流量算 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 選項 BvisionA 自己簽 short-TTL HMAC tokenFAA middleware 多支援visionA HMAC路徑)。
---
## 5. Streaming proxy 設計upload
### 5.1 為什麼要 streaming
- 模型上限 500MBref_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 partstreaming 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 cancellationhandler 收到 client disconnect ctx.Done() goroutine 自動結束pw.Close 觸發 reader EOF
5. 不做 ContentLength forwardconverter 自己 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 完全部 bytesXHR 進度 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 等到 T2converter 201才回 200** early-return**。
| 屬性 | 選項 A converter 201 200 採用 | 選項 Bbrowser 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 |
| C4backend 偵測 converter 拒絕4xx/5xx| converter response | 立即回 frontend不需特別 cleanupconverter 自己沒建 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_idstream 還在 forward 這個區間斷線時**converter 端可能已經建立 job 但收不完 body**。實測上 converterPhase 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 testmock 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.Cancelclient 斷線)→ 連帶 cancel converter request如上 cleanup converter multer 收到 socket close 會自動 abort multipart parsing
### 5.4 Timeout
- handler 整體不設總 timeout500MB 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 401API 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 401API 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 4xxscope 沒註冊 / 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.<short-name>`前端 i18n 字典在 `visionA-frontend/messages/{zh-TW,en}.json`
**`/download` endpoint 錯誤回應策略**GET + server-side stream proxy 場景
由於 `GET /api/conversion/{job_id}/download` server-side stream proxyPhase 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 `<a href>` 觸發時若失敗 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: <sub>}) │
│ ↓ │
│ flow.go InitJob │
│ ├─ multipart streaming 重組(黑名單 client 帶的 user_id
│ ├─ mw.WriteField("user_id", <sub>) ← 唯一灌入點 │
│ └─ 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-GoalsPhase 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 後評估 |
| Webhookconverter 完成 push | 不接收 | converter Phase 2 才提供 |
| 大量批次 upload | 不支援 | 不在路線圖 |
---
## 9. 失敗模式 & retry 矩陣
### 9.1 retry 規則
> **Phase 0.8b 變更**:移除 MC 兩 row下游 401/403API 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`內部 cleanupPhase 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 secretrotate 同步是運維事件不是瞬時抖動401 通常意味visionA env 與下游 env 不同步」,retry 100 次也不會自己變對反而浪費 latency 並掩蓋運維事件直接回 502 `*_auth_failed` SRE 看到
### 9.2 graceful degradation
| 場景 | 處理 |
|------|------|
| converter 完全不可達持續 5xx | `502 converter_unavailable`UI 提示轉檔服務暫時無法使用請稍後再試 |
| converter 401API 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 / 502UI 提示重試Phase 0.8b 兩條 download path 都共用 visionA FAA pull|
| FAA 401API key 不同步| `502 faa_auth_failed`UI 文字檔案存取服務暫時無法使用」;SRE log 排查 |
| visionA-backend 重啟 | in-memory ownership promoted_key cache 全失frontend /conversion `/active` lazy rebuild(§2.6.1rebuild 不到的 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 tokendownload 走 server-side stream proxy。原段落5 分鐘 TTL、`VISIONA_FAA_DELEGATED_TTL_SECONDS` env刪除。
>
> Phase 1+ 若量大改 ADR-015 §7 選項 BvisionA 自簽 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 字元 prefixdebug
- key 洩漏產新 key 雙方同步 env restart visionA / converter FAA)→ 驗證 拔舊 keyrunbook Phase 0.9
- 已洩漏的 stage service client secret `RciRUyi...` API key 後直接作廢 rotate 動作
### 10.4 Object key 不暴露給 frontend JS
- Phase 0.8bvisionA-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不適用 CORSCORS 只管 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.8302 + delegated token| Phase 0.8bserver-side proxy|
|------|----|----|
| Token 結構是否存在 | MC issue5 分鐘 TTL| |
| 攻擊者攔截 visionA browser response 拿到 token | 短期可用 5 分鐘 | 結構性無 token |
| Frontend XSS 影響範圍 | TTL token | token 可竊 |
| Server compromisevisionA 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 成功第二個重複呼叫 ensurePromotedcache 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 limitPhase 1 對齊 `security.md` §4
- converter 端有 process semaphoremax 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 <apiKey>` 直接 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 <apiKey>` 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 redirectendpoint `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」(議題 #2A4 方案);§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 章節整段刪除章節編號 45(4) §4.1 `/download` handler `c.Redirect(302)` server-side stream proxyService 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 構造規則 `<source_filename_stem>_<target_chip_lower>.nef`對齊 wireframe success card 顯示範例」、並補充FAA 端的 object_key `models/<user>/<job>.nef` user 不友善的對比說明純文字釐清無實作行為變更 |