jim800121chen dab13ed984 docs(autoflow): ADR-016 — visionA download 改走 converter GetResult,撤回 FAA delegated token 鏈
致命發現(grep MC + FAA source 確認):
- MC source 沒有 issue delegated download token endpoint
- MC source 沒有 validate delegated download token endpoint
- FAA MemberCenterDelegatedDownloadTokenValidator.cs 假設的 MC introspection endpoint 不存在
- ADR-014 §2 從 5/2 寫完到現在這條鏈一直是斷的、只是因為從未實際 e2e 跑通過所以沒被發現

使用者拍板硬約束:不動 MC + 不動 FAA

新增 ADR-016:
- visionA download 改用 converter GET /api/v1/jobs/{id}/result(新 endpoint)
- visionA backend 用既有 ConverterAPIKey 認證(不需新增 secret)
- 維持 T4 已實作的 stream proxy 結構(io.CopyN + Content-Disposition + size cap)
- promote 仍 PUT FAA(converter 內部用自己的 OAuth、與 visionA 無關)
- 不需動 MC + FAA + warrenchen
- 6 個替代方案逐一說明排除理由

修訂既有文件:
- ADR-014 v1.1 → v1.2:§2 download flow 標註被 ADR-016 部分 supersede
- ADR-015 v2.0 → v2.1:§2 visionA → FAA delegated token 設計(v2.0 從 v1.x 撤回的設計)再次撤回;§9 env 表撤回 v2.0 加回的 OIDC ServiceClient* / TenantID / FAABaseURL;visionA 端 server-to-server 只剩 ConverterAPIKey 一把
- conversion.md v0.5 → v0.6:§1 sequence diagram 重畫(移除 MC node)、§2 模組設計(mc_token_client.go 整檔刪除確認、faa_client.go 改名 converter_result_client.go)、§3.2 visionA → FAA 整段標撤回、§4.1 download handler 改 converter.GetResult、§6 錯誤碼撤回 mc/faa 三個 code 加 result_not_found / result_expired
- api-conversion.md v0.5 → v0.6:檔頭 Auth 段落改寫、§4 download endpoint 改述、error code 表撤回 mc_token_unavailable / download_token_failed
- oidc-tdd.md v0.3 → v0.4:§13.1 環境變數表 OIDC ServiceClient* / TenantID / FAABaseURL 從「重新啟用」改回「再次廢棄」、§13.1.1 stage env 範例移除 service client / tenant_id / FAA URL、§13.1.3 改寫為「v0.4 單線設計」說明

整體影響:
- 不需復活 mc_token_client.go(commit 86b7175 砍除狀態維持)
- 不需復活 OIDCConfig.ServiceClientID/Secret/TenantID(commit 86b7175 移除狀態維持)
- visionA backend faa_client.go 要改名為 converter_result_client.go、改呼叫 converter.GetResult
- visionA backend flow.go DownloadStream / PromoteToModels 改用 converter.GetResult
- jimchen 跨 repo 任務:converter scheduler 加 GET /api/v1/jobs/{id}/result endpoint

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

87 KiB
Raw Blame History

Conversion — 轉檔功能整合Phase 0.8 / Phase 0.8b

角色visionA-backend 端的「轉檔」實作 spec內部模組設計 + API + flow上位文件adr/adr-016-download-via-converter.mdv0.6 新增visionA download 改走 converter 中轉、撤回所有 visionA → MC / FAA 鏈)、adr/adr-015-server-to-server-api-key.md v2.1visionA → converter API key 維持§2 visionA → FAA 整段被 ADR-016 supersedeadr/adr-014-conversion-integration.md§2 download 整段被 ADR-016 supersede§1 upload streaming / §3 半自動分流原則 / §4 模組劃分 / §6 user_id trust boundary 仍有效)、TDD.mdsecurity.md 同層文件api/api-conversion.md(對 frontend 的 API 規格細節) 作者Architect Agent 狀態Phase 0.8b v0.6 修訂 — visionA → converter 走 API key不變visionA → FAA / MC 兩條鏈完全撤回download 改走 converter GET /api/v1/jobs/{id}/result 中轉 最後更新2026-05-16


索引

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

1. 整體 flow端對端

Phase 0.8b v0.6 變更visionA 端 server-to-server 鏈路收斂為單條(只剩 visionA → converter

  • visionA → converterAuthorization: Bearer <VISIONA_CONVERTER_API_KEY>ADR-015 §1不變
  • visionA → FAA / MC完全撤回v0.5 加回的鏈是 broken design對 MC source 全 grep 驗證後確認 MC 沒有 delegated download token endpoint
  • downloadvisionA → converter GET /api/v1/jobs/{id}/resultconverter 從 MinIO stream NEF 回 visionA再 io.CopyN 中轉給 browserADR-016
  • 詳見 §3 與 ADR-016
sequenceDiagram
    participant B as Browser
    participant V as visionA-backend
    participant C as Converter (incl. MinIO)
    participant F as FAA

    Note over B,F: Stage 1 — Init jobstreaming upload
    B->>V: POST /api/conversion/init (multipart)
    V->>V: AuthMiddleware → user_id (OIDC sub)
    V->>V: 檢查同 user active job
    V->>C: POST /api/v1/jobs<br/>Authorization: Bearer <VISIONA_CONVERTER_API_KEY><br/>(streamed multipart, user_id 注入)
    C->>C: middleware: ConstantTimeCompare(key, CONVERTER_API_KEY)
    C-->>V: 201 {job_id, status:created, stage:onnx}
    V->>V: 記錄 job_id ↔ user_id mapping
    V-->>B: 200 {job_id, status:running, stage:onnx}

    Note over B,F: Stage 2 — Poll status
    loop 直到 completed / failed
        B->>V: GET /api/conversion/{job_id}
        V->>V: ownership 檢查
        V->>C: GET /api/v1/jobs/{id}<br/>Authorization: Bearer <VISIONA_CONVERTER_API_KEY>
        C-->>V: {status, stage, progress, ...}
        V-->>B: 整形後 status
    end

    Note over B,F: Stage 3 — Promoteconverter 內部 push FAA與 visionA 無關)
    B->>V: POST /api/conversion/{job_id}/promote-to-models (or download trigger)
    V->>C: POST /api/v1/jobs/{id}/promote<br/>Authorization: Bearer <VISIONA_CONVERTER_API_KEY>
    C->>F: PUT /files/{target_object_key} (NEF — converter 自己的 OAuth + files:upload.write scope與 visionA 完全無關)
    C-->>V: {target_object_key}<br/>(NEF 同時保留在 converter MinIO 7d expires_at)

    Note over B,F: Stage 3a — User 選「加到模型庫」
    V->>C: GET /api/v1/jobs/{id}/result<br/>Authorization: Bearer <VISIONA_CONVERTER_API_KEY>
    C-->>V: 200 NEF binary stream (from converter MinIO)
    V->>V: /api/models/init → /api/models/finalize<br/>(Source=converted, SourceJobID=job_id)
    V-->>B: 201 {model_id}

    Note over B,F: Stage 3b — User 選「下載」Phase 0.8b v0.6: server-side stream proxy from converter
    B->>V: GET /api/conversion/{job_id}/download
    V->>V: AuthMiddleware → user_id + ownership 檢查
    V->>C: POST /api/v1/jobs/{id}/promote (若還沒 promote — 冪等)
    C-->>V: {target_object_key}
    V->>C: GET /api/v1/jobs/{id}/result<br/>Authorization: Bearer <VISIONA_CONVERTER_API_KEY>
    C-->>V: 200 NEF binary stream + Content-Length + Content-Disposition
    V-->>B: stream NEFvisionA backend io.CopyN 中轉、size cap 1 GiB

critical path 說明

  • visionA-backend 在 upload / download / promote 任一階段都先做 OIDC AuthMiddleware既有+ ownership 檢查
  • promote 動作是冪等的converter 端對同一 job 重複 promote 接受visionA-backend 內部以 job_id ↔ promoted_object_key 記錄避免重複呼叫
  • 加到模型庫流程v0.6promote → converter.GetResult 拉 NEF stream(不是直接打 FAA/api/models/init 取得 model_id + presigned PUT URL → visionA-backend 自己 PUT 到 storage → /api/models/finalize(這條路徑要重用既有 handler 邏輯,不繞過)
  • download flowv0.6promote 冪等 → converter.GetResult 拉 NEF stream → io.CopyN 中轉給 browsersize cap 1 GiB
  • Phase 0.8b v0.6 認證鏈說明
    • visionA 端只有一條visionA → converterAPI key、constant-time compare
    • converter → FAAconverter 自己用 OAuth client_credentials + files:upload.write scope既有實作 apps/task-scheduler/src/fileAccessAgent/client.js、Phase 1 已上線;與 visionA 無關)
    • visionA → MC完全不存在user login 的 OIDC public PKCE client 是另一條完全獨立的鏈,不在本文件範圍)
    • visionA → FAA完全不存在

Phase 0.8b v0.6 與 ADR-014 / ADR-015 v1.x / v2.0 的差異說明

面向 ADR-014OAuth client_credentials 兩條線) ADR-015 v1.x兩條線都 API key ADR-015 v2.0converter API key + FAA OAuth + delegated ADR-016 / v0.6visionA 端只剩 converter
visionA → converter 認證 Authorization: Bearer <MC-issued service token> Authorization: Bearer <VISIONA_CONVERTER_API_KEY> 同 v1.x 同 v1.x / v2.0(不變)
visionA → FAAwrite / metadata / delete Authorization: Bearer <MC service token> + scope Authorization: Bearer <VISIONA_FAA_API_KEY> 回到 ADR-014service token 不存在visionA 端不再直接打 FAA
visionA → FAAdownload GET /files/{key} Authorization: Bearer <MC delegated download token> Authorization: Bearer <VISIONA_FAA_API_KEY> 回到 ADR-014delegated token 不存在visionA 端不再直接打 FAA
download 從哪取 NEF FAA GET /files/{key} 同上 同上fictional — delegated token endpoint MC 沒有) converter GET /api/v1/jobs/{id}/result(從 converter MinIO stream
download 在 browser 端流程 302 redirect server-side proxy server-side proxy同 v0.4 server-side proxy同 v0.4 / v0.5,不變)
visionA → MC service token 路徑 啟動 lazy init MCTokenClient + cache 完全移除 部分復活 完全移除(撤回 v2.0 復活mc_token_client.go 整檔砍除)
converter middleware 驗 JWKS + scope + tenant 比對 env 字串constant-time 同 v1.x 同 v1.x不變
FAA middleware 驗 JWKS + scope + tenant + delegated token API key 比對 env 字串 回到 ADR-014dual-auth 不適用visionA 端不再呼叫 FAAconverter → FAA 仍走 ADR-014 OAuth 路徑、但 converter 自己管)

