jim800121chen fb7da5d180 chore(autoflow): migrate .autoflow/ 共享層文件至 docs/autoflow/
依 autoflow-agent workspace v2 設計把 PRD / 設計 / 架構 / 交付類
共享文件從個人層 .autoflow/(ignored)搬到 docs/autoflow/(進 git),
讓團隊可共享產品與架構文件,個人層只留 progress / review / testing 等
per-branch 筆記。

- 02-prd/        21 個檔(PRD、features、market-analysis 等)
- 03-design/     18 個檔(design-spec、wireframes、flows 等)
- 04-architecture/ 31 個檔(TDD、design-doc、ADR×14、API 規格等)
- 07-delivery/   3 個檔(project-summary、phase-0.6-handover、stage-deployment-setup)

合計 73 檔。原檔已從 .autoflow/ 移除(migration 工具執行 git mv,
但因 .autoflow/ 在 .gitignore 中、git 將此操作視為新增、無 rename history)。
2026-05-04 16:55:55 +08:00

938 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Conversion — 轉檔功能整合Phase 0.8
> **角色**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-GoalsPhase 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 jobstreaming 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<br/>(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<br/>(<a href> 或 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<br/>(scope=files:download.delegate)
MC-->>V: opaque token
V-->>B: HTTP 302 Found<br/>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 clientpull 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 statusownership 檢查後 cache 1-2s。
GetJob(ctx context.Context, userID, jobID string) (*Job, error)
// PromoteToModels — 「加到模型庫」流程promote → FAA pull → models repo finalize。
// 回傳新建的 model_id。
PromoteToModels(ctx context.Context, userID, jobID string) (modelID string, err error)
// 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-typeservice 內部處理 streaming。
type InitJobInput struct {
UserID string
ContentType string // 含 boundary 的原始值
Body io.Reader // request.Body
ContentLength int64
TargetChip string // "520" / "720"
// 其他 form fieldsmodel_id, version, enable_*)由 handler 解多 part 後傳入
// — 實作上在 §4 streaming 處理時把這些 field 也透傳給 converter
}
type Job struct {
JobID string `json:"job_id"`
Status string `json:"status"` // created / running / completed / failed
Stage string `json:"stage"` // onnx / bie / nef
Progress int `json:"progress"` // 0-100
StageProgress int `json:"stage_progress"`// 0-100
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ErrorCode string `json:"error_code,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
}
// 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 bodycontent-type 含 boundary透傳給 converter。
// caller 必須:
// 1. 已經把 user_id 透過 multipart.Writer 注入 body在 streaming 過程中)
// 2. content-type 是合法的 multipart/form-data; boundary=...
func (c *ConverterClient) CreateJobStream(ctx context.Context, contentType string, body io.Reader, contentLength int64) (*Job, error)
func (c *ConverterClient) GetJob(ctx context.Context, jobID string) (*Job, error)
func (c *ConverterClient) PromoteJob(ctx context.Context, jobID string) (targetObjectKey string, err error)
```
每個方法內部:
1. `c.tokens.Get(ctx)` 取 service token自動 cache
2.`Authorization: Bearer <service-token>` + `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 tokencache 直到 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 // 預設 3005 分鐘)
}
```
**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` storein-memory
```go
type ownershipStore interface {
Set(jobID, userID string, expiresAt time.Time) // 對齊 converter 7d 過期
Get(jobID string) (userID string, ok bool)
CleanupExpired() // background goroutine 每 60s
}
```
雛形 in-memoryvisionA-backend 重啟 → 所有「我的 job 列表」消失user 等同失去對未完成 job 的後續操作能力(接受的取捨 — converter 端用 user_id 仍可查到,但 visionA UX 上看不到。Phase 0.9 之後可改 DB persist。
#### 2.6.1 visionA-backend 重啟後的 cold start 恢復Phase 0.8 MVP 行為)
**問題**:使用者 A 上傳了一個 job正在 `processing`visionA-backend 重啟部署新版、crash recovery→ in-memory ownership store 全空;使用者 A 重新打開 `/conversion` 頁面,前端打 `GET /api/conversion/active` → backend 找不到任何 ownership → 回 `has_active=false` → 前端顯示「沒有進行中的轉檔」。
**結果**:使用者 A 看到一個假的「乾淨」狀態,認為什麼都沒發生;但 converter 端那個 job 仍在跑(且 converter 端「同 user 1 active job」邏輯仍生效使用者 A 重新 submit 會撞 409。
**Phase 0.8 MVP 的決策(接受的取捨)**
| 選項 | 描述 | 採用? |
|------|------|--------|
| A1 | 維持現狀:重啟即遺失。靠 converter 7 天 expires_at 自然兜底 | ✓ Phase 0.8 採用 |
| A2 | 啟動時對 converter 打 `GET /api/v1/jobs?status=in_progress` 重建所有 ownership | ✗ Phase 0.8 不做 |
| A3 | 把 ownership 寫進 DB / Redis | ✗ Phase 0.9+ 評估 |
| A4 | 啟動時對特定 user 才 lazy 重建(`GET /active` 時若 in-memory 沒有,去 converter 查該 user 的 active job | ✓ Phase 0.8 補上(**新增** |
**A4 實作Phase 0.8 補強)**
```go
// flow.go ActiveJob 內部:
func (f *Flow) ActiveJob(ctx context.Context, userID string) (*conversion.Job, error) {
// 1. 先查 in-memory ownership
if jobID, ok := f.ownership.GetByUser(userID); ok {
return f.GetJob(ctx, userID, jobID)
}
// 2. in-memory miss → fallback 對 converter 查lazy rebuild ownership
job, err := f.converter.ListActiveJobsByUser(ctx, userID)
if err != nil { return nil, err }
if job == nil { return nil, nil }
// 重建 ownership用 converter 回的 created_at + 7d 推算 expires_at
f.ownership.Set(job.JobID, userID, job.CreatedAt.Add(7*24*time.Hour))
return job, nil
}
```
**前提**converter Phase 1 的 `GET /api/v1/jobs?user_id=<id>&status=in_progress` 必須可用。見 §11 跨團隊依賴。
**為什麼選 A4 不選 A2**
- A2 啟動時批次掃所有 in_progress jobs對 converter 是 hammer重啟頻繁時尤甚且大部分 jobs 重啟期間使用者根本沒在等
- A4 是 lazy只有使用者主動進 `/conversion` 才查cost 對應 user 行為,不會打爆 converter
- 取捨:使用者進 `/conversion` 時多 1 次 round-trip< 200ms UX 可接受
**Wireframe / UX 對齊**Design wireframe §3.3 cover進入頁面打 `/active` active 直接落 processing」,A4 行為對 frontend 完全透明同樣 endpoint同樣 response shape)。
#### 2.6.2 expires_at 的來源
| 屬性 | 規格 |
|------|------|
| 定義 | converter 端對 job 7 GC 的截止時間 |
| 來源 | converter job record `created_at + 7 days`converter Phase 1 GC 邏輯 |
| 是否回傳給 visionA-frontend | `GET /api/conversion/{job_id}` response `GET /api/conversion/active` response 都帶 `expires_at` |
| visionA-backend 怎麼知道 | 優先從 converter response 直接讀 converter 沒給Phase 1 OpenAPI spec 待確認visionA-backend 自行 `created_at + 7d` 推算 |
**待確認given to DevOps / Backend Agent**
- 確認 converter Phase 1 `GET /api/v1/jobs/{id}` 是否在 response `expires_at` 欄位
- 若有 `internal/conversion/converter_client.go` `Job` struct `ExpiresAt time.Time`直接透傳
- 若無 backend `Job` 上補 `ExpiresAt = CreatedAt.Add(7 * 24 * time.Hour)`**前端永遠拿到 `expires_at`**無論來源
**Frontend 用途**
- `completed.success` 畫面顯示6 21 小時後自動清除倒數提示
- `expires_at - now() ≤ 0` 時切已過期狀態wireframe §8.2
---
## 3. 新增 visionA-backend API
詳細請求 / 回應 schema `api/api-conversion.md`這裡列總覽
| Method | Path | Auth | 用途 |
|--------|------|------|------|
| `POST` | `/api/conversion/init` | OIDC cookie | 上傳 + jobmultipart streaming |
| `GET` | `/api/conversion/{job_id}` | OIDC cookie | job 狀態 |
| `POST` | `/api/conversion/{job_id}/promote-to-models` | OIDC cookie | 加到模型庫 |
| `GET` | `/api/conversion/{job_id}/download` | OIDC cookie | 下載」— server-side HTTP 302 redirect FAA delegated URL |
| `GET` | `/api/conversion/active` | OIDC cookie | 查當前 user 有無 active jobfrontend pre-checkin-memory miss fallback converter lazy rebuild(§2.6.1 |
> **不對外暴露但內部使用的 converter endpoint**`GET /api/v1/jobs?user_id=&status=in_progress`§2.6.1 lazy rebuild。Phase 0.8 frontend 看不到「歷史列表」UI但後端會用此內部 endpoint 做韌性處理。
>
> **`POST /api/v1/jobs/{id}/cancel`**converter Phase 1 **尚未實作**(已驗證 routes/v1/jobs.js 與 openapi.yaml。Phase 0.8 失敗 cleanup 採「socket close 自然 abort」§4.3.2Phase 1 待 converter 補上 endpoint 後再升級為 best-effort 主動 cancel。
所有 endpoint 通用
- 走既有 `AuthMiddleware``internal/api/middleware/auth.go`
- `UserContextFrom(c)` `uc.UserID`OIDC sub
- response 用既有 `WriteSuccess` / `WriteError` helper
- request_id 透傳給 converter`X-Request-Id` header
### 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 `<a href="..." download>` 觸發 anchor tag 只能發 GET
- GET semantically 對應拿一個資源」,符合下載這個 job 的結果語意
- 既有 OIDC AuthMiddleware 會自然處理 GET 上的 cookie session CSRF 風險沒有狀態變更promote 是冪等的
**Frontend 使用範例**
```html
<!-- 推薦anchor tagbrowser 自動處理 navigation + 302 follow -->
<a href={`/api/conversion/${jobId}/download`} download>下載</a>
```
```ts
// 程式化觸發:等同 anchor tag
window.location.href = `/api/conversion/${jobId}/download`;
```
Frontend **永遠看不到** download token raw object_key token 只活在 visionA-backend browser 302 Location headerbrowser memoryJS 看不到除非開 devtools network 面板)→ 馬上被 browser 用來打 FAA 後消失
---
## 4. Streaming proxy 設計upload
### 4.1 為什麼要 streaming
- 模型上限 500MBref_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 partstreaming copy
fw, err := mw.CreateFormFile(name, part.FileName())
if err != nil { errCh <- err; return }
if _, err := io.Copy(fw, part); err != nil { errCh <- err; return }
}
}
errCh <- nil
}()
// 同步 POST 到 converter
job, err := f.converter.CreateJobStream(ctx, mw.FormDataContentType(), pr, -1)
// 等 goroutine 結束
if goErr := <-errCh; goErr != nil && err == nil {
err = goErr
}
if err != nil { return nil, mapConverterError(err) }
f.ownership.Set(job.JobID, in.UserID, time.Now().Add(7*24*time.Hour))
return job, nil
}
```
**關鍵點**
1. `io.Pipe` client reader converter writer串接期間記憶體只有 multipart.Reader buffer(≈ 64KB 預設
2. 必須先寫 `user_id` field**順序**user_id model file 之前避免 converter multer 解析時 user_id 還沒到就拒絕
3. **黑名單 user_id**忽略 client 帶的 user_id永遠用 visionA-backend 自己灌的
4. context cancellationhandler 收到 client disconnect ctx.Done() goroutine 自動結束pw.Close 觸發 reader EOF
5. 不做 ContentLength forwardconverter 自己 multer
### 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 完全部 bytesXHR 進度 100%
└─ 但 backend → converter 的 io.Pipe 可能還在繼續流buffer 內未消化)
T2: backend 把全部 bytes forward 完給 converter
└─ 這時候才拿到 converter 的 201 + job_id
T3: backend 200 回 frontend
```
**設計選擇Phase 0.8 MVP**visionA-backend 等到 T2converter 201才回 200** early-return**。
| 屬性 | 選項 A converter 201 200 採用 | 選項 Bbrowser send 完就 200背景 forward |
|------|----------------------------------|------------------------------------------|
| Frontend 進度條精確度 | 100% 接近端到端真實狀態 | 進度 100% 後還有未知延遲 假象 |
| UX 延遲感 | 多等 1-3 io.Pipe drain| 立即切 processing 畫面 |
| backend 實作複雜度 | 直接同步等| 需要 background goroutine + ownership `upload_in_progress` + 額外狀態管理 |
| 失敗處理複雜度 | 同步錯誤直接回 frontend| 背景 forward 失敗時 frontend 已切 processing要額外 push 錯誤通知|
**選項 A 的 UX 補償** XHR `loaded === total` backend 還沒回 200 frontend 顯示 `即將完成…` / `伺服器處理中…`對齊 flow-conversion.md §5.3)。這明確告訴使用者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 |
| C4backend 偵測 converter 拒絕4xx/5xx| converter response | 立即回 frontend不需特別 cleanupconverter 自己沒建 job |
**C1-C3 的 cleanup 鏈**
```
client disconnect
gin handler `c.Request.Context().Done()` 觸發
streaming goroutine 的 `pw.Close()` defer 執行 → io.Pipe reader 收到 EOF/error
converter HTTP request 的 body read 端拿到 EOF
converter multer 偵測 incomplete multipart → 拒絕收 job不會建 job_id
```
**但**上面的鏈在backend 已經把 multipart header 寫進去converter 已建 job_idstream 還在 forward 這個區間斷線時**converter 端可能已經建立 job 但收不完 body**。實測上 converterPhase 1的行為是
- 收不完 body multer 拋錯 job 留在 `failed` 狀態 + error_code=`invalid_multipart`
- user active_job 邏輯converter `failed` 視為 active job 結束下次 init 不會撞 409
**為了避免「converter 視為 active 但 visionA 不知道」的孤立 job 風險** converter 是否提供 `/cancel` endpoint 採不同策略
> ### Phase 0.8 限制(重要 — 已驗證實作狀態)
>
> **converter Phase 1 並未實作 `POST /api/v1/jobs/{id}/cancel` endpoint**。
>
> 已驗證範圍:`apps/task-scheduler/src/routes/v1/jobs.js` 只有以下路由:
> - `POST /api/v1/jobs/`(建立 job
> - `GET /api/v1/jobs/`list
> - `GET /api/v1/jobs/:id`(單一狀態)
> - `POST /api/v1/jobs/:id/download-tokens`issue download token
> - `DELETE /api/v1/jobs/:id`(刪 job — 是 hard delete 而不是 cancel running job
>
> openapi.yaml 也沒有 cancel 路徑或 example。
>
> 因此 **Phase 0.8 採「socket close 自然 abort」策略**
>
> ```
> client disconnect / streaming body 中斷
>
> converter multer 拋 invalid_multipart
>
> 該 job 留 failed + error_code=invalid_multipart
>
> converter 對 active_job 邏輯視為已結束failed 不算 active
>
> 下次 init 不會撞 409
> ```
>
> visionA-backend 在 InitJob 失敗時不主動發 cancel沒有對應 endpoint 可發);只在
> log 紀錄失敗事件,依靠 converter 自然 abort 收尾。
> ### Phase 1+ 升級路徑converter 補上 /cancel 之後)
>
> 當 converter 上線 `POST /api/v1/jobs/{id}/cancel` 後visionA-backend 升級為
> best-effort 主動 cancel
>
> ```go
> // flow.go InitJob 內部goroutine 結束後若 err != nil 且我們已拿到 job_id
> if goErr != nil && job != nil && job.JobID != "" {
> // 用獨立 timeout不繼承已 cancel 的 ctx失敗只 log
> cancelCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
> defer cancel()
> if cancelErr := f.converter.CancelJob(cancelCtx, job.JobID); cancelErr != nil {
> logger.Warn("best-effort cancel failed", "job_id", job.JobID, "err", cancelErr)
> }
> }
> ```
>
> 動工項:
> 1. T3 ConverterClient interface 新增 `CancelJob(ctx context.Context, jobID string) error`
> 2. flow.go InitJob 失敗路徑加上面的 best-effort cancel block
> 3. 補對應 unit testmock converter 收到 cancel call
> 4. 對齊 §9.1 retry 矩陣的「Converter `POST /jobs/{id}/cancel`(內部 cleanup」row
**C1 特別處理(使用者按「取消上傳」)**frontend `xhr.abort()` 之前**不應**先打 cancel API多此一舉TCP RST 即已觸發 cleanup後端會自動處理
#### 4.3.3 既有 4.3 內容(保留)
- **進度**visionA-backend 不做進度回報frontend XHR `upload.onprogress` 自己顯示既有前端模式
- **取消**context.Cancelclient 斷線)→ 連帶 cancel converter request如上 cleanup converter multer 收到 socket close 會自動 abort multipart parsing
### 4.4 Timeout
- handler 整體不設總 timeout500MB 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=<ServiceClientID>
client_secret=<ServiceClientSecret>
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 scopeMC 端發單一 token 含全部
- `exp - 15s` 提前重取避免下游使用時剛好過期
- 併發保護double-checked locking5.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=<from MC, never commit>
VISIONA_CONVERTER_BASE_URL=http://192.168.0.130:9501
VISIONA_FAA_BASE_URL=http://192.168.0.130:5081
VISIONA_OIDC_TENANT_ID=<visionA tenant id at MC>
```
---
## 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.<short-name>`前端 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 `<a href>` 觸發時若失敗 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: <sub>}) │
│ ↓ │
│ flow.go InitJob │
│ ├─ multipart streaming 重組(黑名單 client 帶的 user_id
│ ├─ mw.WriteField("user_id", <sub>) ← 唯一灌入點 │
│ └─ POST converter /api/v1/jobs │
└──────────────────────────────────────────────────────────────┘
```
### 7.2 Ownership 檢查
每個 GET / promote / download / models 操作都先檢查 `ownership.Get(jobID) == userCtx.UserID`不符 403 `forbidden`
### 7.3 客戶端不可信原則
- frontend / browser 帶來的 user_id 永遠忽略streaming 重組時黑名單
- frontend / browser 帶來的 object_key 永遠忽略GET /download 不接受 client 指定 object_key visionA 內部 promote 結果反查
- frontend 只能告訴我們 `job_id`其他都從 server side
---
## 8. Non-GoalsPhase 0.8 不做)
對齊 PRD Phase 0.8 邊界
| 項目 | Phase 0.8 行為 | Phase 1+ 計畫 |
|------|--------------|--------------|
| SSE / WebSocket 進度推送 | frontend HTTP polling間隔 2s | SSE endpoint `/api/conversion/{id}/events` |
| 取消 job | 不提供user converter 自己跑完或 7 日後 expires | `POST /api/conversion/{id}/cancel` |
| Job 歷史列表 | 不提供in-memory map 重啟即清 | DB persist + `GET /api/conversion/history` |
| user 多個 active job | 強制 1 pre-check + converter 409 透傳 | 沿用 converter 限制短期內無計畫放寬 |
| chip 同時轉 | 一次只能選一個 target_chip | Phase 1 後評估 |
| Webhookconverter 完成 push | 不接收 | converter Phase 2 才提供 |
| 大量批次 upload | 不支援 | 不在路線圖 |
---
## 9. 失敗模式 & retry 矩陣
### 9.1 retry 規則
| 操作 | 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`內部 cleanupPhase 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-backendin-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 自身的 navigationdevtools 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 不適用 CORSCORS 只管 JS fetch / XHRserver-side 302 redirect browser 原生 navigation 行為
### 10.5 Race condition
- user 同時兩 tab init 第一個成功寫 ownership / converter 接受第二個 pre-check 通過但 converter 409
- tab 同時 promote-to-models 第一個寫 model record 成功第二個重複呼叫 ensurePromotedcache hit)→ FAA pull 兩次接受的取捨FAA 端冪等)→ models repo 寫入時可能撞 model_id 衝突 改用 model_id finalize SELECT 檢查
### 10.6 DoS 防護最小集Phase 1 強化)
- user 1 active job 的限制本身就是 DoS 防護user 不能 init 1000 job
- visionA-backend conversion endpoint 不額外 rate limitPhase 1 對齊 `security.md` §4
- converter 端有 process semaphoremax 5 concurrent upload保底
---
## 變更影響清單
實作此 spec 會動到的檔案 Backend Agent 參考Backend 自己拆任務
- 新增`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 redirectendpoint `POST /{job}/download-token` 改為 `GET /{job}/download`Service interface `DownloadToken` `DownloadRedirectURL``DownloadGrant` 改為 mc_token_client 內部 struct不對外 JSON)、 §3.1 handler 範例 §10.4 token 不過 frontend JS 的安全分析、§6 `/download` 錯誤回應策略 |
| 2026-04-30 | 0.3 | Phase 0.8 三方交叉審閱回饋整合:§2.6.1 visionA-backend 重啟後 lazy rebuild ownership」(議題 #2A4 方案);§2.6.2 expires_at 來源議題 #7);§4.3.1 streaming proxy 進度語意明確化議題 #6採選項 A converter 201 才回 200);§4.3.2 cancel cleanup 鏈與 best-effort cancel converter議題 #5 |