jim800121chen ce6a657df4 feat(visionA-backend): Phase 0.8b v0.6 對齊 — T1+T2 download 改走 converter.GetResult
對齊 ADR-016:visionA download 不再經 FAA delegated token、改用 converter GET /api/v1/jobs/{id}/result 中轉。

T1 — converter_client.go 加 GetResult method:
- 新增 GetResult(ctx, jobID) (io.ReadCloser, *DownloadMetadata, error)
- 新增 ErrResultExpired sentinel + ErrorCode("result_expired") + HTTPStatus 410 mapping
- 獨立 StreamHTTPClient (無 timeout / dial+response header timeout) 給 streaming 大檔
- doStreamWithRetry / doStreamOnce / mapGetResultError / resultRetryBackoff helpers
- parseFilenameFromContentDisposition (RFC 5987 quoted/unquoted/encoded)
- 9 個 GetResult test + 6 個 parseFilename sub-test
- Reviewer 0 Critical / 0 Major / 3 Minor (M-1/M-2/M-3 全部 T2 順手修)

T2 — flow.go + e2e 改造:
- DownloadStream / PromoteToModels 移除 f.faa.GetFile(...) 改 f.converter.GetResult(ctx, jobID)
- filename 仍由 defaultDownloadFilename(cj) 覆寫 (visionA source-of-truth)
- 8 個 flow_test 既有 test 改寫 + 2 個改名 (FAA → Converter) + 2 個 410 透傳 test 新增
- e2e mock converter 加 GET /api/v1/jobs/{id}/result endpoint + 3 helper + 6 斷言更新 (含 negative: FAA 0 命中 / converter /result ≥1 命中)
- T1 reviewer 3 個 Minor 全處理 (mapGetResultError 設計取捨 godoc / 指數退避→線性退避 / 401+403 mask 驗證)
- 保留 faa FAAClient 欄位 + FlowOpts.FAA 必填 (T3 才砍 faa_client.go 整檔)

T2 修補 (architect + backend 平行):
- M-1 conversion.go Service interface DownloadStream/PromoteToModels godoc 對齊 v0.6 (從 flow.go layer 搬上來)
- M-2 conversion.md v0.6 → v0.6.1 — §2.5 ensurePromoted cache 描述「sync.Map cache」改為「Phase 0.8 簡化 (不實作 cache)」+ 4 簡化理由 + 3 Phase 1+ 升級選項 (in-memory / DB / model store 推論);連動修改 line 169 / 300 / 1187 cross-reference
- 3 Minor + 2 Suggestion 順手做 (resultRetryBaseDelay godoc / fixture 註解過渡狀態 / e2e route table 4→5 / flow.go struct T3 預期清單 / e2e negative assertion 強化)

驗證:
- go build ./... exit 0
- go test -race -count=3 ./... 17 packages 全綠
- Reviewer 5 軸 (v0.6-t1-review + v0.6-t2-review + v0.6-t2-fix-review) 全  通過

對齊 ADR-016 §1 / conversion.md v0.6.1 §2.5 §4.1 / api-conversion.md v0.6 §4 / oidc-tdd.md v0.4 §13.1.3

下一步:
- T3 砍 faa_client.go + faa_client_test.go + 對應 ErrFAA* sentinel (B 層強制跑 / s-3/s-4/s-5 必補)
- T4 砍 ConversionConfig FAA* 欄位 + main.go wire 點 + .env*.example
- T5 main.go wire 點全切 + e2e regression 防護

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