為什麼 v0.6 把 download 拉到 converter 中轉

  1. v2.0 設計的 delegated token 鏈是 fictional:對 MC source/Users/jimchen/member_center/src/MemberCenter.Api/Controllers/*.cs 8 個 controller全 grep 確認 MC 沒有 POST /file-access/download-tokens endpoint、也沒有 FAA MemberCenterDelegatedDownloadTokenValidator assume 的 introspection endpoint。整條鏈從 2026-05-02 ADR-014 寫定起即為 broken、只是因 visionA 從未實際 e2e 跑通 download 而沒被發現
  2. 使用者硬約束「不動 MC、不動 FAA」:補 MC endpoint 需 MC team 設計 + onboard scope補 FAA endpoint 需 warrenchen 改公司共用 repo跨人協調 cost 高5/9 撞 scope 沒註冊已驗證)
  3. converter 是 jimchen 自己 repo:加 GET /api/v1/jobs/{id}/result 對 coordination cost 低
  4. failure mode 收斂visionA 端 fail path 從 5 條visionA → MC 4xx/5xx、MC token cache、MC delegated token issue、FAA service token validate、FAA delegated token validate收斂為 3 條converter 401 / 4xx / 5xx
  5. 既有 stream proxy 結構保留v0.4 / v0.5 的 server-side stream proxy + size cap + context cancellation 完全沿用,只是 stream 來源從 FAA 改 converter

詳見 ADR-016 §決策 + 替代方案


2. 模組設計 — internal/conversion/

Phase 0.8b v0.6 變更:撤回 v0.5「mc_token_client 部分復活、faa_client 改回 service token + delegated token」設計。download 改走 converter GET /api/v1/jobs/{id}/result、stream 來源從 FAA 改 converter。

internal/conversion/
├── conversion.go               # Service interface + 對外暴露的 type
├── converter_client.go         # converter scheduler API clientinit / poll / promote / GetResult — 帶 VISIONA_CONVERTER_API_KEY
├── (faa_client.go 刪除 / 改名)  # v0.6visionA 端不再直接打 FAA改名為 converter_result_client.go或併入 converter_client.go唯一職責是打 converter GET result endpoint
├── (mc_token_client.go 刪除)   # v0.6:撤回 v0.5「部分復活」決定visionA 端不再有任何 visionA → MC server-to-server 路徑
├── flow.go                     # 整體 flow 協調download / PromoteToModels 都走 converter.GetResult
├── types.go                    # request / response struct
└── errors.go                   # error code 定義

Phase 0.8b v0.6 模組變更摘要(相對於 v0.5

  • converter_client.go維持 v0.5API key 直接 set header新增 GetResult(ctx, jobID) method 用於拉 NEF binary stream
  • faa_client.go整檔刪除 / 改名v0.5 加的「DownloadWithDelegated + tokens *MCTokenClient 欄位」全部移除;唯一還需要的 stream proxy 結構併入 converter_client.goGetResult 或新建 converter_result_client.go
  • mc_token_client.go整檔刪除(撤回 v0.5「部分復活」commit 86b7175 已砍掉,維持砍除狀態不需復活ServiceToken cache + IssueDelegatedDownload 兩個 method 都不再需要)
  • ↩ flow.go撤回 v0.5「DownloadStream / PromoteToModels 走 IssueDelegatedDownload + DownloadWithDelegated」改成「呼叫 converter.GetResult(ctx, jobID)」;tokens *MCTokenClient 欄位刪除

2.1 conversion.go — 對外 interface

package conversion

import (
    "context"
    "io"
    "time"
)

// Service 是 handler 層的單一進入點。
type Service interface {
    // InitJob 把 client 的 multipart stream 透傳給 converter建立 job。
    // bodyReader 必須是「上層 handler 已 wrap 好的 multipart.Reader」— 由 handler 解多 part
    // 後重新組裝(見 §4避免 service 層關心 multipart.NewReader。
    // 實際實作handler 直接拿 raw request body + content-type由 service 內部處理 streaming。
    InitJob(ctx context.Context, in InitJobInput) (*Job, error)

    // GetJob 查 converter statusownership 檢查後 cache 1-2s。
    GetJob(ctx context.Context, userID, jobID string) (*Job, error)

    // PromoteToModels — 「加到模型庫」流程promote → FAA pull → models repo finalize。
    // 回傳新建的 model_id。
    PromoteToModels(ctx context.Context, userID, jobID string) (modelID string, err error)

    // DownloadStream — 「下載」流程Phase 0.8b v0.6server-side stream proxy + converter `GET /api/v1/jobs/{id}/result`
    //   1. ownership 檢查
    //   2. ensurePromoted冪等 cache 對 converterNEF 確認已在 converter MinIO + FAA
    //   3. converter.GetResult(ctx, jobID) — 直接打 converter GET result endpoint
    //        Authorization: Bearer <ConverterAPIKey>(同其他 converter API method
    //        converter response 200 + NEF binary stream + Content-Length + Content-Disposition
    //   4. handler 直接 io.CopyN stream 給 clientsize cap 1 GiB
    // 不產生 302 redirect URLserver-side proxy 在 T4 已實作v0.4 / v0.5 / v0.6 沿用;不退回 302
    // 不再經過 visionA → MC / visionA → FAA 任何路徑v0.6 整段撤回;詳見 ADR-016
    // Phase 1+ 量大時可評估方案 DvisionA 自簽 HMAC + FAA 加第三條 auth path + 回 302
    DownloadStream(ctx context.Context, userID, jobID string) (stream io.ReadCloser, meta *DownloadMetadata, err error)

    // ActiveJob 查 user 當前是否有 active job給 frontend pre-check 用)。
    ActiveJob(ctx context.Context, userID string) (*Job, error)
}

// InitJobInput 是 handler 傳給 service 的所有資料。
// MultipartBody 由 handler 從 request.Body 取得(已驗 content-typeservice 內部處理 streaming。
type InitJobInput struct {
    UserID         string
    ContentType    string         // 含 boundary 的原始值
    Body           io.Reader      // request.Body
    ContentLength  int64
    TargetChip     string         // "520" / "720"
    // 其他 form fieldsmodel_id, version, enable_*)由 handler 解多 part 後傳入
    // — 實作上在 §4 streaming 處理時把這些 field 也透傳給 converter
}

type Job struct {
    JobID         string    `json:"job_id"`
    Status        string    `json:"status"`        // created / running / completed / failed
    Stage         string    `json:"stage"`         // onnx / bie / nef
    Progress      int       `json:"progress"`      // 0-100
    StageProgress int       `json:"stage_progress"`// 0-100
    CreatedAt     time.Time `json:"created_at"`
    UpdatedAt     time.Time `json:"updated_at"`
    ErrorCode     string    `json:"error_code,omitempty"`
    ErrorMessage  string    `json:"error_message,omitempty"`
}

// Phase 0.8b v0.6:撤回 v0.5 DownloadGrant struct不再需要 delegated token 持有結構)。
// visionA → converter 一條鏈、沒有 token issue 過程flow.go 直接呼叫 converter.GetResult
// 拿 stream + DownloadMetadata 即可。

// DownloadMetadata — DownloadStream 回傳的中介資料(沿用 §2.3 既定的型別)。
// (定義在 converter_result_client.go / converter_client.go避免重複

2.2 converter_client.gov2.0:維持 v1.x — API key 不變)

type ConverterClient struct {
    baseURL    string
    apiKey     string         // Phase 0.8b v1.x+v2.0pre-shared API keyVISIONA_CONVERTER_API_KEY
    httpClient *http.Client
}

// CreateJobStream 把 io.Reader 當作 multipart bodycontent-type 含 boundary透傳給 converter。
// caller 必須:
//   1. 已經把 user_id 透過 multipart.Writer 注入 body在 streaming 過程中)
//   2. content-type 是合法的 multipart/form-data; boundary=...
func (c *ConverterClient) CreateJobStream(ctx context.Context, contentType string, body io.Reader, contentLength int64) (*Job, error)

func (c *ConverterClient) GetJob(ctx context.Context, jobID string) (*Job, error)

func (c *ConverterClient) PromoteJob(ctx context.Context, jobID string) (targetObjectKey string, err error)

// ListActiveJobsByUser — lazy rebuild ownership 用§2.6.1
func (c *ConverterClient) ListActiveJobsByUser(ctx context.Context, userID string) (*Job, error)

每個方法內部v1.x + v2.0 簡化):

  1. req.Header.Set("Authorization", "Bearer "+c.apiKey) — 直接帶 pre-shared API key不查 cache、不打 MC、不重簽
  2. X-Request-Id(從 ctx 取,沿用 visionA-backend 既有 request_id 中介層)
  3. response 5xx / network error 走 retry§9401/403 不 retryAPI key 錯不會自己變對)

2.3 faa_client.gov0.6:整檔刪除 / 改名為 converter_result_client.go

v0.6 撤回 v0.5 設計v0.5 規劃的「tokens *MCTokenClient 欄位 + DownloadWithDelegated(ctx, delegatedToken, objectKey)」整段刪除。visionA 端不再有 FAAClient 概念、不再有任何 internal/conversion/ 內對 FAA 的呼叫。

取而代之:原 faa_client.go 的 stream proxy 結構(io.ReadCloser + DownloadMetadata)改名為 converter_result_client.go(或併入 converter_client.go 作為其 method唯一職責是打 converter GET /api/v1/jobs/{id}/result 拉 NEF binary stream。

// 新檔案(或併入 converter_client.go

// GetResult — 對 converter GET /api/v1/jobs/{id}/result 拉 NEF binary stream。
// 帶 Authorization: Bearer <ConverterAPIKey>(同其他 converter API method
// converter response 200 + Content-Length + Content-Disposition + body stream。
// 對應 converter 端 endpoint spec 詳見 ADR-016 §1。
func (c *ConverterClient) GetResult(ctx context.Context, jobID string) (io.ReadCloser, *DownloadMetadata, error)

type DownloadMetadata struct {
    SizeBytes   int64  // 從 converter response Content-Length 解析
    ContentType string // 固定 application/octet-stream
    Filename    string // 從 converter response Content-Disposition 解析visionA 端可選擇覆寫(見 §4.1 註)
}

v0.6 重要設計約束

  • 「加到模型庫」flow 與「下載」flow 共用同一個 GetResult——兩條 path 都從 converter MinIO 拉 NEF。visionA 端完全不需理解 FAA 的存在。
  • size capvisionA backend handler 端用 io.CopyN(w, stream, 1 GiB) 保護converter 端不另外設 capconverter MinIO 容量為準)。
  • failure mapping
    • converter 401 → converter_auth_failed運維事件API key 不同步)
    • converter 404 → result_not_foundjob_id 不存在 / 已過 7 天 expires_at
    • converter 410 → result_expiredjob completed 但 NEF 已被 converter MinIO GC
    • converter 409 → job_not_completedjob 尚未 completed理論上 visionA 端 ensurePromoted 前已確認、不應發生)
    • converter 5xx / network → converter_unavailable

2.4 mc_token_client.gov0.6:整檔刪除、撤回 v0.5 部分復活)

v0.6 撤回 v0.5「部分復活」決定。對 MC source 全 grep 驗證後確認 MC 沒有 POST /file-access/download-tokens endpoint、也沒有 FAA IDelegatedDownloadTokenValidator assume 的 introspection endpoint——v0.5 規劃的 IssueDelegatedDownload method 是 fictional、永遠 issue 不到 delegated token。

v0.6 處理mc_token_client.go 在 commit 86b7175 已被砍除,維持砍除狀態、不需復活。對應的 test、config 欄位(OIDCConfig.ServiceClientID/SecretConversionConfig.TenantID、envVISIONA_OIDC_SERVICE_CLIENT_ID/_SECRETVISIONA_OIDC_TENANT_IDVISIONA_FAA_BASE_URL)全部撤回。

visionA 端不再有任何 visionA → MC server-to-server 路徑。user login 的 OIDCPKCE / cookie session / JWKS 驗 id_token是另一條完全獨立的鏈、不在本文件範圍詳見 oidc-tdd.md)。

2.5 flow.go — 流程協調

type Flow struct {
    converter *ConverterClient      // 含 init / poll / promote / GetResult method
    // (faa       *FAAClient        — v0.6 刪除)
    // (tokens    *MCTokenClient    — v0.6 刪除,撤回 v0.5 復活)
    models    model.Repository      // 沿用既有 model store
    storage   storage.Store         // 沿用既有 LocalFS / S3
    ownership ownershipStore        // job_id → user_id mapping (in-memory map)
    
    statusCache *jobStatusCache     // 1-2s short cache避免 frontend polling 直接打爆 converter
}

// DownloadStreamv0.6 流程):
// 1. ownership.Check(userID, jobID)
// 2. _, _ := flow.ensurePromoted(ctx, jobID)             // 冪等 cache確保 converter 端 promote 完成NEF 已在 MinIO + FAA
// 3. stream, meta, _ := flow.converter.GetResult(ctx, jobID)
//      內部GET {ConverterBaseURL}/api/v1/jobs/{jobID}/result
//            Authorization: Bearer <ConverterAPIKey>
//      converter response 200 + NEF binary stream + Content-Length + Content-Disposition
// 4. meta.Filename = defaultDownloadFilename(cj)         // visionA 自行構造§4.1 註)覆寫 converter 給的 filename
// 5. return stream, meta, nil
//
// PromoteToModels 內部v0.6 修正):
// 1. ownership.Check(userID, jobID)
// 2. _, _ := flow.ensurePromoted(ctx, jobID)
// 3. reader, meta, _ := flow.converter.GetResult(ctx, jobID)
//      ← v0.6:與 DownloadStream 共用同一 method、不需 delegated token
// 4. modelID, putURL, _ := callModelsInit(...)           // 直接呼叫 internal/api 同 package 既有 helper
// 5. PUT 到 storage或直接 io.Copy 到 storage.Put
// 6. callModelsFinalize(...)
// 7. 在 model record 補 Source="converted" + SourceJobID=jobID
// 8. 回 modelID

// 主要 method 對應 Service interfacev0.6 流程已在 struct 上方註解寫出;此處保留結構說明)

冪等性flow.ensurePromoted(jobID) 內部用 sync.Mapjob_id → target_object_key;同 job 第二次 promote 直接回 cache不打 converter。target_object_key 在 v0.6 仍由 promote response 拿到(給 log / debug 用)、但 visionA 不再用它直接打 FAAconverter GetResult 內部知道哪個 object 屬於哪個 jobID

為什麼移除 delegated token 邏輯v0.5 規劃「IssueDelegatedDownload + DownloadWithDelegated」依賴 MC 有對應 endpoint 才 work對 MC source 驗證後確認該 endpoint 從未存在——v0.5 的設計是 fictional、永遠跑不通。v0.6 把整條鏈撤回、改走 converter 中轉converter 自己用 OAuth 推 FAA、後續 download 從 converter MinIO 拉)。詳見 ADR-016

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. 服務間認證v0.6visionA 端只剩單條 visionA → converter

Phase 0.8b v0.6 變更:本節重寫;對應 ADR-016

歷史

  • v0.4 (2026-05-11):本節將兩條線都改為 pre-shared API key撤回 ADR-014 §5 OAuth 設計)
  • v0.5 (2026-05-16):本節再修,只 converter 線維持 API keyFAA 線回到 ADR-014 §2 原設計service token + delegated download token
  • v0.6 (2026-05-16):對 MC source 驗證後確認 v0.5 設計的 delegated token 鏈是 fictionalMC 沒有對應 endpoint本節再次整段改寫——visionA → FAA / MC 鏈完全撤回download 改走 converter GET /api/v1/jobs/{id}/resultvisionA 端只剩 visionA → converter 一條 server-to-server 認證鏈

3.1 visionA → converterAPI key— v1.x 設計 + v2.0 / v2.1 / v0.6 維持

對應 ADR-015 §1v1.x / v2.0 / v2.1 都不變)。v0.6 新增:同一把 API key 也用於新增的 GET /api/v1/jobs/{id}/result endpointADR-016 §1

3.1.1 取得流程

visionA-backend 啟動
    ↓
讀 cfg.Conversion.ConverterAPIKeyenv VISIONA_CONVERTER_API_KEY
    ↓
[轉檔請求進來]
    ↓
converter_client 發 request 時:
    req.Header.Set("Authorization", "Bearer "+apiKey)
    ↓
converter middleware
    - parse Authorization header → 取 token
    - subtle.ConstantTimeCompare(token, envKey)
    - match → 放行mismatch → 401 + log不附原因

沒有 token cache、沒有 refresh、沒有 retry MC、沒有 scope 驗證。整條鏈路是「visionA → converter」一步。

3.1.2 啟動時驗證

api-server 啟動時 log 一行(不可 log key 本身

[INFO] conversion config: converter=http://192.168.0.130:9501 (api_key_set=true)

3.1.3 Key 產生 / 部署 / Rotate

項目 規格
長度 64 字元 hex256 bit 熵) — openssl rand -hex 32
環境隔離 dev / stage / prod 各自獨立的 key不重用
儲存dev .env.devgitignore
儲存stage stage host .env.stage(不進 git
儲存prod AWS Secrets Manager / Vault
Rotate runbook Phase 0.9 補;流程:產新 key → 雙方同步 env → restart → 驗證 → 拔舊 key
Log policy 永遠不印 key 全文;可印 api_key_set=true/false 或前 8 字元 prefix

3.2 visionA → FAAv0.6 整段撤回;改走 ADR-016 converter 中轉)

v0.6 整段撤回說明v0.5 在本節描述的「visionA → MCissue service token + delegated download token→ FAA」鏈路是 fictional——對 MC source 全 grep 驗證後確認 MC 沒有 POST /file-access/download-tokens endpoint、也沒有 FAA IDelegatedDownloadTokenValidator assume 的 introspection endpoint。

v0.6 採用visionA 端不再有任何 visionA → FAA / visionA → MC server-to-server 路徑。download / 加到模型庫 兩條 path 的 NEF 取得改走 visionA → converter GET /api/v1/jobs/{id}/result(用同一把 VISIONA_CONVERTER_API_KEYconverter 從自己的 MinIO stream NEF 回 visionA。

詳見 ADR-016 §1 / §2

v0.5 本節原內容FAA dual-auth 設計、MC service client 配置、tenant_id claim 驗證等)僅作歷史保留——對應的 source code 已於 commit 86b7175 移除faa_client.go / mc_token_client.go 整檔砍除v0.5 規劃的「mc_token_client 部分復活」決定也撤回(不需復活)。

3.2.1 FAA dual-auth 設計v0.6 撤回 — 僅作歷史保留)

對應 ADR-015 v2.0 §2v0.6 整段撤回)。v0.6 設計請看 ADR-016

3.2.1 FAA dual-auth 設計v0.6 撤回 — 僅作歷史保留說明為什麼 v0.5 路徑走不通)

FAA /Users/jimchen/file_access_agent/src/FileAccessAgent.Api/Program.cs 既有設計(Phase 0.8 / v2.0 都不動 FAA repo

FAA endpoint line range auth 機制 visionA 端 token type
GET /files/metadata/{**objectKey} 80-111 .RequireAuthorization() + EnsureJwtScopeAndTenantscope files:metadata.read + tenant_id 驗) MC service token
HEAD /files/{**objectKey} 113-148 同上 MC service token
PUT /files/{**objectKey} 150-182 .RequireAuthorization() + scope files:upload.write + tenant 驗 MC service token
GET /files/{**objectKey}下載 184-254 .RequireAuthorization();用 IDelegatedDownloadTokenValidator.ValidateAsync(...)active + tenant_id + object_key + method MC delegated download token
DELETE /files/{**objectKey} 256-287 .RequireAuthorization() + scope files:delete + tenant 驗 MC service token

FAA GET /files/{key} 不接 service token必須用 delegated download token。 → visionA Phase 0.8 flow 只用 GET /files/{key}(加到模型庫 pull + 下載 stream proxy 兩條 path 都打這個 endpoint所以兩條 path 都走 delegated download token 路徑。 → 其他 FAA endpointPUT / metadata / HEAD / DELETE保留給 Phase 1+ 擴充用(如 visionA 主動清理 FAA 上的孤兒檔),到時候才走 service token 路徑。

3.2.2 visionA 端流程v0.6 撤回 — 整段不再執行)

visionA-backend 啟動
    ↓
讀 cfg.OIDC.ServiceClientID / ServiceClientSecretenv VISIONA_OIDC_SERVICE_CLIENT_ID / _SECRET
讀 cfg.OIDC.IssuerURLenv VISIONA_OIDC_ISSUER_URL — 同 user login 的 issuer
讀 cfg.Conversion.TenantIDenv VISIONA_OIDC_TENANT_ID
讀 cfg.Conversion.FAABaseURLenv VISIONA_FAA_BASE_URL
    ↓
[Download 請求進來]
    ↓
flow.DownloadStream / flow.PromoteToModels
    ↓
mcTokenClient.ServiceToken(ctx)
    cache hit
      Yes → return cached tokenexp - 15s 內)
      No  → POST {issuer}/oauth/token
              grant_type=client_credentials
              client_id=<ServiceClientID>
              client_secret=<ServiceClientSecret>
              scope=files:upload.write files:metadata.read files:delete files:download.delegate
            → cache + return token
    ↓
mcTokenClient.IssueDelegatedDownload(ctx, objectKey, "GET", 5*time.Minute)
    POST {issuer}/file-access/download-tokens
       Authorization: Bearer <service-token>
       Body: { object_key, method, ttl_seconds, tenant_id }
    → DownloadGrant { token, expires_at, object_key, method }
    ↓
faaClient.DownloadWithDelegated(ctx, grant.Token, objectKey)
    GET {faaBaseURL}/files/{objectKey}
       Authorization: Bearer <delegated-token>
    → io.ReadCloser + metadata
    ↓
flow.go 內把 stream + filename + size 包成 DownloadMetadata 回 handler

3.2.3 Config 對齊v0.6 撤回 — visionA 端不需 ServiceClient* / TenantID / FAABaseURL

visionA-backend/internal/config/config.go 變更v2.0 修訂):

type ConversionConfig struct {
    ConverterBaseURL string `env:"VISIONA_CONVERTER_BASE_URL"`
    FAABaseURL       string `env:"VISIONA_FAA_BASE_URL"`
    ConverterAPIKey  string `env:"VISIONA_CONVERTER_API_KEY"`  // v1.0 新增v2.0 維持
    // FAAAPIKey 撤回v1.0 加的v2.0 移除)
    TenantID         string `env:"VISIONA_OIDC_TENANT_ID"`     // v1.x 廢棄v2.0 重新啟用FAA 線需要)
}

// OIDCConfig.ServiceClientID / ServiceClientSecret 兩欄位 v2.0 重新啟用v1.x 廢棄)
type OIDCConfig struct {
    // ... user login 相關欄位(不變)...
    ServiceClientID     string `env:"VISIONA_OIDC_SERVICE_CLIENT_ID"`
    ServiceClientSecret string `env:"VISIONA_OIDC_SERVICE_CLIENT_SECRET"`
}

func (c ConversionConfig) Enabled() bool {
    return c.ConverterBaseURL != "" &&
           c.FAABaseURL != "" &&
           c.ConverterAPIKey != "" &&
           c.TenantID != ""
    // OIDCConfig.ServiceClientID / Secret 是否設好由 main.go 啟動時組合判斷
}

新增的 stage envv2.0 修訂):

# .env.stage
# === user login不變沿用 oidc-tdd.md §13.1.1===
VISIONA_OIDC_ISSUER_URL=https://stage-9527.innovedus.com:7850/
# ... user login 其他 env ...

# === Phase 0.8b v2.0 — converter 線 API key ===
VISIONA_CONVERTER_BASE_URL=http://192.168.0.130:9501
VISIONA_CONVERTER_API_KEY=<openssl rand -hex 32 產的值,與 converter 端 CONVERTER_API_KEY 對齊>

# === Phase 0.8b v2.0 — FAA 線 MC service token + delegated download token ===
VISIONA_FAA_BASE_URL=https://stage-9527.innovedus.com:5081
VISIONA_OIDC_SERVICE_CLIENT_ID=4242ba63099d4f318dd3f143d27ef4c5
VISIONA_OIDC_SERVICE_CLIENT_SECRET=<see stage host .env.stage; 不進 git / 文件>
VISIONA_OIDC_TENANT_ID=732270c0-449c-489c-bfad-321e9bf89b3d
# Service scopes由 MC service client 註冊時對應):
# files:upload.write files:metadata.read files:delete files:download.delegate

# === Phase 0.8b v2.0 撤回 ===
# VISIONA_FAA_API_KEY 撤回v1.0 加的v2.0 移除)

⚠️ secret 絕不寫進 git / 文件:上方 VISIONA_OIDC_SERVICE_CLIENT_SECRET 真實值僅由使用者放 stage host 的 .env.stage 與部署 secret store文件 / git 一律用 placeholder。

3.2.4 啟動時驗證v0.6 撤回 — visionA 端啟動不再 log FAA s2s config

api-server 啟動時 log 一行(不可 log secret / token

[INFO] FAA s2s config: faa=https://stage-9527.innovedus.com:5081 service_client_set=true tenant_id_set=true

若 OIDC ServiceClientID / Secret / Conversion TenantID / FAABaseURL 任一缺失 → conversion 模組 disabled同 Phase 0.8「partial deploy」相容性

3.2.5 MC scope 與 FAA endpoint 對應v0.6 撤回 — visionA 端不再 issue MC service tokenscope 配置由 converter 自己 / 不影響 visionA

確認 MC service client 4242ba63099d4f318dd3f143d27ef4c5 註冊時對應的 4 個 scope 完整覆蓋 FAA endpoint

FAA endpoint 需要的 scope在 service token 或 delegated token 上) service client 是否備好
PUT /files/... files:upload.write
GET /files/metadata/... + HEAD files:metadata.read
DELETE /files/... files:delete
GET /files/{key} 下載 files:download.delegateservice token 用來向 MC 換 delegated download tokenFAA 端最終驗的是 delegated token 本身,不是 scope

→ Phase 0.8b v2.0 範圍內 visionA 只觸發「GET /files/{key} 下載」這條 path加到模型庫 + 下載),所以 stage e2e 主要驗證 files:download.delegate 走通。其他 3 個 scope 為 Phase 1+ 預留。

待 verify合規性追蹤stage redeploy 前實測 POST /oauth/token 拿到含 4 scope 的 access_token並用該 token 對 MC POST /file-access/download-tokens 成功 issue delegated token。

3.3 Trust boundary 對齊v0.6

  • machine authvisionA 端唯一一條)visionA → converter 用 pre-shared API keyinit / poll / promote / GetResult
  • machine auth不在 visionA 範圍)converter → FAA 用 OAuth client_credentials + files:upload.write scopeconverter 自己管 apps/task-scheduler/src/fileAccessAgent/client.js、Phase 1 已上線)
  • user authbrowser → visionA 用 OIDC cookie session既有未變
  • visionA 是橋樑:從 OIDC sub 解出 user_id → 透過 multipart body 灌進對 converter 的請求init對 download 路徑而言visionA 端的 API key 證明「caller 是 visionA」、ownership store 確認 user_id 與 jobID 的綁定converter 不重複驗 user-job 關係,因為 visionA 已驗)

詳見 §7。


4. 新增 visionA-backend API

詳細請求 / 回應 schema 見 api/api-conversion.md;這裡列總覽。

Method Path Auth 用途
POST /api/conversion/init OIDC cookie 上傳 + 建 jobmultipart streaming
GET /api/conversion/{job_id} OIDC cookie 查 job 狀態
POST /api/conversion/{job_id}/promote-to-models OIDC cookie 「加到模型庫」
GET /api/conversion/{job_id}/download OIDC cookie 「下載」— v0.6server-side stream proxyvisionA backend 中轉 NEF binarysource 從 converter GET /api/v1/jobs/{id}/result 拉,使用 VISIONA_CONVERTER_API_KEY;不再經 FAA / MC
GET /api/conversion/active OIDC cookie 查當前 user 有無 active jobfrontend pre-checkin-memory miss 時 fallback 對 converter lazy rebuild§2.6.1

不對外暴露但內部使用的 converter 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」§5.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

4.1 GET /api/conversion/{job_id}/download — Phase 0.8b v0.6server-side stream proxy from converter

演進

  • Phase 0.8ADR-014 v1.1c.Redirect(302, FAA_URL_with_delegated_token)
  • Phase 0.8b v0.4 (ADR-015 v1.x):改為 server-side stream proxytoken 來源用 visionA API keyv0.5 撤回)
  • Phase 0.8b v0.5 (ADR-015 v2.0)server-side stream proxy 保留token 來源改回 MC delegated download token但對 MC source 驗證後確認此設計 fictional、未實際 e2e 跑通
  • Phase 0.8b v0.6 (ADR-016)server-side stream proxy 保留stream 來源從 FAA 改 converter GET /api/v1/jobs/{id}/resultvisionA 端不再經 MC / FAA
// GET /api/conversion/{job_id}/download
func conversionDownloadHandler(deps Deps) gin.HandlerFunc {
    return func(c *gin.Context) {
        uc, _ := UserContextFrom(c)        // AuthMiddleware 已驗
        jobID := c.Param("job_id")

        // service 內部完成v0.6 流程):
        //   1. ownership 檢查visionA in-memory store
        //   2. ensurePromoted對 converter 冪等 promote確保 converter MinIO 內有 NEF
        //   3. converter.GetResult(ctx, jobID)
        //        GET {ConverterBaseURL}/api/v1/jobs/{jobID}/result
        //        Authorization: Bearer <ConverterAPIKey>
        //        → 200 NEF binary stream + Content-Length + Content-Disposition
        stream, meta, err := deps.Conversion.DownloadStream(c.Request.Context(), uc.UserID, jobID)
        if err != nil {
            writeConversionError(c, err)   // §6 錯誤碼分類
            return
        }
        defer stream.Close()

        // streaming proxy 給 clientio.CopyN不暫存 disk / RAM 全 buffer
        c.Header("Content-Type", meta.ContentType)
        if meta.SizeBytes > 0 {
            c.Header("Content-Length", strconv.FormatInt(meta.SizeBytes, 10))
        }
        // 鼓勵 browser 觸發 save dialog
        // 注意meta.Filename **不是** converter 直接給的 raw object_keyconverter 端的
        // object_key 是 `models/<user>/<job>.nef` 對 user 不友善converter response 的
        // Content-Disposition 雖含 filename 建議值,但 visionA backend 仍在 service 層用
        // `defaultDownloadFilename(cj)` 從 conversion job metadata 重新構造,規則:
        // `<source_filename_stem>_<target_chip_lower>.nef`(例:`yolov5s_kl720.nef`
        // 對齊 wireframe success card 顯示範例(`yolov5s.onnx → yolov5s_kl720.nef`)。
        c.Header("Content-Disposition", `attachment; filename="`+sanitizeFilename(meta.Filename)+`"`)
        c.Header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
        c.Status(http.StatusOK)
        // 注意:用 io.CopyN(c.Writer, stream, sizeCap) 帶 1 GiB 上限保護 visionA backend 不被超大檔吃記憶體
        // sizeCap = 1 << 301 GiB— 對 Phase 0.8 NEF通常 < 100MB寬鬆但有上限
        io.CopyN(c.Writer, stream, 1<<30)
    }
}

為什麼仍用 GET

  • frontend 用 <a href="..." download> 觸發 — anchor tag 只能發 GET
  • GET semantically 對應「拿一個資源」,符合「下載這個 job 的結果」語意
  • 既有 OIDC AuthMiddleware 會自然處理 GET 上的 cookie session無 CSRF 風險沒有狀態變更promote 是冪等的)

Frontend 使用範例(與 Phase 0.8 / v0.4 一致,無需改動):

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

或:

// 程式化觸發
window.location.href = `/api/conversion/${jobId}/download`;

安全性面比較Phase 0.8 → v0.4 → v0.5 → v0.6

面向 Phase 0.8302 + MC delegated token v0.4server-side proxy + visionA API key 撤回) v0.5server-side proxy + MC delegated tokenfictional 從未跑通 v0.6server-side proxy + visionA → converter API key
Token 在 frontend JS / URL bar △ 短暫Location header 流經 browser ✓ 結構性不存在 ✓ 結構性不存在 ✓ 結構性不存在API key 只在 server-side 流動)
要 FAA CORS ✓ 不需要 ✓ 不需要 ✓ 不需要 ✓ 不需要visionA 端不直接打 FAA、CORS 完全不適用)
跨 internet 流量(同 NEF 多次下載) ✓ 直連 FAA ✗ 每次繞 visionA ✗ 每次繞 visionA ✗ 每次繞 visionA同 v0.4 / v0.5,未改變;但 source 從 FAA 換 converter MinIO
visionA backend 是否變 streaming bottleneck ✓ 不是 ✗ 是 ✗ 是 ✗ 是(同 v0.4 / v0.5Phase 0.8b MVP 接受Phase 1+ 升級見 ADR-016 後果 §負面影響)
認證鏈複雜度visionA → 下游) MC service token + MC delegated token 一把 API key MC service token + MC delegated tokenfictional 一把 API key同 v0.4,但這次是真的 work
Token TTL 5 minMC 簽) API key long-lived 5 minMC 簽,但 endpoint 不存在所以 issue 不到) API key long-livedrotate by runbook
Token 洩漏的 blast radius 5 min 內可下載該 object_key 永遠可打 FAA 任何 endpoint 永遠可打 converter 任何 endpointjimchen 自己管 rotateconverter 不存其他 user 資料、攻擊面限於 converter 自己)

為什麼 v0.6 仍對齊 v0.4 / v0.5 的 server-side proxy 而非退回 302:見 §1 整體 flow 變更說明。

Phase 1+ 升級路徑:如量大需回 302 redirect 模式(讓 browser 直連 converter 或 FAA有兩個方向

  • 方向 Aconverter Phase 2converter 補上 POST /api/v1/jobs/:id/download-tokens(既有預留 501給 browser 直連 converterADR-016 與此路徑相容
  • 方向 BFAA HMACvisionA 自己簽 short-TTL HMAC token + FAA middleware 加第三條 auth path同 ADR-015 v2.0 §7 選項 B但需要 warrenchen 改公司共用 FAA repo

5. Streaming proxy 設計upload

5.1 為什麼要 streaming

  • 模型上限 500MBref_images 100×10MB = 1GB 上限
  • 全 buffer 在 RAM → 同時 N 個 user upload 直接 OOM
  • 暫存 disk → 增加 IO 與磁碟空間需求;壞掉的 cleanup 麻煩

5.2 實作 pattern

// 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 算)

5.3 進度 / 取消

5.3.1 進度語意(重要:給 Frontend / Design 對齊)

XHR upload.onprogress 計算的是 browser → visionA-backend 的進度,不是 browser → backend → converter 的端到端進度。在 streaming proxy 模式下,這兩者有時間差:

T0: browser 開始上傳
    └─ XHR onprogress 持續更新loaded / total
T1: browser 已 send 完全部 bytesXHR 進度 100%
    └─ 但 backend → converter 的 io.Pipe 可能還在繼續流buffer 內未消化)
T2: backend 把全部 bytes forward 完給 converter
    └─ 這時候才拿到 converter 的 201 + job_id
T3: backend 200 回 frontend

設計選擇Phase 0.8 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,本文件 §5.3.1。這明確告訴使用者「browser 端已送完,正在等 server 收尾」,不是欺騙性的進度條。

Phase 1 升級路徑:若 Phase 1 量大需要更精準的端到端進度,可改成 SSE 推送 upload_progress 事件backend 主動報「已 forward N bytes 給 converter」但 Phase 0.8 MVP 不做。

5.3.2 Cancel 與 Cleanup 鏈(重要)

情境分類

情境 觸發 backend 行為
C1使用者按「取消上傳」 frontend xhr.abort() TCP RST → backend c.Request.Context().Done() → goroutine cleanup見下
C2使用者重新整理 / 關分頁 browser 中斷 connection 同 C1
C3網路斷線 TCP timeout 同 C1
C4backend 偵測 converter 拒絕4xx/5xx converter response 立即回 frontend不需特別 cleanupconverter 自己沒建 job

C1-C3 的 cleanup 鏈

client disconnect
    ↓
gin handler `c.Request.Context().Done()` 觸發
    ↓
streaming goroutine 的 `pw.Close()` defer 執行 → io.Pipe reader 收到 EOF/error
    ↓
converter HTTP request 的 body read 端拿到 EOF
    ↓
converter multer 偵測 incomplete multipart → 拒絕收 job不會建 job_id

上面的鏈在「backend 已經把 multipart header 寫進去、converter 已建 job_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後端會自動處理。

5.3.3 既有 4.3 內容(保留)

  • 進度visionA-backend 不做進度回報frontend 用 XHR upload.onprogress 自己顯示(既有前端模式)
  • 取消context.Cancelclient 斷線)→ 連帶 cancel converter request如上 cleanup 鏈converter 端 multer 收到 socket close 會自動 abort multipart parsing

5.4 Timeout

  • handler 整體不設總 timeout500MB upload 可能 5-10 分鐘)
  • 但每個 io.Copy 之間用 http.Server.WriteTimeout/IdleTimeout 控制 keep-alive具體值由 DevOps 在 Nginx / ingress 設定(建議 600s

6. 錯誤碼 mapping + i18n key

Phase 0.8b v0.6 變更:撤回 v0.5「回收 MC 兩個 code」決定。visionA 端不再有 MC / FAA 直接呼叫、mc_token_unavailable / download_token_failed 兩個 code 移除。新增 converter result endpoint 的 result_not_found / result_expired 兩個 code。

Converter error visionA error code HTTP i18n key user-friendly 訊息zh-TW
converter validation_error validation_failed 400 conversion.error.validation 上傳的檔案不符合要求(請查看詳細欄位錯誤)
converter invalid_multipart validation_failed 400 conversion.error.invalid_multipart 上傳格式錯誤,請重新嘗試
converter user_has_active_job active_job_exists 409 conversion.error.active_job 你目前已有進行中的轉檔任務
converter file_too_large payload_too_large 413 conversion.error.too_large 檔案超過大小限制
converter service_busy service_busy 503 conversion.error.busy 系統繁忙,請稍後再試
converter storage_unavailableMinIO 不可達) converter_unavailable 502 conversion.error.converter_down 轉檔服務暫時無法使用
converter 5xx / network converter_unavailable 502 conversion.error.converter_down 同上
converter 401API key 不對 / 過期 / rotate 未同步) converter_auth_failed 502 conversion.error.converter_down 轉檔服務暫時無法使用(內部 log 區分 auth_failed vs 5xx
converter 404 result_not_foundv0.6 新增;GET /api/v1/jobs/{id}/result job 不存在) not_found 404 conversion.error.not_found 轉檔任務不存在
converter 410 result_expiredv0.6 新增job completed 但 NEF 已被 converter MinIO GC、超 7 天 expires_at result_expired 410 conversion.error.result_expired 轉檔結果已過期,請重新轉檔
job 不屬於當前 uservisionA 端 ownership 檢查) forbidden 403 conversion.error.forbidden 你無權存取此轉檔任務
job_id 不存在visionA ownership store not_found 404 conversion.error.not_found 轉檔任務不存在
job 還沒 completed job_not_completed 409 conversion.error.not_completed 任務尚未完成,請等轉檔完成再下載

v0.6 變更摘要(相對於 v0.5

code v0.5 狀態 v0.6 狀態 說明
converter_auth_failed 維持 維持 converter API key 仍使用init / poll / promote / GetResult 共用同一把)
converter_unavailable 維持 維持 converter 5xx / network
result_not_found 新增 converter GET /api/v1/jobs/{id}/result 回 404
result_expired 新增 converter GET /api/v1/jobs/{id}/result 回 410job 過期)
faa_unavailable 使用中 撤回 visionA 端不再直接打 FAA
mc_token_unavailable 回收 撤回 visionA 端不再打 MC
download_token_failed 回收 撤回 visionA 端不再 issue delegated token
faa_auth_failed v0.5 撤回 維持撤回 v0.4 短暫存在)
idp_misconfigured / idp_unavailable 維持移除 維持移除

下游錯誤對待原則v0.6

  • converter 401API key 不對齊)→ converter_auth_failed,內部 log 標 reason 給 SRE
  • converter 404job_id 不存在 / 已被 GCresult_not_foundfrontend 顯示「轉檔任務不存在」
  • converter 410job completed 但 NEF 已過 7 天 expires_at 被 GCresult_expiredfrontend 顯示「轉檔結果已過期,請重新轉檔」並提供重新轉檔 CTA
  • converter 4xx 其他 → 透傳 + log
  • converter 5xx / networkconverter_unavailableretry 後仍失敗才回 frontend
  • 對 frontend 不暴露內部細節API key 不對 / converter MinIO 問題 / 其他下游差異)—— 統一 user-friendly 文字 轉檔服務暫時無法使用(除 410 result_expired 給更精確的「過期」訊息)
  • 401 / 4xx 不 retry同 §9—— 都是運維事件或 user 端問題,需人工介入 / 重新轉檔

i18n key 命名:conversion.error.<short-name>,前端 i18n 字典在 visionA-frontend/messages/{zh-TW,en}.json 補。

/download endpoint 錯誤回應策略GET + server-side stream proxy 場景):

由於 GET /api/conversion/{job_id}/download 採 server-side stream proxyPhase 0.8b 變更),錯誤情況直接走既有 WriteError helper 依 Accept header 回應:

  • Accept: application/json → 回標準 visionA error JSON {success:false, error:{code, message}}
  • Accept: text/html(一般 anchor tag / window.location.href 觸發)→ 回 HTML 錯誤頁browser 直接顯示
  • 其他 → 預設 JSON

frontend 用 <a href> 觸發時,若失敗 browser 會把錯誤頁顯示在頁面上。Phase 0.8 不要求 inline 錯誤 UX。


7. user_id 注入與 trust boundary

7.1 唯一可信任點

┌──────────────────────────────────────────────────────────────┐
│  visionA-backend conversion handler                          │
│                                                              │
│   AuthMiddleware → UserContext (OIDC sub from cookie)        │
│         ↓                                                    │
│   conversion.Service.InitJob(InitJobInput{UserID: <sub>})    │
│         ↓                                                    │
│   flow.go InitJob                                            │
│     ├─ multipart streaming 重組(黑名單 client 帶的 user_id│
│     ├─ mw.WriteField("user_id", <sub>) ← 唯一灌入點         │
│     └─ POST converter /api/v1/jobs                          │
└──────────────────────────────────────────────────────────────┘

7.2 Ownership 檢查

每個 GET / promote / download / models 操作都先檢查 ownership.Get(jobID) == userCtx.UserID,不符 → 403 forbidden

7.3 客戶端不可信原則

  • frontend / browser 帶來的 user_id 永遠忽略streaming 重組時黑名單)
  • frontend / browser 帶來的 object_key 永遠忽略GET /download 不接受 client 指定 object_key從 visionA 內部 promote 結果反查)
  • frontend 只能告訴我們 job_id,其他都從 server side 推

8. Non-GoalsPhase 0.8 不做)

對齊 PRD Phase 0.8 邊界:

項目 Phase 0.8 行為 Phase 1+ 計畫
SSE / WebSocket 進度推送 frontend HTTP polling間隔 2s SSE endpoint /api/conversion/{id}/events
取消 job 不提供user 等 converter 自己跑完或 7 日後 expires POST /api/conversion/{id}/cancel
Job 歷史列表 不提供in-memory map 重啟即清 DB persist + GET /api/conversion/history
同 user 多個 active job 強制 1 個pre-check + converter 409 透傳) 沿用 converter 限制(短期內無計畫放寬)
多 chip 同時轉 一次只能選一個 target_chip Phase 1 後評估
Webhookconverter 完成 push 不接收 converter Phase 2 才提供
大量批次 upload 不支援 不在路線圖

9. 失敗模式 & retry 矩陣

9.1 retry 規則

Phase 0.8b v0.6 變更:撤回 v0.5「回收 MC 兩 row」決定。visionA 端不再有 MC / FAA 直接呼叫、相關 row 全部移除。新增 converter GET /jobs/{id}/result row。

操作 401 / 403 4xx 其他 5xx network / timeout max retry 退避
Converter POST /jobs 不重試converter_auth_failed 透傳 retry retry 2 1s, 2s
Converter GET /jobs/{id} 不重試 透傳 retry retry 3 0.5s, 1s, 2s
Converter POST /jobs/{id}/cancel(內部 cleanupPhase 1+ 不重試 不重試 不重試 不重試 0 best-effort失敗只 logPhase 0.8 converter 未實作此 endpoint靠 socket close 兜底§5.3.2
Converter GET /jobs?user_id=&status=in_progresslazy rebuild 不重試 透傳 retry retry 1 0.5s
Converter POST /promote 不重試 透傳 retry retry 2 1s, 2s
Converter GET /jobs/{id}/resultv0.6 新增;下載 NEF stream 不重試401 → converter_auth_failed404 → result_not_found410 → result_expired 透傳 retry retry 2 1s, 2s

每次 retry 之間檢查 ctx.Done()ctx cancel → 立即 return ctx.Err()。

401 / 403 不重試的理由

  • converter 401API key 是 long-lived secretrotate 同步是運維事件、不是瞬時抖動。401 通常意味「visionA env 與下游 env 不同步」retry 100 次也不會自己變對。直接回 502 converter_auth_failed 讓 SRE 看到。
  • converter 404 / 410job_id 不存在(已 GC或結果過期是 user 端狀態問題retry 不會讓 NEF 重新出現。

v0.5 §9.1.1「MC service token cache miss / FAA delegated token 過期」整段撤回v0.6 visionA 端不再有 MC / FAA 直接互動失敗恢復路徑簡化為「converter 5xx retry max 2 次」單條規則)。

9.2 graceful degradation

場景 處理
converter 完全不可達(持續 5xx 502 converter_unavailableUI 提示「轉檔服務暫時無法使用,請稍後再試」
converter 回 401API key 不同步) 502 converter_auth_failedUI 同上文字SRE 從 log 看到 auth_failed 計數異常 → 檢查 env
完成後 promote 失敗converter 5xx job 留在 completed 狀態FAA 上沒檔但 visionA 知道UI 給 user 「重試 promote」按鈕重打 promote-to-models / download
converter GET /jobs/{id}/result 回 404 result_not_foundv0.6 新增) 404 result_not_foundUI 顯示「轉檔任務不存在」
converter GET /jobs/{id}/result 回 410 result_expiredv0.6 新增) 410 result_expiredUI 顯示「轉檔結果已過期,請重新轉檔」並提供重新轉檔 CTA
converter GET /jobs/{id}/result 5xxconverter MinIO 故障 / converter 自身 down 502 converter_unavailableUI 提示重試SRE 從 log 看 5xx body 排查
visionA-backend 重啟 in-memory ownership 與 promoted_key cache 全失frontend 進 /conversion 時 /active lazy rebuild§2.6.1rebuild 不到的 job 由 converter 7 天 expire 自然兜底;v0.6 不再有 MC service token cache已刪除cold start 沒有對應 latency

v0.5「FAA pull 失敗 / FAA 401 / 403 / MC token 失敗 / MC delegated token 失敗」整段 row 撤回v0.6 visionA 端不再有對應路徑)。

9.3 同 user active job 衝突

[Frontend init] → [visionA POST /api/conversion/init]
       ↓
   visionA pre-check (ownership store)
       ├── 有 active job → 409 active_job_exists不打 converter
       └── 沒 → 透傳給 converter
                 ├── converter 200 → 寫 ownership → return
                 └── converter 409 user_has_active_job → 透傳 frontend
                     罕見visionA 的 ownership 與 converter 不同步,
                     例如 visionA 重啟後遺失 mapping以 converter 為準)

10. 安全考量

10.1 visionA-backend 是 user_id 灌入唯一點

詳見 §7。任何繞過此原則的設計都必須先過 ADR review。

10.2 Delegated download token TTLv0.6 整段撤回 — visionA 端不再有 delegated token

v0.4 移除API key 模式下無 delegated tokenv0.5 撤回 v0.4 並回收FAA 線回到 MC delegated download tokenv0.6 再次整段撤回——對 MC source 驗證後確認 MC 沒有 issue delegated token 的 endpointv0.5 設計是 fictional。visionA 端 v0.6 起完全沒有 delegated token 概念。

10.3 Pre-shared API key 保護v0.6 仍縮限至 converter

  • VISIONA_CONVERTER_API_KEY 不可進 git既有 .gitignore.env*,配合 !.env*.example
  • 部署用 AWS Secrets Manager / k8s Secret 注入
  • log 永遠不印 key 全文;可印 api_key_set=true 或前 8 字元 prefixdebug 用)
  • 若 key 洩漏:產新 key → visionA + converter 同步 env → restart → 驗證 → 拔舊 keyrunbook Phase 0.9 補)
  • v0.4 加的 VISIONA_FAA_API_KEY 在 v0.5 / v0.6 維持撤回
  • v0.6 新增:同一把 VISIONA_CONVERTER_API_KEY 也用於 download 路徑converter GET /api/v1/jobs/{id}/result),不需新增 secret

10.4 MC Service Token + Delegated Download Token 保護v0.6 整段撤回 — visionA 端不再有 MC service token / delegated token

v0.5 新增本節給 FAA 線;v0.6 整段撤回——visionA 端不再有 VISIONA_OIDC_SERVICE_CLIENT_ID / _SECRET / VISIONA_OIDC_TENANT_ID 三個 env不再 issue MC service token、不再 issue delegated download token。user login 的 OIDCpublic PKCE是另一條完全獨立的鏈、不在本節範圍詳見 oidc-tdd.md)。

10.5 Object key 不暴露給 frontend JS

  • Phase 0.8b v0.6visionA-backend 透過 server-side stream proxy 把 NEF stream 中轉回 browserconverter MinIO object_key / converter API key 都不出現在任何 frontend response
  • frontend JS 對 object_key / 內部 converter 路徑完全沒有 reference
  • 防快取handler 設 Cache-Control: no-store, no-cache, must-revalidate,避免 browser cache NEF stream
  • 不需 CORSconverter 端或 FAA 端)visionA → converter 是 server-side 同進程內 outbound HTTP call不適用 CORSCORS 只管 browser JS fetch / XHRbrowser 完全不知道 converter / FAA 的存在
  • visionA backend 是 attack surface任何能拿到 visionA cookie session 的 attacker 都能下載自己 user_id 的 NEF — 但這本來就是 user 自己的檔,無 escalation

10.6 Phase 0.8 → v0.4 → v0.5 → v0.6 安全面遷移摘要

面向 Phase 0.8302 + delegated token v0.4server-side proxy + visionA API key v0.5server-side proxy + delegated tokenfictional 從未跑通 v0.6server-side proxy + visionA → converter API key
Token 結構是否存在於 visionA ↔ 下游鏈 MC issue5 分鐘 TTL過 browser visionA API keylong-livedserver-side only MC issue但實際 issue 不到) visionA API keylong-livedserver-side only
攻擊者攔截 visionA → browser response 拿到 token 短期可用 5 分鐘 結構性無 token 結構性無 tokenAPI key 不過 browser
Frontend XSS 影響範圍 短 TTL token 無 token 可竊 無 token 可竊
Server compromisevisionA backend 被攻破) 攻擊者可簽任意 MC delegated token限於 service client 的 scope 攻擊者拿到 visionA API key 後可任意打 FAA 所有 endpoint 攻擊者拿到 VISIONA_CONVERTER_API_KEY 後可任意打 converter 所有 endpoint但 converter 不存其他 user / 其他產品線資料(只存進行中 / 完成的轉檔 job、7 天 GCblast radius 比 v0.4 直接打 FAA 小
MC 是否為依賴 issue token issue service token + delegated token visionA 端 server-to-server 不依賴 MCuser login 仍依賴 MC OIDC但與本表無關
FAA 是否為依賴 是(直接打) 是(直接打) 是(直接打) visionA 端只打 converterconverter → FAA 是 converter 自己的事)
Defense in depth Token TTL + scope 限制 + tenant 限制 API key + visionA OIDC 上游 user auth API key + visionA OIDC 上游 user auth + ownership store + converter MinIO 7 天 GC自然 retention
結論 Phase 0.8 安全靠 MC token TTLv0.4 移除 token 結構但 API key long-livedv0.5 設計上應更安全但實際 fictionalv0.6 與 v0.4 等價安全模型,但 blast radius 限於 converter非 FAA+ 失敗模式收斂為單條鏈,整體 SRE 可運維性最佳

10.7 Race condition

  • 同 user 同時兩 tab init → 第一個成功寫 ownership / converter 接受;第二個 pre-check 通過但 converter 409
  • 兩 tab 同時 promote-to-models → 第一個寫 model record 成功;第二個重複呼叫 ensurePromotedcache hitconverter.GetResult 拉 NEF 兩次接受的取捨converter MinIO 端冪等讀)→ models repo 寫入時可能撞 model_id 衝突 — 改用 model_id 在 finalize 前 SELECT 檢查
  • 兩 tab 同時 download → visionA backend 各自獨立 converter.GetResult無 cache兩條 stream 同時跑、兩條都成功converter MinIO 端冪等讀)— Phase 0.8b 可接受,量大時再加 server-side stream cache 或方向 Aconverter Phase 2 download-tokens 讓 browser 直連 converter

10.8 DoS 防護最小集Phase 1 強化)

  • 同 user 1 個 active job 的限制本身就是 DoS 防護user 不能 init 1000 個 job
  • visionA-backend conversion endpoint 不額外 rate limitPhase 1 補;對齊 security.md §4
  • converter 端有 process semaphoremax 5 concurrent upload保底

變更影響清單

實作此 spec 會動到的檔案(給 Backend Agent 參考Backend 自己拆任務):

Phase 0.8b v0.6 變更(相對於 v0.5 規劃但未上的 codebackend agent 下次任務範圍)

v0.4 階段已上線的 commit 86b7175 把 converter / FAA 兩條線都改為 API key並把 mc_token_client.go 整檔砍除。v0.5 規劃要把 FAA 線回退、mc_token_client 部分復活(但這部分尚未進實作。v0.6 撤回 v0.5 規劃改為commit 86b7175 的 mc_token_client 砍除狀態維持faa_client.go 改名為 converter_result_client.go或併入 converter_client.go承接新 GetResult methodconfig.go / .env 撤回 v0.5 規劃要加回的 OIDC ServiceClient* / TenantID / FAABaseURL。

同時:converter 跨 repo 加新 endpointjimchen 自己處理)。

v0.6 跨 repoconverter schedulerjimchen 在 /Users/jimchen/kneron_model_converter/apps/task-scheduler/

  • 新增 src/routes/v1/result.js(或加進 jobs.jsGET /api/v1/jobs/:id/result handler套用既有 requireReadAuth middlewareAPI key 比對)
  • handler 內job status / expires_at 檢查 → MinIO get object stream → pipeline(minioStream, res) + 設 headers
  • 新增 integration testsrc/routes/v1/__tests__/result.integration.test.js
  • 更新 openapi.yaml(加 GET /api/v1/jobs/{id}/result path 規格,含 200 / 401 / 404 / 409 / 410 / 500 / 502 / 503 response
  • 更新 README.mdAPI 清單加新 endpoint

v0.6 從 v0.5 規劃撤回的 source code 變更visionA backendv0.5 規劃要做、v0.6 不做):

  • 不做visionA-backend/internal/conversion/mc_token_client.go 部分復活v0.5 規劃 → v0.6 撤回commit 86b7175 已砍除狀態維持)
  • 不做visionA-backend/internal/conversion/mc_token_client_test.go 復活
  • 不做visionA-backend/internal/conversion/faa_client.gotokens *MCTokenClient 欄位 + DownloadWithDelegated methodv0.5 規劃 → v0.6 撤回;改成下方「新增 GetResult」
  • 不做visionA-backend/internal/conversion/flow.gotokens *MCTokenClient 欄位 + IssueDelegatedDownload 步驟v0.5 規劃 → v0.6 撤回;改成下方「直接呼叫 converter.GetResult」
  • 不做OIDCConfig.ServiceClientID / ServiceClientSecret 重新啟用v0.5 規劃 → v0.6 撤回)
  • 不做ConversionConfig.TenantID 重新啟用v0.5 規劃 → v0.6 撤回)
  • 不做.env.stage.example / .env.dev.example 加回 VISIONA_OIDC_SERVICE_CLIENT_ID / _SECRET / VISIONA_OIDC_TENANT_IDv0.5 規劃 → v0.6 撤回)

v0.6 visionA backend 實際要做的 source code 變更

  • 改 / 改名:visionA-backend/internal/conversion/faa_client.go改名為 converter_result_client.go(或併入 converter_client.go 作為新 method唯一職責是打 converter GET /api/v1/jobs/{id}/result 拉 NEF stream
  • 改:visionA-backend/internal/conversion/converter_client.go
    • 維持既有 init / poll / promote method 不變
    • 新增 methodGetResult(ctx context.Context, jobID string) (io.ReadCloser, *DownloadMetadata, error),內部 GET {baseURL}/api/v1/jobs/{jobID}/result + Authorization: Bearer <ConverterAPIKey>,解析 response Content-Length / Content-Disposition / body streamerror mapping 對應 ADR-016 §1.3
  • 改:visionA-backend/internal/conversion/flow.go
    • 移除 tokens *MCTokenClient 欄位(如尚未從 commit 86b7175 完全清掉)
    • 移除 faa *FAAClient 欄位(如尚未清掉)
    • DownloadStream 內部:ensurePromoted 之後直接呼叫 flow.converter.GetResult(ctx, jobID)
    • PromoteToModels 內部:同樣改呼叫 flow.converter.GetResult(ctx, jobID)(與 DownloadStream 共用同條 path
    • filename 處理:拿到 stream 後用 defaultDownloadFilename(cj) 覆寫 converter 給的 filename不變、規則同 v0.5
  • 改:visionA-backend/internal/conversion/conversion.go
    • Service.DownloadStream 簽名不變
    • 刪除 v0.5 註解中的 DownloadGrant struct不再需要
  • 改:visionA-backend/internal/config/config.go
    • ConversionConfig.FAAAPIKey 維持移除v0.5 撤回過、v0.6 維持)
    • 新增移除ConversionConfig.FAABaseURLv0.5 規劃要保留 / v0.6 移除)
    • ConversionConfig.TenantID 維持移除v0.5 規劃要加回 / v0.6 撤回)
    • OIDCConfig.ServiceClientID / ServiceClientSecret 維持移除v0.5 規劃要加回 / v0.6 撤回)
    • ConversionConfig.Enabled() 簡化:只判 ConverterBaseURL != "" && ConverterAPIKey != ""
  • 改:visionA-backend/cmd/api-server/main.go — wire conversion.Flow 時不傳 MCTokenClient、不傳 FAAClient、不傳 FAA / Tenant config
  • 改:.env.stage.example / .env.dev.example
    • 維持移除 VISIONA_FAA_API_KEYv0.4 加 / v0.5 撤回 / v0.6 維持撤回)
    • 維持移除 VISIONA_OIDC_SERVICE_CLIENT_ID / _SECRET / VISIONA_OIDC_TENANT_ID
    • 新增移除VISIONA_FAA_BASE_URLv0.5 規劃要保留 / v0.6 移除)
    • 保留 VISIONA_CONVERTER_BASE_URL / VISIONA_CONVERTER_API_KEY
  • 改:對應 unit / integration test — converter_client_test.goGetResult test含 401 / 404 / 410 / 5xx mapping刪除 faa_client_test.gov0.5 規劃要保留 / v0.6 撤回)

v0.6 維持 v0.5 / v0.4 既有的 source code 變更converter API key 線不變):

  • 維持:visionA-backend/internal/conversion/converter_client.go 仍用 apiKey string + Authorization: Bearer <ConverterAPIKey>init / poll / promote method沒變
  • 維持:Service interface DownloadStream(...) (io.ReadCloser, *DownloadMetadata, error)v0.4 改的、v0.5 / v0.6 不退回 DownloadRedirectURL
  • 維持:visionA-backend/internal/api/conversion.go conversionDownloadHandler 仍用 io.CopyN(c.Writer, stream, 1<<30)v0.4 改的、v0.5 / v0.6 不退回 302
  • 維持:ConversionConfig.ConverterAPIKey 欄位 + Enabled() 中對 ConverterAPIKey 的檢查

不動v0.4 / v0.5 / v0.6 都不影響)

  • internal/model/*schema 不變)
  • internal/api/models.go(既有 init/finalize 不動flow.PromoteToModels 內部呼叫 helper
  • OIDC user login 相關全部(internal/oidc/internal/usersession//api/auth/* handlers

⚠️ 本次2026-05-16 / v0.6)的範圍只動共享文件(本文 conversion.md / api-conversion.md / oidc-tdd.md / adr-014 / adr-015 / adr-016。source code 改造由 backend agent 下次任務處理visionA backend 撤回 v0.5 規劃 + 加 converter.GetResult method + 改 flow.go + 改 config / .envconverter 端跨 repo 加新 endpoint 由 jimchen 自行處理)。


版本記錄

日期 版本 變更
2026-04-30 0.1 初稿 — Phase 0.8 轉檔整合 TDD
2026-04-30 0.2 Download flow 改為 server-side HTTP 302 redirectendpoint 從 POST /{job}/download-token 改為 GET /{job}/download、Service interface 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
2026-05-11 0.4 Phase 0.8b:服務間認證從 OAuth client_credentials 改為 pre-shared API key對應 ADR-015)。主要變更:(1) §1 端對端 sequence 拿掉 MC node(2) §2 砍 mc_token_client.go 整個檔;(3) §3 新增「服務間認證API key」章節原 §5 OAuth 章節整段刪除,章節編號 4→5(4) §4.1 /download handler 從 c.Redirect(302) 改 server-side stream proxyService interface DownloadRedirectURLDownloadStream(5) §6 錯誤碼 mapping 移除 MC 4 個 code、新增 converter_auth_failed / faa_auth_failed(6) §9.1 retry 矩陣移除 MC 2 row、所有下游 401/403 不重試;(7) §10.2 刪除 delegated token TTL、§10.3 改為 pre-shared API key 保護、§10.4 改為 server-side stream proxy 安全模型;(8) 變更影響清單列出 backend agent 後續實作要動的 .go 檔。OIDC user login 完全不動。
2026-05-15 0.4.1 修 §4.1 /download handler Content-Disposition filename 來源描述歧義T4 Reviewer M-3— 原註釋「filename 來自 promote 結果」可被誤讀為「FAA promote response 直接給 filename」改為明確標示「visionA backend 在 service 層由 defaultDownloadFilename(cj) 從 conversion job metadata 構造(規則 <source_filename_stem>_<target_chip_lower>.nef),對齊 wireframe success card 顯示範例」、並補充「FAA 端的 object_key 是 models/<user>/<job>.nef 對 user 不友善」的對比說明。純文字釐清、無實作行為變更。
2026-05-16 0.5 對應 ADR-015 v2.0 範圍縮限:撤回 v0.4「visionA → FAA 改 API key」決定、FAA 線回到 ADR-014 §2 原設計MC service token + delegated download tokenvisionA → converter API key 路線v0.4維持。主要變更:(1) §1 整體 flow sequence 加回 MC node、download path 改回「MC issue delegated token → visionA 帶 delegated token 打 FAA」(2) §2 模組設計 — mc_token_client.go 部分復活(保留 service token cache + IssueDelegatedDownload 邏輯、faa_client.go 改 DownloadWithDelegated(ctx, delegatedToken, objectKey)、flow.go 加回 tokens *MCTokenClient 欄位、DownloadStream / PromoteToModels 流程加回 IssueDelegatedDownload 步驟;(3) §3 拆成 §3.1 visionA → converterAPI key+ §3.2 visionA → FAAservice token + delegated download token§3.2 詳述 FAA dual-auth 設計與為什麼 download endpoint 強制用 delegated token(4) §4.1 download handler 流程改回「ensurePromoted → IssueDelegatedDownload → DownloadWithDelegated」保留 server-side stream proxy 不退回 302(5) §6 錯誤碼回收 mc_token_unavailable / download_token_failed 兩個 code撤回 v0.4 加的 faa_auth_failed(6) §9 retry 矩陣回收 MC 兩 row、FAA row 改回 service token + delegated(7) §10 安全考量 — §10.2 delegated token TTL 回收、§10.3 API key 保護縮限至 converter、新增 §10.4 MC service token + delegated download token 保護、§10.6 三方對比加 v0.5 column(8) 變更影響清單列出 backend agent 下次任務範圍(從 v0.4 回退 FAA 線 + mc_token_client 部分復活)。本次純文件修訂、source code 改造留給 backend agent 下次任務。OIDC user login 完全不動。
2026-05-16 0.6 對應 ADR-016:撤回 v0.5「visionA → FAA 線回到 MC service token + delegated download token」全部規劃。原因:對 MC source 全 grep 驗證後確認 MC 沒有 POST /file-access/download-tokens endpoint、也沒有 FAA MemberCenterDelegatedDownloadTokenValidator assume 的 introspection endpoint—— ADR-014 §2 與 ADR-015 v2.0 §2 的 delegated token 鏈是 fictional從 2026-05-02 寫定起未曾 e2e 跑通)。v0.6 新設計visionA download 改走 converter 新增的 GET /api/v1/jobs/{id}/result + visionA stream 中轉visionA 端不再有任何 visionA → MC / visionA → FAA 路徑、server-to-server 認證收斂為單條 visionA → converterAPI keypromote 仍走 visionA → converter POST /promoteconverter 內部 PUT FAA 與 visionA 無關,不變)。主要變更:(1) §1 整體 flow sequence 移除 MC node、download path 改成「converter.GetResult」(2) §2 模組設計 — mc_token_client.go 維持砍除(撤回 v0.5 部分復活、faa_client.go 改名為 converter_result_client.go或併入 converter_client.go新增 GetResult method、flow.go 移除 tokens *MCTokenClient 欄位、DownloadStream / PromoteToModels 都改走 converter.GetResult(3) §3 整段重寫 — §3.1 visionA → converter API key不變、新增同 key 用於 GetResult endpoint+ §3.2 visionA → FAA 整段撤回§3.2.1§3.2.5 全部標 v0.6 撤回);(4) §4.1 download handler 流程改成「ensurePromoted → converter.GetResult」保留 server-side stream proxy 不退回 302(5) §6 錯誤碼撤回 faa_unavailable / mc_token_unavailable / download_token_failed 三個 code、新增 result_not_found / result_expired 兩個 code(6) §9 retry 矩陣移除 MC 兩 row、FAA row 全部撤回、新增 Converter GetResult row(7) §10 安全考量 — §10.2 delegated token TTL 整段撤回、§10.3 API key 保護維持縮限至 converter 同時新增「同一把用於 GetResult」說明、§10.4 MC service token + delegated download token 保護整段撤回、§10.6 加 v0.6 column 對比、§10.7 race condition 與 §10.8 DoS重編號更新(8) 變更影響清單列出 backend agent 下次任務範圍(從 v0.5 規劃撤回 + 新增 converter.GetResult + 跨 repo converter scheduler 加 endpoint本次純文件修訂、source code 改造留給 backend agent 下次任務 + converter 跨 repo 由 jimchen 自行處理。OIDC user login 完全不動。