# Conversion — 轉檔功能整合(Phase 0.8)
> **角色**:visionA-backend 端的「轉檔」實作 spec(內部模組設計 + API + flow)。
> **上位文件**:`adr/adr-014-conversion-integration.md`、`TDD.md`、`security.md`
> **同層文件**:`api/api-conversion.md`(對 frontend 的 API 規格細節)
> **作者**:Architect Agent
> **狀態**:Draft(待 PM / Backend / Frontend / DevOps 交叉審閱)
> **最後更新**:2026-04-30
---
## 索引
1. [整體 flow(端對端)](#1-整體-flow端對端)
2. [模組設計 — `internal/conversion/`](#2-模組設計--internalconversion)
3. [新增 visionA-backend API](#3-新增-visiona-backend-api)
4. [Streaming proxy 設計(upload)](#4-streaming-proxy-設計upload)
5. [Service-to-service token 機制](#5-service-to-service-token-機制)
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(端對端)
```mermaid
sequenceDiagram
participant B as Browser
participant V as visionA-backend
participant MC as Member Center
participant C as Converter
participant F as FAA
Note over B,F: Stage 1 — Init job(streaming upload)
B->>V: POST /api/conversion/init (multipart)
V->>V: AuthMiddleware → user_id (OIDC sub)
V->>V: 檢查同 user active job
V->>MC: POST /oauth/token (cache miss only)
MC-->>V: service token (4 scopes)
V->>C: POST /api/v1/jobs (streamed multipart, user_id 注入)
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} (cache 1-2s)
C-->>V: {status, stage, progress, ...}
V-->>B: 整形後 status
end
Note over B,F: Stage 3a — User 選「加到模型庫」
B->>V: POST /api/conversion/{job_id}/promote-to-models
V->>C: POST /api/v1/jobs/{id}/promote
C->>F: PUT /files/{key} (NEF)
C-->>V: {target_object_key}
V->>F: GET /files/{key} (Bearer service token, scope=files:download.read)
F-->>V: NEF stream
V->>V: /api/models/init → /api/models/finalize
(Source=converted, SourceJobID=job_id)
V-->>B: 201 {model_id}
Note over B,F: Stage 3b — User 選「下載」(server-side 302 redirect)
B->>V: GET /api/conversion/{job_id}/download
( 或 window.location.href)
V->>V: AuthMiddleware → user_id + ownership 檢查
V->>C: POST /api/v1/jobs/{id}/promote (若還沒 promote)
C->>F: PUT /files/{key}
C-->>V: {target_object_key}
V->>MC: POST /file-access/download-tokens
(scope=files:download.delegate)
MC-->>V: opaque token
V-->>B: HTTP 302 Found
Location: https://faa/files/{key}?access_token=...
Note over B: browser 自動 follow 302
B->>F: GET /files/{key}?access_token=... (browser direct)
F->>MC: validate token
MC-->>F: ok
F-->>B: NEF stream
```
**critical path 說明**:
- visionA-backend 在 upload / download / promote 任一階段都先做 OIDC AuthMiddleware(既有)+ ownership 檢查
- promote 動作是冪等的(converter 端對同一 job 重複 promote 接受),visionA-backend 內部以 `job_id ↔ promoted_object_key` 記錄避免重複呼叫
- 加到模型庫流程:promote → FAA pull → `/api/models/init` 取得 model_id + presigned PUT URL → visionA-backend 自己 PUT 到 storage → `/api/models/finalize`(這條路徑要重用既有 handler 邏輯,不繞過)
---
## 2. 模組設計 — `internal/conversion/`
```
internal/conversion/
├── conversion.go # Service interface + 對外暴露的 type
├── converter_client.go # converter scheduler API client
├── faa_client.go # FAA API client(pull NEF)
├── mc_token_client.go # MC token endpoint (client_credentials) + cache
├── flow.go # 整體 flow 協調
├── types.go # request / response struct
└── errors.go # error code 定義
```
### 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)
// DownloadRedirectURL — 「下載」流程:promote (若需要) → MC delegated token → 組好的 FAA download URL。
// handler 拿到後直接 c.Redirect(http.StatusFound, url),token 不出現在任何 JSON response。
// 仿 FAA TestSite `DownloadFileDirect` pattern。
DownloadRedirectURL(ctx context.Context, userID, jobID string) (downloadURL string, 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"`
}
// DownloadGrant 是 mc_token_client 內部用的中間 struct(從 MC 換 token 時的回傳)。
// **不對 frontend JSON 序列化** — Phase 0.8 起 download flow 走 server-side 302 redirect,
// token 與 URL 永遠不出現在任何 visionA-backend → frontend 的 JSON response。
// 留 json tag 純粹給 mc_token_client 內部 unmarshal MC response 用。
type DownloadGrant struct {
DownloadURL string `json:"download_url"`
ExpiresAt time.Time `json:"expires_at"`
}
```
### 2.2 `converter_client.go`
```go
type ConverterClient struct {
baseURL string
httpClient *http.Client
tokens *MCTokenClient
}
// 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)
```
每個方法內部:
1. `c.tokens.Get(ctx)` 取 service token(自動 cache)
2. 帶 `Authorization: Bearer ` + `X-Request-Id` (從 ctx 取,沿用 visionA-backend 既有 request_id 中介層)
3. response 5xx / network error 走 retry(§9)
### 2.3 `faa_client.go`
```go
type FAAClient struct {
baseURL string
httpClient *http.Client
tokens *MCTokenClient
}
// Download server-to-server 拉檔(給「加到模型庫」流程用)。
// 用 service token (scope=files:download.read)。
func (c *FAAClient) Download(ctx context.Context, objectKey string) (io.ReadCloser, *DownloadMetadata, error)
type DownloadMetadata struct {
SizeBytes int64
ContentType string
Checksum string // optional
}
```
`DownloadGrant` 不在這裡產(在 `mc_token_client.go`,因為 token 是 MC 簽的不是 FAA 簽的)。
### 2.4 `mc_token_client.go`
```go
type MCTokenClient struct {
issuerURL string
clientID string
clientSecret string
httpClient *http.Client
mu sync.RWMutex
cachedToken string
cachedExp time.Time
}
// Get 取 service token;cache 直到 exp - 15s 內仍可用。
func (c *MCTokenClient) Get(ctx context.Context) (string, error) {
c.mu.RLock()
if c.cachedToken != "" && time.Until(c.cachedExp) > 15*time.Second {
defer c.mu.RUnlock()
return c.cachedToken, nil
}
c.mu.RUnlock()
c.mu.Lock()
defer c.mu.Unlock()
// double-check 避免併發重複取
if c.cachedToken != "" && time.Until(c.cachedExp) > 15*time.Second {
return c.cachedToken, nil
}
// POST {issuer}/oauth/token grant_type=client_credentials
// 失敗依 §9 retry
// ...
}
// IssueDelegatedDownload 跟 MC 換 browser 直連 FAA 用的 opaque token。
func (c *MCTokenClient) IssueDelegatedDownload(ctx context.Context, in DelegatedDownloadInput) (*DownloadGrant, error)
type DelegatedDownloadInput struct {
TenantID string
UserID string
ObjectKey string
Method string // "GET"
ExpiresInSeconds int // 預設 300(5 分鐘)
}
```
**Tenant 處理**:visionA 是 MC 的單一 tenant;`tenant_id` 從 config 取(`VISIONA_OIDC_TENANT_ID` 或從 issuer / client metadata 推得)— 由 `mcConfig.TenantID` 注入。
### 2.5 `flow.go` — 流程協調
```go
type Flow struct {
converter *ConverterClient
faa *FAAClient
tokens *MCTokenClient
models model.Repository // 沿用既有 model store
storage storage.Store // 沿用既有 LocalFS / S3
ownership ownershipStore // job_id → user_id mapping (in-memory map)
statusCache *jobStatusCache // 1-2s short cache,避免 frontend polling 直接打爆 converter
}
// 主要 method 對應 Service interface。
// PromoteToModels 內部:
// 1. ownership.Check(userID, jobID)
// 2. promotedKey, err := flow.ensurePromoted(ctx, jobID) // 冪等:若已 promote 過用 cache,否則打 converter
// 3. reader, meta, err := faa.Download(ctx, promotedKey)
// 4. modelID, putURL, _ := callModelsInit(...) // 直接呼叫 internal/api 同 package 既有 helper(不走 HTTP)
// 5. PUT 到 storage(或直接 io.Copy 到 storage.Put)
// 6. callModelsFinalize(...)
// 7. 在 model record 補 Source="converted" + SourceJobID=jobID
// 8. 回 modelID
```
**冪等性**:`flow.ensurePromoted(jobID)` 內部用 `sync.Map` 記 `job_id → target_object_key`;同 job 第二次 promote 直接回 cache,不打 converter。
### 2.6 `ownership` store(in-memory)
```go
type ownershipStore interface {
Set(jobID, userID string, expiresAt time.Time) // 對齊 converter 7d 過期
Get(jobID string) (userID string, ok bool)
CleanupExpired() // background goroutine 每 60s
}
```
雛形 in-memory;visionA-backend 重啟 → 所有「我的 job 列表」消失,user 等同失去對未完成 job 的後續操作能力(接受的取捨 — converter 端用 user_id 仍可查到,但 visionA UX 上看不到)。Phase 0.9 之後可改 DB persist。
#### 2.6.1 visionA-backend 重啟後的 cold start 恢復(Phase 0.8 MVP 行為)
**問題**:使用者 A 上傳了一個 job,正在 `processing`;visionA-backend 重啟(部署新版、crash recovery)→ in-memory ownership store 全空;使用者 A 重新打開 `/conversion` 頁面,前端打 `GET /api/conversion/active` → backend 找不到任何 ownership → 回 `has_active=false` → 前端顯示「沒有進行中的轉檔」。
**結果**:使用者 A 看到一個假的「乾淨」狀態,認為什麼都沒發生;但 converter 端那個 job 仍在跑(且 converter 端「同 user 1 active job」邏輯仍生效),使用者 A 重新 submit 會撞 409。
**Phase 0.8 MVP 的決策(接受的取捨)**:
| 選項 | 描述 | 採用? |
|------|------|--------|
| A1 | 維持現狀:重啟即遺失。靠 converter 7 天 expires_at 自然兜底 | ✓ Phase 0.8 採用 |
| A2 | 啟動時對 converter 打 `GET /api/v1/jobs?status=in_progress` 重建所有 ownership | ✗ Phase 0.8 不做 |
| A3 | 把 ownership 寫進 DB / Redis | ✗ Phase 0.9+ 評估 |
| A4 | 啟動時對特定 user 才 lazy 重建(`GET /active` 時若 in-memory 沒有,去 converter 查該 user 的 active job) | ✓ Phase 0.8 補上(**新增**) |
**A4 實作(Phase 0.8 補強)**:
```go
// flow.go ActiveJob 內部:
func (f *Flow) ActiveJob(ctx context.Context, userID string) (*conversion.Job, error) {
// 1. 先查 in-memory ownership
if jobID, ok := f.ownership.GetByUser(userID); ok {
return f.GetJob(ctx, userID, jobID)
}
// 2. in-memory miss → fallback 對 converter 查(lazy rebuild ownership)
job, err := f.converter.ListActiveJobsByUser(ctx, userID)
if err != nil { return nil, err }
if job == nil { return nil, nil }
// 重建 ownership(用 converter 回的 created_at + 7d 推算 expires_at)
f.ownership.Set(job.JobID, userID, job.CreatedAt.Add(7*24*time.Hour))
return job, nil
}
```
**前提**:converter Phase 1 的 `GET /api/v1/jobs?user_id=&status=in_progress` 必須可用。見 §11 跨團隊依賴。
**為什麼選 A4 不選 A2**:
- A2 啟動時批次掃所有 in_progress jobs:對 converter 是 hammer(重啟頻繁時尤甚),且大部分 jobs 重啟期間使用者根本沒在等
- A4 是 lazy(只有使用者主動進 `/conversion` 才查),cost 對應 user 行為,不會打爆 converter
- 取捨:使用者進 `/conversion` 時多 1 次 round-trip(< 200ms),對 UX 可接受
**Wireframe / UX 對齊**:Design wireframe §3.3 已 cover「進入頁面打 `/active`、有 active 直接落 processing」,A4 行為對 frontend 完全透明(同樣 endpoint、同樣 response shape)。
#### 2.6.2 expires_at 的來源
| 屬性 | 規格 |
|------|------|
| 定義 | converter 端對 job 做 7 天 GC 的截止時間 |
| 來源 | converter 的 job record `created_at + 7 days`(converter Phase 1 的 GC 邏輯) |
| 是否回傳給 visionA-frontend | ✓ 是 — `GET /api/conversion/{job_id}` response 與 `GET /api/conversion/active` response 都帶 `expires_at` |
| visionA-backend 怎麼知道 | 優先從 converter response 直接讀;若 converter 沒給(Phase 1 的 OpenAPI spec 待確認),visionA-backend 自行 `created_at + 7d` 推算 |
**待確認(given to DevOps / Backend Agent)**:
- 確認 converter Phase 1 的 `GET /api/v1/jobs/{id}` 是否在 response 含 `expires_at` 欄位
- 若有 → `internal/conversion/converter_client.go` 的 `Job` struct 加 `ExpiresAt time.Time`,直接透傳
- 若無 → backend 在 `Job` 上補 `ExpiresAt = CreatedAt.Add(7 * 24 * time.Hour)`,**前端永遠拿到 `expires_at`**(無論來源)
**Frontend 用途**:
- `completed.success` 畫面顯示「6 天 21 小時後自動清除」倒數提示
- `expires_at - now() ≤ 0` 時切「已過期」狀態(wireframe §8.2)
---
## 3. 新增 visionA-backend API
詳細請求 / 回應 schema 見 `api/api-conversion.md`;這裡列總覽。
| Method | Path | Auth | 用途 |
|--------|------|------|------|
| `POST` | `/api/conversion/init` | OIDC cookie | 上傳 + 建 job(multipart streaming) |
| `GET` | `/api/conversion/{job_id}` | OIDC cookie | 查 job 狀態 |
| `POST` | `/api/conversion/{job_id}/promote-to-models` | OIDC cookie | 「加到模型庫」 |
| `GET` | `/api/conversion/{job_id}/download` | OIDC cookie | 「下載」— server-side HTTP 302 redirect 到 FAA delegated URL |
| `GET` | `/api/conversion/active` | OIDC cookie | 查當前 user 有無 active job(frontend pre-check);in-memory miss 時 fallback 對 converter lazy rebuild(§2.6.1) |
> **不對外暴露但內部使用的 converter endpoint**:`GET /api/v1/jobs?user_id=&status=in_progress`(§2.6.1 lazy rebuild)。Phase 0.8 frontend 看不到「歷史列表」UI,但後端會用此內部 endpoint 做韌性處理。
>
> **`POST /api/v1/jobs/{id}/cancel`**:converter Phase 1 **尚未實作**(已驗證 routes/v1/jobs.js 與 openapi.yaml)。Phase 0.8 失敗 cleanup 採「socket close 自然 abort」(§4.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)
### 3.1 `GET /api/conversion/{job_id}/download` — server-side 302 redirect handler
仿 FAA TestSite `DownloadFileDirect`(`FileAccessAgent.TestSite/Controllers/HomeController.cs:255-282`):
```go
// GET /api/conversion/{job_id}/download
func conversionDownloadHandler(deps Deps) gin.HandlerFunc {
return func(c *gin.Context) {
uc, _ := UserContextFrom(c) // AuthMiddleware 已驗
jobID := c.Param("job_id")
// service 內部完成:ownership 檢查 → ensurePromoted → MC 換 delegated token → 組 URL
downloadURL, err := deps.Conversion.DownloadRedirectURL(c.Request.Context(), uc.UserID, jobID)
if err != nil {
// 錯誤情況不 redirect,直接走既有 WriteError(依 Accept header 回 JSON 或 HTML 錯誤頁)
// mapping 見 §6 + §12
writeConversionError(c, err)
return
}
// server-side HTTP 302 — token 在 Location header,不過 frontend JS、不需 CORS
// 防快取:避免 browser 把 302 + Location 存進 history / disk cache
c.Header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
c.Header("Pragma", "no-cache")
c.Redirect(http.StatusFound, downloadURL)
}
}
```
**為什麼用 GET 不用 POST**:
- frontend 用 `` 觸發 — anchor tag 只能發 GET
- GET semantically 對應「拿一個資源」,符合「下載這個 job 的結果」語意
- 既有 OIDC AuthMiddleware 會自然處理 GET 上的 cookie session,無 CSRF 風險(沒有狀態變更,promote 是冪等的)
**Frontend 使用範例**:
```html
下載
```
或:
```ts
// 程式化觸發:等同 anchor tag
window.location.href = `/api/conversion/${jobId}/download`;
```
Frontend **永遠看不到** download token 與 raw object_key — token 只活在 visionA-backend → browser 的 302 Location header(browser memory,JS 看不到,除非開 devtools network 面板)→ 馬上被 browser 用來打 FAA 後消失。
---
## 4. Streaming proxy 設計(upload)
### 4.1 為什麼要 streaming
- 模型上限 500MB;ref_images 100×10MB = 1GB 上限
- 全 buffer 在 RAM → 同時 N 個 user upload 直接 OOM
- 暫存 disk → 增加 IO 與磁碟空間需求;壞掉的 cleanup 麻煩
### 4.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 算)
### 4.3 進度 / 取消
#### 4.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)。這明確告訴使用者「browser 端已送完,正在等 server 收尾」,不是欺騙性的進度條。
> **Phase 1 升級路徑**:若 Phase 1 量大需要更精準的端到端進度,可改成 SSE 推送 `upload_progress` 事件(backend 主動報「已 forward N bytes 給 converter」),但 Phase 0.8 MVP 不做。
#### 4.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);後端會自動處理。
#### 4.3.3 既有 4.3 內容(保留)
- **進度**:visionA-backend 不做進度回報;frontend 用 XHR `upload.onprogress` 自己顯示(既有前端模式)
- **取消**:context.Cancel(client 斷線)→ 連帶 cancel converter request(如上 cleanup 鏈);converter 端 multer 收到 socket close 會自動 abort multipart parsing
### 4.4 Timeout
- handler 整體不設總 timeout(500MB upload 可能 5-10 分鐘)
- 但每個 io.Copy 之間用 `http.Server.WriteTimeout`/`IdleTimeout` 控制 keep-alive;具體值由 DevOps 在 Nginx / ingress 設定(建議 600s)
---
## 5. Service-to-service token 機制
### 5.1 取得流程
```
visionA-backend 啟動
↓
讀 cfg.OIDC.ServiceClientID/Secret (config 既有預埋)
↓
lazy init MCTokenClient(不主動取)
↓
[第一個轉檔請求進來]
↓
flow → converter_client → tokens.Get(ctx)
↓
cache miss → POST {issuer}/oauth/token
grant_type=client_credentials
client_id=
client_secret=
scope=converter:job.write converter:job.read files:download.read files:download.delegate
↓
MC 回 {access_token, expires_in, scope}
↓
cache (exp = now + expires_in - 15s)
↓
return token
```
### 5.2 Cache 策略
- 單一 token cache 涵蓋 4 個 scope(MC 端發單一 token 含全部)
- `exp - 15s` 提前重取,避免下游使用時剛好過期
- 併發保護:double-checked locking(5.4 §2.4 範例)
- 重啟即清空(in-memory,無持久化)
### 5.3 Config 對齊
`visionA-backend/internal/config/config.go` 已預埋 `OIDCConfig.ServiceClientID/Secret`(A1 階段不啟用)。Phase 0.8 啟用:
```diff
// OIDCConfig.Validate
func (c *Config) Validate() error {
...
+ // Phase 0.8 起 Service Client 啟用(轉檔功能依賴)
+ if c.OIDC.ServiceClientID == "" {
+ missing = append(missing, "VISIONA_OIDC_SERVICE_CLIENT_ID")
+ }
+ if c.OIDC.ServiceClientSecret == "" {
+ missing = append(missing, "VISIONA_OIDC_SERVICE_CLIENT_SECRET")
+ }
...
}
```
新增 env:
```
VISIONA_OIDC_SERVICE_CLIENT_ID=23605e14a2c64660abd97e29963d8d58
VISIONA_OIDC_SERVICE_CLIENT_SECRET=
VISIONA_CONVERTER_BASE_URL=http://192.168.0.130:9501
VISIONA_FAA_BASE_URL=http://192.168.0.130:5081
VISIONA_OIDC_TENANT_ID=
```
---
## 6. 錯誤碼 mapping + i18n key
| Converter / FAA / MC error | visionA error code | HTTP | i18n key | user-friendly 訊息(zh-TW) |
|----------|--------------------|------|----------|--------------------------|
| converter `validation_error` | `validation_failed` | 400 | `conversion.error.validation` | 上傳的檔案不符合要求(請查看詳細欄位錯誤) |
| converter `invalid_multipart` | `validation_failed` | 400 | `conversion.error.invalid_multipart` | 上傳格式錯誤,請重新嘗試 |
| converter `user_has_active_job` | `active_job_exists` | 409 | `conversion.error.active_job` | 你目前已有進行中的轉檔任務 |
| converter `file_too_large` | `payload_too_large` | 413 | `conversion.error.too_large` | 檔案超過大小限制 |
| converter `service_busy` | `service_busy` | 503 | `conversion.error.busy` | 系統繁忙,請稍後再試 |
| converter `storage_unavailable` | `converter_unavailable` | 502 | `conversion.error.converter_down` | 轉檔服務暫時無法使用 |
| converter 5xx / network | `converter_unavailable` | 502 | `conversion.error.converter_down` | 同上 |
| FAA 5xx / network | `faa_unavailable` | 502 | `conversion.error.faa_down` | 檔案存取服務暫時無法使用 |
| MC token 4xx | `idp_misconfigured` | 500 | `conversion.error.idp_misconfig` | 系統設定錯誤,請聯絡支援 |
| MC token 5xx | `idp_unavailable` | 503 | `conversion.error.idp_down` | 認證服務暫時無法使用 |
| MC delegated 4xx | `download_token_failed` | 502 | `conversion.error.token_failed` | 無法取得下載授權,請重試 |
| MC delegated 5xx / network 持續失敗 | `mc_token_unavailable` | 502 | `conversion.error.token_failed` | 無法取得下載授權,請重試 |
| job 不屬於當前 user | `forbidden` | 403 | `conversion.error.forbidden` | 你無權存取此轉檔任務 |
| job_id 不存在 | `not_found` | 404 | `conversion.error.not_found` | 轉檔任務不存在 |
| job 還沒 completed | `job_not_completed` | 409 | `conversion.error.not_completed` | 任務尚未完成,請等轉檔完成再下載 |
i18n key 命名:`conversion.error.`,前端 i18n 字典在 `visionA-frontend/messages/{zh-TW,en}.json` 補。
**`/download` endpoint 錯誤回應策略**(GET + 302 redirect 場景):
由於 `GET /api/conversion/{job_id}/download` 採 server-side 302,錯誤情況**不 redirect**,改用既有 `WriteError` helper 依 `Accept` header 回應:
- `Accept: application/json` → 回標準 visionA error JSON `{success:false, error:{code, message}}`(給 fetch / 程式化 retry 用)
- `Accept: text/html`(一般 anchor tag / window.location.href 觸發)→ 回 HTML 錯誤頁;browser 直接顯示
- 其他 → 預設 JSON
frontend 用 `` 觸發時,若失敗 browser 會把錯誤頁顯示在頁面上。若希望 inline 處理錯誤(例如 toast 提示),改用 `fetch(..., {redirect: 'manual'})` + 檢查 status code(但這條路徑要小心 fetch 對 302 的處理)— Phase 0.8 不要求此 UX,先用 anchor tag 觸發即可。
---
## 7. user_id 注入與 trust boundary
### 7.1 唯一可信任點
```
┌──────────────────────────────────────────────────────────────┐
│ visionA-backend conversion handler │
│ │
│ AuthMiddleware → UserContext (OIDC sub from cookie) │
│ ↓ │
│ conversion.Service.InitJob(InitJobInput{UserID: }) │
│ ↓ │
│ flow.go InitJob │
│ ├─ multipart streaming 重組(黑名單 client 帶的 user_id)│
│ ├─ mw.WriteField("user_id", ) ← 唯一灌入點 │
│ └─ POST converter /api/v1/jobs │
└──────────────────────────────────────────────────────────────┘
```
### 7.2 Ownership 檢查
每個 GET / promote / download / models 操作都先檢查 `ownership.Get(jobID) == userCtx.UserID`,不符 → 403 `forbidden`。
### 7.3 客戶端不可信原則
- frontend / browser 帶來的 user_id 永遠忽略(streaming 重組時黑名單)
- frontend / browser 帶來的 object_key 永遠忽略(GET /download 不接受 client 指定 object_key,從 visionA 內部 promote 結果反查)
- frontend 只能告訴我們 `job_id`,其他都從 server side 推
---
## 8. Non-Goals(Phase 0.8 不做)
對齊 PRD Phase 0.8 邊界:
| 項目 | Phase 0.8 行為 | Phase 1+ 計畫 |
|------|--------------|--------------|
| SSE / WebSocket 進度推送 | frontend HTTP polling,間隔 2s | SSE endpoint `/api/conversion/{id}/events` |
| 取消 job | 不提供;user 等 converter 自己跑完或 7 日後 expires | `POST /api/conversion/{id}/cancel` |
| Job 歷史列表 | 不提供;in-memory map 重啟即清 | DB persist + `GET /api/conversion/history` |
| 同 user 多個 active job | 強制 1 個(pre-check + converter 409 透傳) | 沿用 converter 限制(短期內無計畫放寬) |
| 多 chip 同時轉 | 一次只能選一個 target_chip | Phase 1 後評估 |
| Webhook(converter 完成 push) | 不接收 | converter Phase 2 才提供 |
| 大量批次 upload | 不支援 | 不在路線圖 |
---
## 9. 失敗模式 & retry 矩陣
### 9.1 retry 規則
| 操作 | 4xx | 5xx | network / timeout | max retry | 退避 |
|------|-----|-----|------------------|-----------|------|
| Converter `POST /jobs` | 透傳 | 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 兜底**(§4.3.2)|
| Converter `GET /jobs?user_id=&status=in_progress`(lazy rebuild)| 透傳 | retry | retry | 1 | 0.5s |
| Converter `POST /promote` | 透傳 | retry | retry | 2 | 1s, 2s |
| FAA `GET /files/{key}`(s2s) | 透傳 | retry | retry | 2 | 1s, 2s |
| MC `POST /oauth/token` | 4xx → fatal | retry | retry | 2 | 1s, 2s |
| MC `POST /file-access/download-tokens` | 透傳 | retry | retry | 2 | 1s, 2s |
每次 retry 之間檢查 `ctx.Done()`;ctx cancel → 立即 return ctx.Err()。
### 9.2 graceful degradation
| 場景 | 處理 |
|------|------|
| converter 完全不可達(持續 5xx) | `502 converter_unavailable`,UI 提示「轉檔服務暫時無法使用,請稍後再試」 |
| 完成後 promote 失敗(converter 5xx) | job 留在 completed 狀態(FAA 上沒檔但 visionA 知道),UI 給 user 「重試 promote」按鈕(重打 promote-to-models / download) |
| FAA pull 失敗(加到模型庫流程) | model record 不寫入;UI 提示重試;不影響「下載」路徑(後者直接 browser ↔ FAA) |
| MC delegated token 失敗 | UI 給 user 「改用『加到模型庫』流程」備援選項 |
| visionA-backend 重啟 | in-memory ownership 與 promoted_key cache 全失,user 已建立的 job 在 frontend 看不到,但 converter 端仍存在 — 需 user 知道下次只能等 converter 自然 expire(接受的取捨;MVP 階段內部使用者) |
### 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
- 預設 5 分鐘(300 秒),可由 `VISIONA_FAA_DELEGATED_TTL_SECONDS` env 調整(範圍 60-900)
- TTL 越短越安全但 user UX 越差;MVP 取 5 分鐘平衡
- visionA-frontend **不應快取** download_url(每次「下載」都重新打 backend 換新 token)
### 10.3 Service token 保護
- `VISIONA_OIDC_SERVICE_CLIENT_SECRET` 不可進 git(既有 `.gitignore` 含 `.env`)
- 部署用 AWS Secrets Manager / k8s Secret 注入
- log 永遠不印 secret 與 access token;只印 token 前 8 字元前綴 `Bearer ey1234...`
- 若 secret 洩漏:MC 端 rotate → 重新部署 visionA-backend;in-memory cache 自然失效
### 10.4 Object key 與 download token 不暴露給 frontend JS
- visionA-backend 透過 HTTP 302 redirect 把含 token 的 download URL 放在 `Location` header,**不回 JSON body、不放 URL bar 永久 history**
- Token 與 raw `object_key` **永遠不出現**在任何 visionA-backend → frontend 的 JSON response — frontend JS 對它們完全沒有 reference
- 唯一觀察點是 browser 自身的 navigation(devtools network 面板能看到 302 Location,但這是 browser 本機的事,跟 server 把 token 寫進 JSON 給 JS 處理是不同的攻擊面)
- 防快取:handler 設 `Cache-Control: no-store` + `Pragma: no-cache`,避免 browser 把 302 Location 寫入 disk cache
- 即使有人 capture URL(例如從 devtools 複製貼出去),也只能在 5 分鐘 TTL 內用,且 method=GET 被綁死
- **不需 FAA CORS**:browser navigation request 不適用 CORS(CORS 只管 JS fetch / XHR);server-side 302 redirect 是 browser 原生 navigation 行為
### 10.5 Race condition
- 同 user 同時兩 tab init → 第一個成功寫 ownership / converter 接受;第二個 pre-check 通過但 converter 409
- 兩 tab 同時 promote-to-models → 第一個寫 model record 成功;第二個重複呼叫 ensurePromoted(cache hit)→ FAA pull 兩次(接受的取捨;FAA 端冪等)→ models repo 寫入時可能撞 model_id 衝突 — 改用 model_id 在 finalize 前 SELECT 檢查
### 10.6 DoS 防護(最小集,Phase 1 強化)
- 同 user 1 個 active job 的限制本身就是 DoS 防護(user 不能 init 1000 個 job)
- visionA-backend conversion endpoint 不額外 rate limit(Phase 1 補;對齊 `security.md` §4)
- converter 端有 process semaphore(max 5 concurrent upload)保底
---
## 變更影響清單
實作此 spec 會動到的檔案(給 Backend Agent 參考;Backend 自己拆任務):
- 新增:`visionA-backend/internal/conversion/*.go`(含 `_test.go`)
- 新增:`visionA-backend/internal/api/conversion.go`(handler)
- 新增:`visionA-backend/internal/api/conversion_test.go`
- 修改:`visionA-backend/internal/config/config.go`(啟用 ServiceClientID/Secret + 新增 ConverterBaseURL / FAABaseURL / TenantID)
- 修改:`visionA-backend/internal/api/api.go`(Deps 加 `Conversion conversion.Service`、router 註冊 `/api/conversion/*`)
- 修改:`visionA-backend/cmd/api-server/main.go`(wire conversion.Flow)
- 不動:`internal/model/*`(schema 不變)
- 不動:`internal/api/models.go`(既有 init/finalize 不動,flow.PromoteToModels 內部呼叫 helper)
---
## 版本記錄
| 日期 | 版本 | 變更 |
|------|------|------|
| 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) |