對齊 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>
1284 lines
90 KiB
Markdown
1284 lines
90 KiB
Markdown
# 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-Goals(Phase 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)
|
||
> - download:visionA → converter `GET /api/v1/jobs/{id}/result`,converter 從 MinIO stream NEF 回 visionA,再 io.CopyN 中轉給 browser(ADR-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 job(streaming upload)
|
||
B->>V: POST /api/conversion/init (multipart)
|
||
V->>V: AuthMiddleware → user_id (OIDC sub)
|
||
V->>V: 檢查同 user active job
|
||
V->>C: POST /api/v1/jobs<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 — Promote(converter 內部 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 NEF(visionA 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.6):promote → **converter.GetResult 拉 NEF stream**(不是直接打 FAA)→ `/api/models/init` 取得 model_id + presigned PUT URL → visionA-backend 自己 PUT 到 storage → `/api/models/finalize`(這條路徑要重用既有 handler 邏輯,不繞過)
|
||
- download flow(v0.6):promote 冪等 → **converter.GetResult 拉 NEF stream** → io.CopyN 中轉給 browser(size cap 1 GiB)
|
||
- **Phase 0.8b v0.6 認證鏈說明**:
|
||
- visionA 端只有一條:visionA → converter(API key、constant-time compare)
|
||
- converter → FAA:converter 自己用 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-014(OAuth client_credentials 兩條線)| ADR-015 v1.x(兩條線都 API key)| ADR-015 v2.0(converter API key + FAA OAuth + delegated)| **ADR-016 / v0.6(visionA 端只剩 converter)**|
|
||
|------|--------------------------------|------------------|---|---|
|
||
| visionA → converter 認證 | `Authorization: Bearer <MC-issued service token>` | `Authorization: Bearer <VISIONA_CONVERTER_API_KEY>` | 同 v1.x | 同 v1.x / v2.0(不變)|
|
||
| visionA → FAA(write / metadata / delete)| `Authorization: Bearer <MC service token>` + scope | `Authorization: Bearer <VISIONA_FAA_API_KEY>` | 回到 ADR-014(service token) | **不存在**(visionA 端不再直接打 FAA)|
|
||
| visionA → FAA(download `GET /files/{key}`)| `Authorization: Bearer <MC delegated download token>` | `Authorization: Bearer <VISIONA_FAA_API_KEY>` | 回到 ADR-014(delegated 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-014(dual-auth) | 不適用(visionA 端不再呼叫 FAA;converter → 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 client(init / poll / promote / GetResult — 帶 VISIONA_CONVERTER_API_KEY)
|
||
├── (faa_client.go 刪除 / 改名) # v0.6:visionA 端不再直接打 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.5(API 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 status;ownership 檢查後 cache 1-2s。
|
||
GetJob(ctx context.Context, userID, jobID string) (*Job, error)
|
||
|
||
// PromoteToModels — 「加到模型庫」流程:promote → FAA pull → models repo finalize。
|
||
// 回傳新建的 model_id。
|
||
PromoteToModels(ctx context.Context, userID, jobID string) (modelID string, err error)
|
||
|
||
// DownloadStream — 「下載」流程(Phase 0.8b v0.6:server-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 給 client(size cap 1 GiB)
|
||
// 不產生 302 redirect URL(server-side proxy 在 T4 已實作,v0.4 / v0.5 / v0.6 沿用;不退回 302)。
|
||
// 不再經過 visionA → MC / visionA → FAA 任何路徑(v0.6 整段撤回;詳見 ADR-016)。
|
||
// Phase 1+ 量大時可評估方案 D(visionA 自簽 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-type),service 內部處理 streaming。
|
||
type InitJobInput struct {
|
||
UserID string
|
||
ContentType string // 含 boundary 的原始值
|
||
Body io.Reader // request.Body
|
||
ContentLength int64
|
||
TargetChip string // "520" / "720"
|
||
// 其他 form fields(model_id, version, enable_*)由 handler 解多 part 後傳入
|
||
// — 實作上在 §4 streaming 處理時把這些 field 也透傳給 converter
|
||
}
|
||
|
||
type Job struct {
|
||
JobID string `json:"job_id"`
|
||
Status string `json:"status"` // created / running / completed / failed
|
||
Stage string `json:"stage"` // onnx / bie / nef
|
||
Progress int `json:"progress"` // 0-100
|
||
StageProgress int `json:"stage_progress"`// 0-100
|
||
CreatedAt time.Time `json:"created_at"`
|
||
UpdatedAt time.Time `json:"updated_at"`
|
||
ErrorCode string `json:"error_code,omitempty"`
|
||
ErrorMessage string `json:"error_message,omitempty"`
|
||
}
|
||
|
||
// Phase 0.8b 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.0:pre-shared API key(VISIONA_CONVERTER_API_KEY)
|
||
httpClient *http.Client
|
||
}
|
||
|
||
// CreateJobStream 把 io.Reader 當作 multipart body(content-type 含 boundary)透傳給 converter。
|
||
// caller 必須:
|
||
// 1. 已經把 user_id 透過 multipart.Writer 注入 body(在 streaming 過程中)
|
||
// 2. content-type 是合法的 multipart/form-data; boundary=...
|
||
func (c *ConverterClient) CreateJobStream(ctx context.Context, contentType string, body io.Reader, contentLength int64) (*Job, error)
|
||
|
||
func (c *ConverterClient) GetJob(ctx context.Context, jobID string) (*Job, error)
|
||
|
||
func (c *ConverterClient) PromoteJob(ctx context.Context, jobID string) (targetObjectKey string, err error)
|
||
|
||
// ListActiveJobsByUser — lazy rebuild ownership 用(§2.6.1)
|
||
func (c *ConverterClient) ListActiveJobsByUser(ctx context.Context, userID string) (*Job, error)
|
||
```
|
||
|
||
每個方法內部(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(§9);401/403 不 retry(API 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 cap:visionA backend handler 端用 `io.CopyN(w, stream, 1 GiB)` 保護;converter 端不另外設 cap(converter 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 的 OIDC(PKCE / 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
|
||
}
|
||
|
||
// DownloadStream(v0.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 interface(v0.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 / debug,visionA 不再用它直接打 FAA(converter `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 PUT;visionA 端 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 通常 ≤ 3,user 看到 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 仍每次重 promote(download 路徑覆蓋率不完整) |
|
||
|
||
選哪個視 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` store(in-memory)
|
||
|
||
```go
|
||
type ownershipStore interface {
|
||
Set(jobID, userID string, expiresAt time.Time) // 對齊 converter 7d 過期
|
||
Get(jobID string) (userID string, ok bool)
|
||
CleanupExpired() // background goroutine 每 60s
|
||
}
|
||
```
|
||
|
||
雛形 in-memory;visionA-backend 重啟 → 所有「我的 job 列表」消失,user 等同失去對未完成 job 的後續操作能力(接受的取捨 — converter 端用 user_id 仍可查到,但 visionA UX 上看不到)。Phase 0.9 之後可改 DB persist。
|
||
|
||
#### 2.6.1 visionA-backend 重啟後的 cold start 恢復(Phase 0.8 MVP 行為)
|
||
|
||
**問題**:使用者 A 上傳了一個 job,正在 `processing`;visionA-backend 重啟(部署新版、crash recovery)→ in-memory ownership store 全空;使用者 A 重新打開 `/conversion` 頁面,前端打 `GET /api/conversion/active` → backend 找不到任何 ownership → 回 `has_active=false` → 前端顯示「沒有進行中的轉檔」。
|
||
|
||
**結果**:使用者 A 看到一個假的「乾淨」狀態,認為什麼都沒發生;但 converter 端那個 job 仍在跑(且 converter 端「同 user 1 active job」邏輯仍生效),使用者 A 重新 submit 會撞 409。
|
||
|
||
**Phase 0.8 MVP 的決策(接受的取捨)**:
|
||
|
||
| 選項 | 描述 | 採用? |
|
||
|------|------|--------|
|
||
| A1 | 維持現狀:重啟即遺失。靠 converter 7 天 expires_at 自然兜底 | ✓ Phase 0.8 採用 |
|
||
| A2 | 啟動時對 converter 打 `GET /api/v1/jobs?status=in_progress` 重建所有 ownership | ✗ Phase 0.8 不做 |
|
||
| A3 | 把 ownership 寫進 DB / Redis | ✗ Phase 0.9+ 評估 |
|
||
| A4 | 啟動時對特定 user 才 lazy 重建(`GET /active` 時若 in-memory 沒有,去 converter 查該 user 的 active job) | ✓ Phase 0.8 補上(**新增**) |
|
||
|
||
**A4 實作(Phase 0.8 補強)**:
|
||
|
||
```go
|
||
// flow.go ActiveJob 內部:
|
||
func (f *Flow) ActiveJob(ctx context.Context, userID string) (*conversion.Job, error) {
|
||
// 1. 先查 in-memory ownership
|
||
if jobID, ok := f.ownership.GetByUser(userID); ok {
|
||
return f.GetJob(ctx, userID, jobID)
|
||
}
|
||
// 2. in-memory miss → fallback 對 converter 查(lazy rebuild ownership)
|
||
job, err := f.converter.ListActiveJobsByUser(ctx, userID)
|
||
if err != nil { return nil, err }
|
||
if job == nil { return nil, nil }
|
||
// 重建 ownership(用 converter 回的 created_at + 7d 推算 expires_at)
|
||
f.ownership.Set(job.JobID, userID, job.CreatedAt.Add(7*24*time.Hour))
|
||
return job, nil
|
||
}
|
||
```
|
||
|
||
**前提**:converter Phase 1 的 `GET /api/v1/jobs?user_id=<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.6:visionA 端只剩單條 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 key,FAA 線回到 ADR-014 §2 原設計(service token + delegated download token)
|
||
> - **v0.6 (2026-05-16)**:對 MC source 驗證後確認 v0.5 設計的 delegated token 鏈是 fictional(MC 沒有對應 endpoint);本節再次整段改寫——visionA → FAA / MC 鏈**完全撤回**,download 改走 converter `GET /api/v1/jobs/{id}/result`,visionA 端只剩 visionA → converter 一條 server-to-server 認證鏈
|
||
|
||
### 3.1 visionA → converter(API 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` endpoint(ADR-016 §1)。
|
||
|
||
#### 3.1.1 取得流程
|
||
|
||
```
|
||
visionA-backend 啟動
|
||
↓
|
||
讀 cfg.Conversion.ConverterAPIKey(env 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 字元 hex(256 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 → MC(issue 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 endpoint(PUT / metadata / HEAD / DELETE)保留給 Phase 1+ 擴充用(如 visionA 主動清理 FAA 上的孤兒檔),到時候才走 service token 路徑。
|
||
|
||
#### ~~3.2.2 visionA 端流程~~(v0.6 撤回 — 整段不再執行)
|
||
|
||
```
|
||
visionA-backend 啟動
|
||
↓
|
||
讀 cfg.OIDC.ServiceClientID / ServiceClientSecret(env VISIONA_OIDC_SERVICE_CLIENT_ID / _SECRET)
|
||
讀 cfg.OIDC.IssuerURL(env VISIONA_OIDC_ISSUER_URL — 同 user login 的 issuer)
|
||
讀 cfg.Conversion.TenantID(env VISIONA_OIDC_TENANT_ID)
|
||
讀 cfg.Conversion.FAABaseURL(env VISIONA_FAA_BASE_URL)
|
||
↓
|
||
[Download 請求進來]
|
||
↓
|
||
flow.DownloadStream / flow.PromoteToModels:
|
||
↓
|
||
mcTokenClient.ServiceToken(ctx)
|
||
cache hit?
|
||
Yes → return cached token(exp - 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 env(v2.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.8「partial deploy」相容性)。
|
||
|
||
#### ~~3.2.5 MC scope 與 FAA endpoint 對應~~(v0.6 撤回 — visionA 端不再 issue MC service token,scope 配置由 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 token;FAA 端最終驗的是 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 auth(visionA 端唯一一條)**:visionA → converter 用 pre-shared API key(init / poll / promote / **GetResult**)
|
||
- **machine auth(不在 visionA 範圍)**:converter → FAA 用 OAuth client_credentials + `files:upload.write` scope(converter 自己管 `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 | 上傳 + 建 job(multipart streaming) |
|
||
| `GET` | `/api/conversion/{job_id}` | OIDC cookie | 查 job 狀態 |
|
||
| `POST` | `/api/conversion/{job_id}/promote-to-models` | OIDC cookie | 「加到模型庫」 |
|
||
| `GET` | `/api/conversion/{job_id}/download` | OIDC cookie | 「下載」— v0.6:server-side stream proxy;visionA backend 中轉 NEF binary(source 從 converter `GET /api/v1/jobs/{id}/result` 拉,使用 `VISIONA_CONVERTER_API_KEY`;不再經 FAA / MC) |
|
||
| `GET` | `/api/conversion/active` | OIDC cookie | 查當前 user 有無 active job(frontend pre-check);in-memory miss 時 fallback 對 converter lazy rebuild(§2.6.1) |
|
||
|
||
> **不對外暴露但內部使用的 converter endpoint**:`GET /api/v1/jobs?user_id=&status=in_progress`(§2.6.1 lazy rebuild)。Phase 0.8 frontend 看不到「歷史列表」UI,但後端會用此內部 endpoint 做韌性處理。
|
||
>
|
||
> **`POST /api/v1/jobs/{id}/cancel`**:converter Phase 1 **尚未實作**(已驗證 routes/v1/jobs.js 與 openapi.yaml)。Phase 0.8 失敗 cleanup 採「socket close 自然 abort」(§5.3.2);Phase 1 待 converter 補上 endpoint 後再升級為 best-effort 主動 cancel。
|
||
|
||
所有 endpoint 通用:
|
||
|
||
- 走既有 `AuthMiddleware`(`internal/api/middleware/auth.go`)
|
||
- 從 `UserContextFrom(c)` 拿 `uc.UserID`(OIDC sub)
|
||
- response 用既有 `WriteSuccess` / `WriteError` helper
|
||
- request_id 透傳給 converter(`X-Request-Id` header)
|
||
|
||
### 4.1 `GET /api/conversion/{job_id}/download` — Phase 0.8b v0.6:server-side stream proxy from converter
|
||
|
||
> **演進**:
|
||
> - **Phase 0.8(ADR-014 v1.1)**:`c.Redirect(302, FAA_URL_with_delegated_token)`
|
||
> - **Phase 0.8b v0.4 (ADR-015 v1.x)**:改為 server-side stream proxy;token 來源用 visionA API key(v0.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 給 client(io.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_key(converter 端的
|
||
// 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 << 30(1 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 tag,browser 自動處理 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.8(302 + MC delegated token)| v0.4(server-side proxy + visionA API key 撤回)| v0.5(server-side proxy + MC delegated token;**fictional 從未跑通**)| **v0.6(server-side proxy + visionA → converter API key)** |
|
||
|------|----|----|---|---|
|
||
| Token 在 frontend JS / URL bar | △ 短暫(Location header 流經 browser) | ✓ 結構性不存在 | ✓ 結構性不存在 | ✓ 結構性不存在(API key 只在 server-side 流動)|
|
||
| 要 FAA CORS | ✓ 不需要 | ✓ 不需要 | ✓ 不需要 | ✓ 不需要(visionA 端不直接打 FAA、CORS 完全不適用) |
|
||
| 跨 internet 流量(同 NEF 多次下載)| ✓ 直連 FAA | ✗ 每次繞 visionA | ✗ 每次繞 visionA | ✗ 每次繞 visionA(同 v0.4 / v0.5,未改變;但 source 從 FAA 換 converter MinIO)|
|
||
| visionA backend 是否變 streaming bottleneck | ✓ 不是 | ✗ 是 | ✗ 是 | ✗ 是(同 v0.4 / v0.5,Phase 0.8b MVP 接受;Phase 1+ 升級見 ADR-016 後果 §負面影響)|
|
||
| 認證鏈複雜度(visionA → 下游)| MC service token + MC delegated token | 一把 API key | MC service token + MC delegated token(fictional)| 一把 API key(同 v0.4,但這次是真的 work)|
|
||
| Token TTL | 5 min(MC 簽)| ∞(API key long-lived)| 5 min(MC 簽,但 endpoint 不存在所以 issue 不到)| ∞(API key long-lived;rotate by runbook)|
|
||
| Token 洩漏的 blast radius | 5 min 內可下載該 object_key | 永遠可打 FAA 任何 endpoint | — | 永遠可打 converter 任何 endpoint(jimchen 自己管 rotate;converter 不存其他 user 資料、攻擊面限於 converter 自己)|
|
||
|
||
**為什麼 v0.6 仍對齊 v0.4 / v0.5 的 server-side proxy 而非退回 302**:見 §1 整體 flow 變更說明。
|
||
|
||
**Phase 1+ 升級路徑**:如量大需回 302 redirect 模式(讓 browser 直連 converter 或 FAA),有兩個方向:
|
||
- 方向 A(converter Phase 2):converter 補上 `POST /api/v1/jobs/:id/download-tokens`(既有預留 501),給 browser 直連 converter;ADR-016 與此路徑相容
|
||
- 方向 B(FAA HMAC):visionA 自己簽 short-TTL HMAC token + FAA middleware 加第三條 auth path(同 ADR-015 v2.0 §7 選項 B);但需要 warrenchen 改公司共用 FAA repo
|
||
|
||
---
|
||
|
||
## 5. Streaming proxy 設計(upload)
|
||
|
||
### 5.1 為什麼要 streaming
|
||
|
||
- 模型上限 500MB;ref_images 100×10MB = 1GB 上限
|
||
- 全 buffer 在 RAM → 同時 N 個 user upload 直接 OOM
|
||
- 暫存 disk → 增加 IO 與磁碟空間需求;壞掉的 cleanup 麻煩
|
||
|
||
### 5.2 實作 pattern
|
||
|
||
```go
|
||
// handler 收到 request:
|
||
func conversionInitHandler(deps Deps) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
uc, _ := UserContextFrom(c)
|
||
userID := uc.UserID
|
||
|
||
ct := c.GetHeader("Content-Type")
|
||
if !strings.HasPrefix(ct, "multipart/form-data") {
|
||
WriteError(c, 400, ErrCodeValidationFailed, "expect multipart/form-data", nil)
|
||
return
|
||
}
|
||
|
||
// 同 user active job pre-check
|
||
if active, _ := deps.Conversion.ActiveJob(c.Request.Context(), userID); active != nil {
|
||
WriteError(c, 409, ErrCodeActiveJobExists,
|
||
"user has active job", map[string]any{"job": active})
|
||
return
|
||
}
|
||
|
||
// service 內部做 streaming
|
||
job, err := deps.Conversion.InitJob(c.Request.Context(), conversion.InitJobInput{
|
||
UserID: userID,
|
||
ContentType: ct,
|
||
Body: c.Request.Body,
|
||
ContentLength: c.Request.ContentLength,
|
||
})
|
||
// ... error handling
|
||
}
|
||
}
|
||
```
|
||
|
||
```go
|
||
// service 內部 (flow.go InitJob):
|
||
func (f *Flow) InitJob(ctx context.Context, in conversion.InitJobInput) (*conversion.Job, error) {
|
||
pr, pw := io.Pipe()
|
||
mw := multipart.NewWriter(pw)
|
||
|
||
// goroutine:解 client 的 multipart,重新寫到 mw
|
||
errCh := make(chan error, 1)
|
||
go func() {
|
||
defer pw.Close()
|
||
defer mw.Close()
|
||
|
||
mr, err := readerFromContentType(in.Body, in.ContentType)
|
||
if err != nil { errCh <- err; return }
|
||
|
||
// 先寫 user_id(重點:visionA backend 灌的,不是 client 灌的)
|
||
if err := mw.WriteField("user_id", in.UserID); err != nil { errCh <- err; return }
|
||
|
||
for {
|
||
part, err := mr.NextPart()
|
||
if err == io.EOF { break }
|
||
if err != nil { errCh <- err; return }
|
||
|
||
name := part.FormName()
|
||
|
||
// 黑名單:client 不允許自己塞 user_id
|
||
if name == "user_id" {
|
||
continue // 忽略,用我們自己灌的
|
||
}
|
||
|
||
if part.FileName() == "" {
|
||
// form field:直接複製
|
||
fw, err := mw.CreateFormField(name)
|
||
if err != nil { errCh <- err; return }
|
||
if _, err := io.Copy(fw, part); err != nil { errCh <- err; return }
|
||
} else {
|
||
// file part:streaming copy
|
||
fw, err := mw.CreateFormFile(name, part.FileName())
|
||
if err != nil { errCh <- err; return }
|
||
if _, err := io.Copy(fw, part); err != nil { errCh <- err; return }
|
||
}
|
||
}
|
||
errCh <- nil
|
||
}()
|
||
|
||
// 同步 POST 到 converter
|
||
job, err := f.converter.CreateJobStream(ctx, mw.FormDataContentType(), pr, -1)
|
||
|
||
// 等 goroutine 結束
|
||
if goErr := <-errCh; goErr != nil && err == nil {
|
||
err = goErr
|
||
}
|
||
|
||
if err != nil { return nil, mapConverterError(err) }
|
||
|
||
f.ownership.Set(job.JobID, in.UserID, time.Now().Add(7*24*time.Hour))
|
||
return job, nil
|
||
}
|
||
```
|
||
|
||
**關鍵點**:
|
||
|
||
1. `io.Pipe` 把「client 端 reader → converter 端 writer」串接,期間記憶體只有 multipart.Reader 的 buffer(≈ 64KB 預設)
|
||
2. 必須先寫 `user_id` field(**順序**:user_id 在 model file 之前,避免 converter multer 解析時 user_id 還沒到就拒絕)
|
||
3. **黑名單 user_id**:忽略 client 帶的 user_id,永遠用 visionA-backend 自己灌的
|
||
4. context cancellation:handler 收到 client disconnect → ctx.Done() → goroutine 自動結束(pw.Close 觸發 reader EOF)
|
||
5. 不做 ContentLength forward(converter 自己 multer 算)
|
||
|
||
### 5.3 進度 / 取消
|
||
|
||
#### 5.3.1 進度語意(重要:給 Frontend / Design 對齊)
|
||
|
||
XHR `upload.onprogress` 計算的是 **browser → visionA-backend** 的進度,**不是** browser → backend → converter 的端到端進度。在 streaming proxy 模式下,這兩者有時間差:
|
||
|
||
```
|
||
T0: browser 開始上傳
|
||
└─ XHR onprogress 持續更新(loaded / total)
|
||
T1: browser 已 send 完全部 bytes(XHR 進度 100%)
|
||
└─ 但 backend → converter 的 io.Pipe 可能還在繼續流(buffer 內未消化)
|
||
T2: backend 把全部 bytes forward 完給 converter
|
||
└─ 這時候才拿到 converter 的 201 + job_id
|
||
T3: backend 200 回 frontend
|
||
```
|
||
|
||
**設計選擇(Phase 0.8 MVP)**:visionA-backend 等到 T2(converter 回 201)才回 200,**不 early-return**。
|
||
|
||
| 屬性 | 選項 A:等 converter 201 才 200 ✓ 採用 | 選項 B:browser send 完就 200,背景 forward |
|
||
|------|----------------------------------|------------------------------------------|
|
||
| Frontend 進度條精確度 | 100% 接近端到端真實狀態 | 進度 100% 後還有未知延遲 → 假象 |
|
||
| UX 延遲感 | 多等 1-3 秒(io.Pipe drain)| 立即切 processing 畫面 |
|
||
| backend 實作複雜度 | 低(直接同步等)| 高(需要 background goroutine + ownership 標 `upload_in_progress` + 額外狀態管理) |
|
||
| 失敗處理複雜度 | 低(同步錯誤直接回 frontend)| 高(背景 forward 失敗時 frontend 已切 processing,要額外 push 錯誤通知)|
|
||
|
||
**選項 A 的 UX 補償**:當 XHR `loaded === total` 但 backend 還沒回 200 時,frontend 顯示 `即將完成…` / `伺服器處理中…`(對齊 flow-conversion.md §5.3,本文件 §5.3.1)。這明確告訴使用者「browser 端已送完,正在等 server 收尾」,不是欺騙性的進度條。
|
||
|
||
> **Phase 1 升級路徑**:若 Phase 1 量大需要更精準的端到端進度,可改成 SSE 推送 `upload_progress` 事件(backend 主動報「已 forward N bytes 給 converter」),但 Phase 0.8 MVP 不做。
|
||
|
||
#### 5.3.2 Cancel 與 Cleanup 鏈(重要)
|
||
|
||
**情境分類**:
|
||
|
||
| 情境 | 觸發 | backend 行為 |
|
||
|------|------|-------------|
|
||
| C1:使用者按「取消上傳」 | frontend `xhr.abort()` | TCP RST → backend `c.Request.Context().Done()` → goroutine cleanup(見下) |
|
||
| C2:使用者重新整理 / 關分頁 | browser 中斷 connection | 同 C1 |
|
||
| C3:網路斷線 | TCP timeout | 同 C1 |
|
||
| C4:backend 偵測 converter 拒絕(4xx/5xx)| converter response | 立即回 frontend,不需特別 cleanup(converter 自己沒建 job) |
|
||
|
||
**C1-C3 的 cleanup 鏈**:
|
||
|
||
```
|
||
client disconnect
|
||
↓
|
||
gin handler `c.Request.Context().Done()` 觸發
|
||
↓
|
||
streaming goroutine 的 `pw.Close()` defer 執行 → io.Pipe reader 收到 EOF/error
|
||
↓
|
||
converter HTTP request 的 body read 端拿到 EOF
|
||
↓
|
||
converter multer 偵測 incomplete multipart → 拒絕收 job(不會建 job_id)
|
||
```
|
||
|
||
**但**:上面的鏈在「backend 已經把 multipart header 寫進去、converter 已建 job_id、stream 還在 forward 中」這個區間斷線時,**converter 端可能已經建立 job 但收不完 body**。實測上 converter(Phase 1)的行為是:
|
||
- 收不完 body → multer 拋錯 → 該 job 留在 `failed` 狀態 + error_code=`invalid_multipart`
|
||
- 該 user 的 active_job 邏輯:converter 把 `failed` 視為 active job 結束,下次 init 不會撞 409
|
||
|
||
**為了避免「converter 視為 active 但 visionA 不知道」的孤立 job 風險**,依 converter 是否提供 `/cancel` endpoint 採不同策略:
|
||
|
||
> ### Phase 0.8 限制(重要 — 已驗證實作狀態)
|
||
>
|
||
> **converter Phase 1 並未實作 `POST /api/v1/jobs/{id}/cancel` endpoint**。
|
||
>
|
||
> 已驗證範圍:`apps/task-scheduler/src/routes/v1/jobs.js` 只有以下路由:
|
||
> - `POST /api/v1/jobs/`(建立 job)
|
||
> - `GET /api/v1/jobs/`(list)
|
||
> - `GET /api/v1/jobs/:id`(單一狀態)
|
||
> - `POST /api/v1/jobs/:id/download-tokens`(issue download token)
|
||
> - `DELETE /api/v1/jobs/:id`(刪 job — 是 hard delete 而不是 cancel running job)
|
||
>
|
||
> openapi.yaml 也沒有 cancel 路徑或 example。
|
||
>
|
||
> 因此 **Phase 0.8 採「socket close 自然 abort」策略**:
|
||
>
|
||
> ```
|
||
> client disconnect / streaming body 中斷
|
||
> ↓
|
||
> converter multer 拋 invalid_multipart
|
||
> ↓
|
||
> 該 job 留 failed + error_code=invalid_multipart
|
||
> ↓
|
||
> converter 對 active_job 邏輯視為已結束(failed 不算 active)
|
||
> ↓
|
||
> 下次 init 不會撞 409
|
||
> ```
|
||
>
|
||
> visionA-backend 在 InitJob 失敗時不主動發 cancel(沒有對應 endpoint 可發);只在
|
||
> log 紀錄失敗事件,依靠 converter 自然 abort 收尾。
|
||
|
||
> ### Phase 1+ 升級路徑(converter 補上 /cancel 之後)
|
||
>
|
||
> 當 converter 上線 `POST /api/v1/jobs/{id}/cancel` 後,visionA-backend 升級為
|
||
> best-effort 主動 cancel:
|
||
>
|
||
> ```go
|
||
> // flow.go InitJob 內部,goroutine 結束後若 err != nil 且我們已拿到 job_id:
|
||
> if goErr != nil && job != nil && job.JobID != "" {
|
||
> // 用獨立 timeout(不繼承已 cancel 的 ctx),失敗只 log
|
||
> cancelCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
> defer cancel()
|
||
> if cancelErr := f.converter.CancelJob(cancelCtx, job.JobID); cancelErr != nil {
|
||
> logger.Warn("best-effort cancel failed", "job_id", job.JobID, "err", cancelErr)
|
||
> }
|
||
> }
|
||
> ```
|
||
>
|
||
> 動工項:
|
||
> 1. T3 ConverterClient interface 新增 `CancelJob(ctx context.Context, jobID string) error`
|
||
> 2. flow.go InitJob 失敗路徑加上面的 best-effort cancel block
|
||
> 3. 補對應 unit test(mock converter 收到 cancel call)
|
||
> 4. 對齊 §9.1 retry 矩陣的「Converter `POST /jobs/{id}/cancel`(內部 cleanup)」row
|
||
|
||
**C1 特別處理(使用者按「取消上傳」)**:frontend 在 `xhr.abort()` 之前**不應**先打 cancel API(多此一舉,TCP RST 即已觸發 cleanup);後端會自動處理。
|
||
|
||
#### 5.3.3 既有 4.3 內容(保留)
|
||
|
||
- **進度**:visionA-backend 不做進度回報;frontend 用 XHR `upload.onprogress` 自己顯示(既有前端模式)
|
||
- **取消**:context.Cancel(client 斷線)→ 連帶 cancel converter request(如上 cleanup 鏈);converter 端 multer 收到 socket close 會自動 abort multipart parsing
|
||
|
||
### 5.4 Timeout
|
||
|
||
- handler 整體不設總 timeout(500MB upload 可能 5-10 分鐘)
|
||
- 但每個 io.Copy 之間用 `http.Server.WriteTimeout`/`IdleTimeout` 控制 keep-alive;具體值由 DevOps 在 Nginx / ingress 設定(建議 600s)
|
||
|
||
---
|
||
|
||
## 6. 錯誤碼 mapping + i18n key
|
||
|
||
> **Phase 0.8b 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 401(API 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 不屬於當前 user(visionA 端 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` 回 410(job 過期)|
|
||
| `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 proxy(Phase 0.8b 變更),錯誤情況直接走既有 `WriteError` helper 依 `Accept` header 回應:
|
||
|
||
- `Accept: application/json` → 回標準 visionA error JSON `{success:false, error:{code, message}}`
|
||
- `Accept: text/html`(一般 anchor tag / window.location.href 觸發)→ 回 HTML 錯誤頁;browser 直接顯示
|
||
- 其他 → 預設 JSON
|
||
|
||
frontend 用 `<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-Goals(Phase 0.8 不做)
|
||
|
||
對齊 PRD Phase 0.8 邊界:
|
||
|
||
| 項目 | Phase 0.8 行為 | Phase 1+ 計畫 |
|
||
|------|--------------|--------------|
|
||
| SSE / WebSocket 進度推送 | frontend HTTP polling,間隔 2s | SSE endpoint `/api/conversion/{id}/events` |
|
||
| 取消 job | 不提供;user 等 converter 自己跑完或 7 日後 expires | `POST /api/conversion/{id}/cancel` |
|
||
| Job 歷史列表 | 不提供;in-memory map 重啟即清 | DB persist + `GET /api/conversion/history` |
|
||
| 同 user 多個 active job | 強制 1 個(pre-check + converter 409 透傳) | 沿用 converter 限制(短期內無計畫放寬) |
|
||
| 多 chip 同時轉 | 一次只能選一個 target_chip | Phase 1 後評估 |
|
||
| Webhook(converter 完成 push) | 不接收 | converter Phase 2 才提供 |
|
||
| 大量批次 upload | 不支援 | 不在路線圖 |
|
||
|
||
---
|
||
|
||
## 9. 失敗模式 & retry 矩陣
|
||
|
||
### 9.1 retry 規則
|
||
|
||
> **Phase 0.8b 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`(內部 cleanup,Phase 1+)| 不重試 | 不重試 | 不重試 | 不重試 | 0 | best-effort(失敗只 log);**Phase 0.8 converter 未實作此 endpoint,靠 socket close 兜底**(§5.3.2)|
|
||
| Converter `GET /jobs?user_id=&status=in_progress`(lazy rebuild)| **不重試** | 透傳 | retry | retry | 1 | 0.5s |
|
||
| Converter `POST /promote` | **不重試** | 透傳 | retry | retry | 2 | 1s, 2s |
|
||
| **Converter `GET /jobs/{id}/result`**(v0.6 新增;下載 NEF stream)| **不重試**(401 → converter_auth_failed;404 → result_not_found;410 → result_expired)| 透傳 | retry | retry | 2 | 1s, 2s |
|
||
|
||
每次 retry 之間檢查 `ctx.Done()`;ctx cancel → 立即 return ctx.Err()。
|
||
|
||
**401 / 403 不重試的理由**:
|
||
|
||
- **converter 401**:API key 是 long-lived secret,rotate 同步是運維事件、不是瞬時抖動。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 回 401(API key 不同步)| `502 converter_auth_failed`,UI 同上文字;SRE 從 log 看到 auth_failed 計數異常 → 檢查 env |
|
||
| 完成後 promote 失敗(converter 5xx) | job 留在 completed 狀態(FAA 上沒檔但 visionA 知道),UI 給 user 「重試 promote」按鈕(重打 promote-to-models / download) |
|
||
| 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` 5xx(converter 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.1);rebuild 不到的 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 token);v0.5 撤回 v0.4 並回收(FAA 線回到 MC delegated download token);**v0.6 再次整段撤回**——對 MC source 驗證後確認 MC 沒有 issue delegated token 的 endpoint,v0.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 字元 prefix(debug 用)
|
||
- 若 key 洩漏:產新 key → visionA + converter 同步 env → restart → 驗證 → 拔舊 key(runbook 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 的 OIDC(public PKCE)是另一條完全獨立的鏈、不在本節範圍(詳見 `oidc-tdd.md`)。
|
||
|
||
### 10.5 Object key 不暴露給 frontend JS
|
||
|
||
- Phase 0.8b v0.6:visionA-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
|
||
- **不需 CORS(converter 端或 FAA 端)**:visionA → converter 是 server-side 同進程內 outbound HTTP call,不適用 CORS(CORS 只管 browser JS fetch / XHR);browser 完全不知道 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.8(302 + delegated token)| v0.4(server-side proxy + visionA API key)| v0.5(server-side proxy + delegated token;**fictional 從未跑通**)| **v0.6(server-side proxy + visionA → converter API key)**|
|
||
|------|----|----|---|---|
|
||
| Token 結構是否存在於 visionA ↔ 下游鏈 | 是(MC issue,5 分鐘 TTL,過 browser)| 是(visionA API key,long-lived,server-side only)| 是(MC issue,但實際 issue 不到)| 是(visionA API key,long-lived,server-side only)|
|
||
| 攻擊者攔截 visionA → browser response 拿到 token | 短期可用 5 分鐘 | 結構性無 token | — | 結構性無 token(API key 不過 browser)|
|
||
| Frontend XSS 影響範圍 | 短 TTL token | 無 token 可竊 | — | 無 token 可竊 |
|
||
| Server compromise(visionA backend 被攻破)| 攻擊者可簽任意 MC delegated token(限於 service client 的 scope)| 攻擊者拿到 visionA API key 後可任意打 FAA 所有 endpoint | — | 攻擊者拿到 `VISIONA_CONVERTER_API_KEY` 後可任意打 converter 所有 endpoint;但 converter **不存其他 user / 其他產品線資料**(只存進行中 / 完成的轉檔 job、7 天 GC),blast radius 比 v0.4 直接打 FAA 小 |
|
||
| MC 是否為依賴 | 是(issue token)| 否 | 是(issue service token + delegated token)| 否(visionA 端 server-to-server 不依賴 MC;user login 仍依賴 MC OIDC,但與本表無關)|
|
||
| FAA 是否為依賴 | 是(直接打)| 是(直接打)| 是(直接打)| **否**(visionA 端只打 converter;converter → 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 TTL;v0.4 移除 token 結構但 API key long-lived;v0.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 成功;第二個重複呼叫 ensurePromoted(visionA 端無 cache、直接打 converter,converter 端 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 或方向 A(converter 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 limit(Phase 1 補;對齊 `security.md` §4)
|
||
- converter 端有 process semaphore(max 5 concurrent upload)保底
|
||
|
||
---
|
||
|
||
## 變更影響清單
|
||
|
||
實作此 spec 會動到的檔案(給 Backend Agent 參考;Backend 自己拆任務):
|
||
|
||
**Phase 0.8b v0.6 變更(相對於 v0.5 規劃但未上的 code,backend 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` method;config.go / .env 撤回 v0.5 規劃要加回的 OIDC ServiceClient* / TenantID / FAABaseURL。
|
||
>
|
||
> 同時:**converter 跨 repo 加新 endpoint**(jimchen 自己處理)。
|
||
|
||
**v0.6 跨 repo:converter scheduler(jimchen 在 `/Users/jimchen/kneron_model_converter/apps/task-scheduler/`)**:
|
||
|
||
- 新增 `src/routes/v1/result.js`(或加進 `jobs.js`):`GET /api/v1/jobs/:id/result` handler,套用既有 `requireReadAuth` middleware(API 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 backend;v0.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` method(v0.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 stream;error 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 / .env;converter 端跨 repo 加新 endpoint 由 jimchen 自行處理)。
|
||
|
||
---
|
||
|
||
## 版本記錄
|
||
|
||
| 日期 | 版本 | 變更 |
|
||
|------|------|------|
|
||
| 2026-04-30 | 0.1 | 初稿 — Phase 0.8 轉檔整合 TDD |
|
||
| 2026-04-30 | 0.2 | Download flow 改為 server-side HTTP 302 redirect:endpoint 從 `POST /{job}/download-token` 改為 `GET /{job}/download`、Service interface `DownloadToken` → `DownloadRedirectURL`、`DownloadGrant` 改為 mc_token_client 內部 struct(不對外 JSON)、補 §3.1 handler 範例、補 §10.4 token 不過 frontend JS 的安全分析、§6 補 `/download` 錯誤回應策略 |
|
||
| 2026-04-30 | 0.3 | Phase 0.8 三方交叉審閱回饋整合:§2.6.1 補「visionA-backend 重啟後 lazy rebuild ownership」(議題 #2,A4 方案);§2.6.2 補 expires_at 來源(議題 #7);§4.3.1 streaming proxy 進度語意明確化(議題 #6,採選項 A:等 converter 201 才回 200);§4.3.2 補 cancel cleanup 鏈與 best-effort cancel converter(議題 #5) |
|
||
| 2026-05-11 | 0.4 | **Phase 0.8b**:服務間認證從 OAuth `client_credentials` 改為 pre-shared API key(對應 [ADR-015](./adr/adr-015-server-to-server-api-key.md))。主要變更:(1) §1 端對端 sequence 拿掉 MC node;(2) §2 砍 `mc_token_client.go` 整個檔;(3) §3 新增「服務間認證(API key)」章節(原 §5 OAuth 章節整段刪除,章節編號 4→5);(4) §4.1 `/download` handler 從 `c.Redirect(302)` 改 server-side stream proxy(Service interface `DownloadRedirectURL` → `DownloadStream`);(5) §6 錯誤碼 mapping 移除 MC 4 個 code、新增 `converter_auth_failed` / `faa_auth_failed`;(6) §9.1 retry 矩陣移除 MC 2 row、所有下游 401/403 不重試;(7) §10.2 刪除 delegated token TTL、§10.3 改為 pre-shared API key 保護、§10.4 改為 server-side stream proxy 安全模型;(8) 變更影響清單列出 backend agent 後續實作要動的 .go 檔。OIDC user login 完全不動。 |
|
||
| 2026-05-15 | 0.4.1 | 修 §4.1 `/download` handler `Content-Disposition` filename 來源描述歧義(T4 Reviewer M-3)— 原註釋「filename 來自 promote 結果」可被誤讀為「FAA promote response 直接給 filename」;改為明確標示「visionA backend 在 service 層由 `defaultDownloadFilename(cj)` 從 conversion job metadata 構造(規則 `<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.4「visionA → FAA 改 API key」決定、FAA 線回到 ADR-014 §2 原設計(MC service token + delegated download token);visionA → converter API key 路線(v0.4)**維持**。主要變更:(1) §1 整體 flow sequence 加回 MC node、download 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 → converter(API key)+ §3.2 visionA → FAA(service 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 兩 row、FAA 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 實際 implementation(flow.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.5「visionA → 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 → converter(API key);promote 仍走 visionA → converter `POST /promote`(converter 內部 PUT FAA 與 visionA 無關,不變)。主要變更:(1) §1 整體 flow sequence 移除 MC node、download path 改成「converter.GetResult」;(2) §2 模組設計 — mc_token_client.go 維持砍除(撤回 v0.5 部分復活)、faa_client.go 改名為 converter_result_client.go(或併入 converter_client.go),新增 `GetResult` method、flow.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 兩 row、FAA 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 完全不動。 |
|