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

44 KiB
Raw Blame History

Conversion — 轉檔功能整合Phase 0.8

角色visionA-backend 端的「轉檔」實作 spec內部模組設計 + API + flow上位文件adr/adr-014-conversion-integration.mdTDD.mdsecurity.md 同層文件api/api-conversion.md(對 frontend 的 API 規格細節) 作者Architect Agent 狀態Draft待 PM / Backend / Frontend / DevOps 交叉審閱) 最後更新2026-04-30


索引

  1. 整體 flow端對端
  2. 模組設計 — internal/conversion/
  3. 新增 visionA-backend API
  4. Streaming proxy 設計upload
  5. Service-to-service token 機制
  6. 錯誤碼 mapping + i18n key
  7. user_id 注入與 trust boundary
  8. Non-GoalsPhase 0.8 不做)
  9. 失敗模式 & retry 矩陣
  10. 安全考量

1. 整體 flow端對端

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

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

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

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

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 的單一 tenanttenant_id 從 config 取(VISIONA_OIDC_TENANT_ID 或從 issuer / client metadata 推得)— 由 mcConfig.TenantID 注入。

2.5 flow.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.Mapjob_id → target_object_key;同 job 第二次 promote 直接回 cache不打 converter。

2.6 ownership storein-memory

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正在 processingvisionA-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 補強)

// 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 daysconverter 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.goJob 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 endpointGET /api/v1/jobs?user_id=&status=in_progress§2.6.1 lazy rebuild。Phase 0.8 frontend 看不到「歷史列表」UI但後端會用此內部 endpoint 做韌性處理。

POST /api/v1/jobs/{id}/cancelconverter 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 通用:

  • 走既有 AuthMiddlewareinternal/api/middleware/auth.go
  • UserContextFrom(c)uc.UserIDOIDC sub
  • response 用既有 WriteSuccess / WriteError helper
  • request_id 透傳給 converterX-Request-Id header

3.1 GET /api/conversion/{job_id}/download — server-side 302 redirect handler

仿 FAA TestSite DownloadFileDirectFileAccessAgent.TestSite/Controllers/HomeController.cs:255-282

// 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 使用範例

<!-- 推薦anchor tagbrowser 自動處理 navigation + 302 follow -->
<a href={`/api/conversion/${jobId}/download`} download>下載</a>

或:

// 程式化觸發:等同 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

// 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
    }
}
// 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 MVPvisionA-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_id、stream 還在 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-tokensissue 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}/cancelvisionA-backend 升級為 best-effort 主動 cancel

// 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/SecretA1 階段不啟用。Phase 0.8 啟用:

 // 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_progresslazy 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_unavailableUI 提示「轉檔服務暫時無法使用,請稍後再試」
完成後 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 CORSbrowser 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.gohandler
  • 新增:visionA-backend/internal/api/conversion_test.go
  • 修改:visionA-backend/internal/config/config.go(啟用 ServiceClientID/Secret + 新增 ConverterBaseURL / FAABaseURL / TenantID
  • 修改:visionA-backend/internal/api/api.goDeps 加 Conversion conversion.Service、router 註冊 /api/conversion/*
  • 修改:visionA-backend/cmd/api-server/main.gowire 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 DownloadTokenDownloadRedirectURLDownloadGrant 改為 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