1284 lines
90 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-016-download-via-converter.md`](./adr/adr-016-download-via-converter.md)**v0.6 新增**visionA download 改走 converter 中轉、撤回所有 visionA → MC / FAA 鏈)、[`adr/adr-015-server-to-server-api-key.md`](./adr/adr-015-server-to-server-api-key.md) **v2.1**visionA → converter API key 維持§2 visionA → FAA 整段被 ADR-016 supersede、[`adr/adr-014-conversion-integration.md`](./adr/adr-014-conversion-integration.md)**§2 download 整段被 ADR-016 supersede**§1 upload streaming / §3 半自動分流原則 / §4 模組劃分 / §6 user_id trust boundary 仍有效)、`TDD.md`、`security.md`
> **同層文件**`api/api-conversion.md`(對 frontend 的 API 規格細節)
> **作者**Architect Agent
> **狀態**Phase 0.8b v0.6 修訂 — visionA → converter 走 API key不變**visionA → FAA / MC 兩條鏈完全撤回**download 改走 converter `GET /api/v1/jobs/{id}/result` 中轉
> **最後更新**2026-05-16
---
## 索引
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 v0.6 變更**visionA 端 server-to-server 鏈路**收斂為單條**(只剩 visionA → converter
> - visionA → converter`Authorization: Bearer <VISIONA_CONVERTER_API_KEY>`ADR-015 §1不變
> - visionA → FAA / MC**完全撤回**v0.5 加回的鏈是 broken design對 MC source 全 grep 驗證後確認 MC 沒有 delegated download token endpoint
> - downloadvisionA → converter `GET /api/v1/jobs/{id}/result`converter 從 MinIO stream NEF 回 visionA再 io.CopyN 中轉給 browserADR-016
> - 詳見 §3 與 [ADR-016](./adr/adr-016-download-via-converter.md)
```mermaid
sequenceDiagram
participant B as Browser
participant V as visionA-backend
participant C as Converter (incl. MinIO)
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 3 — Promoteconverter 內部 push FAA與 visionA 無關)
B->>V: POST /api/conversion/{job_id}/promote-to-models (or download trigger)
V->>C: POST /api/v1/jobs/{id}/promote<br/>Authorization: Bearer <VISIONA_CONVERTER_API_KEY>
C->>F: PUT /files/{target_object_key} (NEF — converter 自己的 OAuth + files:upload.write scope與 visionA 完全無關)
C-->>V: {target_object_key}<br/>(NEF 同時保留在 converter MinIO 7d expires_at)
Note over B,F: Stage 3a — User 選「加到模型庫」
V->>C: GET /api/v1/jobs/{id}/result<br/>Authorization: Bearer <VISIONA_CONVERTER_API_KEY>
C-->>V: 200 NEF binary stream (from converter MinIO)
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 v0.6: server-side stream proxy from converter
B->>V: GET /api/conversion/{job_id}/download
V->>V: AuthMiddleware → user_id + ownership 檢查
V->>C: POST /api/v1/jobs/{id}/promote (若還沒 promote — 冪等)
C-->>V: {target_object_key}
V->>C: GET /api/v1/jobs/{id}/result<br/>Authorization: Bearer <VISIONA_CONVERTER_API_KEY>
C-->>V: 200 NEF binary stream + Content-Length + Content-Disposition
V-->>B: stream NEFvisionA backend io.CopyN 中轉、size cap 1 GiB
```
**critical path 說明**
- visionA-backend 在 upload / download / promote 任一階段都先做 OIDC AuthMiddleware既有+ ownership 檢查
- promote 動作是冪等的converter 端對同一 job 重複 promote 接受visionA-backend 內部以 `job_id ↔ promoted_object_key` 記錄避免重複呼叫
- 加到模型庫流程v0.6promote → **converter.GetResult 拉 NEF stream**(不是直接打 FAA`/api/models/init` 取得 model_id + presigned PUT URL → visionA-backend 自己 PUT 到 storage → `/api/models/finalize`(這條路徑要重用既有 handler 邏輯,不繞過)
- download flowv0.6promote 冪等 → **converter.GetResult 拉 NEF stream** → io.CopyN 中轉給 browsersize cap 1 GiB
- **Phase 0.8b v0.6 認證鏈說明**
- visionA 端只有一條visionA → converterAPI key、constant-time compare
- converter → FAAconverter 自己用 OAuth client_credentials + `files:upload.write` scope既有實作 `apps/task-scheduler/src/fileAccessAgent/client.js`、Phase 1 已上線;與 visionA 無關)
- visionA → MC**完全不存在**user login 的 OIDC public PKCE client 是另一條完全獨立的鏈,不在本文件範圍)
- visionA → FAA**完全不存在**
**Phase 0.8b v0.6 與 ADR-014 / ADR-015 v1.x / v2.0 的差異說明**
| 面向 | ADR-014OAuth client_credentials 兩條線)| ADR-015 v1.x兩條線都 API key| ADR-015 v2.0converter API key + FAA OAuth + delegated| **ADR-016 / v0.6visionA 端只剩 converter**|
|------|--------------------------------|------------------|---|---|
| visionA → converter 認證 | `Authorization: Bearer <MC-issued service token>` | `Authorization: Bearer <VISIONA_CONVERTER_API_KEY>` | 同 v1.x | 同 v1.x / v2.0(不變)|
| visionA → FAAwrite / metadata / delete| `Authorization: Bearer <MC service token>` + scope | `Authorization: Bearer <VISIONA_FAA_API_KEY>` | 回到 ADR-014service token | **不存在**visionA 端不再直接打 FAA|
| visionA → FAAdownload `GET /files/{key}`| `Authorization: Bearer <MC delegated download token>` | `Authorization: Bearer <VISIONA_FAA_API_KEY>` | 回到 ADR-014delegated token | **不存在**visionA 端不再直接打 FAA|
| download 從哪取 NEF | FAA `GET /files/{key}` | 同上 | 同上fictional — delegated token endpoint MC 沒有)| **converter `GET /api/v1/jobs/{id}/result`**(從 converter MinIO stream|
| download 在 browser 端流程 | 302 redirect | server-side proxy | server-side proxy同 v0.4| server-side proxy同 v0.4 / v0.5,不變)|
| visionA → MC service token 路徑 | 啟動 lazy init MCTokenClient + cache | 完全移除 | 部分復活 | **完全移除**(撤回 v2.0 復活mc_token_client.go 整檔砍除)|
| converter middleware | 驗 JWKS + scope + tenant | 比對 env 字串constant-time| 同 v1.x | 同 v1.x不變|
| FAA middleware | 驗 JWKS + scope + tenant + delegated token | API key 比對 env 字串 | 回到 ADR-014dual-auth | 不適用visionA 端不再呼叫 FAAconverter → FAA 仍走 ADR-014 OAuth 路徑、但 converter 自己管)|
> **為什麼 v0.6 把 download 拉到 converter 中轉**
>
> 1. **v2.0 設計的 delegated token 鏈是 fictional**:對 MC source`/Users/jimchen/member_center/src/MemberCenter.Api/Controllers/*.cs` 8 個 controller全 grep 確認 MC 沒有 `POST /file-access/download-tokens` endpoint、也沒有 FAA `MemberCenterDelegatedDownloadTokenValidator` assume 的 introspection endpoint。整條鏈從 2026-05-02 ADR-014 寫定起即為 broken、只是因 visionA 從未實際 e2e 跑通 download 而沒被發現
> 2. **使用者硬約束「不動 MC、不動 FAA」**:補 MC endpoint 需 MC team 設計 + onboard scope補 FAA endpoint 需 warrenchen 改公司共用 repo跨人協調 cost 高5/9 撞 scope 沒註冊已驗證)
> 3. **converter 是 jimchen 自己 repo**:加 `GET /api/v1/jobs/{id}/result` 對 coordination cost 低
> 4. **failure mode 收斂**visionA 端 fail path 從 5 條visionA → MC 4xx/5xx、MC token cache、MC delegated token issue、FAA service token validate、FAA delegated token validate收斂為 3 條converter 401 / 4xx / 5xx
> 5. **既有 stream proxy 結構保留**v0.4 / v0.5 的 server-side stream proxy + size cap + context cancellation 完全沿用,只是 stream 來源從 FAA 改 converter
>
> 詳見 [ADR-016 §決策 + 替代方案](./adr/adr-016-download-via-converter.md)。
---
## 2. 模組設計 — `internal/conversion/`
> **Phase 0.8b v0.6 變更**:撤回 v0.5「mc_token_client 部分復活、faa_client 改回 service token + delegated token」設計。download 改走 converter `GET /api/v1/jobs/{id}/result`、stream 來源從 FAA 改 converter。
```
internal/conversion/
├── conversion.go # Service interface + 對外暴露的 type
├── converter_client.go # converter scheduler API clientinit / poll / promote / GetResult — 帶 VISIONA_CONVERTER_API_KEY
├── (faa_client.go 刪除 / 改名) # v0.6visionA 端不再直接打 FAA改名為 converter_result_client.go或併入 converter_client.go唯一職責是打 converter GET result endpoint
├── (mc_token_client.go 刪除) # v0.6:撤回 v0.5「部分復活」決定visionA 端不再有任何 visionA → MC server-to-server 路徑
├── flow.go # 整體 flow 協調download / PromoteToModels 都走 converter.GetResult
├── types.go # request / response struct
└── errors.go # error code 定義
```
**Phase 0.8b v0.6 模組變更摘要(相對於 v0.5**
- ✅ converter_client.go維持 v0.5API key 直接 set header**新增 `GetResult(ctx, jobID)` method** 用於拉 NEF binary stream
- ❌ faa_client.go**整檔刪除 / 改名**v0.5 加的「`DownloadWithDelegated` + `tokens *MCTokenClient` 欄位」全部移除;唯一還需要的 stream proxy 結構併入 `converter_client.go``GetResult` 或新建 `converter_result_client.go`
- ❌ mc_token_client.go**整檔刪除**(撤回 v0.5「部分復活」commit `86b7175` 已砍掉,**維持砍除狀態**不需復活ServiceToken cache + IssueDelegatedDownload 兩個 method 都不再需要)
- ↩ flow.go撤回 v0.5「DownloadStream / PromoteToModels 走 IssueDelegatedDownload + DownloadWithDelegated」改成「呼叫 `converter.GetResult(ctx, jobID)`」;`tokens *MCTokenClient` 欄位刪除
### 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.8b v0.6server-side stream proxy + converter `GET /api/v1/jobs/{id}/result`
// 1. ownership 檢查
// 2. ensurePromoted對 converter 冪等呼叫NEF 確認已在 converter MinIO + FAA
// 3. converter.GetResult(ctx, jobID) — 直接打 converter GET result endpoint
// Authorization: Bearer <ConverterAPIKey>(同其他 converter API method
// converter response 200 + NEF binary stream + Content-Length + Content-Disposition
// 4. handler 直接 io.CopyN stream 給 clientsize cap 1 GiB
// 不產生 302 redirect URLserver-side proxy 在 T4 已實作v0.4 / v0.5 / v0.6 沿用;不退回 302
// 不再經過 visionA → MC / visionA → FAA 任何路徑v0.6 整段撤回;詳見 ADR-016
// Phase 1+ 量大時可評估方案 DvisionA 自簽 HMAC + FAA 加第三條 auth path + 回 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.8b v0.6:撤回 v0.5 DownloadGrant struct不再需要 delegated token 持有結構)。
// visionA → converter 一條鏈、沒有 token issue 過程flow.go 直接呼叫 converter.GetResult
// 拿 stream + DownloadMetadata 即可。
// DownloadMetadata — DownloadStream 回傳的中介資料(沿用 §2.3 既定的型別)。
// (定義在 converter_result_client.go / converter_client.go避免重複
```
### 2.2 `converter_client.go`v2.0:維持 v1.x — API key 不變)
```go
type ConverterClient struct {
baseURL string
apiKey string // Phase 0.8b v1.x+v2.0pre-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)
```
每個方法內部v1.x + v2.0 簡化):
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`~~v0.6:整檔刪除 / 改名為 `converter_result_client.go`
> **v0.6 撤回 v0.5 設計**v0.5 規劃的「`tokens *MCTokenClient` 欄位 + `DownloadWithDelegated(ctx, delegatedToken, objectKey)`」整段刪除。visionA 端不再有 `FAAClient` 概念、不再有任何 `internal/conversion/` 內對 FAA 的呼叫。
>
> 取而代之:原 `faa_client.go` 的 stream proxy 結構(`io.ReadCloser` + `DownloadMetadata`)改名為 `converter_result_client.go`(或併入 `converter_client.go` 作為其 method唯一職責是打 converter `GET /api/v1/jobs/{id}/result` 拉 NEF binary stream。
```go
// 新檔案(或併入 converter_client.go
// GetResult — 對 converter GET /api/v1/jobs/{id}/result 拉 NEF binary stream。
// 帶 Authorization: Bearer <ConverterAPIKey>(同其他 converter API method
// converter response 200 + Content-Length + Content-Disposition + body stream。
// 對應 converter 端 endpoint spec 詳見 ADR-016 §1。
func (c *ConverterClient) GetResult(ctx context.Context, jobID string) (io.ReadCloser, *DownloadMetadata, error)
type DownloadMetadata struct {
SizeBytes int64 // 從 converter response Content-Length 解析
ContentType string // 固定 application/octet-stream
Filename string // 從 converter response Content-Disposition 解析visionA 端可選擇覆寫(見 §4.1 註)
}
```
**v0.6 重要設計約束**
- 「加到模型庫」flow 與「下載」flow **共用同一個 `GetResult`**——兩條 path 都從 converter MinIO 拉 NEF。visionA 端完全不需理解 FAA 的存在。
- size capvisionA backend handler 端用 `io.CopyN(w, stream, 1 GiB)` 保護converter 端不另外設 capconverter MinIO 容量為準)。
- failure mapping
- converter 401 → `converter_auth_failed`運維事件API key 不同步)
- converter 404 → `result_not_found`job_id 不存在 / 已過 7 天 expires_at
- converter 410 → `result_expired`job completed 但 NEF 已被 converter MinIO GC
- converter 409 → `job_not_completed`job 尚未 completed理論上 visionA 端 ensurePromoted 前已確認、不應發生)
- converter 5xx / network → `converter_unavailable`
### 2.4 ~~`mc_token_client.go`~~v0.6:整檔刪除、撤回 v0.5 部分復活)
> **v0.6 撤回 v0.5「部分復活」決定**。對 MC source 全 grep 驗證後確認 MC **沒有** `POST /file-access/download-tokens` endpoint、也**沒有** FAA `IDelegatedDownloadTokenValidator` assume 的 introspection endpoint——v0.5 規劃的 `IssueDelegatedDownload` method 是 fictional、永遠 issue 不到 delegated token。
>
> **v0.6 處理**mc_token_client.go 在 commit `86b7175` 已被砍除,**維持砍除狀態**、不需復活。對應的 test、config 欄位(`OIDCConfig.ServiceClientID/Secret`、`ConversionConfig.TenantID`、env`VISIONA_OIDC_SERVICE_CLIENT_ID/_SECRET`、`VISIONA_OIDC_TENANT_ID`、`VISIONA_FAA_BASE_URL`)全部撤回。
>
> visionA 端**不再有任何 visionA → MC server-to-server 路徑**。user login 的 OIDCPKCE / cookie session / JWKS 驗 id_token是另一條完全獨立的鏈、不在本文件範圍詳見 `oidc-tdd.md`)。
### 2.5 `flow.go` — 流程協調
```go
type Flow struct {
converter *ConverterClient // 含 init / poll / promote / GetResult method
// (faa *FAAClient — v0.6 刪除)
// (tokens *MCTokenClient — v0.6 刪除,撤回 v0.5 復活)
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
}
// DownloadStreamv0.6 流程):
// 1. ownership.Check(userID, jobID)
// 2. _, _ := flow.ensurePromoted(ctx, jobID) // 對 converter 冪等呼叫converter 端 idempotent確保 NEF 已在 MinIO + FAA
// 3. stream, meta, _ := flow.converter.GetResult(ctx, jobID)
// 內部GET {ConverterBaseURL}/api/v1/jobs/{jobID}/result
// Authorization: Bearer <ConverterAPIKey>
// converter response 200 + NEF binary stream + Content-Length + Content-Disposition
// 4. meta.Filename = defaultDownloadFilename(cj) // visionA 自行構造§4.1 註)覆寫 converter 給的 filename
// 5. return stream, meta, nil
//
// PromoteToModels 內部v0.6 修正):
// 1. ownership.Check(userID, jobID)
// 2. _, _ := flow.ensurePromoted(ctx, jobID)
// 3. reader, meta, _ := flow.converter.GetResult(ctx, jobID)
// ← v0.6:與 DownloadStream 共用同一 method、不需 delegated token
// 4. modelID, putURL, _ := callModelsInit(...) // 直接呼叫 internal/api 同 package 既有 helper
// 5. PUT 到 storage或直接 io.Copy 到 storage.Put
// 6. callModelsFinalize(...)
// 7. 在 model record 補 Source="converted" + SourceJobID=jobID
// 8. 回 modelID
// 主要 method 對應 Service interfacev0.6 流程已在 struct 上方註解寫出;此處保留結構說明)
```
**冪等性Phase 0.8 簡化)**`flow.ensurePromoted(jobID)` **每次呼叫都直接打 converter `POST /api/v1/jobs/{id}/promote`、不在 visionA 端 cache**`target_object_key` 由 visionA 端 `buildTargetObjectKey(userID, jobID)` 構造(規則 `models/<user>/<job>.nef`固定、promote response 回的 `target_object_key` 用於 log / debugvisionA 不再用它直接打 FAAconverter `GetResult` 內部知道哪個 object 屬於哪個 jobID
**為什麼 Phase 0.8 不實作 cache**4 個理由):
1. **converter 端 promote 本身就 idempotent**ADR-016 §1.6):同 `job_id` + 同 `target_object_key` 重複呼叫 → converter 內部已 ensure同步保留 MinIO 物件 + PUT FAA 兩端都是 set semantic、無副作用、只多 1 個 network round-trip。
2. **cache 只省 round-trip、不省 promote 本身的 work**converter 端真正的成本在 MinIO + FAA PUTvisionA 端 sync.Map cache hit 只省「visionA → converter」這 1 跳(~10-50ms LAN、不影響 user-perceived latency 大頭。
3. **visionA restart 後 cache 清空、first request 仍重 promote、沒長期收益**MVP 部署頻繁、cache hit rate 低;且既有 `modelStore.FindBySourceJobID` 在 promote-to-models 已有「真正的」冪等檢查(基於 DB record——download 路徑沒這層、但 ensurePromoted 對 converter 重複呼叫等價於那個 check。
4. **Phase 0.8 MVP 流量小、沒觀察到 promote 流量問題**:每個 user 一個 active job、download 觸發頻率「每 job 1-N 次N 通常 ≤ 3user 看到 success card 後可能下載 + 同時 promote-to-models」。
**Phase 1+ 升級路徑**:如果 production 觀察到 promote 流量問題converter promote endpoint p99 飆高 / FAA PUT 對 converter 端是瓶頸)、再加 cache 不遲。三個選項:
| 選項 | 描述 | 優點 | 缺點 |
|------|------|------|------|
| A | visionA 端 in-memory `sync.Map[jobID]bool` cache | 實作最簡10 行)、無外部依賴 | restart 後失效、多 instance 部署時各自 cache不共享 |
| B | DB / Redis 持久化 promoted 狀態(新增 `conversion_jobs.promoted_at` 欄位或 Redis SET | restart 友善、多 instance 共享 | 多一個外部依賴、寫入路徑多一跳 |
| C | 從既有 `model store``source_job_id` 推論 promoted | 不需新欄位 / 新結構、復用 promote-to-models 的 source-of-truth | 只 cover「已 promote-to-models」的 case、純 download 未 promote-to-models 的 job 仍每次重 promotedownload 路徑覆蓋率不完整) |
選哪個視 production 觀測結果決定:流量集中在 download-only flow → 選 A 或 B流量集中在 promote-to-models flow → C 已自然 cover。
**為什麼移除 delegated token 邏輯**v0.5 規劃「IssueDelegatedDownload + DownloadWithDelegated」依賴 MC 有對應 endpoint 才 work對 MC source 驗證後確認該 endpoint **從未存在**——v0.5 的設計是 fictional、永遠跑不通。v0.6 把整條鏈撤回、改走 converter 中轉converter 自己用 OAuth 推 FAA、後續 download 從 converter MinIO 拉)。詳見 [ADR-016](./adr/adr-016-download-via-converter.md)。
### 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. 服務間認證v0.6visionA 端只剩單條 visionA → converter
> **Phase 0.8b v0.6 變更**:本節重寫;對應 [ADR-016](./adr/adr-016-download-via-converter.md)。
>
> **歷史**
> - **v0.4 (2026-05-11)**:本節將兩條線都改為 pre-shared API key撤回 ADR-014 §5 OAuth 設計)
> - **v0.5 (2026-05-16)**:本節再修,只 converter 線維持 API keyFAA 線回到 ADR-014 §2 原設計service token + delegated download token
> - **v0.6 (2026-05-16)**:對 MC source 驗證後確認 v0.5 設計的 delegated token 鏈是 fictionalMC 沒有對應 endpoint本節再次整段改寫——visionA → FAA / MC 鏈**完全撤回**download 改走 converter `GET /api/v1/jobs/{id}/result`visionA 端只剩 visionA → converter 一條 server-to-server 認證鏈
### 3.1 visionA → converterAPI key— v1.x 設計 + v2.0 / v2.1 / v0.6 維持
對應 [ADR-015 §1](./adr/adr-015-server-to-server-api-key.md)v1.x / v2.0 / v2.1 都不變)。**v0.6 新增**同一把 API key 也用於新增的 `GET /api/v1/jobs/{id}/result` endpointADR-016 §1)。
#### 3.1.1 取得流程
```
visionA-backend 啟動
讀 cfg.Conversion.ConverterAPIKeyenv VISIONA_CONVERTER_API_KEY
[轉檔請求進來]
converter_client 發 request 時:
req.Header.Set("Authorization", "Bearer "+apiKey)
converter middleware
- parse Authorization header → 取 token
- subtle.ConstantTimeCompare(token, envKey)
- match → 放行mismatch → 401 + log不附原因
```
**沒有 token cache、沒有 refresh、沒有 retry MC、沒有 scope 驗證**整條鏈路是visionA converter一步
#### 3.1.2 啟動時驗證
api-server 啟動時 log 一行**不可 log key 本身**
```
[INFO] conversion config: converter=http://192.168.0.130:9501 (api_key_set=true)
```
#### 3.1.3 Key 產生 / 部署 / Rotate
| 項目 | 規格 |
|------|------|
| 長度 | 64 字元 hex256 bit `openssl rand -hex 32` |
| 環境隔離 | dev / stage / prod 各自獨立的 key**不重用** |
| 儲存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.2 ~~visionA → FAA~~v0.6 整段撤回;改走 ADR-016 converter 中轉)
> **v0.6 整段撤回說明**v0.5 在本節描述的「visionA → MCissue service token + delegated download token→ FAA」鏈路**是 fictional**——對 MC source 全 grep 驗證後確認 MC 沒有 `POST /file-access/download-tokens` endpoint、也沒有 FAA `IDelegatedDownloadTokenValidator` assume 的 introspection endpoint。
>
> **v0.6 採用**visionA 端不再有任何 visionA → FAA / visionA → MC server-to-server 路徑。download / 加到模型庫 兩條 path 的 NEF 取得改走 visionA → converter `GET /api/v1/jobs/{id}/result`(用同一把 `VISIONA_CONVERTER_API_KEY`converter 從自己的 MinIO stream NEF 回 visionA。
>
> 詳見 [ADR-016 §1 / §2](./adr/adr-016-download-via-converter.md)。
>
> **v0.5 本節原內容**FAA dual-auth 設計、MC service client 配置、tenant_id claim 驗證等)**僅作歷史保留**——對應的 source code 已於 commit `86b7175` 移除faa_client.go / mc_token_client.go 整檔砍除v0.5 規劃的「mc_token_client 部分復活」決定也撤回(不需復活)。
#### ~~3.2.1 FAA dual-auth 設計~~v0.6 撤回 — 僅作歷史保留)
~~對應 [ADR-015 v2.0 §2](./adr/adr-015-server-to-server-api-key.md)~~v0.6 整段撤回)。**v0.6 設計請看 [ADR-016](./adr/adr-016-download-via-converter.md)**。
#### ~~3.2.1 FAA dual-auth 設計~~v0.6 撤回 — 僅作歷史保留說明為什麼 v0.5 路徑走不通)
FAA `/Users/jimchen/file_access_agent/src/FileAccessAgent.Api/Program.cs` 既有設計**Phase 0.8 / v2.0 都不動 FAA repo**
| FAA endpoint | line range | auth 機制 | visionA token type |
|--------------|-----------|----------|----------------------|
| `GET /files/metadata/{**objectKey}` | 80-111 | `.RequireAuthorization()` + `EnsureJwtScopeAndTenant`scope `files:metadata.read` + tenant_id | MC service token |
| `HEAD /files/{**objectKey}` | 113-148 | 同上 | MC service token |
| `PUT /files/{**objectKey}` | 150-182 | `.RequireAuthorization()` + scope `files:upload.write` + tenant | MC service token |
| **`GET /files/{**objectKey}`****下載**| **184-254** | **無 `.RequireAuthorization()`;用 `IDelegatedDownloadTokenValidator.ValidateAsync(...)` 驗active + tenant_id + object_key + method**| **MC delegated download token** |
| `DELETE /files/{**objectKey}` | 256-287 | `.RequireAuthorization()` + scope `files:delete` + tenant | MC service token |
**FAA `GET /files/{key}` 不接 service token必須用 delegated download token**
visionA Phase 0.8 flow 只用 `GET /files/{key}`加到模型庫 pull + 下載 stream proxy 兩條 path 都打這個 endpoint所以**兩條 path 都走 delegated download token 路徑**。
其他 FAA endpointPUT / metadata / HEAD / DELETE保留給 Phase 1+ 擴充用 visionA 主動清理 FAA 上的孤兒檔到時候才走 service token 路徑
#### ~~3.2.2 visionA 端流程~~v0.6 撤回 — 整段不再執行)
```
visionA-backend 啟動
讀 cfg.OIDC.ServiceClientID / ServiceClientSecretenv VISIONA_OIDC_SERVICE_CLIENT_ID / _SECRET
讀 cfg.OIDC.IssuerURLenv VISIONA_OIDC_ISSUER_URL — 同 user login 的 issuer
讀 cfg.Conversion.TenantIDenv VISIONA_OIDC_TENANT_ID
讀 cfg.Conversion.FAABaseURLenv VISIONA_FAA_BASE_URL
[Download 請求進來]
flow.DownloadStream / flow.PromoteToModels
mcTokenClient.ServiceToken(ctx)
cache hit
Yes → return cached tokenexp - 15s 內)
No → POST {issuer}/oauth/token
grant_type=client_credentials
client_id=<ServiceClientID>
client_secret=<ServiceClientSecret>
scope=files:upload.write files:metadata.read files:delete files:download.delegate
→ cache + return token
mcTokenClient.IssueDelegatedDownload(ctx, objectKey, "GET", 5*time.Minute)
POST {issuer}/file-access/download-tokens
Authorization: Bearer <service-token>
Body: { object_key, method, ttl_seconds, tenant_id }
→ DownloadGrant { token, expires_at, object_key, method }
faaClient.DownloadWithDelegated(ctx, grant.Token, objectKey)
GET {faaBaseURL}/files/{objectKey}
Authorization: Bearer <delegated-token>
→ io.ReadCloser + metadata
flow.go 內把 stream + filename + size 包成 DownloadMetadata 回 handler
```
#### ~~3.2.3 Config 對齊~~v0.6 撤回 — visionA 端不需 ServiceClient* / TenantID / FAABaseURL
`visionA-backend/internal/config/config.go` 變更v2.0 修訂
```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"` // v1.0 新增v2.0 維持
// FAAAPIKey 撤回v1.0 加的v2.0 移除)
TenantID string `env:"VISIONA_OIDC_TENANT_ID"` // v1.x 廢棄v2.0 重新啟用FAA 線需要)
}
// OIDCConfig.ServiceClientID / ServiceClientSecret 兩欄位 v2.0 重新啟用v1.x 廢棄)
type OIDCConfig struct {
// ... user login 相關欄位(不變)...
ServiceClientID string `env:"VISIONA_OIDC_SERVICE_CLIENT_ID"`
ServiceClientSecret string `env:"VISIONA_OIDC_SERVICE_CLIENT_SECRET"`
}
func (c ConversionConfig) Enabled() bool {
return c.ConverterBaseURL != "" &&
c.FAABaseURL != "" &&
c.ConverterAPIKey != "" &&
c.TenantID != ""
// OIDCConfig.ServiceClientID / Secret 是否設好由 main.go 啟動時組合判斷
}
```
新增的 stage envv2.0 修訂
```bash
# .env.stage
# === user login不變沿用 oidc-tdd.md §13.1.1===
VISIONA_OIDC_ISSUER_URL=https://stage-9527.innovedus.com:7850/
# ... user login 其他 env ...
# === Phase 0.8b v2.0 — converter 線 API key ===
VISIONA_CONVERTER_BASE_URL=http://192.168.0.130:9501
VISIONA_CONVERTER_API_KEY=<openssl rand -hex 32 產的值,與 converter 端 CONVERTER_API_KEY 對齊>
# === Phase 0.8b v2.0 — FAA 線 MC service token + delegated download token ===
VISIONA_FAA_BASE_URL=https://stage-9527.innovedus.com:5081
VISIONA_OIDC_SERVICE_CLIENT_ID=4242ba63099d4f318dd3f143d27ef4c5
VISIONA_OIDC_SERVICE_CLIENT_SECRET=<see stage host .env.stage; 不進 git / 文件>
VISIONA_OIDC_TENANT_ID=732270c0-449c-489c-bfad-321e9bf89b3d
# Service scopes由 MC service client 註冊時對應):
# files:upload.write files:metadata.read files:delete files:download.delegate
# === Phase 0.8b v2.0 撤回 ===
# VISIONA_FAA_API_KEY 撤回v1.0 加的v2.0 移除)
```
> ⚠️ **secret 絕不寫進 git / 文件**:上方 `VISIONA_OIDC_SERVICE_CLIENT_SECRET` 真實值僅由使用者放 stage host 的 `.env.stage` 與部署 secret store文件 / git 一律用 placeholder。
#### ~~3.2.4 啟動時驗證~~v0.6 撤回 — visionA 端啟動不再 log FAA s2s config
api-server 啟動時 log 一行**不可 log secret / token**
```
[INFO] FAA s2s config: faa=https://stage-9527.innovedus.com:5081 service_client_set=true tenant_id_set=true
```
OIDC ServiceClientID / Secret / Conversion TenantID / FAABaseURL 任一缺失 conversion 模組 disabled Phase 0.8partial deploy相容性)。
#### ~~3.2.5 MC scope 與 FAA endpoint 對應~~v0.6 撤回 — visionA 端不再 issue MC service tokenscope 配置由 converter 自己 / 不影響 visionA
確認 MC service client `4242ba63099d4f318dd3f143d27ef4c5` 註冊時對應的 4 scope 完整覆蓋 FAA endpoint
| FAA endpoint | 需要的 scope service token delegated token | service client 是否備好 |
|--------------|--------------------------------------------------|---------------------|
| `PUT /files/...` | `files:upload.write` | |
| `GET /files/metadata/...` + `HEAD` | `files:metadata.read` | |
| `DELETE /files/...` | `files:delete` | |
| `GET /files/{key}` 下載 | `files:download.delegate`service token 用來向 MC delegated download tokenFAA 端最終驗的是 delegated token 本身不是 scope| |
Phase 0.8b v2.0 範圍內 visionA 只觸發`GET /files/{key}` 下載這條 path加到模型庫 + 下載所以 stage e2e 主要驗證 `files:download.delegate` 走通其他 3 scope Phase 1+ 預留
**待 verify合規性追蹤**stage redeploy 前實測 `POST /oauth/token` 拿到含 4 scope access_token並用該 token MC `POST /file-access/download-tokens` 成功 issue delegated token
### 3.3 Trust boundary 對齊v0.6
- **machine authvisionA 端唯一一條**visionA converter pre-shared API keyinit / poll / promote / **GetResult**
- **machine auth不在 visionA 範圍**converter FAA OAuth client_credentials + `files:upload.write` scopeconverter 自己管 `apps/task-scheduler/src/fileAccessAgent/client.js`Phase 1 已上線
- **user auth**browser visionA OIDC cookie session既有未變
- visionA 是橋樑 OIDC sub 解出 user_id 透過 multipart body 灌進對 converter 的請求init download 路徑而言visionA 端的 API key 證明caller visionA」、ownership store 確認 user_id jobID 的綁定converter 不重複驗 user-job 關係因為 visionA 已驗
詳見 §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 | 下載」— v0.6server-side stream proxyvisionA backend 中轉 NEF binarysource converter `GET /api/v1/jobs/{id}/result` 使用 `VISIONA_CONVERTER_API_KEY`不再經 FAA / MC |
| `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.8b v0.6server-side stream proxy from converter
> **演進**
> - **Phase 0.8ADR-014 v1.1**`c.Redirect(302, FAA_URL_with_delegated_token)`
> - **Phase 0.8b v0.4 (ADR-015 v1.x)**:改為 server-side stream proxytoken 來源用 visionA API keyv0.5 撤回)
> - **Phase 0.8b v0.5 (ADR-015 v2.0)**server-side stream proxy 保留token 來源改回 MC delegated download token**但對 MC source 驗證後確認此設計 fictional、未實際 e2e 跑通**
> - **Phase 0.8b v0.6 (ADR-016)**server-side stream proxy 保留stream 來源**從 FAA 改 converter `GET /api/v1/jobs/{id}/result`**visionA 端不再經 MC / FAA
```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 內部完成v0.6 流程):
// 1. ownership 檢查visionA in-memory store
// 2. ensurePromoted對 converter 冪等 promote確保 converter MinIO 內有 NEF
// 3. converter.GetResult(ctx, jobID)
// GET {ConverterBaseURL}/api/v1/jobs/{jobID}/result
// Authorization: Bearer <ConverterAPIKey>
// → 200 NEF binary stream + Content-Length + Content-Disposition
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.CopyN不暫存 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 **不是** converter 直接給的 raw object_keyconverter 端的
// object_key 是 `models/<user>/<job>.nef` 對 user 不友善converter response 的
// Content-Disposition 雖含 filename 建議值,但 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.CopyN(c.Writer, stream, sizeCap) 帶 1 GiB 上限保護 visionA backend 不被超大檔吃記憶體
// sizeCap = 1 << 301 GiB— 對 Phase 0.8 NEF通常 < 100MB寬鬆但有上限
io.CopyN(c.Writer, stream, 1<<30)
}
}
```
**為什麼仍用 GET**
- frontend `<a href="..." download>` 觸發 anchor tag 只能發 GET
- GET semantically 對應拿一個資源」,符合下載這個 job 的結果語意
- 既有 OIDC AuthMiddleware 會自然處理 GET 上的 cookie session CSRF 風險沒有狀態變更promote 是冪等的
**Frontend 使用範例** Phase 0.8 / v0.4 一致無需改動
```html
<!-- 推薦anchor tagbrowser 自動處理 navigation + 收 attachment -->
<a href={`/api/conversion/${jobId}/download`} download>下載</a>
```
```ts
// 程式化觸發
window.location.href = `/api/conversion/${jobId}/download`;
```
**安全性面比較Phase 0.8 → v0.4 → v0.5 → v0.6**
| 面向 | Phase 0.8302 + MC delegated token| v0.4server-side proxy + visionA API key 撤回| v0.5server-side proxy + MC delegated token**fictional 從未跑通**| **v0.6server-side proxy + visionA → converter API key** |
|------|----|----|---|---|
| Token frontend JS / URL bar | 短暫Location header 流經 browser | 結構性不存在 | 結構性不存在 | 結構性不存在API key 只在 server-side 流動|
| FAA CORS | 不需要 | 不需要 | 不需要 | 不需要visionA 端不直接打 FAACORS 完全不適用 |
| internet 流量 NEF 多次下載| 直連 FAA | 每次繞 visionA | 每次繞 visionA | 每次繞 visionA v0.4 / v0.5未改變 source FAA converter MinIO|
| visionA backend 是否變 streaming bottleneck | 不是 | | | v0.4 / v0.5Phase 0.8b MVP 接受Phase 1+ 升級見 ADR-016 後果 §負面影響|
| 認證鏈複雜度visionA 下游| MC service token + MC delegated token | 一把 API key | MC service token + MC delegated tokenfictional| 一把 API key v0.4但這次是真的 work|
| Token TTL | 5 minMC | ∞(API key long-lived| 5 minMC endpoint 不存在所以 issue 不到| ∞(API key long-livedrotate by runbook|
| Token 洩漏的 blast radius | 5 min 內可下載該 object_key | 永遠可打 FAA 任何 endpoint | | 永遠可打 converter 任何 endpointjimchen 自己管 rotateconverter 不存其他 user 資料攻擊面限於 converter 自己|
**為什麼 v0.6 仍對齊 v0.4 / v0.5 的 server-side proxy 而非退回 302** §1 整體 flow 變更說明
**Phase 1+ 升級路徑**如量大需回 302 redirect 模式 browser 直連 converter FAA有兩個方向
- 方向 Aconverter Phase 2converter 補上 `POST /api/v1/jobs/:id/download-tokens`既有預留 501 browser 直連 converterADR-016 與此路徑相容
- 方向 BFAA HMACvisionA 自己簽 short-TTL HMAC token + FAA middleware 加第三條 auth path ADR-015 v2.0 §7 選項 B但需要 warrenchen 改公司共用 FAA repo
---
## 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 v0.6 變更**:撤回 v0.5「回收 MC 兩個 code」決定。visionA 端不再有 MC / FAA 直接呼叫、`mc_token_unavailable` / `download_token_failed` 兩個 code 移除。新增 converter result endpoint 的 `result_not_found` / `result_expired` 兩個 code。
| Converter 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`MinIO 不可達| `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|
| **converter 404 `result_not_found`**v0.6 新增`GET /api/v1/jobs/{id}/result` job 不存在| `not_found` | 404 | `conversion.error.not_found` | 轉檔任務不存在 |
| **converter 410 `result_expired`**v0.6 新增job completed NEF 已被 converter MinIO GC 7 expires_at| `result_expired` | 410 | `conversion.error.result_expired` | 轉檔結果已過期請重新轉檔 |
| job 不屬於當前 uservisionA ownership 檢查| `forbidden` | 403 | `conversion.error.forbidden` | 你無權存取此轉檔任務 |
| job_id 不存在visionA ownership store | `not_found` | 404 | `conversion.error.not_found` | 轉檔任務不存在 |
| job 還沒 completed | `job_not_completed` | 409 | `conversion.error.not_completed` | 任務尚未完成請等轉檔完成再下載 |
**v0.6 變更摘要**相對於 v0.5
| code | v0.5 狀態 | v0.6 狀態 | 說明 |
|------|---------|---------|------|
| `converter_auth_failed` | 維持 | **維持** | converter API key 仍使用init / poll / promote / GetResult 共用同一把|
| `converter_unavailable` | 維持 | **維持** | converter 5xx / network |
| `result_not_found` | | **新增** | converter `GET /api/v1/jobs/{id}/result` 404 |
| `result_expired` | | **新增** | converter `GET /api/v1/jobs/{id}/result` 410job 過期|
| `faa_unavailable` | 使用中 | **撤回** | visionA 端不再直接打 FAA |
| `mc_token_unavailable` | 回收 | **撤回** | visionA 端不再打 MC |
| `download_token_failed` | 回收 | **撤回** | visionA 端不再 issue delegated token |
| ~~`faa_auth_failed`~~ | v0.5 撤回 | 維持撤回 | v0.4 短暫存在|
| ~~`idp_misconfigured`~~ / ~~`idp_unavailable`~~ | 維持移除 | 維持移除 | |
下游錯誤對待原則v0.6
- **converter 401**API key 不對齊)→ `converter_auth_failed`內部 log reason SRE
- **converter 404**job_id 不存在 / 已被 GC)→ `result_not_found`frontend 顯示轉檔任務不存在
- **converter 410**job completed NEF 已過 7 expires_at GC)→ `result_expired`frontend 顯示轉檔結果已過期請重新轉檔並提供重新轉檔 CTA
- **converter 4xx 其他** 透傳 + log
- **converter 5xx / network** `converter_unavailable`retry 後仍失敗才回 frontend
- frontend 不暴露內部細節API key 不對 / converter MinIO 問題 / 其他下游差異)—— 統一 user-friendly 文字 `轉檔服務暫時無法使用` 410 `result_expired` 給更精確的過期訊息
- 401 / 4xx retry §9)—— 都是運維事件或 user 端問題需人工介入 / 重新轉檔
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 v0.6 變更**:撤回 v0.5「回收 MC 兩 row」決定。visionA 端不再有 MC / FAA 直接呼叫、相關 row 全部移除。新增 converter `GET /jobs/{id}/result` row。
| 操作 | 401 / 403 | 4xx 其他 | 5xx | network / timeout | max retry | 退避 |
|------|-----------|---------|-----|------------------|-----------|------|
| Converter `POST /jobs` | **不重試**converter_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 |
| **Converter `GET /jobs/{id}/result`**v0.6 新增下載 NEF stream| **不重試**401 converter_auth_failed404 result_not_found410 result_expired| 透傳 | retry | retry | 2 | 1s, 2s |
每次 retry 之間檢查 `ctx.Done()`ctx cancel 立即 return ctx.Err()。
**401 / 403 不重試的理由**
- **converter 401**API key long-lived secretrotate 同步是運維事件不是瞬時抖動401 通常意味visionA env 與下游 env 不同步」,retry 100 次也不會自己變對直接回 502 `converter_auth_failed` SRE 看到
- **converter 404 / 410**job_id 不存在 GC或結果過期 user 端狀態問題retry 不會讓 NEF 重新出現
> **v0.5 §9.1.1「MC service token cache miss / FAA delegated token 過期」整段撤回**v0.6 visionA 端不再有 MC / FAA 直接互動失敗恢復路徑簡化為「converter 5xx retry max 2 次」單條規則)。
### 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 |
| converter `GET /jobs/{id}/result` 404 `result_not_found`v0.6 新增| `404 result_not_found`UI 顯示轉檔任務不存在 |
| converter `GET /jobs/{id}/result` 410 `result_expired`v0.6 新增| `410 result_expired`UI 顯示轉檔結果已過期請重新轉檔並提供重新轉檔 CTA |
| converter `GET /jobs/{id}/result` 5xxconverter MinIO 故障 / converter 自身 down| `502 converter_unavailable`UI 提示重試SRE log 5xx body 排查 |
| visionA-backend 重啟 | in-memory ownership promoted_key cache 全失frontend /conversion `/active` lazy rebuild(§2.6.1rebuild 不到的 job converter 7 expire 自然兜底**v0.6 不再有 MC service token cache已刪除cold start 沒有對應 latency**|
> **v0.5「FAA pull 失敗 / FAA 401 / 403 / MC token 失敗 / MC delegated token 失敗」整段 row 撤回**v0.6 visionA 端不再有對應路徑)。
### 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~~v0.6 整段撤回 — visionA 端不再有 delegated token
> v0.4 移除API key 模式下無 delegated tokenv0.5 撤回 v0.4 並回收FAA 線回到 MC delegated download token**v0.6 再次整段撤回**——對 MC source 驗證後確認 MC 沒有 issue delegated token 的 endpointv0.5 設計是 fictional。visionA 端 v0.6 起完全沒有 delegated token 概念。
### 10.3 Pre-shared API key 保護v0.6 仍縮限至 converter
- `VISIONA_CONVERTER_API_KEY` 不可進 git既有 `.gitignore` `.env*`配合 `!.env*.example`
- 部署用 AWS Secrets Manager / k8s Secret 注入
- log 永遠不印 key 全文可印 `api_key_set=true` 或前 8 字元 prefixdebug
- key 洩漏產新 key visionA + converter 同步 env restart 驗證 拔舊 keyrunbook Phase 0.9
- v0.4 加的 `VISIONA_FAA_API_KEY` v0.5 / v0.6 維持撤回
- **v0.6 新增**同一把 `VISIONA_CONVERTER_API_KEY` 也用於 download 路徑converter `GET /api/v1/jobs/{id}/result`不需新增 secret
### ~~10.4 MC Service Token + Delegated Download Token 保護~~v0.6 整段撤回 — visionA 端不再有 MC service token / delegated token
> v0.5 新增本節給 FAA 線;**v0.6 整段撤回**——visionA 端不再有 `VISIONA_OIDC_SERVICE_CLIENT_ID` / `_SECRET` / `VISIONA_OIDC_TENANT_ID` 三個 env不再 issue MC service token、不再 issue delegated download token。user login 的 OIDCpublic PKCE是另一條完全獨立的鏈、不在本節範圍詳見 `oidc-tdd.md`)。
### 10.5 Object key 不暴露給 frontend JS
- Phase 0.8b v0.6visionA-backend 透過 server-side stream proxy NEF stream 中轉回 browser**converter MinIO object_key / converter API key 都不出現在任何 frontend response**
- frontend JS object_key / 內部 converter 路徑完全沒有 reference
- 防快取handler `Cache-Control: no-store, no-cache, must-revalidate`避免 browser cache NEF stream
- **不需 CORSconverter 端或 FAA **visionA converter server-side 同進程內 outbound HTTP call不適用 CORSCORS 只管 browser JS fetch / XHRbrowser 完全不知道 converter / FAA 的存在
- visionA backend attack surface任何能拿到 visionA cookie session attacker 都能下載自己 user_id NEF 但這本來就是 user 自己的檔 escalation
### 10.6 Phase 0.8 → v0.4 → v0.5 → v0.6 安全面遷移摘要
| 面向 | Phase 0.8302 + delegated token| v0.4server-side proxy + visionA API key| v0.5server-side proxy + delegated token**fictional 從未跑通**| **v0.6server-side proxy + visionA → converter API key**|
|------|----|----|---|---|
| Token 結構是否存在於 visionA 下游鏈 | MC issue5 分鐘 TTL browser| visionA API keylong-livedserver-side only| MC issue但實際 issue 不到| visionA API keylong-livedserver-side only|
| 攻擊者攔截 visionA browser response 拿到 token | 短期可用 5 分鐘 | 結構性無 token | | 結構性無 tokenAPI key 不過 browser|
| Frontend XSS 影響範圍 | TTL token | token 可竊 | | token 可竊 |
| Server compromisevisionA backend 被攻破| 攻擊者可簽任意 MC delegated token限於 service client scope| 攻擊者拿到 visionA API key 後可任意打 FAA 所有 endpoint | | 攻擊者拿到 `VISIONA_CONVERTER_API_KEY` 後可任意打 converter 所有 endpoint converter **不存其他 user / 其他產品線資料**只存進行中 / 完成的轉檔 job7 GCblast radius v0.4 直接打 FAA |
| MC 是否為依賴 | issue token| | issue service token + delegated token| visionA server-to-server 不依賴 MCuser login 仍依賴 MC OIDC但與本表無關|
| FAA 是否為依賴 | 直接打| 直接打| 直接打| **否**visionA 端只打 converterconverter FAA converter 自己的事|
| Defense in depth | Token TTL + scope 限制 + tenant 限制 | API key + visionA OIDC 上游 user auth | | API key + visionA OIDC 上游 user auth + ownership store + converter MinIO 7 GC自然 retention |
| 結論 | Phase 0.8 安全靠 MC token TTLv0.4 移除 token 結構但 API key long-livedv0.5 設計上應更安全但實際 fictional**v0.6 v0.4 等價安全模型 blast radius 限於 converter FAA+ 失敗模式收斂為單條鏈整體 SRE 可運維性最佳**|
### 10.7 Race condition
- user 同時兩 tab init 第一個成功寫 ownership / converter 接受第二個 pre-check 通過但 converter 409
- tab 同時 promote-to-models 第一個寫 model record 成功第二個重複呼叫 ensurePromotedvisionA 端無 cache直接打 converterconverter idempotent;§2.5)→ **converter.GetResult 拉 NEF 兩次**接受的取捨converter MinIO 端冪等讀)→ models repo 寫入時可能撞 model_id 衝突 改用 model_id finalize SELECT 檢查
- tab 同時 download visionA backend 各自獨立 converter.GetResult cache兩條 stream 同時跑兩條都成功converter MinIO 端冪等讀)— Phase 0.8b 可接受量大時再加 server-side stream cache 或方向 Aconverter Phase 2 download-tokens browser 直連 converter
### 10.8 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 v0.6 變更(相對於 v0.5 規劃但未上的 codebackend agent 下次任務範圍)**
> v0.4 階段已上線的 commit `86b7175` 把 converter / FAA 兩條線都改為 API key並把 mc_token_client.go 整檔砍除。v0.5 規劃要把 FAA 線回退、mc_token_client 部分復活(**但這部分尚未進實作**。v0.6 撤回 v0.5 規劃改為commit `86b7175` 的 mc_token_client 砍除狀態**維持**faa_client.go 改名為 converter_result_client.go或併入 converter_client.go承接新 `GetResult` methodconfig.go / .env 撤回 v0.5 規劃要加回的 OIDC ServiceClient* / TenantID / FAABaseURL。
>
> 同時:**converter 跨 repo 加新 endpoint**jimchen 自己處理)。
**v0.6 跨 repoconverter schedulerjimchen 在 `/Users/jimchen/kneron_model_converter/apps/task-scheduler/`**
- 新增 `src/routes/v1/result.js`或加進 `jobs.js``GET /api/v1/jobs/:id/result` handler套用既有 `requireReadAuth` middlewareAPI key 比對
- handler job status / expires_at 檢查 MinIO get object stream `pipeline(minioStream, res)` + headers
- 新增 integration test`src/routes/v1/__tests__/result.integration.test.js`
- 更新 `openapi.yaml` `GET /api/v1/jobs/{id}/result` path 規格 200 / 401 / 404 / 409 / 410 / 500 / 502 / 503 response
- 更新 `README.md`API 清單加新 endpoint
**v0.6 從 v0.5 規劃撤回的 source code 變更**visionA backendv0.5 規劃要做v0.6 不做
- **不做**`visionA-backend/internal/conversion/mc_token_client.go` 部分復活v0.5 規劃 v0.6 撤回commit `86b7175` 已砍除狀態維持
- **不做**`visionA-backend/internal/conversion/mc_token_client_test.go` 復活
- **不做**`visionA-backend/internal/conversion/faa_client.go` `tokens *MCTokenClient` 欄位 + `DownloadWithDelegated` methodv0.5 規劃 v0.6 撤回改成下方新增 GetResult」)
- **不做**`visionA-backend/internal/conversion/flow.go` `tokens *MCTokenClient` 欄位 + `IssueDelegatedDownload` 步驟v0.5 規劃 v0.6 撤回改成下方直接呼叫 converter.GetResult」)
- **不做**`OIDCConfig.ServiceClientID` / `ServiceClientSecret` 重新啟用v0.5 規劃 v0.6 撤回
- **不做**`ConversionConfig.TenantID` 重新啟用v0.5 規劃 v0.6 撤回
- **不做**`.env.stage.example` / `.env.dev.example` 加回 `VISIONA_OIDC_SERVICE_CLIENT_ID` / `_SECRET` / `VISIONA_OIDC_TENANT_ID`v0.5 規劃 v0.6 撤回
**v0.6 visionA backend 實際要做的 source code 變更**
- / 改名`visionA-backend/internal/conversion/faa_client.go` **改名為 `converter_result_client.go`**或併入 `converter_client.go` 作為新 method唯一職責是打 converter `GET /api/v1/jobs/{id}/result` NEF stream
- `visionA-backend/internal/conversion/converter_client.go`
- 維持既有 init / poll / promote method 不變
- **新增 method**`GetResult(ctx context.Context, jobID string) (io.ReadCloser, *DownloadMetadata, error)`內部 `GET {baseURL}/api/v1/jobs/{jobID}/result` + `Authorization: Bearer <ConverterAPIKey>`解析 response Content-Length / Content-Disposition / body streamerror mapping 對應 ADR-016 §1.3
- `visionA-backend/internal/conversion/flow.go`
- 移除 `tokens *MCTokenClient` 欄位如尚未從 commit `86b7175` 完全清掉
- 移除 `faa *FAAClient` 欄位如尚未清掉
- `DownloadStream` 內部`ensurePromoted` 之後直接呼叫 `flow.converter.GetResult(ctx, jobID)`
- `PromoteToModels` 內部同樣改呼叫 `flow.converter.GetResult(ctx, jobID)` DownloadStream 共用同條 path
- filename 處理拿到 stream 後用 `defaultDownloadFilename(cj)` 覆寫 converter 給的 filename不變規則同 v0.5
- `visionA-backend/internal/conversion/conversion.go`
- `Service.DownloadStream` 簽名不變
- 刪除 v0.5 註解中的 `DownloadGrant` struct不再需要
- `visionA-backend/internal/config/config.go`
- `ConversionConfig.FAAAPIKey` 維持移除v0.5 撤回過v0.6 維持
- **新增移除**`ConversionConfig.FAABaseURL`v0.5 規劃要保留 / v0.6 移除
- `ConversionConfig.TenantID` 維持移除v0.5 規劃要加回 / v0.6 撤回
- `OIDCConfig.ServiceClientID` / `ServiceClientSecret` 維持移除v0.5 規劃要加回 / v0.6 撤回
- `ConversionConfig.Enabled()` 簡化只判 `ConverterBaseURL != "" && ConverterAPIKey != ""`
- `visionA-backend/cmd/api-server/main.go` wire `conversion.Flow` 時不傳 `MCTokenClient`不傳 `FAAClient`不傳 FAA / Tenant config
- `.env.stage.example` / `.env.dev.example`
- 維持移除 `VISIONA_FAA_API_KEY`v0.4 / v0.5 撤回 / v0.6 維持撤回
- 維持移除 `VISIONA_OIDC_SERVICE_CLIENT_ID` / `_SECRET` / `VISIONA_OIDC_TENANT_ID`
- **新增移除**`VISIONA_FAA_BASE_URL`v0.5 規劃要保留 / v0.6 移除
- 保留 `VISIONA_CONVERTER_BASE_URL` / `VISIONA_CONVERTER_API_KEY`
- 對應 unit / integration test `converter_client_test.go` `GetResult` test 401 / 404 / 410 / 5xx mapping刪除 `faa_client_test.go`v0.5 規劃要保留 / v0.6 撤回
**v0.6 維持 v0.5 / v0.4 既有的 source code 變更**converter API key 線不變
- 維持`visionA-backend/internal/conversion/converter_client.go` 仍用 `apiKey string` + `Authorization: Bearer <ConverterAPIKey>`init / poll / promote method沒變
- 維持`Service` interface `DownloadStream(...) (io.ReadCloser, *DownloadMetadata, error)`v0.4 改的v0.5 / v0.6 不退回 DownloadRedirectURL
- 維持`visionA-backend/internal/api/conversion.go` `conversionDownloadHandler` 仍用 `io.CopyN(c.Writer, stream, 1<<30)`v0.4 改的v0.5 / v0.6 不退回 302
- 維持`ConversionConfig.ConverterAPIKey` 欄位 + `Enabled()` 中對 ConverterAPIKey 的檢查
**不動v0.4 / v0.5 / v0.6 都不影響)**
- `internal/model/*`schema 不變
- `internal/api/models.go`既有 init/finalize 不動flow.PromoteToModels 內部呼叫 helper
- OIDC user login 相關全部`internal/oidc/``internal/usersession/``/api/auth/*` handlers
> ⚠️ **本次2026-05-16 / v0.6)的範圍只動共享文件**(本文 conversion.md / api-conversion.md / oidc-tdd.md / adr-014 / adr-015 / adr-016。source code 改造由 backend agent 下次任務處理visionA backend 撤回 v0.5 規劃 + 加 converter.GetResult method + 改 flow.go + 改 config / .envconverter 端跨 repo 加新 endpoint 由 jimchen 自行處理)。
---
## 版本記錄
| 日期 | 版本 | 變更 |
|------|------|------|
| 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 不友善的對比說明純文字釐清無實作行為變更 |
| 2026-05-16 | 0.5 | **對應 ADR-015 v2.0 範圍縮限**撤回 v0.4visionA FAA API key決定FAA 線回到 ADR-014 §2 原設計MC service token + delegated download tokenvisionA converter API key 路線v0.4**維持**。主要變更(1) §1 整體 flow sequence 加回 MC nodedownload path 改回MC issue delegated token visionA delegated token FAA」;(2) §2 模組設計 mc_token_client.go 部分復活保留 service token cache + IssueDelegatedDownload 邏輯)、faa_client.go `DownloadWithDelegated(ctx, delegatedToken, objectKey)`flow.go 加回 `tokens *MCTokenClient` 欄位DownloadStream / PromoteToModels 流程加回 IssueDelegatedDownload 步驟(3) §3 拆成 §3.1 visionA converterAPI key+ §3.2 visionA FAAservice token + delegated download token),§3.2 詳述 FAA dual-auth 設計與為什麼 download endpoint 強制用 delegated token(4) §4.1 download handler 流程改回ensurePromoted IssueDelegatedDownload DownloadWithDelegated」(保留 server-side stream proxy 不退回 302(5) §6 錯誤碼回收 `mc_token_unavailable` / `download_token_failed` 兩個 code撤回 v0.4 加的 `faa_auth_failed`(6) §9 retry 矩陣回收 MC rowFAA row 改回 service token + delegated(7) §10 安全考量 §10.2 delegated token TTL 回收、§10.3 API key 保護縮限至 converter新增 §10.4 MC service token + delegated download token 保護、§10.6 三方對比加 v0.5 column(8) 變更影響清單列出 backend agent 下次任務範圍 v0.4 回退 FAA + mc_token_client 部分復活)。**本次純文件修訂source code 改造留給 backend agent 下次任務**。OIDC user login 完全不動 |
| 2026-05-16 | 0.6.1 | §2.5 ensurePromoted cache 描述歧義T2 review M-2)— 原本寫實作 `sync.Map[jobID]bool` cache job 第二次 promote 直接回 cache visionA backend 實際 implementationflow.go `ensurePromoted`沒實作這個 cache每次都直接打 converter `POST /promote`改為明確標示Phase 0.8 簡化 不實作 cache並補 4 個簡化理由converter promote idempotent / cache 只省 round-trip / restart 後失效 / MVP 流量小 + 3 Phase 1+ 升級選項in-memory sync.Map / DB-or-Redis 持久化 / model store source_job_id 推論)。code 行為不變純文件對齊 |
| 2026-05-16 | 0.6 | **對應 [ADR-016](./adr/adr-016-download-via-converter.md)**撤回 v0.5visionA FAA 線回到 MC service token + delegated download token」**全部規劃**。原因 MC source grep 驗證後確認 MC **沒有** `POST /file-access/download-tokens` endpoint也沒有 FAA `MemberCenterDelegatedDownloadTokenValidator` assume introspection endpoint—— ADR-014 §2 ADR-015 v2.0 §2 delegated token 鏈是 fictional 2026-05-02 寫定起未曾 e2e 跑通)。**v0.6 新設計**visionA download 改走 **converter 新增的 `GET /api/v1/jobs/{id}/result` + visionA stream 中轉**visionA 端不再有任何 visionA MC / visionA FAA 路徑server-to-server 認證收斂為單條 visionA converterAPI keypromote 仍走 visionA converter `POST /promote`converter 內部 PUT FAA visionA 無關不變)。主要變更(1) §1 整體 flow sequence 移除 MC nodedownload path 改成converter.GetResult」;(2) §2 模組設計 mc_token_client.go 維持砍除撤回 v0.5 部分復活)、faa_client.go 改名為 converter_result_client.go或併入 converter_client.go新增 `GetResult` methodflow.go 移除 `tokens *MCTokenClient` 欄位DownloadStream / PromoteToModels 都改走 converter.GetResult(3) §3 整段重寫 §3.1 visionA converter API key不變新增同 key 用於 GetResult endpoint+ §3.2 visionA FAA 整段撤回(§3.2.1~§3.2.5 全部標 v0.6 撤回(4) §4.1 download handler 流程改成ensurePromoted converter.GetResult」(保留 server-side stream proxy 不退回 302(5) §6 錯誤碼撤回 `faa_unavailable` / `mc_token_unavailable` / `download_token_failed` 三個 code新增 `result_not_found` / `result_expired` 兩個 code(6) §9 retry 矩陣移除 MC rowFAA row 全部撤回新增 Converter GetResult row(7) §10 安全考量 §10.2 delegated token TTL 整段撤回、§10.3 API key 保護維持縮限至 converter 同時新增同一把用於 GetResult說明、§10.4 MC service token + delegated download token 保護整段撤回、§10.6 v0.6 column 對比、§10.7 race condition §10.8 DoS重編號更新(8) 變更影響清單列出 backend agent 下次任務範圍 v0.5 規劃撤回 + 新增 converter.GetResult + repo converter scheduler endpoint)。**本次純文件修訂source code 改造留給 backend agent 下次任務 + converter repo jimchen 自行處理**。OIDC user login 完全不動 |