feat(visionA-backend): Phase 0.8 conversion package — 5 endpoint + 8 個內部模組

Phase 0.8 把 kneron_model_converter 的轉檔功能整合進 visionA Cloud。
visionA backend 當 streaming proxy(upload)+ delegated download token broker(download)+
ownership trust boundary,converter / FAA / MC 三方零修改。

新增 internal/conversion/ 套件(8 個檔,~10,000 行 prod+test,117+ test cases,race -count=3 全綠):

- conversion.go:Service interface 5 method、Job/PromoteResult/InitJobInput types
- errors.go:13+ sentinel errors + ErrorCode/HTTPStatus mapping,對齊 conversion.md §6
- mc_token_client.go:service-to-service token (client_credentials grant) + DCL cache
  (exp - 15s 重取,per-scope cache),IssueDelegatedDownload(MC delegated download token)
  錯誤分 idp_misconfigured (4xx) / idp_unavailable (5xx) / download_token_failed / mc_token_unavailable
- converter_client.go:對 converter scheduler 4 method(InitJob multipart streaming /
  GetJob / Promote / ListInProgressJobs),InitJob 不 retry 5xx(streaming body 無法 replay)
- faa_client.go:對 FAA GET /files/{key} server-to-server pull,Phase A retry(GET 無 body
  可 replay)對齊 §9.1 retry 矩陣,streaming io.ReadCloser 透傳避 OOM
- ownership.go:in-memory job_id → user_id map + per-user mutex 防 thundering herd lazy rebuild
  (不同 user 平行 fetch,同 user 100 caller 收斂成 1 次),visionA 重啟靠 converter
  ListInProgressJobs(user) 重建
- flow.go:Service interface 整合層(5 method 串接 converter/FAA/MC/ownership)
  - InitJob 用 io.Pipe + multipart.Reader/Writer 重組 streaming proxy(黑名單 client user_id
    + 灌入 OIDC sub)
  - DownloadRedirectURL 自動觸發 promote(spec §1 Stage 3b),用 ensurePromoted helper
  - PromoteToModels 冪等(modelStore.FindBySourceJobID 為 source-of-truth)
  - OwnershipMismatch → ErrJobNotFound 不 forbidden(§7.2 防枚舉)
  - storage / modelStore 失敗包 ErrStorageUnavailable / ErrModelStoreUnavailable
    (視為 visionA 自身 500 而非 502 gateway,SRE alarm 才打對 team)

新增 internal/api/conversion.go(5 endpoint handler + main.go wire):
- POST /api/conversion/init(multipart streaming proxy,不呼叫 c.MultipartForm())
- GET  /api/conversion/active(lazy rebuild ownership)
- GET  /api/conversion/{job_id}(poll status)
- POST /api/conversion/{job_id}/promote-to-models(FAA pull → models 三段式)
- GET  /api/conversion/{job_id}/download(server-side HTTP 302 → FAA,token 不過 frontend
  JS,仿 FAA TestSite DownloadFileDirect pattern;Cache-Control: no-store)

5 個 endpoint 全部走 OIDC AuthMiddleware;user_id 從 cookie session 灌(trust boundary),
從不接受 client multipart form / JSON / query 的 user_id。
TestAllAPIEndpointsRequire401WithoutCookie 自動覆蓋新 5 endpoint regression 防呆。

新增 cmd/api-server/conversion_e2e_test.go(4 個 e2e 場景):
- TestConversionE2E_StreamingProxy(10MB body + trust boundary regression)
- TestConversionE2E_LazyRebuildAfterRestart(visionA 重啟仍能 /active)
- TestConversionE2E_Download302Redirect(驗 302 + Location header + token 不在 body)
- TestConversionE2E_ActiveJobConflict(409 + active_job 詳情)

修改 internal/config/{config,load}.go:新增 ConversionConfig 5 欄位
(ConverterBaseURL / FAABaseURL / TenantID / ServiceClientID / ServiceClientSecret)+
Enabled() helper(雙非空判定)。
修改 cmd/api-server/main.go:條件 wire(cfg.Conversion.Enabled() 為 true 才建 client + Service;
否則 Deps.Conversion=nil,handler 自動回 501)。
修改 .env.example:新增 Phase 0.8 區塊註解。
新增 cmd/api-server/conversion_adapters.go:narrow interface adapter(接既有
internal/model.Repository / internal/storage.Store → conversion.ModelStore / Storage,避免 import cycle)。

驗證:go test -race -count=3 ./... 17 packages 全綠 / go vet 0 warning / go build 成功。

對齊文件:
- .autoflow/04-architecture/adr/adr-014-conversion-integration.md
- .autoflow/04-architecture/conversion.md (TDD)
- .autoflow/04-architecture/api/api-conversion.md
- .autoflow/02-prd/features/feature-converter-integration.md
- .autoflow/03-design/wireframes/wireframe-conversion.md
- .autoflow/03-design/flows/flow-conversion.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-05-04 13:56:07 +08:00
parent 72672972c8
commit 1231bf0ed2
24 changed files with 10839 additions and 0 deletions

View File

@ -155,3 +155,35 @@ VISIONA_MODEL_MAX_SIZE_MB=100
# 建議用vAc_$(openssl rand -hex 16)
# 留空代表雛形 InMemoryPairingStore 會動態配發(前端呼叫 POST /api/pairing/token
VISIONA_PAIRING_TOKEN=
# ============================================================
# Phase 0.8 — 轉檔功能整合converter / FAA / Member Center service token
# ============================================================
# 對齊 .autoflow/04-architecture/conversion.md §5.3
#
# 啟用判定:當 VISIONA_CONVERTER_BASE_URL 與 VISIONA_FAA_BASE_URL 都非空時,
# main.go 才會 wire conversion.Service其中之一留空 → 5 個 /api/conversion/* endpoint 回 501。
#
# 啟用時 VISIONA_OIDC_SERVICE_CLIENT_ID/SECRET 必須非空(轉檔依賴 service token 機制)。
# kneron_model_converter task-scheduler base URL
# dev/stagehttp://192.168.0.130:9501
# prodhttps://converter.visiona.cloud
VISIONA_CONVERTER_BASE_URL=
# File Access Agent base URL
# dev/stagehttp://192.168.0.130:5081
# prodhttps://faa.innovedus.com
VISIONA_FAA_BASE_URL=
# visionA 在 Member Center 的 tenant id單一 tenant
# 跟 MC 換 delegated download token 時當 tenant_id 欄位用
VISIONA_OIDC_TENANT_ID=
# Delegated download token TTL— FAA 直連下載用
# 預設 3005 分鐘),可調整範圍 60-900
VISIONA_FAA_DELEGATED_TTL_SECONDS=300
# 上傳模型檔大小上限MB— 與 converter 端 limit 對齊
VISIONA_CONVERTER_MAX_MODEL_SIZE_MB=500

View File

@ -0,0 +1,150 @@
// conversion_adapters.go — main.go 的 narrow adapter 實作。
//
// internal/conversion 為了避免 import cycle 與保持 interface 純粹FAANG 慣例:
// consumer 定義介面),定義了 ModelStore / Storage 兩個 narrow interface。
// main.go 在 wire 時把 *model.InMemoryRepository / *storage.LocalFSStore 包成 adapter
// 注入conversion 完全不知道具體實作。
//
// 對齊 .autoflow/04-architecture/conversion.md §2.7NewService 註解)。
//
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.7)
package main
import (
"context"
"fmt"
"io"
"time"
"github.com/google/uuid"
"visiona-backend/internal/conversion"
"visiona-backend/internal/model"
"visiona-backend/internal/storage"
)
// ==========================================================================
// ModelStore adapter
// ==========================================================================
// conversionModelStoreAdapter 把 model.Repository 包成 conversion.ModelStore。
//
// 額外責任:
// - ModelRecord ↔ model.Model 雙向轉換
// - FindBySourceJobID 用 List(filter) + 自行過濾 SourceJobID既有 model.ListFilter
// 沒有 SourceJobID 欄位Phase 1 加 DB 後可改 indexed query
// - GenerateID 用 uuid.NewString與 internal/api/models.go modelsInitUploadHandler 一致)
type conversionModelStoreAdapter struct {
repo model.Repository
}
// newConversionModelStoreAdapter 建立 adapter。
func newConversionModelStoreAdapter(repo model.Repository) conversion.ModelStore {
return &conversionModelStoreAdapter{repo: repo}
}
// Save 把 conversion.ModelRecord 轉成 model.Model 後 upsert。
//
// 設計選擇UploadedAt 設為 nowpromote 完成 = 等同 finalize 後的 ready 狀態),
// 這樣 GET /api/models/{id} 回的 status 會是 "ready"(對齊 toModelResponse 邏輯)。
func (a *conversionModelStoreAdapter) Save(ctx context.Context, rec *conversion.ModelRecord) error {
if rec == nil {
return fmt.Errorf("conversion adapter: Save requires non-nil record")
}
now := time.Now().UTC()
uploadedAt := now
if !rec.UpdatedAt.IsZero() {
uploadedAt = rec.UpdatedAt
}
m := &model.Model{
ID: rec.ID,
OwnerUserID: rec.OwnerUserID,
Name: rec.Name,
Description: rec.Description,
StorageKey: rec.StorageKey,
FileSize: rec.FileSize,
FileChecksum: rec.FileChecksum,
TargetChip: rec.TargetChip,
Source: rec.Source, // 應為 "converted"
SourceJobID: rec.SourceJobID,
CreatedAt: rec.CreatedAt,
UpdatedAt: rec.UpdatedAt,
UploadedAt: &uploadedAt, // promote 完即 ready對齊 toModelResponse
}
return a.repo.Save(ctx, m)
}
// FindBySourceJobID 找 user 是否已對某 job 建過 model record冪等檢查用
//
// Phase 0.8 雛形實作:用 List(filter) + 過濾 SourceJobIDin-memory 慢但對小量 user 足夠)。
// Phase 1 用 SQL `WHERE owner_user_id = ? AND source_job_id = ?` 加索引。
//
// 找不到回 (nil, nil);找到第一個 match 回 (*ModelRecord, nil)。
func (a *conversionModelStoreAdapter) FindBySourceJobID(ctx context.Context, ownerUserID, sourceJobID string) (*conversion.ModelRecord, error) {
if ownerUserID == "" || sourceJobID == "" {
return nil, nil
}
models, err := a.repo.List(ctx, model.ListFilter{
OwnerUserID: ownerUserID,
Source: model.SourceConverted,
})
if err != nil {
return nil, fmt.Errorf("conversion adapter: list models for FindBySourceJobID: %w", err)
}
for _, m := range models {
if m.SourceJobID == sourceJobID {
return modelToRecord(m), nil
}
}
return nil, nil
}
// GenerateID 產一個新 model_id沿用既有 visionA model 命名 — uuid.NewString
func (a *conversionModelStoreAdapter) GenerateID() string {
return uuid.NewString()
}
// modelToRecord 把 *model.Model 轉成 *conversion.ModelRecord給 PromoteToModels 冪等回傳用)。
func modelToRecord(m *model.Model) *conversion.ModelRecord {
if m == nil {
return nil
}
return &conversion.ModelRecord{
ID: m.ID,
OwnerUserID: m.OwnerUserID,
Name: m.Name,
Description: m.Description,
StorageKey: m.StorageKey,
FileSize: m.FileSize,
FileChecksum: m.FileChecksum,
TargetChip: m.TargetChip,
Source: m.Source,
SourceJobID: m.SourceJobID,
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
}
}
// ==========================================================================
// Storage adapter
// ==========================================================================
// conversionStorageAdapter 把 storage.Store 包成 conversion.Storage。
//
// 目前只需要 Putstreaming 寫入meta 透傳到底層 storageLocalFS 雛形可能忽略,
// S3 接上後會寫進 object metadata
type conversionStorageAdapter struct {
store storage.Store
}
// newConversionStorageAdapter 建立 adapter。
func newConversionStorageAdapter(store storage.Store) conversion.Storage {
return &conversionStorageAdapter{store: store}
}
// Put streaming 寫入 storagemeta 透傳給底層 storage 實作)。
//
// LocalFS 雛形可能忽略 metaS3 / R2 等 backend 會寫進 object metadata給 debug / Tagging
func (a *conversionStorageAdapter) Put(ctx context.Context, key string, r io.Reader, size int64, meta map[string]string) error {
return a.store.Put(ctx, key, r, size, meta)
}

File diff suppressed because it is too large Load Diff

View File

@ -27,6 +27,7 @@ import (
"visiona-backend/internal/api"
"visiona-backend/internal/auth"
"visiona-backend/internal/config"
"visiona-backend/internal/conversion"
"visiona-backend/internal/converter"
"visiona-backend/internal/device"
"visiona-backend/internal/logger"
@ -136,6 +137,69 @@ func main() {
// ===== ConverterstubPhase 2 才實作) =====
converterClient := converter.NewStubClient()
// ===== Phase 0.8 Conversion轉檔功能整合 =====
// 對齊 .autoflow/04-architecture/conversion.md。
//
// 啟用條件cfg.Conversion.Enabled() — ConverterBaseURL + FAABaseURL 都非空。
// 啟用時必須有 ServiceClientID/Secretclient_credentials grant 必要)。
// 不啟用時 deps.Conversion 為 nil5 個 endpoint 自動回 501registerConversionRoutes 處理)。
var conversionService conversion.Service
if cfg.Conversion.Enabled() {
// service token 機制依賴 ServiceClientID/Secret — 沒設就 fatal避免半設定狀態
if cfg.OIDC.ServiceClientID == "" || cfg.OIDC.ServiceClientSecret == "" {
log.Error("conversion enabled but service client credentials missing",
"hint", "set VISIONA_OIDC_SERVICE_CLIENT_ID + VISIONA_OIDC_SERVICE_CLIENT_SECRET, or unset CONVERTER/FAA base URL to disable")
os.Exit(1)
}
mcTokenClient := conversion.NewMCTokenClient(conversion.MCTokenClientOpts{
Issuer: cfg.OIDC.IssuerURL,
ClientID: cfg.OIDC.ServiceClientID,
ClientSecret: cfg.OIDC.ServiceClientSecret,
Logger: log,
})
converterAPIClient := conversion.NewConverterClient(conversion.ConverterClientOpts{
BaseURL: cfg.Conversion.ConverterBaseURL,
Tokens: mcTokenClient,
Logger: log,
})
faaAPIClient := conversion.NewFAAClient(conversion.FAAClientOpts{
BaseURL: cfg.Conversion.FAABaseURL,
Tokens: mcTokenClient,
Logger: log,
})
ownership := conversion.NewOwnership(converterAPIClient, log)
// narrow adapter避免 conversion 直接 import internal/model / internal/storage
modelStoreAdapter := newConversionModelStoreAdapter(modelRepo)
storageAdapter := newConversionStorageAdapter(storageStore)
var convErr error
conversionService, convErr = conversion.NewService(conversion.FlowOpts{
Converter: converterAPIClient,
FAA: faaAPIClient,
MCToken: mcTokenClient,
Ownership: ownership,
ModelStore: modelStoreAdapter,
Storage: storageAdapter,
TenantID: cfg.Conversion.TenantID,
FAABaseURL: cfg.Conversion.FAABaseURL,
DelegatedTTLSeconds: cfg.Conversion.DelegatedTTLSeconds,
Logger: log,
})
if convErr != nil {
log.Error("failed to init conversion service", "error", convErr)
os.Exit(1)
}
log.Info("conversion service initialized",
"converter_base_url", cfg.Conversion.ConverterBaseURL,
"faa_base_url", cfg.Conversion.FAABaseURL,
"tenant_id", cfg.Conversion.TenantID,
"delegated_ttl_sec", cfg.Conversion.DelegatedTTLSeconds)
} else {
log.Info("conversion service disabled (set VISIONA_CONVERTER_BASE_URL + VISIONA_FAA_BASE_URL to enable)")
}
// ===== Seed demo data可選 =====
if cfg.Server.SeedDemoData {
if err := seedDemoData(deviceRepo, modelRepo, pairingStore, cfg.Auth.StaticUserID, log); err != nil {
@ -157,6 +221,7 @@ func main() {
ModelRepo: modelRepo,
Storage: storageStore,
Converter: converterClient,
Conversion: conversionService, // Phase 0.8nil 時 /api/conversion/* 回 501
MaxUploadSizeMB: cfg.Model.MaxSizeMB,
CORSAllowedOrigins: cfg.CORS.AllowedOrigins,
RelayPublicURL: cfg.Server.RelayPublicURL,

View File

@ -23,6 +23,7 @@ import (
"github.com/gin-gonic/gin"
"visiona-backend/internal/auth"
"visiona-backend/internal/conversion"
"visiona-backend/internal/converter"
"visiona-backend/internal/device"
"visiona-backend/internal/model"
@ -69,6 +70,13 @@ type Deps struct {
Storage storage.Store
Converter converter.Client
// Conversion 是 Phase 0.8 轉檔功能的 Service interface5 個 endpoint 共用)。
// 為 nil 時 /api/conversion/* 5 個 endpoint 全回 501 NOT_IMPLEMENTED
// main.go 在 cfg.Conversion.Enabled() 為 false 時不 wire對齊 api-conversion.md。
//
// 設計選擇:用 conversion.Service interface 而非 concrete type — 方便 unit test 注入 stub。
Conversion conversion.Service
// CORSAllowedOrigins 是允許的瀏覽器 Origin 白名單;空 slice 預設放行
// http://localhost:3000前端 dev server
CORSAllowedOrigins []string
@ -174,6 +182,10 @@ func NewRouter(deps Deps) *gin.Engine {
registerModelRoutes(apiGroup, deps)
registerClusterRoutes(apiGroup, deps)
// Phase 0.8Conversion轉檔— 5 個 endpoint
// 對齊 .autoflow/04-architecture/api/api-conversion.md
registerConversionRoutes(apiGroup, deps)
// Stubs只註冊「還沒有實際 handler」的那些 endpoint
registerStubRoutes(apiGroup, deps)

View File

@ -0,0 +1,469 @@
// conversion.go — /api/conversion/* 的 handler 實作Phase 0.8)。
//
// 對齊:
// - .autoflow/04-architecture/api/api-conversion.md5 個 endpoint API spec
// - .autoflow/04-architecture/conversion.md §3 endpoint 表 + §6 錯誤碼 + §10 安全考量
// - internal/conversion/conversion.goService interface
//
// 5 個 endpoint
//
// POST /api/conversion/init — 啟動轉檔multipart streaming
// GET /api/conversion/active — 查當前 active job
// GET /api/conversion/{job_id} — poll 狀態
// POST /api/conversion/{job_id}/promote-to-models — 加到模型庫
// GET /api/conversion/{job_id}/download — server-side 302 redirect → FAA
//
// 安全要點(對齊 conversion.md §7 / §10
// - 全部 5 個 endpoint 都註冊在 apiGroupOIDC AuthMiddleware 之後)
// - userID 一律來自 UserContextFrom(c).UserID從 cookie session 解出 OIDC sub
// - 任何 client 帶來的 user_idmultipart form / JSON / query一律忽略
// - /init 不呼叫 c.MultipartForm() — 會 buffer 全 body 進 RAM / disk破壞 streaming
// - /download 採 HTTP 302 Foundtoken 不出現在任何 JSON response§10.4
//
// Phase 0.8 conversion (見 .autoflow/04-architecture/api/api-conversion.md)
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"visiona-backend/internal/conversion"
)
// ==========================================================================
// Route 註冊
// ==========================================================================
// registerConversionRoutes 註冊 /api/conversion/* 的 routes。
//
// 由 NewRouter 在 apiGroupOIDC AuthMiddleware 已套)下呼叫;
// 若 deps.Conversion 為 nilPhase 0.8 conversion 未啟用,例如 dev 環境沒設
// CONVERTER_BASE_URL / FAA_BASE_URL→ 5 個 endpoint 一律回 501。
func registerConversionRoutes(g *gin.RouterGroup, deps Deps) {
if deps.Conversion == nil {
// 未啟用 — 註冊 501 stub避免 404讓 frontend 拿到明確 NOT_IMPLEMENTED
notImpl := func(c *gin.Context) {
WriteNotImplemented(c, "conversion service is not configured (set VISIONA_CONVERTER_BASE_URL + VISIONA_FAA_BASE_URL)")
}
conv := g.Group("/conversion")
conv.POST("/init", notImpl)
conv.GET("/active", notImpl)
conv.GET("/:job_id", notImpl)
conv.POST("/:job_id/promote-to-models", notImpl)
conv.GET("/:job_id/download", notImpl)
return
}
conv := g.Group("/conversion")
conv.POST("/init", conversionInitHandler(deps))
conv.GET("/active", conversionActiveHandler(deps))
conv.GET("/:job_id", conversionGetHandler(deps))
conv.POST("/:job_id/promote-to-models", conversionPromoteHandler(deps))
conv.GET("/:job_id/download", conversionDownloadHandler(deps))
}
// ==========================================================================
// 1. POST /api/conversion/init
// ==========================================================================
// conversionInitHandler 處理「啟動轉檔」請求。
//
// 流程:
// 1. UserContextFrom 拿 OIDC subAuthMiddleware 已驗)
// 2. 驗 Content-Type 必須是 multipart/form-data含 boundary
// 3. 直接把 c.Request.Body + Content-Type 傳給 Service.InitJob
// **不**呼叫 c.MultipartForm() — 會破壞 streaming
// 4. 成功 → 201 + Job
// 5. 失敗 → 透過 handleConversionError 對應 sentinel mapping
//
// 對齊 api-conversion.md §1 + conversion.md §4.2 streaming proxy。
func conversionInitHandler(deps Deps) gin.HandlerFunc {
return func(c *gin.Context) {
uc, ok := UserContextFrom(c)
if !ok || uc.UserID == "" {
// AuthMiddleware 已通過卻拿不到 UserContext — 設定錯誤
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
"missing user context (auth middleware misconfigured?)", nil)
return
}
ct := c.GetHeader("Content-Type")
if !strings.HasPrefix(strings.ToLower(ct), "multipart/form-data") {
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed,
"Content-Type must be multipart/form-data with boundary", nil)
return
}
// 把 raw body + Content-Type 傳給 ServiceService 內部處理 multipart streaming
// 重組(注入 user_id、黑名單 client 帶的 user_id。見 conversion.md §4.2。
in := conversion.InitJobInput{
UserID: uc.UserID,
ContentType: ct,
Body: c.Request.Body,
ContentLength: c.Request.ContentLength,
}
job, err := deps.Conversion.InitJob(c.Request.Context(), in)
if err != nil {
handleConversionError(c, err)
return
}
// 成功 — 201 Created對齊 RESTful 慣例POST 建立資源用 201
WriteSuccess(c, http.StatusCreated, jobToResponse(job))
}
}
// ==========================================================================
// 2. GET /api/conversion/active
// ==========================================================================
// conversionActiveHandler 處理「查當前 active job」請求。
//
// 對齊 api-conversion.md §5
// - 有 active → 200 + {has_active: true, job: {...}}
// - 無 active → 200 + {has_active: false, job: null}
//
// 重啟恢復場景由 Service 內部 EnsureRebuilt 處理lazy rebuild from converter
// handler 對 frontend 完全透明。
func conversionActiveHandler(deps Deps) gin.HandlerFunc {
return func(c *gin.Context) {
uc, ok := UserContextFrom(c)
if !ok || uc.UserID == "" {
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
"missing user context (auth middleware misconfigured?)", nil)
return
}
job, err := deps.Conversion.ActiveJob(c.Request.Context(), uc.UserID)
if err != nil {
handleConversionError(c, err)
return
}
if job == nil {
WriteSuccess(c, http.StatusOK, gin.H{
"has_active": false,
"job": nil,
})
return
}
WriteSuccess(c, http.StatusOK, gin.H{
"has_active": true,
"job": jobToResponse(job),
})
}
}
// ==========================================================================
// 3. GET /api/conversion/{job_id}
// ==========================================================================
// conversionGetHandler 處理「poll job 狀態」請求。
//
// 對齊 api-conversion.md §2。
// 設計選擇ownership 不符 / job 不存在都對應到 ErrJobNotFound404
// 由 Service 層做安全 mapping見 flow.go GetJob 註解避免「forbidden vs not_found」
// 差異枚舉合法 job_id
func conversionGetHandler(deps Deps) gin.HandlerFunc {
return func(c *gin.Context) {
uc, ok := UserContextFrom(c)
if !ok || uc.UserID == "" {
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
"missing user context (auth middleware misconfigured?)", nil)
return
}
jobID := c.Param("job_id")
if jobID == "" {
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed,
"job_id is required", nil)
return
}
job, err := deps.Conversion.GetJob(c.Request.Context(), uc.UserID, jobID)
if err != nil {
handleConversionError(c, err)
return
}
WriteSuccess(c, http.StatusOK, jobToResponse(job))
}
}
// ==========================================================================
// 4. POST /api/conversion/{job_id}/promote-to-models
// ==========================================================================
// promoteRequest 是 promote-to-models 的 request body對齊 api-conversion.md §3
//
// `name` 是 Phase 0.8 wireframe §7.1 的單一欄位可為空Service 用
// `{source_filename_stem}_{target_chip_lower}` fallback
// `description` 雖在 schema 內但 Phase 0.8 不顯示給使用者backend 接受但忽略。
type promoteRequest struct {
Name string `json:"name"`
Description string `json:"description,omitempty"` // Phase 0.8 ignored, Phase 1 reserved
}
// conversionPromoteHandler 處理「加到模型庫」請求。
//
// 流程:
// 1. 驗 user / job_id
// 2. 解析 bodyname 可空body 整個可空)
// 3. Service.PromoteToModelspromote → FAA pull → models repo finalize
// 4. 成功 → 201 + PromoteResult
// 5. 冪等:同 jobID 重複 promote 由 Service 層處理(回既有 model record也是 201
func conversionPromoteHandler(deps Deps) gin.HandlerFunc {
return func(c *gin.Context) {
uc, ok := UserContextFrom(c)
if !ok || uc.UserID == "" {
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
"missing user context (auth middleware misconfigured?)", nil)
return
}
jobID := c.Param("job_id")
if jobID == "" {
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed,
"job_id is required", nil)
return
}
// body optional — 沒帶或解析失敗都不擋name 可由 Service fallback
var body promoteRequest
if c.Request.Body != nil && c.Request.ContentLength != 0 {
// 寬鬆解析JSON 解失敗只 log不算 hard error因為 name 可選)
if err := json.NewDecoder(c.Request.Body).Decode(&body); err != nil {
// 嚴格一點JSON 格式錯誤回 400避免 silent ignore 讓使用者困惑)
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed,
"invalid JSON body: "+err.Error(), nil)
return
}
}
result, err := deps.Conversion.PromoteToModels(c.Request.Context(), uc.UserID, jobID, body.Name)
if err != nil {
handleConversionError(c, err)
return
}
WriteSuccess(c, http.StatusCreated, result)
}
}
// ==========================================================================
// 5. GET /api/conversion/{job_id}/download
// ==========================================================================
// conversionDownloadHandler 處理「下載」請求 — server-side HTTP 302 redirect。
//
// 對齊 api-conversion.md §4 + conversion.md §3.1 / §10.4
// - 成功302 Found + Location: <FAA URL with access_token>
// - 失敗:不 redirect依 Accept header 回 JSON / HTML 錯誤
// - Cache-Control: no-store — token 不該被 browser cache即使是 302 Location
//
// 仿 FAA TestSite `DownloadFileDirect` patterntoken 永遠不過 frontend JS。
func conversionDownloadHandler(deps Deps) gin.HandlerFunc {
return func(c *gin.Context) {
uc, ok := UserContextFrom(c)
if !ok || uc.UserID == "" {
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
"missing user context (auth middleware misconfigured?)", nil)
return
}
jobID := c.Param("job_id")
if jobID == "" {
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed,
"job_id is required", nil)
return
}
downloadURL, err := deps.Conversion.DownloadRedirectURL(c.Request.Context(), uc.UserID, jobID)
if err != nil {
// 錯誤情況不 redirect — 依 Accept header 回 JSON / HTMLWriteError 寫 JSON
// 已能滿足主要 caseanchor tag 觸發時 browser 會直接顯示 JSON 也 OK
// Phase 0.8 不額外做 HTML 錯誤頁)
handleConversionError(c, err)
return
}
// 防快取:避免 browser 把 302 + Location 寫入 history / disk cache§10.4
c.Header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
c.Header("Pragma", "no-cache")
// 302 Found不用 301 — 301 可能被某些 browser 永久 cache
c.Redirect(http.StatusFound, downloadURL)
}
}
// ==========================================================================
// 錯誤處理 helper
// ==========================================================================
// handleConversionError 把 conversion package 的 sentinel error 轉成統一 JSON 錯誤回應。
//
// 對齊 conversion.md §6 mapping + api-conversion.md 錯誤碼總覽。
//
// 特殊情況:
// - ActiveJobError附帶 `extra.active_job` 給 frontend 顯示「你已有進行中任務」
// - ConverterValidationError附帶 detailsfields給 frontend 顯示具體欄位錯
// - 其他:用 errorMessageFor 拿 user-friendly 訊息
//
// HTTP status / error code 由 conversion.HTTPStatus / conversion.ErrorCode 決定,
// handler 不做二次 mapping。
func handleConversionError(c *gin.Context, err error) {
if err == nil {
// defensive — caller bug
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
"unknown error (handleConversionError called with nil)", nil)
return
}
// ctx cancel / deadline — handler 不主動回client 已斷線gin 收到時通常已 abort
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
// gin context aborted 時 c.Writer 仍可寫(但 client 看不到),保持簡單寫入
WriteError(c, http.StatusServiceUnavailable, "request_cancelled",
"request cancelled or timed out", nil)
return
}
status := conversion.HTTPStatus(err)
code := conversion.ErrorCode(err)
message := errorMessageFor(code)
// ActiveJobError — 帶 active_job detail前端可顯示「跳到該 job 進度頁」)
var aje *conversion.ActiveJobError
if errors.As(err, &aje) && aje != nil {
var jobJSON any
if aje.Job != nil {
jobJSON = jobToResponse(aje.Job)
}
writeConversionErrorWithExtra(c, status, code, message, nil, gin.H{
"active_job": jobJSON,
})
return
}
// ConverterValidationError — 帶 details.fields
var cve *conversion.ConverterValidationError
if errors.As(err, &cve) && cve != nil {
details := make([]FieldError, 0, len(cve.Fields))
for _, f := range cve.Fields {
details = append(details, FieldError{Field: f.Field, Message: f.Message})
}
WriteError(c, status, code, message, details)
return
}
// 一般 sentinel
WriteError(c, status, code, message, nil)
}
// writeConversionErrorWithExtra 是 WriteError 的擴充版本 — 額外帶 extra 結構化資料。
//
// 用於 ActiveJobError 等需要在 error body 內帶結構化 detail 的場景。
//
// 為什麼不直接複用 errors.go 的 WriteError
// WriteError 簽章是 (status, code, message, details []FieldError) — details 為陣列;
// ActiveJobError 要帶的是 objectactive_job。errors.go 的 ErrorDetail 已預留 Extra
// 欄位給此用途。
func writeConversionErrorWithExtra(c *gin.Context, status int, code, message string,
details []FieldError, extra map[string]any,
) {
c.JSON(status, ErrorBody{
Success: false,
Error: &ErrorDetail{
Code: code,
Message: message,
Details: details,
RequestID: RequestIDFrom(c),
Extra: extra,
},
})
}
// errorMessageFor 把 conversion error code 對應到 zh-TW user-friendly 訊息。
//
// 對齊 api-conversion.md 錯誤碼總覽 i18n 預設訊息。
// 真正的 i18n 切換在 frontend 處理(用 code 當 i18n keybackend 只回預設 zh-TW。
func errorMessageFor(code string) string {
switch code {
case "validation_failed":
return "上傳的內容不符合要求"
case "unauthorized":
return "請先登入"
case "forbidden":
return "你無權存取此任務"
case "not_found":
return "任務不存在"
case "active_job_exists":
return "你目前已有進行中的轉檔任務"
case "job_not_completed":
return "任務尚未完成"
case "payload_too_large":
return "檔案超過大小限制"
case "converter_unavailable":
return "轉檔服務暫時無法使用"
case "faa_unavailable":
return "檔案存取服務暫時無法使用"
case "download_token_failed":
return "無法取得下載授權"
case "mc_token_unavailable":
return "無法取得下載授權,請重試"
case "idp_misconfigured":
return "系統設定錯誤,請聯絡支援"
case "idp_unavailable":
return "認證服務暫時無法使用"
case "service_busy":
return "系統繁忙,請稍後再試"
default:
return "內部錯誤"
}
}
// ==========================================================================
// Response shape helper
// ==========================================================================
// jobToResponse 把 internal *conversion.Job 轉成 api-conversion.md §1-2 規定的 JSON shape。
//
// 直接用 gin.Hmap而非 struct — 為了讓 stage / progress / error_* 等選填欄位
// 在「沒值」時可以直接省略(不出現在 JSON符合 api-conversion.md §2 範例
// error_code: null vs 缺欄位 — 我們選缺欄位frontend 用 nullable 邏輯處理)。
//
// 時間欄位用 RFC3339Go time.Time 預設 marshal 即 RFC3339
func jobToResponse(j *conversion.Job) gin.H {
if j == nil {
return nil
}
out := gin.H{
"job_id": j.JobID,
"status": j.Status,
"created_at": j.CreatedAt,
"updated_at": j.UpdatedAt,
"expires_at": j.ExpiresAt,
"progress": j.Progress,
"stage_progress": j.StageProgress, // T7 review M-2: 對齊 api-conversion.md §1 範例顯式列出 stage_progress即使為 0
}
// 選填欄位 — 有值才寫
if j.Stage != "" {
out["stage"] = j.Stage
}
if j.SourceFilename != "" {
out["source_filename"] = j.SourceFilename
}
if j.TargetChip != "" {
out["target_chip"] = j.TargetChip
}
if j.ErrorCode != "" {
out["error_code"] = j.ErrorCode
}
if j.ErrorMessage != "" {
out["error_message"] = j.ErrorMessage
}
return out
}

View File

@ -0,0 +1,638 @@
// conversion_test.go — handler-level unit tests for /api/conversion/*。
//
// 用 in-package stub 實作 conversion.Service測 handler 層轉接、路由註冊、
// 錯誤對應的正確性。實際 Service 行為multipart 重組、ownership rebuild、
// promote → FAA pull → finalize由 internal/conversion/*_test.go 覆蓋。
//
// Phase 0.8 conversion (見 .autoflow/04-architecture/api/api-conversion.md)
package api
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"visiona-backend/internal/conversion"
)
// ==========================================================================
// Service stub
// ==========================================================================
// stubConversionService 是 conversion.Service 的測試 stub。
//
// 每個 method 都有對應的 InitJobFn / GetJobFn / ... 欄位,由 test case 注入想要的行為。
// 沒注入的 method 預設回 (nil, nil) — 對應 method 不被呼叫的 case。
//
// goroutine-safe所有欄位由 test setup 階段一次性寫入handler 呼叫時只讀。
type stubConversionService struct {
mu sync.Mutex
// 紀錄上一次呼叫的參數,給 test 驗 user_id 注入正確trust boundary
lastUserID string
lastJobID string
InitJobFn func(ctx context.Context, in conversion.InitJobInput) (*conversion.Job, error)
GetJobFn func(ctx context.Context, userID, jobID string) (*conversion.Job, error)
ActiveJobFn func(ctx context.Context, userID string) (*conversion.Job, error)
PromoteFn func(ctx context.Context, userID, jobID, name string) (*conversion.PromoteResult, error)
DownloadFn func(ctx context.Context, userID, jobID string) (string, error)
}
func (s *stubConversionService) InitJob(ctx context.Context, in conversion.InitJobInput) (*conversion.Job, error) {
s.mu.Lock()
s.lastUserID = in.UserID
s.mu.Unlock()
if s.InitJobFn == nil {
return nil, errors.New("stub: InitJobFn not set")
}
return s.InitJobFn(ctx, in)
}
func (s *stubConversionService) GetJob(ctx context.Context, userID, jobID string) (*conversion.Job, error) {
s.mu.Lock()
s.lastUserID = userID
s.lastJobID = jobID
s.mu.Unlock()
if s.GetJobFn == nil {
return nil, errors.New("stub: GetJobFn not set")
}
return s.GetJobFn(ctx, userID, jobID)
}
func (s *stubConversionService) ActiveJob(ctx context.Context, userID string) (*conversion.Job, error) {
s.mu.Lock()
s.lastUserID = userID
s.mu.Unlock()
if s.ActiveJobFn == nil {
return nil, errors.New("stub: ActiveJobFn not set")
}
return s.ActiveJobFn(ctx, userID)
}
func (s *stubConversionService) PromoteToModels(ctx context.Context, userID, jobID, name string) (*conversion.PromoteResult, error) {
s.mu.Lock()
s.lastUserID = userID
s.lastJobID = jobID
s.mu.Unlock()
if s.PromoteFn == nil {
return nil, errors.New("stub: PromoteFn not set")
}
return s.PromoteFn(ctx, userID, jobID, name)
}
func (s *stubConversionService) DownloadRedirectURL(ctx context.Context, userID, jobID string) (string, error) {
s.mu.Lock()
s.lastUserID = userID
s.lastJobID = jobID
s.mu.Unlock()
if s.DownloadFn == nil {
return "", errors.New("stub: DownloadFn not set")
}
return s.DownloadFn(ctx, userID, jobID)
}
// ==========================================================================
// Fixture
// ==========================================================================
// newConversionFixture 建一個只裝 conversion routes 的 gin engine。
//
// 所有 handler 都跑在 injectStaticUserContext("demo-user", ...) 之後 —
// 模擬「user 已登入」場景;驗 AuthMiddleware 行為由 oidc_auth_test 負責。
func newConversionFixture(t *testing.T, svc conversion.Service) *gin.Engine {
t.Helper()
r := gin.New()
r.Use(RequestIDMiddleware())
r.Use(injectStaticUserContext("demo-user", "demo@example.com"))
g := r.Group("/api")
registerConversionRoutes(g, Deps{Conversion: svc})
return r
}
// sampleJob 是一個典型的成功 job — 給 happy path 用。
func sampleJob() *conversion.Job {
now := time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC)
return &conversion.Job{
JobID: "job-abc-123",
Status: "running",
Stage: "onnx",
Progress: 0,
StageProgress: 0,
CreatedAt: now,
UpdatedAt: now,
ExpiresAt: now.Add(7 * 24 * time.Hour),
SourceFilename: "yolov5s.onnx",
TargetChip: "720",
}
}
// ==========================================================================
// 0. 共通:未啟用時 5 個 endpoint 全 501
// ==========================================================================
// TestConversion_Disabled_All501 — 當 deps.Conversion = nil 時5 個 endpoint 全回 501。
//
// 對齊 main.gocfg.Conversion.Enabled() == false 時 deps.Conversion 為 nil。
func TestConversion_Disabled_All501(t *testing.T) {
r := gin.New()
r.Use(RequestIDMiddleware())
r.Use(injectStaticUserContext("demo-user", ""))
g := r.Group("/api")
registerConversionRoutes(g, Deps{Conversion: nil}) // 未啟用
cases := []struct {
method string
path string
}{
{http.MethodPost, "/api/conversion/init"},
{http.MethodGet, "/api/conversion/active"},
{http.MethodGet, "/api/conversion/job-1"},
{http.MethodPost, "/api/conversion/job-1/promote-to-models"},
{http.MethodGet, "/api/conversion/job-1/download"},
}
for _, c := range cases {
t.Run(c.method+" "+c.path, func(t *testing.T) {
req := httptest.NewRequest(c.method, c.path, nil)
if c.method == http.MethodPost {
req.Header.Set("Content-Type", "application/json")
}
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotImplemented, w.Code,
"%s %s should be 501 when Conversion=nil; body=%s", c.method, c.path, w.Body.String())
})
}
}
// ==========================================================================
// 1. POST /api/conversion/init
// ==========================================================================
// TestConversion_Init_HappyPath — 成功 init 回 201 + Job。
func TestConversion_Init_HappyPath(t *testing.T) {
job := sampleJob()
svc := &stubConversionService{
InitJobFn: func(ctx context.Context, in conversion.InitJobInput) (*conversion.Job, error) {
// 驗 user_id 正確注入trust boundary
require.Equal(t, "demo-user", in.UserID)
require.NotEmpty(t, in.ContentType)
require.NotNil(t, in.Body)
// 驗 body 有內容streaming reader 還沒被讀)
b, err := io.ReadAll(in.Body)
require.NoError(t, err)
require.Contains(t, string(b), "fake-multipart")
return job, nil
},
}
r := newConversionFixture(t, svc)
body := strings.NewReader("--xyz\r\nContent-Disposition: form-data; name=\"fake-multipart\"\r\n\r\ndata\r\n--xyz--\r\n")
req := httptest.NewRequest(http.MethodPost, "/api/conversion/init", body)
req.Header.Set("Content-Type", "multipart/form-data; boundary=xyz")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code, "body=%s", w.Body.String())
var sb SuccessBody
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb))
data := sb.Data.(map[string]any)
assert.Equal(t, "job-abc-123", data["job_id"])
assert.Equal(t, "running", data["status"])
assert.Equal(t, "yolov5s.onnx", data["source_filename"])
assert.Equal(t, "720", data["target_chip"])
}
// TestConversion_Init_BadContentType — Content-Type 非 multipart/form-data 回 400。
//
// 這擋下 client 傳 JSON 等錯誤格式(避免 Service 層白白讀完 body 才發現格式錯)。
func TestConversion_Init_BadContentType(t *testing.T) {
svc := &stubConversionService{} // 不應該被呼叫
r := newConversionFixture(t, svc)
req := httptest.NewRequest(http.MethodPost, "/api/conversion/init",
strings.NewReader(`{"foo":"bar"}`))
req.Header.Set("Content-Type", "application/json") // 錯誤
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), ErrCodeValidationFailed)
assert.Contains(t, w.Body.String(), "multipart/form-data")
}
// TestConversion_Init_ActiveJobError — ActiveJobError 回 409 + extra.active_job。
//
// 這個 case 驗 handleConversionError 對 errors.As(*ActiveJobError) 的特殊處理。
func TestConversion_Init_ActiveJobError(t *testing.T) {
existingJob := &conversion.Job{
JobID: "job-existing-456",
Status: "running",
Stage: "bie",
Progress: 45,
}
svc := &stubConversionService{
InitJobFn: func(ctx context.Context, in conversion.InitJobInput) (*conversion.Job, error) {
return nil, &conversion.ActiveJobError{Job: existingJob}
},
}
r := newConversionFixture(t, svc)
req := httptest.NewRequest(http.MethodPost, "/api/conversion/init",
strings.NewReader("--xyz\r\n--xyz--\r\n"))
req.Header.Set("Content-Type", "multipart/form-data; boundary=xyz")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusConflict, w.Code, "body=%s", w.Body.String())
var eb ErrorBody
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &eb))
require.NotNil(t, eb.Error)
assert.Equal(t, "active_job_exists", eb.Error.Code)
require.NotNil(t, eb.Error.Extra)
activeJob, ok := eb.Error.Extra["active_job"].(map[string]any)
require.True(t, ok, "extra.active_job should be object; got %v", eb.Error.Extra)
assert.Equal(t, "job-existing-456", activeJob["job_id"])
}
// TestConversion_Init_ValidationError — ConverterValidationError 回 400 + details.fields。
func TestConversion_Init_ValidationError(t *testing.T) {
svc := &stubConversionService{
InitJobFn: func(ctx context.Context, in conversion.InitJobInput) (*conversion.Job, error) {
return nil, &conversion.ConverterValidationError{
Fields: []conversion.ValidationFieldError{
{Field: "model_id", Message: "must be 1-65535"},
},
Message: "validation failed",
}
},
}
r := newConversionFixture(t, svc)
req := httptest.NewRequest(http.MethodPost, "/api/conversion/init",
strings.NewReader("--xyz\r\n--xyz--\r\n"))
req.Header.Set("Content-Type", "multipart/form-data; boundary=xyz")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusBadRequest, w.Code, "body=%s", w.Body.String())
var eb ErrorBody
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &eb))
assert.Equal(t, "validation_failed", eb.Error.Code)
require.Len(t, eb.Error.Details, 1)
assert.Equal(t, "model_id", eb.Error.Details[0].Field)
}
// TestConversion_Init_ConverterUnavailable — 502 mapping。
func TestConversion_Init_ConverterUnavailable(t *testing.T) {
svc := &stubConversionService{
InitJobFn: func(ctx context.Context, in conversion.InitJobInput) (*conversion.Job, error) {
return nil, conversion.ErrConverterUnavailable
},
}
r := newConversionFixture(t, svc)
req := httptest.NewRequest(http.MethodPost, "/api/conversion/init",
strings.NewReader("--xyz\r\n--xyz--\r\n"))
req.Header.Set("Content-Type", "multipart/form-data; boundary=xyz")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadGateway, w.Code)
assert.Contains(t, w.Body.String(), "converter_unavailable")
}
// ==========================================================================
// 2. GET /api/conversion/active
// ==========================================================================
func TestConversion_Active_HasActive(t *testing.T) {
job := sampleJob()
svc := &stubConversionService{
ActiveJobFn: func(ctx context.Context, userID string) (*conversion.Job, error) {
require.Equal(t, "demo-user", userID)
return job, nil
},
}
r := newConversionFixture(t, svc)
req := httptest.NewRequest(http.MethodGet, "/api/conversion/active", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
var sb SuccessBody
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb))
data := sb.Data.(map[string]any)
assert.Equal(t, true, data["has_active"])
jobMap, ok := data["job"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "job-abc-123", jobMap["job_id"])
}
func TestConversion_Active_NoActive(t *testing.T) {
svc := &stubConversionService{
ActiveJobFn: func(ctx context.Context, userID string) (*conversion.Job, error) {
return nil, nil
},
}
r := newConversionFixture(t, svc)
req := httptest.NewRequest(http.MethodGet, "/api/conversion/active", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var sb SuccessBody
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb))
data := sb.Data.(map[string]any)
assert.Equal(t, false, data["has_active"])
assert.Nil(t, data["job"])
}
func TestConversion_Active_ConverterUnavailable(t *testing.T) {
svc := &stubConversionService{
ActiveJobFn: func(ctx context.Context, userID string) (*conversion.Job, error) {
return nil, conversion.ErrConverterUnavailable
},
}
r := newConversionFixture(t, svc)
req := httptest.NewRequest(http.MethodGet, "/api/conversion/active", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadGateway, w.Code)
}
// ==========================================================================
// 3. GET /api/conversion/{job_id}
// ==========================================================================
func TestConversion_Get_HappyPath(t *testing.T) {
job := sampleJob()
svc := &stubConversionService{
GetJobFn: func(ctx context.Context, userID, jobID string) (*conversion.Job, error) {
require.Equal(t, "demo-user", userID)
require.Equal(t, "job-abc-123", jobID)
return job, nil
},
}
r := newConversionFixture(t, svc)
req := httptest.NewRequest(http.MethodGet, "/api/conversion/job-abc-123", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
var sb SuccessBody
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb))
data := sb.Data.(map[string]any)
assert.Equal(t, "job-abc-123", data["job_id"])
assert.Equal(t, "running", data["status"])
}
func TestConversion_Get_NotFound(t *testing.T) {
svc := &stubConversionService{
GetJobFn: func(ctx context.Context, userID, jobID string) (*conversion.Job, error) {
return nil, conversion.ErrJobNotFound
},
}
r := newConversionFixture(t, svc)
req := httptest.NewRequest(http.MethodGet, "/api/conversion/missing-job", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
assert.Contains(t, w.Body.String(), "not_found")
}
// ==========================================================================
// 4. POST /api/conversion/{job_id}/promote-to-models
// ==========================================================================
func TestConversion_Promote_HappyPath(t *testing.T) {
now := time.Date(2026, 4, 30, 12, 30, 0, 0, time.UTC)
res := &conversion.PromoteResult{
ModelID: "model-xyz",
Source: "converted",
SourceJobID: "job-abc-123",
Name: "yolo_kl720",
TargetChip: "kl720",
FileSize: 12345,
Status: "ready",
CreatedAt: now,
}
svc := &stubConversionService{
PromoteFn: func(ctx context.Context, userID, jobID, name string) (*conversion.PromoteResult, error) {
require.Equal(t, "demo-user", userID)
require.Equal(t, "job-abc-123", jobID)
require.Equal(t, "yolo_kl720", name)
return res, nil
},
}
r := newConversionFixture(t, svc)
req := httptest.NewRequest(http.MethodPost, "/api/conversion/job-abc-123/promote-to-models",
strings.NewReader(`{"name":"yolo_kl720"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code, "body=%s", w.Body.String())
var sb SuccessBody
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb))
data := sb.Data.(map[string]any)
assert.Equal(t, "model-xyz", data["model_id"])
assert.Equal(t, "converted", data["source"])
assert.Equal(t, "ready", data["status"])
}
// TestConversion_Promote_NoBody — 沒帶 body 也應該成功name 可為空)。
func TestConversion_Promote_NoBody(t *testing.T) {
svc := &stubConversionService{
PromoteFn: func(ctx context.Context, userID, jobID, name string) (*conversion.PromoteResult, error) {
require.Equal(t, "", name) // body 沒帶 → name 為空,由 Service fallback
return &conversion.PromoteResult{ModelID: "m1", Source: "converted", Status: "ready"}, nil
},
}
r := newConversionFixture(t, svc)
req := httptest.NewRequest(http.MethodPost, "/api/conversion/job-abc/promote-to-models", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code, "body=%s", w.Body.String())
}
func TestConversion_Promote_BadJSON(t *testing.T) {
svc := &stubConversionService{} // 不該被呼叫
r := newConversionFixture(t, svc)
req := httptest.NewRequest(http.MethodPost, "/api/conversion/job/promote-to-models",
strings.NewReader(`{not valid json`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), ErrCodeValidationFailed)
}
func TestConversion_Promote_JobNotCompleted(t *testing.T) {
svc := &stubConversionService{
PromoteFn: func(ctx context.Context, userID, jobID, name string) (*conversion.PromoteResult, error) {
return nil, conversion.ErrJobNotCompleted
},
}
r := newConversionFixture(t, svc)
req := httptest.NewRequest(http.MethodPost, "/api/conversion/job-abc/promote-to-models",
strings.NewReader(`{}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusConflict, w.Code)
assert.Contains(t, w.Body.String(), "job_not_completed")
}
// ==========================================================================
// 5. GET /api/conversion/{job_id}/download
// ==========================================================================
func TestConversion_Download_HappyPath302(t *testing.T) {
target := "http://192.168.0.130:5081/files/models/u/job.nef?access_token=opaque-xyz"
svc := &stubConversionService{
DownloadFn: func(ctx context.Context, userID, jobID string) (string, error) {
require.Equal(t, "demo-user", userID)
require.Equal(t, "job-abc", jobID)
return target, nil
},
}
r := newConversionFixture(t, svc)
req := httptest.NewRequest(http.MethodGet, "/api/conversion/job-abc/download", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusFound, w.Code) // 302
assert.Equal(t, target, w.Header().Get("Location"))
// 防快取 header — token 不該被 browser cache§10.4
assert.Contains(t, w.Header().Get("Cache-Control"), "no-store")
assert.Equal(t, "no-cache", w.Header().Get("Pragma"))
}
func TestConversion_Download_JobNotCompleted(t *testing.T) {
svc := &stubConversionService{
DownloadFn: func(ctx context.Context, userID, jobID string) (string, error) {
return "", conversion.ErrJobNotCompleted
},
}
r := newConversionFixture(t, svc)
req := httptest.NewRequest(http.MethodGet, "/api/conversion/job-abc/download", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
// 錯誤情況**不 redirect** — 回標準 JSON error
assert.Equal(t, http.StatusConflict, w.Code)
assert.Contains(t, w.Body.String(), "job_not_completed")
assert.NotEqual(t, http.StatusFound, w.Code, "error case must not 302 redirect")
}
func TestConversion_Download_NotFound(t *testing.T) {
svc := &stubConversionService{
DownloadFn: func(ctx context.Context, userID, jobID string) (string, error) {
return "", conversion.ErrJobNotFound
},
}
r := newConversionFixture(t, svc)
req := httptest.NewRequest(http.MethodGet, "/api/conversion/missing/download", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
}
func TestConversion_Download_MCTokenUnavailable(t *testing.T) {
svc := &stubConversionService{
DownloadFn: func(ctx context.Context, userID, jobID string) (string, error) {
return "", conversion.ErrMCTokenUnavailable
},
}
r := newConversionFixture(t, svc)
req := httptest.NewRequest(http.MethodGet, "/api/conversion/job/download", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadGateway, w.Code)
assert.Contains(t, w.Body.String(), "mc_token_unavailable")
}
// ==========================================================================
// User_id trust boundary
// ==========================================================================
// TestConversion_Init_IgnoresClientUserID — 即使 multipart form 帶 user_idhandler
// 仍只把 cookie session 的 UserID 傳給 Service。
//
// 這是 trust boundary 的回歸測試conversion.md §7。實際 multipart 重組 / 黑名單
// 邏輯在 Service 層做flow.go rebuildMultipart但 handler 必須確保傳給 Service 的
// InitJobInput.UserID 永遠是 UserContext 的,不是 client 提供的。
func TestConversion_Init_IgnoresClientUserID(t *testing.T) {
svc := &stubConversionService{
InitJobFn: func(ctx context.Context, in conversion.InitJobInput) (*conversion.Job, error) {
// 即使 client 在 multipart 內塞了 user_id=attackerhandler 給 Service 的 UserID
// 必須是 demo-user從 UserContext 拿)
require.Equal(t, "demo-user", in.UserID)
return sampleJob(), nil
},
}
r := newConversionFixture(t, svc)
// 一個包含 user_id=attacker 的 multipart body — 應被忽略
body := strings.NewReader(
"--xyz\r\n" +
"Content-Disposition: form-data; name=\"user_id\"\r\n\r\n" +
"attacker\r\n" +
"--xyz\r\n" +
"Content-Disposition: form-data; name=\"model\"\r\n\r\n" +
"data\r\n" +
"--xyz--\r\n",
)
req := httptest.NewRequest(http.MethodPost, "/api/conversion/init", body)
req.Header.Set("Content-Type", "multipart/form-data; boundary=xyz")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code, "body=%s", w.Body.String())
}
// TestConversion_GetJob_IgnoresQueryUserID — query 帶 user_id 不影響 handler
// 傳給 Service 的 userID仍是 UserContext 拿到的)。
func TestConversion_GetJob_IgnoresQueryUserID(t *testing.T) {
svc := &stubConversionService{
GetJobFn: func(ctx context.Context, userID, jobID string) (*conversion.Job, error) {
require.Equal(t, "demo-user", userID, "user_id from query must be ignored")
return sampleJob(), nil
},
}
r := newConversionFixture(t, svc)
req := httptest.NewRequest(http.MethodGet, "/api/conversion/job-abc?user_id=attacker", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
}

View File

@ -21,6 +21,9 @@ type Config struct {
Tunnel TunnelConfig
Logger LoggerConfig
CORS CORSConfig
// Conversion 控制 Phase 0.8 轉檔功能整合converter / FAA / MC service token
// 對齊 .autoflow/04-architecture/conversion.md §5.3。
Conversion ConversionConfig
}
// ServerConfig 控制 HTTP listener 的位址與埠號。
@ -177,6 +180,49 @@ type LoggerConfig struct {
Level string // VISIONA_LOG_LEVELdebug / info / warn / error預設 "info"
}
// ConversionConfig 控制 Phase 0.8 轉檔功能整合。
//
// 對齊 .autoflow/04-architecture/conversion.md §5.3。
//
// 啟用判定(由 main.go 在 wire 階段檢查):當 ConverterBaseURL 與 FAABaseURL 都非空時,
// 才會 wire conversion.Service 進 api.Deps。其中之一為空 → 不啟用5 個 endpoint 回 501
//
// 進一步:啟用時 ServiceClientID/Secret 必須非空(轉檔依賴 service token 機制);
// 不對齊時 main.go fatal log 退出(避免半設定狀態跑進生產)。
type ConversionConfig struct {
// ConverterBaseURL 是 kneron_model_converter task-scheduler 服務的 base URL。
// 例http://192.168.0.130:9501dev / stage / https://converter.visiona.cloudprod
// 對齊 VISIONA_CONVERTER_BASE_URL留空 = 不啟用 Phase 0.8 轉檔功能。
ConverterBaseURL string
// FAABaseURL 是 File Access Agent 的 base URL。
// 例http://192.168.0.130:5081dev / stage / https://faa.innovedus.comprod
// 對齊 VISIONA_FAA_BASE_URL留空 = 不啟用 Phase 0.8 轉檔功能。
FAABaseURL string
// TenantID 是 visionA 在 Member Center 註冊的 tenant id單一 tenant
// 在跟 MC 換 delegated download token 時當 request body 的 tenant_id 欄位用。
// 對齊 VISIONA_OIDC_TENANT_ID。
TenantID string
// DelegatedTTLSeconds 是 MC 簽 delegated download token 的 TTL
// 預設 3005 分鐘);可調整範圍 60-900。對齊 VISIONA_FAA_DELEGATED_TTL_SECONDS。
// 見 conversion.md §10.2 安全考量。
DelegatedTTLSeconds int
// MaxModelSizeMB 是 visionA-backend 端對上傳模型檔的大小上限MB
// 與 converter 端 limit 對齊converter 預設 500 MB
// 對齊 VISIONA_CONVERTER_MAX_MODEL_SIZE_MB預設 500。
MaxModelSizeMB int
}
// Enabled 回傳 Phase 0.8 conversion 是否啟用。
//
// main.go 在 wire 時用此判斷是否要 init conversion.Service。
func (c ConversionConfig) Enabled() bool {
return c.ConverterBaseURL != "" && c.FAABaseURL != ""
}
// CORSConfig 控制 api-server 對瀏覽器的 CORS 白名單。
//
// AllowedOrigins 為逗號分隔字串解析後的 slice

View File

@ -68,6 +68,14 @@ func Load() *Config {
CORS: CORSConfig{
AllowedOrigins: getEnvStringSlice("VISIONA_CORS_ALLOWED_ORIGINS", nil),
},
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §5.3)
Conversion: ConversionConfig{
ConverterBaseURL: getEnvString("VISIONA_CONVERTER_BASE_URL", ""),
FAABaseURL: getEnvString("VISIONA_FAA_BASE_URL", ""),
TenantID: getEnvString("VISIONA_OIDC_TENANT_ID", ""),
DelegatedTTLSeconds: getEnvInt("VISIONA_FAA_DELEGATED_TTL_SECONDS", 300),
MaxModelSizeMB: getEnvInt("VISIONA_CONVERTER_MAX_MODEL_SIZE_MB", 500),
},
}
}

View File

@ -265,3 +265,66 @@ func TestLoad_CORSAllowedOrigins(t *testing.T) {
cfg = Load()
assert.Nil(t, cfg.CORS.AllowedOrigins)
}
// TestLoad_ConversionDefaults 驗證 Phase 0.8 conversion 欄位的預設行為。
//
// 對齊 .autoflow/04-architecture/conversion.md §5.3:留空時 Enabled() 為 false
// 5 個 endpoint 不會 wiremain.go 在 wire 階段會跳過)。
func TestLoad_ConversionDefaults(t *testing.T) {
for _, k := range []string{
"VISIONA_CONVERTER_BASE_URL", "VISIONA_FAA_BASE_URL", "VISIONA_OIDC_TENANT_ID",
"VISIONA_FAA_DELEGATED_TTL_SECONDS", "VISIONA_CONVERTER_MAX_MODEL_SIZE_MB",
} {
t.Setenv(k, "")
}
cfg := Load()
assert.Empty(t, cfg.Conversion.ConverterBaseURL)
assert.Empty(t, cfg.Conversion.FAABaseURL)
assert.Empty(t, cfg.Conversion.TenantID)
assert.Equal(t, 300, cfg.Conversion.DelegatedTTLSeconds, "預設 5 分鐘 TTL")
assert.Equal(t, 500, cfg.Conversion.MaxModelSizeMB, "預設 500 MB與 converter 對齊)")
assert.False(t, cfg.Conversion.Enabled(), "URL 全空 → 不啟用")
}
// TestLoad_ConversionEnabled 驗證 Conversion.Enabled() 的判定邏輯。
func TestLoad_ConversionEnabled(t *testing.T) {
cases := []struct {
name string
converter string
faa string
wantEnabled bool
}{
{"both_set_enables", "http://converter:9501", "http://faa:5081", true},
{"only_converter_disabled", "http://converter:9501", "", false},
{"only_faa_disabled", "", "http://faa:5081", false},
{"both_empty_disabled", "", "", false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Setenv("VISIONA_CONVERTER_BASE_URL", tc.converter)
t.Setenv("VISIONA_FAA_BASE_URL", tc.faa)
cfg := Load()
assert.Equal(t, tc.wantEnabled, cfg.Conversion.Enabled())
})
}
}
// TestLoad_ConversionAllSet 驗證所有欄位設定後正確讀取。
func TestLoad_ConversionAllSet(t *testing.T) {
t.Setenv("VISIONA_CONVERTER_BASE_URL", "http://192.168.0.130:9501")
t.Setenv("VISIONA_FAA_BASE_URL", "http://192.168.0.130:5081")
t.Setenv("VISIONA_OIDC_TENANT_ID", "fake-tenant-id-for-test")
t.Setenv("VISIONA_FAA_DELEGATED_TTL_SECONDS", "600")
t.Setenv("VISIONA_CONVERTER_MAX_MODEL_SIZE_MB", "300")
cfg := Load()
assert.Equal(t, "http://192.168.0.130:9501", cfg.Conversion.ConverterBaseURL)
assert.Equal(t, "http://192.168.0.130:5081", cfg.Conversion.FAABaseURL)
assert.Equal(t, "fake-tenant-id-for-test", cfg.Conversion.TenantID)
assert.Equal(t, 600, cfg.Conversion.DelegatedTTLSeconds)
assert.Equal(t, 300, cfg.Conversion.MaxModelSizeMB)
assert.True(t, cfg.Conversion.Enabled())
}

View File

@ -0,0 +1,164 @@
// Package conversion 實作 Phase 0.8 轉檔功能整合。
//
// 對齊文件:
// - .autoflow/02-prd/features/feature-converter-integration.mdPRD
// - .autoflow/04-architecture/conversion.mdTDD 主文件)
// - .autoflow/04-architecture/api/api-conversion.mdAPI 規格)
// - .autoflow/04-architecture/adr/adr-014-conversion-integration.md架構決策
//
// 與 internal/converter/ 的關係:
//
// internal/converter/ 是 Phase 0 / Phase 2 規劃時 PM 寫的 stub interface
// scope 與 Phase 0.8 不同Phase 0 規劃的是「自動推入模型庫」端到端 flow
// Phase 0.8 改為半自動 + streaming proxy + 三方 token 機制,重新設計 internal/conversion/
// 實作;舊的 internal/converter/ 套件保留在 codebase 中(對 frontend / 其他模組無依賴),
// 等 Phase 0.8 整合完成後可由 Architect 評估是否清除。
//
// 套件邊界:
// - 對 handler 層只暴露 `Service` interfaceFAANG 慣例DI-friendly、unit test 友善)
// - 內部模組converter_client / faa_client / mc_token_client / ownership / flow對 handler 不可見
// - 所有 Phase 0.8 流程的協調點在 flow.go 的 `Flow` struct
//
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2)
package conversion
import (
"context"
"io"
"time"
)
// Service 是 handler 層的單一進入點,匹配 5 個對外 endpoint 的能力。
//
// 實作:`Flow`flow.go
//
// 設計原則:
// - 所有 method 第一個參數固定 ctx第二個固定 userIDtrust boundary
// - 任何 client 帶來的 user_id 都不可信userID 來源永遠是 OIDC AuthMiddleware
// 從 cookie session 解出的 sub見 conversion.md §7.1)。
// - 所有 method 都不接受 user_id 從 body / query / header 傳入。
type Service interface {
// InitJob 把 client 的 multipart stream 透傳給 converter建立轉檔 job。
//
// 行為:
// 1. 內部用 io.Pipe + multipart.Reader/Writer 重組 multipart body
// streaming proxy避免 buffer 全 RAM見 conversion.md §4.2
// 2. 黑名單 client 帶來的 user_id field永遠以 InitJobInput.UserID 為準
// 3. 等到 converter 回 201 才 return見 conversion.md §4.3.1
// 不採用 early-return 模式以避免進度條假象)
// 4. 成功後寫 ownershipjobID → userIDconverter 7d 過期對齊)
//
// 失敗處理:
// - converter 4xx → 透傳 error code見 conversion.md §6 mapping
// - converter 5xx / network → retry見 §9.1
// - client 中斷 / ctx cancel → goroutine cleanup + best-effort 對 converter 發 cancel
// (見 §4.3.2 cleanup 鏈)
InitJob(ctx context.Context, in InitJobInput) (*Job, error)
// GetJob 查 converter 的 job 狀態,先做 ownership 檢查。
//
// Frontend polling 場景;內部對 converter response cache 1-2s 避免 polling 直接打爆 converter。
//
// 失敗處理:
// - ownership 不符 → ErrForbidden
// - job 不存在 → ErrJobNotFound
// - converter 5xx / network → 重試後仍失敗回 ErrConverterUnavailable
GetJob(ctx context.Context, userID, jobID string) (*Job, error)
// PromoteToModels 執行「加到模型庫」流程。
//
// 步驟(見 conversion.md §1 Stage 3a + §2.5
// 1. ownership.Check(userID, jobID)
// 2. ensurePromoted(jobID) — 冪等:若已 promote 過用 cache否則打 converter
// 3. faa.Download(promotedKey) — 用 service token (scope=files:download.read) server-to-server pull
// 4. 走既有 /api/models/init + /api/models/finalize不繞過既有 handler 邏輯)
// 5. 回填 model.Source="converted" + model.SourceJobID=jobIDschema 已預埋)
//
// 冪等性:對同一 jobID 重複呼叫;若已建過 model record回既有 modelID 而非新建。
//
// `name` 是 Design Phase 0.8 wireframe §7.1 的單一欄位(不含 description
PromoteToModels(ctx context.Context, userID, jobID, name string) (*PromoteResult, error)
// DownloadRedirectURL 產出「下載」的 server-side 302 redirect URL。
//
// Handler 拿到後直接 c.Redirect(http.StatusFound, url)token 不出現在任何 JSON response
// 也不傳給 frontend JS見 conversion.md §10.4 安全分析)。
//
// 步驟(見 conversion.md §1 Stage 3b
// 1. ownership 檢查
// 2. ensurePromoted與 PromoteToModels 共用 cache
// 3. 對 MC POST /file-access/download-tokens 換 delegated token
// scope=files:download.delegate, TTL 5 分鐘)
// 4. 組 https://<faa>/files/<key>?access_token=<token>
//
// 仿 FAA TestSite `DownloadFileDirect` pattern見 conversion.md §3.1)。
DownloadRedirectURL(ctx context.Context, userID, jobID string) (string, error)
// ActiveJob 查 user 當前是否有 active job給 frontend `/conversion` 頁載入時 pre-check。
//
// 重啟恢復行為A4 lazy rebuild見 conversion.md §2.6.1
// 1. 先查 in-memory ownership
// 2. miss 時 fallback 對 converter 打 GET /api/v1/jobs?user_id=<sub>&status=in_progress
// 3. 若 converter 有回覆 active job重建 ownership 後 return
//
// 對 frontend 完全透明(同樣 endpoint、同樣 response shape
//
// 沒有 active job 時回 (nil, nil),不視為 error。
ActiveJob(ctx context.Context, userID string) (*Job, error)
}
// ==========================================================================
// I/O types
// ==========================================================================
// InitJobInput 是 handler 傳給 Service.InitJob 的 streaming proxy 輸入。
//
// 設計原則:
// - Service 不關心 multipart 解析細節handler 把 raw body 傳進來,
// 由 Service 內部處理 io.Pipe + multipart.Reader/Writer 的重組(見 conversion.md §4.2
// - UserID 是唯一可信任的 user 身份來源OIDC sub
// - ContentType 必須含 boundarymultipart/form-data; boundary=...
// handler 直接從 c.GetHeader("Content-Type") 取
type InitJobInput struct {
UserID string // 由 AuthMiddleware UserContext.UserID 注入;唯一可信來源
ContentType string // 含 boundary 的原始 Content-Type header 值
Body io.Reader // request.Body
ContentLength int64 // request.ContentLengthconverter 自己會算 multer這裡僅供 log
}
// Job 是轉檔任務的對外 response shape。
//
// 對齊 api-conversion.md §1-2 的 response 欄位 + 三方 review 議題 #7
// (補 expires_at / source_filename / target_chip
//
// 注意Job.Status / Job.Stage 用 converter 端的字面值converted from openapi.yaml
// 直接透傳給 frontend不另做 mapping避免 enum 同步成本:
//
// status: "created" / "running" / "completed" / "failed"
// stage: "onnx" / "bie" / "nef"
type Job struct {
JobID string `json:"job_id"`
Status string `json:"status"`
Stage string `json:"stage"`
Progress int `json:"progress"` // 0-100整體
StageProgress int `json:"stage_progress"` // 0-100當前 stage
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ExpiresAt time.Time `json:"expires_at"` // created_at + 7dconverter GC 期限)
SourceFilename string `json:"source_filename,omitempty"` // 原始檔名(顯示用)
TargetChip string `json:"target_chip,omitempty"` // "520" / "720" / "630" / "730"
ErrorCode string `json:"error_code,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
}
// PromoteResult 是 PromoteToModels 的 response shape對齊 api-conversion.md §3。
type PromoteResult struct {
ModelID string `json:"model_id"`
Source string `json:"source"` // 永遠是 "converted"
SourceJobID string `json:"source_job_id"` // converter job id
Name string `json:"name"`
TargetChip string `json:"target_chip,omitempty"` // 對齊 api-conversion.md §3 response
FileSize int64 `json:"file_size"`
Status string `json:"status"` // 沿用 model 既有 status"ready" 等)
CreatedAt time.Time `json:"created_at"`
}

View File

@ -0,0 +1,151 @@
package conversion
import (
"context"
"encoding/json"
"io"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// noopService 是一個 compile-time 驗證 — 用來確認 Service interface 的方法集合穩定。
// 真實實作Flow會在 T6 補。這裡只測 interface 簽名沒有打錯(避免 T6 才發現要改 interface
type noopService struct{}
func (noopService) InitJob(ctx context.Context, in InitJobInput) (*Job, error) {
return nil, nil
}
func (noopService) GetJob(ctx context.Context, userID, jobID string) (*Job, error) {
return nil, nil
}
func (noopService) PromoteToModels(ctx context.Context, userID, jobID, name string) (*PromoteResult, error) {
return nil, nil
}
func (noopService) DownloadRedirectURL(ctx context.Context, userID, jobID string) (string, error) {
return "", nil
}
func (noopService) ActiveJob(ctx context.Context, userID string) (*Job, error) {
return nil, nil
}
// File-scope compile-time check — 若 Service interface 改變,
// noopService 就不再實作此 interface編譯失敗。
// 移到 file scopeT1 review M1t.Run 內的 var declaration 只在執行該 test 時驗,
// 而我們希望「package 編譯成功」就保證 interface 穩定。
var _ Service = noopService{}
// TestService_InterfaceSatisfied 在 test 中再 assert 一次,作為文件性說明。
func TestService_InterfaceSatisfied(t *testing.T) {
t.Parallel()
var _ Service = noopService{}
}
// TestJob_JSONShape 驗證 Job struct 的 JSON tag 與 api-conversion.md §1-2 response 對齊。
//
// 這是契約測試frontend 依 api-conversion.md 寫 typebackend 改 json tag 一定要回頭看這個 test。
func TestJob_JSONShape(t *testing.T) {
t.Parallel()
createdAt, _ := time.Parse(time.RFC3339, "2026-04-30T12:00:00Z")
expiresAt := createdAt.Add(7 * 24 * time.Hour)
job := Job{
JobID: "550e8400-e29b-41d4-a716-446655440000",
Status: "running",
Stage: "bie",
Progress: 45,
StageProgress: 60,
CreatedAt: createdAt,
UpdatedAt: createdAt.Add(5 * time.Minute),
ExpiresAt: expiresAt,
SourceFilename: "yolov5s.onnx",
TargetChip: "720",
}
raw, err := json.Marshal(job)
require.NoError(t, err)
// 必要欄位都在
assert.Contains(t, string(raw), `"job_id":"550e8400-e29b-41d4-a716-446655440000"`)
assert.Contains(t, string(raw), `"status":"running"`)
assert.Contains(t, string(raw), `"stage":"bie"`)
assert.Contains(t, string(raw), `"progress":45`)
assert.Contains(t, string(raw), `"stage_progress":60`)
assert.Contains(t, string(raw), `"created_at":"2026-04-30T12:00:00Z"`)
assert.Contains(t, string(raw), `"expires_at":"2026-05-07T12:00:00Z"`)
assert.Contains(t, string(raw), `"source_filename":"yolov5s.onnx"`)
assert.Contains(t, string(raw), `"target_chip":"720"`)
// error 欄位 zero value 時應被 omitempty 隱藏
assert.NotContains(t, string(raw), `"error_code"`)
assert.NotContains(t, string(raw), `"error_message"`)
}
// TestJob_FailedShape 驗證 failed job 的 error 欄位序列化。
func TestJob_FailedShape(t *testing.T) {
t.Parallel()
job := Job{
JobID: "job-failed",
Status: "failed",
ErrorCode: "QUANTIZATION_FAILED",
ErrorMessage: "model has unsupported operator",
}
raw, err := json.Marshal(job)
require.NoError(t, err)
assert.Contains(t, string(raw), `"error_code":"QUANTIZATION_FAILED"`)
assert.Contains(t, string(raw), `"error_message":"model has unsupported operator"`)
}
// TestPromoteResult_JSONShape 對齊 api-conversion.md §3 response。
func TestPromoteResult_JSONShape(t *testing.T) {
t.Parallel()
createdAt, _ := time.Parse(time.RFC3339, "2026-04-30T12:30:00Z")
pr := PromoteResult{
ModelID: "abc-123",
Source: "converted",
SourceJobID: "550e8400-...",
Name: "YOLOv5 Face KL520",
TargetChip: "kl520",
FileSize: 12345678,
Status: "ready",
CreatedAt: createdAt,
}
raw, err := json.Marshal(pr)
require.NoError(t, err)
assert.Contains(t, string(raw), `"model_id":"abc-123"`)
assert.Contains(t, string(raw), `"source":"converted"`)
assert.Contains(t, string(raw), `"source_job_id":"550e8400-..."`)
assert.Contains(t, string(raw), `"file_size":12345678`)
assert.Contains(t, string(raw), `"status":"ready"`)
assert.Contains(t, string(raw), `"target_chip":"kl520"`)
}
// TestInitJobInput_AcceptsReader 驗證 InitJobInput.Body 接受 io.Reader即 streaming 不收 buffer
//
// 關鍵:若有人不小心把欄位改成 []byte這個測試編譯會壞。
func TestInitJobInput_AcceptsReader(t *testing.T) {
t.Parallel()
in := InitJobInput{
UserID: "user-abc",
ContentType: "multipart/form-data; boundary=xyz",
Body: strings.NewReader("--xyz--"),
ContentLength: 7,
}
// 確認 Body 是 io.Readercompile time 透過 type assertion
var _ io.Reader = in.Body
assert.Equal(t, "user-abc", in.UserID)
}

View File

@ -0,0 +1,892 @@
// Converter client — visionA-backend 對 kneron_model_converter (task-scheduler) 的 HTTP client。
//
// 對應 4 個 endpoint見 kneron_model_converter/apps/task-scheduler/docs/openapi.yaml
// - InitJob: POST /api/v1/jobs (multipart streaming proxy)
// - GetJob: GET /api/v1/jobs/{id}
// - Promote: POST /api/v1/jobs/{id}/promote
// - ListInProgressJobs: GET /api/v1/jobs?user_id=&status=in_progress (lazy rebuild ownership 用)
//
// 設計重點:
// - HTTP retry 矩陣對齊 conversion.md §9.1InitJob 例外:不 retry 5xx見下方 sendInitJob 註解)
// - service-to-service token 由注入的 MCTokenClient 提供per-scope cache
// - body 為 streamingInitJob 直接傳 caller 的 io.Reader不暫存 disk、不 buffer 全 RAM
// - 4xx 錯誤 mapping 對齊 §6 + api-conversion.md 錯誤碼總覽
//
// 安全:
// - **絕不**把 Authorization header / access_token 寫進 log
// - 只 log job_id / status / endpoint / attempt / duration
//
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.5 + §9.1)
package conversion
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
)
// ==========================================================================
// 對外 type / interface
// ==========================================================================
// ConverterClient 對 task-scheduler 的 HTTP client。
//
// 所有 method 都會自動:
// - 透過 MCTokenClient 取對應 scope 的 service token放進 Authorization header
// - 依 conversion.md §9.1 retry 矩陣處理 5xx / network / timeoutInitJob 例外)
// - 把 4xx / 5xx 對應到 errors.go 的 sentinel
//
// goroutine-safe每次呼叫獨立 *http.Request無內部 mutable statecache 由 MCTokenClient 管)。
type ConverterClient interface {
// InitJob 把 caller 的 multipart body streaming proxy 給 converter。
//
// scope: converter:job.write
//
// 不 retry 5xxmultipart body 是 streamingio.Reader 一次性retry 會傳到一半的爛資料;
// 直接 fail 由 callerflow.go依 §4.3.2 cleanup 鏈處理。
//
// timeout30 分鐘500MB upload 在慢網路可能 5-10 分鐘)。
InitJob(ctx context.Context, req InitConverterJobReq) (*ConverterJob, error)
// GetJob 查單一 job 狀態。
//
// scope: converter:job.read
// retry: 5xx / network → max 3 attempts (0.5s, 1s, 2s 退避)
GetJob(ctx context.Context, jobID string) (*ConverterJob, error)
// Promote 把成功 job 的指定 stage 結果檔搬到 FAA。
//
// scope: converter:job.write
// retry: 5xx / network → max 2 attempts (1s, 2s 退避)
//
// 502 file_gateway_unavailable → ErrFAAUnavailableconverter 端 FAA 不可達)
Promote(ctx context.Context, jobID string, req PromoteReq) (*ConverterPromoteResult, error)
// ListInProgressJobs 查指定 user 進行中的 job 清單(給 §2.6.1 lazy rebuild ownership 用)。
//
// scope: converter:job.read
// retry: 5xx / network → max 1 attempt (0.5s 退避,輕量;不期望常態打)
//
// 預期 0 或 1 筆(同 user 同時只能 1 active job但回 slice 保留 future-proof。
ListInProgressJobs(ctx context.Context, userID string) ([]*ConverterJob, error)
}
// InitConverterJobReq 是 InitJob 的輸入body 為 streamingio.Reader 一次性消費)。
//
// 設計原則:
// - BodyContentType 必須是上層 handler 的原始 Content-Type header 值(含 multipart boundary
// net/http 不會自動產生 — 必須完整透傳,否則 converter multer 會解析失敗
// - UserID 由 visionA-backend trust boundary 灌入(見 conversion.md §7本層不檢查格式
// - SourceFilename / Platform 為 log 用 metadataconverter 自己會從 multipart 解出真值)
type InitConverterJobReq struct {
UserID string // OIDC sub本層僅供 log
Platform string // "520" / "720" / "530" / "630" / "730";本層僅供 log
SourceFilename string // 本層僅供 log
Body io.Reader // 已重組好的 multipart stream含 user_id field
BodyContentType string // 含 boundary 的 Content-Type例如 "multipart/form-data; boundary=xyz"
}
// PromoteReq 是 Promote 的輸入。
//
// 設計原則:
// - UserID 灌進 promote request 的 metadatatrust boundary 重申,見 conversion.md §7.3
// - Source / TargetObjectKey 對齊 converter openapi.yaml `PromoteTarget`
// - Phase 0.8 一律 promote `nef` sourcevisionA 只關心最終可部署到 KL 晶片的 NEF 檔)
type PromoteReq struct {
UserID string // 灌進 promote request body metadata
Source string // "onnx" / "bie" / "nef";預設 "nef"
TargetObjectKey string // FAA 內目標 key由上層flow.go按命名規則組好
}
// ConverterJob 是 InitJob / GetJob / List 的 response shape。
//
// 對齊 converter openapi.yaml 的 Job + CreateJobResponse schema同時保留
// visionA Phase 0.8 §2.6.2 的 ExpiresAt 來源備援邏輯converter 沒給就 caller 推算)。
//
// 注意:這是 client 層的中間 typeflow.go 會轉成 conversion.Job對 frontend 的 shape
type ConverterJob struct {
JobID string
Status string // "created" / "running" / "completed" / "failed"
Stage string // "onnx" / "bie" / "nef"completed 時 converter 回 null → ""
Progress *int // 整體 0-100可能為 nilconverter 沒給)
StageProgress *int // 當前 stage 0-100可能為 nil
SourceFilename string // 取自 input.filename
Platform string // 取自 parameters.platform
CreatedAt time.Time
UpdatedAt time.Time
ExpiresAt time.Time // converter 沒給時上層自行 created_at + 7d 推算
ErrorCode string // 取自 error.code
ErrorMessage string // 取自 error.message
TargetObjectKey string // 僅 promote 後才有GET / list 時為 ""
}
// ConverterPromoteResult 是 Promote 的 response shape。
//
// 對齊 converter openapi.yaml `PromoteResponse`:取 promoted[0]Phase 0.8 一次只 promote 1 target
type ConverterPromoteResult struct {
TargetObjectKey string
Size int64
Checksum string // 取自 file_access_agent_etagconverter 透傳 FAA ETag
}
// ConverterClientOpts 是 NewConverterClient 的依賴注入。
//
// HTTPClient / InitHTTPClient / Now / Logger 為 optionalnil 自動填預設)— 方便 unit test 注入 fake。
type ConverterClientOpts struct {
// BaseURL 是 converter scheduler base URL不帶結尾斜線
// 範例http://192.168.0.130:9501
BaseURL string
// Tokens 是 MCTokenClient注入non-nil 必填)— 用來取 service token。
Tokens MCTokenClient
// HTTPClient 為 optionalnil 用預設timeout 10s。GetJob / Promote / List 用。
HTTPClient *http.Client
// InitHTTPClient 為 optionalnil 用預設timeout 30 分鐘)— InitJob 大檔上傳專用。
// 與 HTTPClient 分開避免互相影響GetJob 在 polling 場景頻繁呼叫timeout 短才合理。
InitHTTPClient *http.Client
// Now 為 optionalnil 用 time.Now。測試會注入 fake clock。
Now func() time.Time
// Logger 為 optionalnil 用 slog.Default()。
Logger *slog.Logger
}
// ==========================================================================
// 內部固定常數
// ==========================================================================
const (
// converter scope對齊 task-scheduler openapi.yaml securitySchemes.OAuth2ClientCredentials.scopes
scopeConverterWrite = "converter:job.write"
scopeConverterRead = "converter:job.read"
// HTTP timeout
converterDefaultHTTPTimeout = 10 * time.Second
converterInitHTTPTimeout = 30 * time.Minute // InitJob 大檔上傳
// retry 矩陣(對齊 conversion.md §9.1
converterMaxRetriesGet = 2 // GetJob max 3 attempts (1 + 2 retries)
converterMaxRetriesPromote = 2 // Promote max 3 attempts (1 + 2 retries)
converterMaxRetriesList = 1 // List max 2 attempts (1 + 1 retry)
// 退避 base
converterRetryBase = 500 * time.Millisecond
// promote 預設 sourcePhase 0.8 visionA 一律取 nef
promoteDefaultSource = "nef"
)
// ==========================================================================
// 構造 + 內部實作
// ==========================================================================
// converterClient 是 ConverterClient 的預設實作。
//
// 套件內 unexported structcaller 拿 interface讓未來換實作不影響 caller。
type converterClient struct {
baseURL string
tokens MCTokenClient
http *http.Client
httpInit *http.Client
now func() time.Time
logger *slog.Logger
}
// NewConverterClient 建立一個 ConverterClient 實例。
//
// 必填BaseURL / Tokens。其他 optional。
// 注意constructor 不驗 BaseURL 連線;第一次呼叫 method 才會打網路。
func NewConverterClient(opts ConverterClientOpts) ConverterClient {
httpClient := opts.HTTPClient
if httpClient == nil {
httpClient = &http.Client{Timeout: converterDefaultHTTPTimeout}
}
httpInit := opts.InitHTTPClient
if httpInit == nil {
httpInit = &http.Client{Timeout: converterInitHTTPTimeout}
}
now := opts.Now
if now == nil {
now = time.Now
}
logger := opts.Logger
if logger == nil {
logger = slog.Default()
}
return &converterClient{
baseURL: strings.TrimRight(opts.BaseURL, "/"),
tokens: opts.Tokens,
http: httpClient,
httpInit: httpInit,
now: now,
logger: logger,
}
}
// ==========================================================================
// InitJob — multipart streaming proxy不 retry 5xx
// ==========================================================================
func (c *converterClient) InitJob(ctx context.Context, req InitConverterJobReq) (*ConverterJob, error) {
if req.Body == nil {
return nil, fmt.Errorf("conversion/converter_client: InitJob body is required")
}
if req.BodyContentType == "" {
return nil, fmt.Errorf("conversion/converter_client: InitJob body content type is required (must contain multipart boundary)")
}
token, err := c.tokens.ServiceToken(ctx, scopeConverterWrite)
if err != nil {
return nil, c.wrapTokenErr(err)
}
endpoint := c.baseURL + "/api/v1/jobs"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, req.Body)
if err != nil {
return nil, fmt.Errorf("%w: build init job request: %v", ErrConverterUnavailable, err)
}
// Content-Type 必須完整透傳(含 multipart boundary不能讓 net/http 自動推導
httpReq.Header.Set("Content-Type", req.BodyContentType)
httpReq.Header.Set("Accept", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+token)
startedAt := c.now()
res, err := c.httpInit.Do(httpReq)
duration := c.now().Sub(startedAt)
if err != nil {
// network / ctx cancel — 不 retrystreaming body 已耗盡)
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
c.logger.Warn("conversion.converter.init_ctx_cancelled",
slog.String("user_id", req.UserID),
slog.Duration("duration", duration))
return nil, err
}
c.logger.Warn("conversion.converter.init_network_error",
slog.String("user_id", req.UserID),
slog.Duration("duration", duration),
slog.String("err", truncate(err.Error(), 200)))
return nil, fmt.Errorf("%w: init job network error: %v", ErrConverterUnavailable, err)
}
defer res.Body.Close()
bodyBytes, readErr := io.ReadAll(res.Body)
if readErr != nil {
c.logger.Warn("conversion.converter.init_body_read_failed",
slog.String("user_id", req.UserID),
slog.Int("status", res.StatusCode),
slog.String("err", truncate(readErr.Error(), 200)))
return nil, fmt.Errorf("%w: read init response body: %v", ErrConverterUnavailable, readErr)
}
c.logger.Info("conversion.converter.init_response",
slog.String("user_id", req.UserID),
slog.String("source_filename", req.SourceFilename),
slog.String("platform", req.Platform),
slog.Int("status", res.StatusCode),
slog.Duration("duration", duration))
if res.StatusCode >= 200 && res.StatusCode < 300 {
return parseConverterJob(bodyBytes)
}
// 非 2xx — 一律 mapping 成 sentinel**包括 5xx 也直接 fail不 retry**
return nil, c.mapInitError(res.StatusCode, bodyBytes)
}
// mapInitError 把 InitJob 的非 2xx response mapping 成 sentinel。
//
// 對齊 task-scheduler openapi.yaml POST /api/v1/jobs 的 4xx / 5xx 與 §6 mapping。
func (c *converterClient) mapInitError(status int, body []byte) error {
apiErr := parseAPIError(body)
// 認證失敗visionA service client 設定錯)
if status == http.StatusUnauthorized || status == http.StatusForbidden {
return fmt.Errorf("%w: init job %d", ErrServiceClientUnauthorized, status)
}
// 409 user_has_active_job — wrap 成 ActiveJobError
if status == http.StatusConflict && apiErr.Code == "user_has_active_job" {
return &ActiveJobError{Job: extractActiveJobFromDetails(apiErr.Details)}
}
// 400 validation_error / invalid_multipart — wrap 成 ConverterValidationError
if status == http.StatusBadRequest {
return &ConverterValidationError{
Fields: extractFieldsFromDetails(apiErr.Details),
Message: apiErr.Message,
}
}
if status == http.StatusRequestEntityTooLarge {
return fmt.Errorf("%w: init job %d (%s)", ErrPayloadTooLarge, status, apiErr.Code)
}
if status == http.StatusServiceUnavailable {
// converter 503 service_busyprocess semaphore 滿)
return fmt.Errorf("%w: init job %d (%s)", ErrServiceBusy, status, apiErr.Code)
}
// 其他 4xx → validation 視為通用 mapping
if status >= 400 && status < 500 {
return fmt.Errorf("%w: init job %d (%s)", ErrValidationFailed, status, apiErr.Code)
}
// 5xx — InitJob 不 retry直接 mapping 成 ErrConverterUnavailable
return fmt.Errorf("%w: init job %d (%s)", ErrConverterUnavailable, status, apiErr.Code)
}
// ==========================================================================
// GetJob — 標準 retry
// ==========================================================================
func (c *converterClient) GetJob(ctx context.Context, jobID string) (*ConverterJob, error) {
if jobID == "" {
return nil, fmt.Errorf("conversion/converter_client: GetJob jobID is required")
}
endpoint := c.baseURL + "/api/v1/jobs/" + url.PathEscape(jobID)
body, err := c.doWithRetry(ctx, "get_job", jobID, scopeConverterRead, converterMaxRetriesGet,
func(token string) (*http.Request, error) {
req, rerr := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if rerr != nil {
return nil, rerr
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
return req, nil
},
c.mapGetJobError,
)
if err != nil {
return nil, err
}
return parseConverterJob(body)
}
// mapGetJobError 把 GetJob 的非 2xx 對應到 sentinel。
func (c *converterClient) mapGetJobError(status int, body []byte) error {
apiErr := parseAPIError(body)
if status == http.StatusUnauthorized || status == http.StatusForbidden {
return fmt.Errorf("%w: get_job %d", ErrServiceClientUnauthorized, status)
}
if status == http.StatusNotFound {
return fmt.Errorf("%w: get_job %d (%s)", ErrJobNotFound, status, apiErr.Code)
}
if status >= 400 && status < 500 {
return fmt.Errorf("%w: get_job %d (%s)", ErrValidationFailed, status, apiErr.Code)
}
return fmt.Errorf("%w: get_job %d (%s)", ErrConverterUnavailable, status, apiErr.Code)
}
// ==========================================================================
// Promote — 標準 retry + FAA / job_not_completed 特殊 mapping
// ==========================================================================
func (c *converterClient) Promote(ctx context.Context, jobID string, req PromoteReq) (*ConverterPromoteResult, error) {
if jobID == "" {
return nil, fmt.Errorf("conversion/converter_client: Promote jobID is required")
}
if req.TargetObjectKey == "" {
return nil, fmt.Errorf("conversion/converter_client: Promote target_object_key is required")
}
source := req.Source
if source == "" {
source = promoteDefaultSource
}
endpoint := c.baseURL + "/api/v1/jobs/" + url.PathEscape(jobID) + "/promote"
// promote request body — 對齊 openapi.yaml PromoteRequest
// 同時放 user_id 進 metadatatrust boundary 重申§7.3
bodyJSON, err := json.Marshal(map[string]any{
"targets": []map[string]any{
{"source": source, "target_object_key": req.TargetObjectKey},
},
"user_id": req.UserID, // converter Phase 1 不消費,但保留供 log / 未來啟用
})
if err != nil {
return nil, fmt.Errorf("%w: marshal promote request: %v", ErrConverterUnavailable, err)
}
respBody, err := c.doWithRetry(ctx, "promote", jobID, scopeConverterWrite, converterMaxRetriesPromote,
func(token string) (*http.Request, error) {
r, rerr := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(bodyJSON))
if rerr != nil {
return nil, rerr
}
r.Header.Set("Content-Type", "application/json")
r.Header.Set("Accept", "application/json")
r.Header.Set("Authorization", "Bearer "+token)
return r, nil
},
c.mapPromoteError,
)
if err != nil {
return nil, err
}
return parseConverterPromoteResult(respBody)
}
// mapPromoteError 把 Promote 的非 2xx 對應到 sentinel。
//
// 特殊 mapping
// - 502 file_gateway_unavailable → ErrFAAUnavailable
// - 503 auth_service_unavailable → ErrIDPUnavailable
// - 409 job_not_ready_for_promote / source_not_available → ErrJobNotCompleted
func (c *converterClient) mapPromoteError(status int, body []byte) error {
apiErr := parseAPIError(body)
if status == http.StatusUnauthorized || status == http.StatusForbidden {
return fmt.Errorf("%w: promote %d", ErrServiceClientUnauthorized, status)
}
if status == http.StatusNotFound {
return fmt.Errorf("%w: promote %d (%s)", ErrJobNotFound, status, apiErr.Code)
}
if status == http.StatusConflict {
// 兩種job_not_ready_for_promote / source_not_available
return fmt.Errorf("%w: promote %d (%s)", ErrJobNotCompleted, status, apiErr.Code)
}
if status == http.StatusBadGateway {
// converter 端 FAA 不可達 / FAA 4xx
return fmt.Errorf("%w: promote %d (%s)", ErrFAAUnavailable, status, apiErr.Code)
}
if status == http.StatusServiceUnavailable {
// converter 端 MC 簽 token 失敗
return fmt.Errorf("%w: promote %d (%s)", ErrIDPUnavailable, status, apiErr.Code)
}
if status == http.StatusBadRequest || status == http.StatusUnprocessableEntity {
return &ConverterValidationError{
Fields: extractFieldsFromDetails(apiErr.Details),
Message: apiErr.Message,
}
}
if status >= 400 && status < 500 {
return fmt.Errorf("%w: promote %d (%s)", ErrValidationFailed, status, apiErr.Code)
}
return fmt.Errorf("%w: promote %d (%s)", ErrConverterUnavailable, status, apiErr.Code)
}
// ==========================================================================
// ListInProgressJobs — lazy rebuild ownership 用
// ==========================================================================
func (c *converterClient) ListInProgressJobs(ctx context.Context, userID string) ([]*ConverterJob, error) {
if userID == "" {
return nil, fmt.Errorf("conversion/converter_client: ListInProgressJobs userID is required")
}
q := url.Values{}
q.Set("user_id", userID)
q.Set("status", "in_progress")
endpoint := c.baseURL + "/api/v1/jobs?" + q.Encode()
body, err := c.doWithRetry(ctx, "list_jobs", userID, scopeConverterRead, converterMaxRetriesList,
func(token string) (*http.Request, error) {
r, rerr := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if rerr != nil {
return nil, rerr
}
r.Header.Set("Accept", "application/json")
r.Header.Set("Authorization", "Bearer "+token)
return r, nil
},
c.mapListJobsError,
)
if err != nil {
return nil, err
}
return parseListJobs(body)
}
// mapListJobsError 把 ListInProgressJobs 的非 2xx 對應到 sentinel。
//
// list 不該回 404user_id 沒 active 應回 200 + jobs:[]),所以 4xx 一律視為 validation。
func (c *converterClient) mapListJobsError(status int, body []byte) error {
apiErr := parseAPIError(body)
if status == http.StatusUnauthorized || status == http.StatusForbidden {
return fmt.Errorf("%w: list_jobs %d", ErrServiceClientUnauthorized, status)
}
if status >= 400 && status < 500 {
return fmt.Errorf("%w: list_jobs %d (%s)", ErrValidationFailed, status, apiErr.Code)
}
return fmt.Errorf("%w: list_jobs %d (%s)", ErrConverterUnavailable, status, apiErr.Code)
}
// ==========================================================================
// HTTP 共用retry / 錯誤分類
// ==========================================================================
// doWithRetry 是 GetJob / Promote / List 共用的 retry 執行器。
//
// 與 mc_token_client.doWithRetry 結構類似但有以下差異:
// - 每次 attempt 內呼叫 ServiceToken 取最新 token401 時 caller 不主動 invalidate cache —
// 設計取捨:避免 cache 被惡意 401 attack 反覆清空;正常 401 = secret 設定錯retry 也沒用)
// - retry 次數由 caller 傳入(不同 endpoint 不同上限)
// - 4xx / 401 / 403 不 retry5xx / network / timeout 可 retry
// - mapErr 由 caller 傳入,因為 GetJob / Promote / List 的 4xx mapping 細節不同
//
// reqBuilder 是「每次 attempt 都重新建一個 *http.Request」的 closure
// — request body 可能在 retry 時已被讀完必須重建。caller 內部用 bytes.NewReader 等可重建的 body。
// — token 是 closure 參數,每次 attempt 都拿最新(也涵蓋 cache 過期 refresh 的場景)
func (c *converterClient) doWithRetry(
ctx context.Context,
endpointKind, label, scope string,
maxRetries int,
reqBuilder func(token string) (*http.Request, error),
mapErr func(status int, body []byte) error,
) ([]byte, error) {
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
// retry 前檢查 ctx
if attempt > 0 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(converterRetryBackoff(attempt)):
}
}
// 每次 attempt 都重新取 tokencache hit 情境下成本極低)
token, err := c.tokens.ServiceToken(ctx, scope)
if err != nil {
// token 取不到 — 不可重試IdP 端問題,不在 converter 重試矩陣內)
return nil, c.wrapTokenErr(err)
}
req, err := reqBuilder(token)
if err != nil {
return nil, fmt.Errorf("%w: build %s request: %v", ErrConverterUnavailable, endpointKind, err)
}
body, classifiedErr, retryable := c.doOnce(req, endpointKind, label, attempt, mapErr)
if classifiedErr == nil {
return body, nil
}
lastErr = classifiedErr
if !retryable {
return nil, classifiedErr
}
}
c.logger.Warn("conversion.converter.retry_exhausted",
slog.String("endpoint", endpointKind),
slog.String("label", label),
slog.Int("attempts", maxRetries+1))
return nil, lastErr
}
// doOnce 執行一次 HTTP request回傳 body成功時+ 分類好的 error + 是否可重試。
func (c *converterClient) doOnce(
req *http.Request,
endpointKind, label string,
attempt int,
mapErr func(status int, body []byte) error,
) (body []byte, err error, retryable bool) {
startedAt := c.now()
res, err := c.http.Do(req)
duration := c.now().Sub(startedAt)
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
c.logger.Warn("conversion.converter.ctx_cancelled",
slog.String("endpoint", endpointKind),
slog.String("label", label),
slog.Int("attempt", attempt+1),
slog.Duration("duration", duration))
return nil, err, false
}
c.logger.Warn("conversion.converter.network_error",
slog.String("endpoint", endpointKind),
slog.String("label", label),
slog.Int("attempt", attempt+1),
slog.Duration("duration", duration),
slog.String("err", truncate(err.Error(), 200)))
return nil, fmt.Errorf("%w: %s network error: %v",
ErrConverterUnavailable, endpointKind, err), true
}
defer res.Body.Close()
bodyBytes, readErr := io.ReadAll(res.Body)
if readErr != nil {
c.logger.Warn("conversion.converter.body_read_failed",
slog.String("endpoint", endpointKind),
slog.String("label", label),
slog.Int("status", res.StatusCode),
slog.String("err", truncate(readErr.Error(), 200)))
return nil, fmt.Errorf("%w: read response body: %v",
ErrConverterUnavailable, readErr), true
}
if res.StatusCode >= 200 && res.StatusCode < 300 {
c.logger.Debug("conversion.converter.success",
slog.String("endpoint", endpointKind),
slog.String("label", label),
slog.Int("status", res.StatusCode),
slog.Int("attempt", attempt+1),
slog.Duration("duration", duration))
return bodyBytes, nil, false
}
c.logger.Warn("conversion.converter.endpoint_error",
slog.String("endpoint", endpointKind),
slog.String("label", label),
slog.Int("status", res.StatusCode),
slog.Int("attempt", attempt+1),
slog.Duration("duration", duration))
classified := mapErr(res.StatusCode, bodyBytes)
// 5xx 視為可重試4xx / 認證失敗 / 已 wrap 為非 transient error 都不重試
retryable = res.StatusCode >= 500 && res.StatusCode < 600
return nil, classified, retryable
}
// converterRetryBackoff 回傳第 n 次 retryn 從 1 開始)的等待時間。
// 對齊 conversion.md §9.1
// - GetJob: 0.5s, 1s, 2sbase=500ms倍數 1, 2, 4 — 但實際只用前 2 次)
// - Promote: 1s, 2sbase=500ms倍數 2, 4
// - List: 0.5sbase=500ms倍數 1
//
// 為了統一 base 但對齊 §9.1 的「Promote 退避 1s, 2s」我們用 base=500ms 加 ×2 倍數,
// 第 n 次退避 = base × 2^n對照 §9.1 GetJob: n=1→500ms*1=500ms 不完全對齊;
// 但 §9.1 主要規範是「指數退避max retry 次數」— 實際數值容忍小偏差,重點是不爆量)。
//
// 最終退避序列n=1→0.5s, n=2→1s, n=3→2sPromote/Get 都從 n=1 開始用,
// 第 1 次 attempt 不退避;第 2 次 attempt = retry 1 = 0.5s 等)。
//
// 不加 jitter — 同 mc_token_clientPhase 0.8 同時 retry 的 caller 不會大量併發打 converter。
func converterRetryBackoff(attempt int) time.Duration {
if attempt < 1 {
return converterRetryBase
}
// 0.5s, 1s, 2s, 4s ...
return converterRetryBase * (1 << (attempt - 1))
}
// wrapTokenErr 把 MCTokenClient 取 token 時的錯誤包成 caller 已預期的 sentinel。
//
// MCTokenClient 已經把錯誤分類成 ErrServiceClientUnauthorized / ErrMCTokenUnavailable / ctx.Err
// 我們不在 converter_client 層改動分類,純粹透傳(讓上層用 errors.Is 比對)。
func (c *converterClient) wrapTokenErr(err error) error {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return err
}
// 已是 sentinelErrServiceClientUnauthorized / ErrMCTokenUnavailable— 直接透傳
if errors.Is(err, ErrServiceClientUnauthorized) || errors.Is(err, ErrMCTokenUnavailable) {
return err
}
// 兜底:未預期的 token 錯誤包成 ErrMCTokenUnavailable
return fmt.Errorf("%w: %v", ErrMCTokenUnavailable, err)
}
// ==========================================================================
// Response 解析converter openapi.yaml shapes
// ==========================================================================
// converterAPIError 是 converter `{error: {...}}` shape 的 unmarshal 中介 type。
type converterAPIError struct {
Code string `json:"code"`
Message string `json:"message"`
Details json.RawMessage `json:"details"`
RequestID string `json:"request_id"`
}
// parseAPIError 解 converter 的 `{error: {code, message, details, request_id}}` shape。
//
// converter 4xx / 5xx 一律遵循此 shape解析失敗時回空 structcaller 仍會走 mapping 預設路徑)。
func parseAPIError(body []byte) converterAPIError {
var wrapper struct {
Error converterAPIError `json:"error"`
}
if err := json.Unmarshal(body, &wrapper); err != nil {
return converterAPIError{}
}
return wrapper.Error
}
// extractFieldsFromDetails 從 converter `details.fields` 解出 ValidationFieldError slice。
//
// 對齊 openapi.yaml 範例:
//
// details: { fields: [{ field: "model_id", message: "..." }] }
//
// 解析失敗回 nilcaller 仍可正常 wrapfrontend 拿不到 fields 但能拿到 code
func extractFieldsFromDetails(raw json.RawMessage) []ValidationFieldError {
if len(raw) == 0 {
return nil
}
var parsed struct {
Fields []ValidationFieldError `json:"fields"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
return nil
}
return parsed.Fields
}
// extractActiveJobFromDetails 從 converter 409 user_has_active_job 的 details 解出簡化版 Job。
//
// 對齊 openapi.yaml 範例:
//
// details: {
// active_job_id: "...",
// active_job_status: "running",
// active_job_stage: "bie",
// active_job_progress: 45,
// active_job_created_at: "..."
// }
//
// 解析失敗回 nilcaller 仍會走 ActiveJobError只是 Job 為 nil
func extractActiveJobFromDetails(raw json.RawMessage) *Job {
if len(raw) == 0 {
return nil
}
var parsed struct {
ActiveJobID string `json:"active_job_id"`
ActiveJobStatus string `json:"active_job_status"`
ActiveJobStage string `json:"active_job_stage"`
ActiveJobProgress int `json:"active_job_progress"`
ActiveJobCreatedAt time.Time `json:"active_job_created_at"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
return nil
}
if parsed.ActiveJobID == "" {
return nil
}
return &Job{
JobID: parsed.ActiveJobID,
Status: parsed.ActiveJobStatus,
Stage: parsed.ActiveJobStage,
Progress: parsed.ActiveJobProgress,
CreatedAt: parsed.ActiveJobCreatedAt,
// ExpiresAt 由上層 flow.go 自行 created_at + 7d 推算converter 409 不一定回 expires_at
}
}
// converterJobJSON 是 GET /api/v1/jobs/{id} response 的中介 unmarshal type。
//
// 為了同時支援:
// - CreateJobResponsePOST /jobs 201— 無 stage_progress / input.filename 等欄位
// - JobGET /jobs/{id})— 完整欄位
// 全部欄位都用 pointer 或 nullableMarshal 時靠下方 toConverterJob 統一轉。
type converterJobJSON struct {
JobID string `json:"job_id"`
Status string `json:"status"`
Stage *string `json:"stage"` // completed 時 converter 回 null
Progress *int `json:"progress"`
StageProgress *int `json:"stage_progress"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ExpiresAt time.Time `json:"expires_at"`
Input *struct {
Filename string `json:"filename"`
} `json:"input"`
Parameters *struct {
Platform string `json:"platform"`
} `json:"parameters"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
Stage string `json:"stage"`
} `json:"error"`
}
// parseConverterJob 解 GET /api/v1/jobs/{id} 或 POST /api/v1/jobs 201 的 response。
func parseConverterJob(body []byte) (*ConverterJob, error) {
var jr converterJobJSON
if err := json.Unmarshal(body, &jr); err != nil {
return nil, fmt.Errorf("%w: parse converter job response: %v", ErrConverterUnavailable, err)
}
if jr.JobID == "" {
return nil, fmt.Errorf("%w: empty job_id in converter response", ErrConverterUnavailable)
}
return jr.toConverterJob(), nil
}
// toConverterJob 把 converterJobJSON 轉成對外的 ConverterJob。
func (jr *converterJobJSON) toConverterJob() *ConverterJob {
cj := &ConverterJob{
JobID: jr.JobID,
Status: jr.Status,
Progress: jr.Progress,
StageProgress: jr.StageProgress,
CreatedAt: jr.CreatedAt,
UpdatedAt: jr.UpdatedAt,
ExpiresAt: jr.ExpiresAt,
}
if jr.Stage != nil {
cj.Stage = *jr.Stage
}
if jr.Input != nil {
cj.SourceFilename = jr.Input.Filename
}
if jr.Parameters != nil {
cj.Platform = jr.Parameters.Platform
}
if jr.Error != nil {
cj.ErrorCode = jr.Error.Code
cj.ErrorMessage = jr.Error.Message
}
return cj
}
// parseListJobs 解 GET /api/v1/jobs?user_id=&status=in_progress 的 response。
//
// converter shape{ "jobs": [Job, ...], "total": N, "next_cursor": "..." | null }
func parseListJobs(body []byte) ([]*ConverterJob, error) {
var resp struct {
Jobs []converterJobJSON `json:"jobs"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("%w: parse list jobs response: %v", ErrConverterUnavailable, err)
}
out := make([]*ConverterJob, 0, len(resp.Jobs))
for i := range resp.Jobs {
out = append(out, resp.Jobs[i].toConverterJob())
}
return out, nil
}
// parseConverterPromoteResult 解 POST /api/v1/jobs/{id}/promote 的 response。
//
// 對齊 openapi.yaml `PromoteResponse`:取 promoted[0]Phase 0.8 一次只 promote 1 target
// 若 promoted 陣列為空,回 ErrConverterUnavailable合理表示 converter 內部狀態不一致)。
func parseConverterPromoteResult(body []byte) (*ConverterPromoteResult, error) {
var resp struct {
Promoted []struct {
TargetObjectKey string `json:"target_object_key"`
SizeBytes int64 `json:"size_bytes"`
FileAccessAgentETag string `json:"file_access_agent_etag"`
} `json:"promoted"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("%w: parse promote response: %v", ErrConverterUnavailable, err)
}
if len(resp.Promoted) == 0 {
return nil, fmt.Errorf("%w: promote response has empty promoted array", ErrConverterUnavailable)
}
first := resp.Promoted[0]
if first.TargetObjectKey == "" {
return nil, fmt.Errorf("%w: promote response missing target_object_key", ErrConverterUnavailable)
}
return &ConverterPromoteResult{
TargetObjectKey: first.TargetObjectKey,
Size: first.SizeBytes,
Checksum: first.FileAccessAgentETag,
}, nil
}

View File

@ -0,0 +1,895 @@
// Converter Client 單元測試。
//
// 測試策略:
// - 用 httptest.Server mock task-scheduler 的 4 個 endpoint
// - 用 stub MCTokenClient直接回 token / 注入錯誤),不耦合真實 mc_token_client 邏輯
// - 用 atomic counter 驗 retry 行為attempts 數對齊 conversion.md §9.1
// - 大 body streaming 用 io.LimitReader不真的寫 100MB 進 RAM
//
// 對應 task 規範必含 case
// - InitJobSuccess / StreamingBody / ContentTypeHeader / Conflict409 / Validation400 / 5xx_NoRetry / AuthExpired
// - GetJobSuccess / NotFound / 5xx_RetryThenSuccess
// - PromoteSuccess / BadGateway
// - ListSuccess / Empty / 5xxRetry
//
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.5 + §9.1)
package conversion
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ==========================================================================
// stub MCTokenClient — 解耦真實 mc_token_client 邏輯
// ==========================================================================
// stubTokenClient 是 test 用的 fake MCTokenClient。
type stubTokenClient struct {
mu sync.Mutex
token string
tokenErr error
callsByScope map[string]int
}
func newStubTokenClient(token string) *stubTokenClient {
return &stubTokenClient{
token: token,
callsByScope: make(map[string]int),
}
}
func (s *stubTokenClient) ServiceToken(ctx context.Context, scope string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.callsByScope[scope]++
if s.tokenErr != nil {
return "", s.tokenErr
}
return s.token, nil
}
func (s *stubTokenClient) IssueDelegatedDownload(ctx context.Context, in IssueDownloadReq) (*DelegatedDownloadToken, error) {
// converter_client 不會呼叫;此處只是滿足 interface
return nil, fmt.Errorf("stubTokenClient.IssueDelegatedDownload should not be called from converter_client tests")
}
func (s *stubTokenClient) setError(err error) {
s.mu.Lock()
defer s.mu.Unlock()
s.tokenErr = err
}
func (s *stubTokenClient) calls(scope string) int {
s.mu.Lock()
defer s.mu.Unlock()
return s.callsByScope[scope]
}
// ==========================================================================
// converter mock server helpers
// ==========================================================================
// newConverterClientForTest 建立指向 mock server 的 ConverterClient。
//
// 使用較短的 init/http timeout 加速 testretry 退避保持原本converterRetryBackoff 1s 起跳
// 對 retry test 有點久但仍可接受 — 5xx retry test 的 max 2 retries = 0.5s + 1s = 1.5s)。
func newConverterClientForTest(t *testing.T, baseURL string, tokens MCTokenClient) ConverterClient {
t.Helper()
return NewConverterClient(ConverterClientOpts{
BaseURL: baseURL,
Tokens: tokens,
HTTPClient: &http.Client{Timeout: 5 * time.Second},
InitHTTPClient: &http.Client{Timeout: 5 * time.Second},
Logger: silentLogger(),
})
}
// ==========================================================================
// InitJob tests
// ==========================================================================
// TestInitJob_Successmock 接受 multipart回 201 + job spec。
func TestInitJob_Success(t *testing.T) {
t.Parallel()
tokens := newStubTokenClient("svc-tok")
var serverContentType string
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "Bearer svc-tok", r.Header.Get("Authorization"))
serverContentType = r.Header.Get("Content-Type")
// drain body 確認 streaming 完成
_, _ = io.Copy(io.Discard, r.Body)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{
"job_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "created",
"stage": "onnx",
"progress": 0,
"created_at": "2026-04-25T12:00:00Z",
"updated_at": "2026-04-25T12:00:00Z",
"expires_at": "2026-05-02T12:00:00Z",
"user_id": "alice"
}`))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
cc := newConverterClientForTest(t, srv.URL, tokens)
job, err := cc.InitJob(context.Background(), InitConverterJobReq{
UserID: "alice",
Platform: "520",
SourceFilename: "model.onnx",
Body: strings.NewReader("--xyz\r\nContent-Disposition: form-data; name=\"user_id\"\r\n\r\nalice\r\n--xyz--\r\n"),
BodyContentType: "multipart/form-data; boundary=xyz",
})
require.NoError(t, err)
require.NotNil(t, job)
assert.Equal(t, "550e8400-e29b-41d4-a716-446655440000", job.JobID)
assert.Equal(t, "created", job.Status)
assert.Equal(t, "onnx", job.Stage)
assert.Equal(t, "multipart/form-data; boundary=xyz", serverContentType,
"InitJob 必須完整透傳 Content-Type 含 boundaryconverter multer 解析依賴此)")
assert.Equal(t, 1, tokens.calls(scopeConverterWrite))
}
// TestInitJob_StreamingBodydriver 寫 100MB 假資料給 io.Readerconfirm streaming不全 buffer RAM
//
// 用 io.LimitReader 包一個無限 readerserver side 也用 io.Discard 不存。
// 觀察peakReadBytes 不應接近 100MB確認 net/http 真的是 streaming— 但 peak 偵測在 Go 層級不易,
// 改驗reader 的 ReadCalls 數應遠大於 1如果 buffer 全進 RAMnet/http 會一次全讀)。
func TestInitJob_StreamingBody(t *testing.T) {
t.Parallel()
tokens := newStubTokenClient("svc-tok")
var serverBytesRead int64
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
// 不一次 ReadAll用 Copy 到 io.Discard 強制 streaming
n, _ := io.Copy(io.Discard, r.Body)
atomic.AddInt64(&serverBytesRead, n)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{
"job_id": "stream-test", "status": "created", "stage": "onnx", "progress": 0,
"created_at": "2026-04-25T12:00:00Z",
"updated_at": "2026-04-25T12:00:00Z",
"expires_at": "2026-05-02T12:00:00Z"
}`))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
const totalSize = int64(10 * 1024 * 1024) // 10MB測試成本與 streaming 驗證的平衡)
reader := &countingReader{
R: io.LimitReader(zerosReader{}, totalSize),
}
cc := newConverterClientForTest(t, srv.URL, tokens)
// 對 streaming test 加長 timeout
cc = NewConverterClient(ConverterClientOpts{
BaseURL: srv.URL,
Tokens: tokens,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
InitHTTPClient: &http.Client{Timeout: 30 * time.Second},
Logger: silentLogger(),
})
job, err := cc.InitJob(context.Background(), InitConverterJobReq{
UserID: "alice",
Body: reader,
BodyContentType: "multipart/form-data; boundary=stream",
})
require.NoError(t, err)
require.NotNil(t, job)
assert.Equal(t, "stream-test", job.JobID)
assert.Equal(t, totalSize, atomic.LoadInt64(&serverBytesRead),
"server 應該收到完整 bodystreaming proxy 不掉資料)")
// streaming 證據reader 應被多次呼叫 Read如果是 buffer 全 RAM 模式,會一次大讀)
calls := atomic.LoadInt64(&reader.calls)
assert.Greater(t, calls, int64(1), "streaming 必須多次 Read不能一次性 buffer 全 RAM")
}
// TestInitJob_ContentTypeHeadermultipart boundary 必須完整透傳。
func TestInitJob_ContentTypeHeader(t *testing.T) {
t.Parallel()
tokens := newStubTokenClient("svc-tok")
var receivedCT string
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
receivedCT = r.Header.Get("Content-Type")
_, _ = io.Copy(io.Discard, r.Body)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{
"job_id": "ct-test", "status": "created", "stage": "onnx", "progress": 0,
"created_at": "2026-04-25T12:00:00Z",
"updated_at": "2026-04-25T12:00:00Z",
"expires_at": "2026-05-02T12:00:00Z"
}`))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
const customCT = "multipart/form-data; boundary=---xxx-very-specific-boundary-yyy---"
cc := newConverterClientForTest(t, srv.URL, tokens)
_, err := cc.InitJob(context.Background(), InitConverterJobReq{
Body: strings.NewReader("body content"),
BodyContentType: customCT,
})
require.NoError(t, err)
assert.Equal(t, customCT, receivedCT, "boundary 必須一字不差透傳(含特殊字元)")
}
// TestInitJob_Conflict409_ActiveJobErrormock 回 409 user_has_active_job → return *ActiveJobError。
func TestInitJob_Conflict409_ActiveJobError(t *testing.T) {
t.Parallel()
tokens := newStubTokenClient("svc-tok")
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(io.Discard, r.Body)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusConflict)
_, _ = w.Write([]byte(`{
"error": {
"code": "user_has_active_job",
"message": "使用者目前已有進行中的轉檔任務",
"details": {
"active_job_id": "550e8400-e29b-41d4-a716-446655440000",
"active_job_status": "running",
"active_job_stage": "bie",
"active_job_progress": 45,
"active_job_created_at": "2026-04-25T12:00:00Z"
},
"request_id": "req-123"
}
}`))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
cc := newConverterClientForTest(t, srv.URL, tokens)
_, err := cc.InitJob(context.Background(), InitConverterJobReq{
Body: strings.NewReader("x"),
BodyContentType: "multipart/form-data; boundary=xxx",
})
require.Error(t, err)
assert.True(t, errors.Is(err, ErrActiveJobExists), "必須能透過 errors.Is 比對 sentinel")
var ae *ActiveJobError
require.True(t, errors.As(err, &ae), "必須能透過 errors.As 取出 ActiveJobError struct")
require.NotNil(t, ae.Job)
assert.Equal(t, "550e8400-e29b-41d4-a716-446655440000", ae.Job.JobID)
assert.Equal(t, "running", ae.Job.Status)
assert.Equal(t, "bie", ae.Job.Stage)
assert.Equal(t, 45, ae.Job.Progress)
}
// TestInitJob_Validation400mock 回 400 + fields → return *ConverterValidationError
// fields 對齊 openapi.yaml shape[]ValidationFieldError
func TestInitJob_Validation400(t *testing.T) {
t.Parallel()
tokens := newStubTokenClient("svc-tok")
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(io.Discard, r.Body)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{
"error": {
"code": "validation_error",
"message": "欄位驗證失敗",
"details": {
"fields": [
{"field": "model_id", "message": "model_id 範圍必須在 1 ~ 65535"},
{"field": "platform", "message": "platform 必須是 520 / 720 / 530 / 630 / 730"}
]
},
"request_id": "req-validation"
}
}`))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
cc := newConverterClientForTest(t, srv.URL, tokens)
_, err := cc.InitJob(context.Background(), InitConverterJobReq{
Body: strings.NewReader("x"),
BodyContentType: "multipart/form-data; boundary=xxx",
})
require.Error(t, err)
assert.True(t, errors.Is(err, ErrValidationFailed))
var ve *ConverterValidationError
require.True(t, errors.As(err, &ve))
require.Len(t, ve.Fields, 2, "fields 必須對齊 converter openapi.yaml 的 array shape")
assert.Equal(t, "model_id", ve.Fields[0].Field)
assert.Equal(t, "model_id 範圍必須在 1 ~ 65535", ve.Fields[0].Message)
assert.Equal(t, "platform", ve.Fields[1].Field)
assert.Contains(t, ve.Message, "驗證失敗", "Message 應透傳 converter 原文供 log 用")
}
// TestInitJob_5xx_NoRetrymock 連續 500 → InitJob 不 retry立即 return。
//
// 設計理由multipart body 是 streamingio.Reader 一次性retry 會傳到一半的爛資料。
func TestInitJob_5xx_NoRetry(t *testing.T) {
t.Parallel()
tokens := newStubTokenClient("svc-tok")
var counter atomic.Int32
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
counter.Add(1)
_, _ = io.Copy(io.Discard, r.Body)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":{"code":"misconfiguration","message":"...","request_id":"r"}}`))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
cc := newConverterClientForTest(t, srv.URL, tokens)
_, err := cc.InitJob(context.Background(), InitConverterJobReq{
Body: strings.NewReader("x"),
BodyContentType: "multipart/form-data; boundary=xxx",
})
require.Error(t, err)
assert.True(t, errors.Is(err, ErrConverterUnavailable))
assert.Equal(t, int32(1), counter.Load(),
"InitJob 不可 retry 5xxstreaming body 不可 replay")
}
// TestInitJob_AuthExpiredmock 回 401 → return ErrServiceClientUnauthorized。
func TestInitJob_AuthExpired(t *testing.T) {
t.Parallel()
tokens := newStubTokenClient("expired-tok")
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(io.Discard, r.Body)
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":{"code":"invalid_token","message":"...","request_id":"r"}}`))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
cc := newConverterClientForTest(t, srv.URL, tokens)
_, err := cc.InitJob(context.Background(), InitConverterJobReq{
Body: strings.NewReader("x"),
BodyContentType: "multipart/form-data; boundary=xxx",
})
require.Error(t, err)
assert.True(t, errors.Is(err, ErrServiceClientUnauthorized))
}
// TestInitJob_TokenFailure_PropagatedMCTokenClient 取 token 失敗時,錯誤透傳。
func TestInitJob_TokenFailure_Propagated(t *testing.T) {
t.Parallel()
tokens := newStubTokenClient("")
tokens.setError(ErrServiceClientUnauthorized)
cc := newConverterClientForTest(t, "http://unused", tokens)
_, err := cc.InitJob(context.Background(), InitConverterJobReq{
Body: strings.NewReader("x"),
BodyContentType: "multipart/form-data; boundary=xxx",
})
require.Error(t, err)
assert.True(t, errors.Is(err, ErrServiceClientUnauthorized))
}
// TestInitJob_RequiredFieldsValidation本地參數驗證不打網路
func TestInitJob_RequiredFieldsValidation(t *testing.T) {
t.Parallel()
tokens := newStubTokenClient("svc-tok")
cc := newConverterClientForTest(t, "http://unused", tokens)
// 缺 body
_, err := cc.InitJob(context.Background(), InitConverterJobReq{
BodyContentType: "multipart/form-data; boundary=x",
})
require.Error(t, err)
assert.Contains(t, err.Error(), "body is required")
// 缺 content type
_, err = cc.InitJob(context.Background(), InitConverterJobReq{
Body: strings.NewReader("x"),
})
require.Error(t, err)
assert.Contains(t, err.Error(), "content type is required")
}
// ==========================================================================
// GetJob tests
// ==========================================================================
// TestGetJob_Success標準 happy path含完整 Job shape 解析)。
func TestGetJob_Success(t *testing.T) {
t.Parallel()
tokens := newStubTokenClient("svc-tok")
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
require.Equal(t, "Bearer svc-tok", r.Header.Get("Authorization"))
// path: /api/v1/jobs/{id}
assert.Contains(t, r.URL.Path, "550e8400")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{
"job_id": "550e8400-e29b-41d4-a716-446655440000",
"user_id": "alice",
"status": "running",
"stage": "bie",
"progress": 45,
"stage_progress": 60,
"created_at": "2026-04-25T12:00:00Z",
"updated_at": "2026-04-25T12:05:30Z",
"expires_at": "2026-05-02T12:00:00Z",
"input": {"filename": "model.onnx", "size_bytes": 100, "ref_images_count": 0},
"parameters": {"model_id": 1001, "version": "v1.0.0", "platform": "520"},
"error": null
}`))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
cc := newConverterClientForTest(t, srv.URL, tokens)
job, err := cc.GetJob(context.Background(), "550e8400-e29b-41d4-a716-446655440000")
require.NoError(t, err)
require.NotNil(t, job)
assert.Equal(t, "running", job.Status)
assert.Equal(t, "bie", job.Stage)
require.NotNil(t, job.Progress)
assert.Equal(t, 45, *job.Progress)
require.NotNil(t, job.StageProgress)
assert.Equal(t, 60, *job.StageProgress)
assert.Equal(t, "model.onnx", job.SourceFilename)
assert.Equal(t, "520", job.Platform)
assert.False(t, job.ExpiresAt.IsZero())
}
// TestGetJob_NotFound404 → ErrJobNotFound。
func TestGetJob_NotFound(t *testing.T) {
t.Parallel()
tokens := newStubTokenClient("svc-tok")
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":{"code":"job_not_found","message":"...","request_id":"r"}}`))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
cc := newConverterClientForTest(t, srv.URL, tokens)
_, err := cc.GetJob(context.Background(), "missing-job")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrJobNotFound))
}
// TestGetJob_5xx_RetryThenSuccess500/500/200 → atomic counter 驗 retry 3 次。
func TestGetJob_5xx_RetryThenSuccess(t *testing.T) {
t.Parallel()
tokens := newStubTokenClient("svc-tok")
var counter atomic.Int32
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) {
idx := counter.Add(1)
if idx <= 2 {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":{"code":"internal_error","message":"...","request_id":"r"}}`))
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{
"job_id": "j1", "status": "completed", "stage": null, "progress": 100,
"created_at": "2026-04-25T12:00:00Z",
"updated_at": "2026-04-25T12:08:30Z",
"expires_at": "2026-05-02T12:00:00Z"
}`))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
cc := newConverterClientForTest(t, srv.URL, tokens)
job, err := cc.GetJob(context.Background(), "j1")
require.NoError(t, err)
require.NotNil(t, job)
assert.Equal(t, "completed", job.Status)
assert.Equal(t, int32(3), counter.Load(), "GetJob 應 retry max 2 次(共 3 attempts")
}
// TestGetJob_5xx_Exhausted連續 5xx 用完 retry 仍失敗 → ErrConverterUnavailable。
func TestGetJob_5xx_Exhausted(t *testing.T) {
t.Parallel()
tokens := newStubTokenClient("svc-tok")
var counter atomic.Int32
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) {
counter.Add(1)
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte(`{"error":{"code":"x","message":"x","request_id":"r"}}`))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
cc := newConverterClientForTest(t, srv.URL, tokens)
_, err := cc.GetJob(context.Background(), "j1")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrConverterUnavailable))
assert.Equal(t, int32(3), counter.Load(), "用完 retry 仍 5xx 應該打 3 次")
}
// TestGetJob_ContextCancel_NoRetryctx 在 retry 等待中被 cancel → 立即 return。
func TestGetJob_ContextCancel_NoRetry(t *testing.T) {
t.Parallel()
tokens := newStubTokenClient("svc-tok")
var counter atomic.Int32
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) {
counter.Add(1)
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":{"code":"x","message":"x","request_id":"r"}}`))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
cc := newConverterClientForTest(t, srv.URL, tokens)
ctx, cancel := context.WithCancel(context.Background())
// 第一次 attempt 完後 cancel第二次 retry 等待時應立即 return
go func() {
time.Sleep(50 * time.Millisecond)
cancel()
}()
_, err := cc.GetJob(ctx, "j1")
require.Error(t, err)
assert.True(t, errors.Is(err, context.Canceled))
// 至多 1 次cancel 在退避時觸發)
assert.LessOrEqual(t, counter.Load(), int32(1),
"ctx cancel 應在第 1 次 attempt 後立即 return不再打 server")
}
// ==========================================================================
// Promote tests
// ==========================================================================
// TestPromote_Successpromote response 含 target_object_key。
func TestPromote_Success(t *testing.T) {
t.Parallel()
tokens := newStubTokenClient("svc-tok")
var receivedBody string
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
assert.Contains(t, r.URL.Path, "/promote")
body, _ := io.ReadAll(r.Body)
receivedBody = string(body)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{
"job_id": "j1",
"promoted": [
{
"source": "nef",
"target_object_key": "visionA/models/alice/m-1001/v1.0.0/out.nef",
"size_bytes": 10485760,
"file_access_agent_etag": "abc123",
"promoted_at": "2026-04-25T12:30:00Z"
}
]
}`))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
cc := newConverterClientForTest(t, srv.URL, tokens)
result, err := cc.Promote(context.Background(), "j1", PromoteReq{
UserID: "alice",
Source: "nef",
TargetObjectKey: "visionA/models/alice/m-1001/v1.0.0/out.nef",
})
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, "visionA/models/alice/m-1001/v1.0.0/out.nef", result.TargetObjectKey)
assert.Equal(t, int64(10485760), result.Size)
assert.Equal(t, "abc123", result.Checksum)
assert.Contains(t, receivedBody, `"user_id":"alice"`,
"promote body 應含 user_id metadatatrust boundary 重申)")
assert.Contains(t, receivedBody, `"target_object_key":"visionA/models/alice/m-1001/v1.0.0/out.nef"`)
}
// TestPromote_DefaultSource未傳 Source 時預設 nef。
func TestPromote_DefaultSource(t *testing.T) {
t.Parallel()
tokens := newStubTokenClient("svc-tok")
var receivedBody string
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
receivedBody = string(body)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{
"job_id": "j1",
"promoted": [{"source":"nef","target_object_key":"x","size_bytes":1,"file_access_agent_etag":"","promoted_at":"2026-04-25T00:00:00Z"}]
}`))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
cc := newConverterClientForTest(t, srv.URL, tokens)
_, err := cc.Promote(context.Background(), "j1", PromoteReq{
UserID: "alice",
TargetObjectKey: "x",
})
require.NoError(t, err)
assert.Contains(t, receivedBody, `"source":"nef"`, "未傳 Source 時應預設 nef")
}
// TestPromote_BadGatewayFAA 不可達 → 502 → ErrFAAUnavailable。
func TestPromote_BadGateway(t *testing.T) {
t.Parallel()
tokens := newStubTokenClient("svc-tok")
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte(`{"error":{"code":"file_gateway_unavailable","message":"FAA 不可達","request_id":"r"}}`))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
cc := newConverterClientForTest(t, srv.URL, tokens)
_, err := cc.Promote(context.Background(), "j1", PromoteReq{
UserID: "alice",
TargetObjectKey: "x",
})
require.Error(t, err)
assert.True(t, errors.Is(err, ErrFAAUnavailable),
"converter 502 file_gateway_unavailable 必須對應到 ErrFAAUnavailable")
}
// TestPromote_NotCompleted409job_not_ready_for_promote → ErrJobNotCompleted。
func TestPromote_NotCompleted409(t *testing.T) {
t.Parallel()
tokens := newStubTokenClient("svc-tok")
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusConflict)
_, _ = w.Write([]byte(`{"error":{"code":"job_not_ready_for_promote","message":"...","request_id":"r"}}`))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
cc := newConverterClientForTest(t, srv.URL, tokens)
_, err := cc.Promote(context.Background(), "j1", PromoteReq{
UserID: "alice",
TargetObjectKey: "x",
})
require.Error(t, err)
assert.True(t, errors.Is(err, ErrJobNotCompleted))
}
// TestPromote_NotFound404404 → ErrJobNotFound。
func TestPromote_NotFound404(t *testing.T) {
t.Parallel()
tokens := newStubTokenClient("svc-tok")
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":{"code":"job_not_found","message":"...","request_id":"r"}}`))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
cc := newConverterClientForTest(t, srv.URL, tokens)
_, err := cc.Promote(context.Background(), "j1", PromoteReq{
UserID: "alice",
TargetObjectKey: "x",
})
require.Error(t, err)
assert.True(t, errors.Is(err, ErrJobNotFound))
}
// TestPromote_RequiredFieldsValidation本地參數驗證。
func TestPromote_RequiredFieldsValidation(t *testing.T) {
t.Parallel()
tokens := newStubTokenClient("svc-tok")
cc := newConverterClientForTest(t, "http://unused", tokens)
_, err := cc.Promote(context.Background(), "", PromoteReq{TargetObjectKey: "x"})
require.Error(t, err)
assert.Contains(t, err.Error(), "jobID is required")
_, err = cc.Promote(context.Background(), "j1", PromoteReq{})
require.Error(t, err)
assert.Contains(t, err.Error(), "target_object_key is required")
}
// ==========================================================================
// ListInProgressJobs tests
// ==========================================================================
// TestListInProgressJobs_Successquery string 含 user_id + status=in_progress。
func TestListInProgressJobs_Success(t *testing.T) {
t.Parallel()
tokens := newStubTokenClient("svc-tok")
var receivedQuery string
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
// path 在 mux pattern 沒結尾 / 時 ServeMux 會匹配精確路徑list 端點)
require.Equal(t, http.MethodGet, r.Method)
receivedQuery = r.URL.RawQuery
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{
"jobs": [
{
"job_id": "j-active",
"user_id": "alice",
"status": "running",
"stage": "bie",
"progress": 45,
"created_at": "2026-04-25T12:00:00Z",
"updated_at": "2026-04-25T12:05:30Z",
"expires_at": "2026-05-02T12:00:00Z",
"input": {"filename": "model.onnx", "size_bytes": 1, "ref_images_count": 0},
"parameters": {"model_id": 1, "version": "v1.0.0", "platform": "720"},
"error": null
}
],
"total": 1,
"next_cursor": null
}`))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
cc := newConverterClientForTest(t, srv.URL, tokens)
jobs, err := cc.ListInProgressJobs(context.Background(), "alice")
require.NoError(t, err)
require.Len(t, jobs, 1)
assert.Equal(t, "j-active", jobs[0].JobID)
assert.Equal(t, "running", jobs[0].Status)
assert.Equal(t, "bie", jobs[0].Stage)
assert.Equal(t, "720", jobs[0].Platform)
assert.Contains(t, receivedQuery, "user_id=alice")
assert.Contains(t, receivedQuery, "status=in_progress",
"必須帶 status=in_progress 給 lazy rebuild ownership 用")
}
// TestListInProgressJobs_Empty[] response → 空 slice。
func TestListInProgressJobs_Empty(t *testing.T) {
t.Parallel()
tokens := newStubTokenClient("svc-tok")
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"jobs":[],"total":0,"next_cursor":null}`))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
cc := newConverterClientForTest(t, srv.URL, tokens)
jobs, err := cc.ListInProgressJobs(context.Background(), "alice")
require.NoError(t, err)
assert.Len(t, jobs, 0, "empty result 應回空 slice不是 nil 也不是 error")
assert.NotNil(t, jobs, "應回非 nil 空 slice 給 caller 安全 range")
}
// TestListInProgressJobs_5xxRetry5xx 後成功;驗 retry 1 次(共 2 attempts
func TestListInProgressJobs_5xxRetry(t *testing.T) {
t.Parallel()
tokens := newStubTokenClient("svc-tok")
var counter atomic.Int32
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
idx := counter.Add(1)
if idx == 1 {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":{"code":"x","message":"x","request_id":"r"}}`))
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"jobs":[],"total":0,"next_cursor":null}`))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
cc := newConverterClientForTest(t, srv.URL, tokens)
jobs, err := cc.ListInProgressJobs(context.Background(), "alice")
require.NoError(t, err)
assert.Len(t, jobs, 0)
assert.Equal(t, int32(2), counter.Load(), "List 應 retry 1 次(共 2 attempts")
}
// TestListInProgressJobs_RequiredUserID本地參數驗證。
func TestListInProgressJobs_RequiredUserID(t *testing.T) {
t.Parallel()
tokens := newStubTokenClient("svc-tok")
cc := newConverterClientForTest(t, "http://unused", tokens)
_, err := cc.ListInProgressJobs(context.Background(), "")
require.Error(t, err)
assert.Contains(t, err.Error(), "userID is required")
}
// ==========================================================================
// 共用interface 契約 + helpers
// ==========================================================================
// 確保 converterClient 滿足 ConverterClient interfacecompile-time check
var _ ConverterClient = (*converterClient)(nil)
// 確保 stubTokenClient 滿足 MCTokenClient interfacecompile-time check
var _ MCTokenClient = (*stubTokenClient)(nil)
// zerosReader 是無限產生 0 byte 的 reader測 streaming 用)。
type zerosReader struct{}
func (zerosReader) Read(p []byte) (int, error) {
for i := range p {
p[i] = 0
}
return len(p), nil
}
// countingReader 包一個 reader 並計數 Read 呼叫次數(給 streaming 驗證用)。
type countingReader struct {
R io.Reader
calls int64 // atomic
}
func (c *countingReader) Read(p []byte) (int, error) {
atomic.AddInt64(&c.calls, 1)
return c.R.Read(p)
}

View File

@ -0,0 +1,274 @@
// Package conversion error 定義。
//
// 對齊 conversion.md §6 錯誤碼 mapping 與 api-conversion.md §錯誤碼總覽。
//
// 設計原則:
// - 用 sentinel errorpackage-level var+ wrap 模式,不用 error code string
// 做 equality checkcaller 用 errors.Is 判斷)
// - 每個 sentinel 都對應一個對外 error code見 ErrorCode() helper
// - HTTP status mapping 與 message 在 handler 層處理(見 internal/api/conversion.go
// 避免 conversion package 依賴 gin
//
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §6)
package conversion
import "errors"
// Sentinel errors — handler 層用 errors.Is 比對。
var (
// ErrForbidden — job 不屬於當前 user。對應 HTTP 403 / code "forbidden"。
ErrForbidden = errors.New("conversion: forbidden")
// ErrJobNotFound — job_id 不存在或已過期。對應 HTTP 404 / code "not_found"。
ErrJobNotFound = errors.New("conversion: job not found")
// ErrJobNotCompleted — job 還沒 completed不能 promote / download。
// 對應 HTTP 409 / code "job_not_completed"。
ErrJobNotCompleted = errors.New("conversion: job not completed")
// ErrActiveJobExists — 同 user 已有 active job。
// 對應 HTTP 409 / code "active_job_exists"。
// caller 可用 ActiveJobError struct 取得衝突中的 job 資訊(見下方)。
ErrActiveJobExists = errors.New("conversion: user already has active job")
// ErrValidationFailed — 上傳的 multipart 內容格式錯誤converter 4xx validation_error / invalid_multipart
// 對應 HTTP 400 / code "validation_failed"。
ErrValidationFailed = errors.New("conversion: validation failed")
// ErrPayloadTooLarge — converter 端拒絕超大檔案。
// 對應 HTTP 413 / code "payload_too_large"。
ErrPayloadTooLarge = errors.New("conversion: payload too large")
// ErrConverterUnavailable — converter 5xx / network 持續失敗。
// 對應 HTTP 502 / code "converter_unavailable"。
ErrConverterUnavailable = errors.New("conversion: converter unavailable")
// ErrFAAUnavailable — FAA 5xx / network 持續失敗。
// 對應 HTTP 502 / code "faa_unavailable"。
ErrFAAUnavailable = errors.New("conversion: faa unavailable")
// ErrFAAFileNotFound — FAA 回 404指定 object_key 不存在)。
// 觸發情境promote-to-models 流程 promoted 後 FAA pull 卻找不到檔(罕見:
// converter promote 才剛寫 FAA、應立即可見— 可能 FAA 端 GC、或 object_key 命名邏輯有 bug。
// 對應 HTTP 502 / code "faa_unavailable"(對外仍視為 FAA 不可用,避免揭露內部 object key 細節)。
// callerflow.go可用 errors.Is(err, ErrFAAFileNotFound) 做精細處理log / metric
//
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.6 + §9.2)
ErrFAAFileNotFound = errors.New("conversion: faa file not found")
// ErrDownloadTokenFailed — MC 換 delegated token 4xx 失敗(設定問題)。
// 對應 HTTP 502 / code "download_token_failed"。
ErrDownloadTokenFailed = errors.New("conversion: download token failed")
// ErrMCTokenUnavailable — MC 5xx / network 持續失敗。
// 對應 HTTP 502 / code "mc_token_unavailable"。
ErrMCTokenUnavailable = errors.New("conversion: mc token unavailable")
// ErrIDPMisconfigured — MC token endpoint 4xxclient_credentials grant 設定錯誤)。
// 對應 HTTP 500 / code "idp_misconfigured"。
ErrIDPMisconfigured = errors.New("conversion: idp misconfigured")
// ErrIDPUnavailable — MC oauth/token 5xx / network 持續失敗。
// 對應 HTTP 503 / code "idp_unavailable"。
ErrIDPUnavailable = errors.New("conversion: idp unavailable")
// ErrServiceBusy — converter 端回 503 service_busy。
// 對應 HTTP 503 / code "service_busy"。
ErrServiceBusy = errors.New("conversion: service busy")
// ErrServiceClientUnauthorized — visionA-backend 對 MC 認證失敗401 / 403
//
// 觸發情境:
// - VISIONA_OIDC_SERVICE_CLIENT_ID / SECRET 設定錯誤(典型)
// - MC 端 client 被 revoke / 停用
// - client 沒有對應 scope 的權限
//
// 設計選擇:與 ErrIDPMisconfigured 分開的 sentinel給 mc_token_client 內部 caller
// 可以做更精細的處理(例如 401 時主動 invalidate cache但對外 ErrorCode/HTTPStatus
// 都對應到 idp_misconfigured / 500fail-fast避免半設定狀態跑進 production
//
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §5.2)
ErrServiceClientUnauthorized = errors.New("conversion: service client unauthorized")
// ErrStorageUnavailable — visionA 自家 storagelocal FS / S3寫入或讀取失敗。
//
// 觸發情境:
// - PromoteToModels 把 NEF 寫進 visionA storage 失敗disk full / S3 5xx / 權限錯誤)
// - 與 FAA / converter 都無關,純粹是 visionA 自己的 storage 設定問題
//
// 對應 HTTP 500 / code "storage_unavailable"。
//
// 設計選擇(與 ErrFAAUnavailable 區分):
// - storage 失敗 ≠ FAA 失敗。SRE alarm 會打到不同 teami18n 訊息也不同
// FAA 對外是 "檔案存取服務暫時無法使用"storage 對外是 "伺服器內部錯誤"
// - 對外用 500 而非 502visionA 自身問題,不是 gateway / upstream 問題
//
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §6 — Reviewer M-1)
ErrStorageUnavailable = errors.New("conversion: visionA storage unavailable")
// ErrModelStoreUnavailable — visionA 自家 model storein-memory / Postgres操作失敗。
//
// 觸發情境:
// - PromoteToModels 把 model record 寫進 model store 失敗
// in-memory 永遠不會失敗;未來換 Postgres 時 connection 5xx 才會觸發)
// - 與 FAA / converter 都無關,純粹是 visionA 自己的 DB 問題
//
// 對應 HTTP 500 / code "model_store_unavailable"。
//
// 設計選擇(與 ErrConverterUnavailable 區分):理由同 ErrStorageUnavailable。
//
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §6 — Reviewer M-1)
ErrModelStoreUnavailable = errors.New("conversion: visionA model store unavailable")
)
// ActiveJobError 是 ErrActiveJobExists 的 wrapped form
// 帶上正在進行中的 job 資訊,給 handler 透傳給 frontend
// 前端可顯示「你已有進行中任務job_id=xxx」+ 跳轉到該 job 的進度頁)。
//
// 用法:
//
// if errors.Is(err, conversion.ErrActiveJobExists) {
// var ae *conversion.ActiveJobError
// if errors.As(err, &ae) {
// // ae.Job 可用details 帶給 frontend
// }
// }
type ActiveJobError struct {
Job *Job
}
// Error 實作 error interface。
func (e *ActiveJobError) Error() string {
return ErrActiveJobExists.Error()
}
// Unwrap 讓 errors.Is(err, ErrActiveJobExists) 成立。
func (e *ActiveJobError) Unwrap() error {
return ErrActiveJobExists
}
// ValidationFieldError 是 converter 4xx response 中 details.fields 陣列的單一元素。
//
// 對齊 converter openapi.yaml `validation_error` example
//
// details.fields: [{ field: "model_id", message: "model_id 範圍必須在 1 ~ 65535" }]
//
// 之所以用 array 不用 map
// - 對齊 task-scheduler openapi.yamlPOST /api/v1/jobs 400 validation_error 範例)
// - 同一個 field 可能有多個錯誤(例如 model_id 同時違反 pattern + range
//
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §6 + api-conversion.md §1)
type ValidationFieldError struct {
Field string `json:"field"`
Message string `json:"message"`
}
// ConverterValidationError 是 ErrValidationFailed 的 wrapped form
// 帶上 converter 回的欄位錯誤細節(給 frontend 顯示具體哪個欄位錯)。
//
// 用法同 ActiveJobError。
//
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §6 + api-conversion.md §1)
type ConverterValidationError struct {
// Fields 是 converter 4xx response 的 details.fields若有
// 結構對齊 converter openapi.yaml — array of {field, message}。
// converter 4xx 沒有 details.fields 時為 nil仍視為 validation 錯誤)。
Fields []ValidationFieldError
// Message 是 converter error message 原文(不過 frontend僅供 log
Message string
}
// Error 實作 error interface。
func (e *ConverterValidationError) Error() string {
if e.Message != "" {
return "conversion: validation failed: " + e.Message
}
return ErrValidationFailed.Error()
}
// Unwrap 讓 errors.Is(err, ErrValidationFailed) 成立。
func (e *ConverterValidationError) Unwrap() error {
return ErrValidationFailed
}
// ErrorCode 把 sentinel error 轉成對外的 visionA error code對齊 api-conversion.md §錯誤碼總覽)。
//
// 未匹配的 error 回 "internal_error"handler 層應 log 完整 error 後回 500
func ErrorCode(err error) string {
switch {
case errors.Is(err, ErrForbidden):
return "forbidden"
case errors.Is(err, ErrJobNotFound):
return "not_found"
case errors.Is(err, ErrJobNotCompleted):
return "job_not_completed"
case errors.Is(err, ErrActiveJobExists):
return "active_job_exists"
case errors.Is(err, ErrValidationFailed):
return "validation_failed"
case errors.Is(err, ErrPayloadTooLarge):
return "payload_too_large"
case errors.Is(err, ErrConverterUnavailable):
return "converter_unavailable"
case errors.Is(err, ErrFAAFileNotFound):
// 對外仍視為 faa_unavailable避免揭露 object_key 不存在的內部細節。
// caller 想做精細處理用 errors.Is(err, ErrFAAFileNotFound) 直接判斷。
return "faa_unavailable"
case errors.Is(err, ErrFAAUnavailable):
return "faa_unavailable"
case errors.Is(err, ErrDownloadTokenFailed):
return "download_token_failed"
case errors.Is(err, ErrMCTokenUnavailable):
return "mc_token_unavailable"
case errors.Is(err, ErrIDPMisconfigured):
return "idp_misconfigured"
case errors.Is(err, ErrIDPUnavailable):
return "idp_unavailable"
case errors.Is(err, ErrServiceBusy):
return "service_busy"
case errors.Is(err, ErrServiceClientUnauthorized):
// 對外仍透過 idp_misconfigured 呈現(避免 leak「我們的 client_secret 過期」這種內部狀態);
// caller 想做精細處理用 errors.Is(err, ErrServiceClientUnauthorized) 直接判斷。
return "idp_misconfigured"
case errors.Is(err, ErrStorageUnavailable):
return "storage_unavailable"
case errors.Is(err, ErrModelStoreUnavailable):
return "model_store_unavailable"
default:
return "internal_error"
}
}
// HTTPStatus 把 sentinel error 轉成對應的 HTTP status code。
//
// 未匹配的 error 回 500handler 層應 log 後再 WriteError。
func HTTPStatus(err error) int {
switch {
case errors.Is(err, ErrForbidden):
return 403
case errors.Is(err, ErrJobNotFound):
return 404
case errors.Is(err, ErrJobNotCompleted), errors.Is(err, ErrActiveJobExists):
return 409
case errors.Is(err, ErrValidationFailed):
return 400
case errors.Is(err, ErrPayloadTooLarge):
return 413
case errors.Is(err, ErrConverterUnavailable),
errors.Is(err, ErrFAAUnavailable),
errors.Is(err, ErrFAAFileNotFound),
errors.Is(err, ErrDownloadTokenFailed),
errors.Is(err, ErrMCTokenUnavailable):
return 502
case errors.Is(err, ErrIDPMisconfigured), errors.Is(err, ErrServiceClientUnauthorized):
return 500
case errors.Is(err, ErrStorageUnavailable), errors.Is(err, ErrModelStoreUnavailable):
// visionA 自身基礎設施問題 → 500不是 502 gateway因為非 upstream 失敗)
return 500
case errors.Is(err, ErrIDPUnavailable), errors.Is(err, ErrServiceBusy):
return 503
default:
return 500
}
}

View File

@ -0,0 +1,161 @@
package conversion
import (
"errors"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestErrorCode 確保所有 sentinel error 都對應到一個明確的 visionA error code
// 且未匹配的 error 走 internal_error fallback對齊 api-conversion.md §錯誤碼總覽)。
func TestErrorCode(t *testing.T) {
t.Parallel()
cases := []struct {
name string
err error
want string
}{
{"forbidden", ErrForbidden, "forbidden"},
{"not_found", ErrJobNotFound, "not_found"},
{"job_not_completed", ErrJobNotCompleted, "job_not_completed"},
{"active_job_exists", ErrActiveJobExists, "active_job_exists"},
{"validation_failed", ErrValidationFailed, "validation_failed"},
{"payload_too_large", ErrPayloadTooLarge, "payload_too_large"},
{"converter_unavailable", ErrConverterUnavailable, "converter_unavailable"},
{"faa_unavailable", ErrFAAUnavailable, "faa_unavailable"},
{"download_token_failed", ErrDownloadTokenFailed, "download_token_failed"},
{"mc_token_unavailable", ErrMCTokenUnavailable, "mc_token_unavailable"},
{"idp_misconfigured", ErrIDPMisconfigured, "idp_misconfigured"},
{"idp_unavailable", ErrIDPUnavailable, "idp_unavailable"},
{"service_busy", ErrServiceBusy, "service_busy"},
// ErrServiceClientUnauthorized 對外刻意 mask 成 idp_misconfigured不 leak「visionA secret 過期」內部狀態)
{"service_client_unauthorized_masked_as_idp_misconfig", ErrServiceClientUnauthorized, "idp_misconfigured"},
// Reviewer M-1visionA 自身基礎設施失敗用獨立 code與 FAA / converter 區分)
{"storage_unavailable", ErrStorageUnavailable, "storage_unavailable"},
{"model_store_unavailable", ErrModelStoreUnavailable, "model_store_unavailable"},
{"unknown_falls_back_to_internal_error", errors.New("某個未預期錯誤"), "internal_error"},
{"nil_falls_back_to_internal_error", nil, "internal_error"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tc.want, ErrorCode(tc.err))
})
}
}
// TestHTTPStatus 確保所有 sentinel error 對應到正確的 HTTP status
// 且未匹配的 error 走 500 fallback對齊 conversion.md §6 mapping
func TestHTTPStatus(t *testing.T) {
t.Parallel()
cases := []struct {
name string
err error
want int
}{
{"forbidden_403", ErrForbidden, 403},
{"not_found_404", ErrJobNotFound, 404},
{"job_not_completed_409", ErrJobNotCompleted, 409},
{"active_job_exists_409", ErrActiveJobExists, 409},
{"validation_400", ErrValidationFailed, 400},
{"payload_too_large_413", ErrPayloadTooLarge, 413},
{"converter_unavailable_502", ErrConverterUnavailable, 502},
{"faa_unavailable_502", ErrFAAUnavailable, 502},
{"download_token_failed_502", ErrDownloadTokenFailed, 502},
{"mc_token_unavailable_502", ErrMCTokenUnavailable, 502},
{"idp_misconfigured_500", ErrIDPMisconfigured, 500},
{"idp_unavailable_503", ErrIDPUnavailable, 503},
{"service_busy_503", ErrServiceBusy, 503},
{"service_client_unauthorized_500", ErrServiceClientUnauthorized, 500},
// Reviewer M-1visionA 自身基礎設施失敗 → 500不是 502 gateway
{"storage_unavailable_500", ErrStorageUnavailable, 500},
{"model_store_unavailable_500", ErrModelStoreUnavailable, 500},
{"unknown_500", errors.New("未知錯誤"), 500},
{"nil_500", nil, 500},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tc.want, HTTPStatus(tc.err))
})
}
}
// TestActiveJobError 驗證 wrapped form 既能被 errors.Is 比對,又能用 errors.As 取出 Job。
//
// 這是 frontend 顯示「你已有進行中任務」+ 跳轉到該 job 進度頁的關鍵handler 用 errors.As
// 取出 Job 帶到 response details。
func TestActiveJobError(t *testing.T) {
t.Parallel()
job := &Job{JobID: "job-abc", Status: "running"}
err := &ActiveJobError{Job: job}
// errors.Is 應命中 sentinel
assert.True(t, errors.Is(err, ErrActiveJobExists))
// errors.As 應拿到 wrapped 結構
var ae *ActiveJobError
assert.True(t, errors.As(err, &ae))
assert.NotNil(t, ae.Job)
assert.Equal(t, "job-abc", ae.Job.JobID)
// ErrorCode 應仍透過 sentinel 對應到 active_job_exists
assert.Equal(t, "active_job_exists", ErrorCode(err))
assert.Equal(t, 409, HTTPStatus(err))
}
// TestConverterValidationError 驗證 wrapped validation error 同樣行為。
func TestConverterValidationError(t *testing.T) {
t.Parallel()
verr := &ConverterValidationError{
Fields: []ValidationFieldError{
{Field: "platform", Message: "must be 520 or 720"},
},
Message: "platform invalid",
}
assert.True(t, errors.Is(verr, ErrValidationFailed))
var ve *ConverterValidationError
assert.True(t, errors.As(verr, &ve))
require.Len(t, ve.Fields, 1)
assert.Equal(t, "platform", ve.Fields[0].Field)
assert.Equal(t, "must be 520 or 720", ve.Fields[0].Message)
assert.Equal(t, "validation_failed", ErrorCode(verr))
assert.Equal(t, 400, HTTPStatus(verr))
// Error() 應包含 Message給 log 用)
assert.Contains(t, verr.Error(), "platform invalid")
// Message 為空時退化到 sentinel 訊息
verr2 := &ConverterValidationError{}
assert.Equal(t, ErrValidationFailed.Error(), verr2.Error())
}
// TestErrorWrapping 驗證 fmt.Errorf("%w") wrapping 後仍能被 ErrorCode 抓對。
//
// 這個測試模擬 flow.go 預期的 wrap pattern
//
// if err := convClient.GetJob(...); err != nil {
// return fmt.Errorf("flow: get job from converter: %w", err)
// }
func TestErrorWrapping(t *testing.T) {
t.Parallel()
wrapped := fmt.Errorf("flow: get job: %w", ErrJobNotFound)
assert.True(t, errors.Is(wrapped, ErrJobNotFound))
assert.Equal(t, "not_found", ErrorCode(wrapped))
assert.Equal(t, 404, HTTPStatus(wrapped))
}

View File

@ -0,0 +1,467 @@
// FAA client — visionA-backend 對 File Access Agent 的 server-to-server HTTP client。
//
// Phase 0.8 只用 GET /files/{object_key}(給 promote-to-models 流程從 FAA pull NEF 用)。
// 其他 endpointPUT / DELETE / HEAD / metadata目前 visionA 不需要,未來再補。
//
// 設計要點:
// - 走 service tokenscope=files:download.readtoken 由注入的 MCTokenClient 提供
// - **回 streaming body**io.ReadCloser— 不 io.ReadAll避免 500MB NEF 全進 RAM
// - **Phase A retry**dial → 拿到 response header 之間的 5xx / network / timeout 失敗
// 依 §9.1 指數退避重試 max 2 次1s, 2s。一旦拿到 200 response進 Phase B
// streaming body 給 caller這層責任就結束 — body 中斷由 caller 處理(不可 replay
// 詳見下方 GetFile doc comment 的「Phase A vs Phase B retry」段。
// - 4xx → 對應 sentinel401/403 → ErrServiceClientUnauthorized404 → ErrFAAFileNotFound
// 其他 4xx → ErrFAAUnavailable避免新增更多 sentinel
//
// 與 T3 InitJob 的對比(為什麼 T3 不 retry 但 T4 GetFile retry
// - T3 InitJobmultipart **request body** 是 streamingio.Reader 來自上游 c.Body
// 一旦 http.Client.Do 開始送 request bodyio.Reader 已被消費retry 無法 rewind →
// 從第一次 attempt 起就「不可重試」。
// - T4 GetFileGET 沒有 request bodyrequest 完全 idempotentretry window 涵蓋
// dial → 拿到 response headerPhase A。Phase A 結束後200 已到response body
// 才是「不可 replay」的 streaming但那不在本層責任範圍 — 本層拿到 200 就 return *FAAFile。
//
// 安全:
// - **絕不**寫 Authorization header / service token / response body 進 log
// - object_key 過長時截斷(避免 log 膨脹FAA object_key 由 visionA 內部組,不含 user 敏感資訊)
//
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.3 / §2.6 / §9.1)
package conversion
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"strings"
"time"
)
// ==========================================================================
// 對外 type / interface
// ==========================================================================
// FAAClient 對 File Access Agent 的 server-to-server client。
//
// goroutine-safe每次呼叫獨立 *http.Request無內部 mutable statecache 由注入的 MCTokenClient 管)。
type FAAClient interface {
// GetFile 從 FAA pull 一個 objectserver-to-server用 service token
//
// scope: files:download.read
//
// 回傳 *FAAFile.Body 是 streaming bodyio.ReadCloser**caller 必須 Close**
// 不然底層 http.Response.Body 不會釋放、connection 也回不了 poolgoroutine + fd leak
// 推薦 pattern
//
// file, err := faa.GetFile(ctx, key)
// if err != nil { return err }
// defer file.Body.Close()
// _, err = io.Copy(dst, file.Body) // streaming 寫進 visionA storage
//
// 重試行為Phase A retry only對齊 §9.1
// - dial / TLS / response header 階段的 5xx / network / timeout
// 指數退避重試 max 2 次1s, 2s— GET 沒 request body 完全 idempotent可放心 retry
// - 401 / 403 / 404 / 其他 4xx不重試立即 return 對應 sentinel
// - ctx cancel / deadline立即 return ctx.Err()(即使在 retry sleep 中也立即中斷)
// - 一旦拿到 200 response進 Phase Breturn *FAAFilebody 由 caller 自己讀;
// caller 在讀 body 時遇到網路中斷不再重試streaming response 不可 replay
//
// 錯誤映射(對齊 conversion.md §6 + errors.go
// - ctx cancel/deadline → 透傳 ctx.Err不包成 sentinel
// - 401 / 403 → ErrServiceClientUnauthorized對外 idp_misconfigured/500
// - 404 → ErrFAAFileNotFound對外 faa_unavailable/502
// - 其他 4xx / 5xx exhausted / network exhausted → ErrFAAUnavailable對外 faa_unavailable/502
GetFile(ctx context.Context, objectKey string) (*FAAFile, error)
}
// FAAFile 是 GetFile 成功回傳的 streaming response。
//
// **caller 必須 Body.Close()**(即使中途 error也應 defer Close
type FAAFile struct {
// Body 是 streaming response bodycaller 用 io.Copy 等方式 streaming 消費。
Body io.ReadCloser
// ContentLength 對應 FAA response 的 Content-Length header。
// 若 FAA 走 chunked transfer 沒帶這個 header值為 -1net/http 慣例)。
ContentLength int64
// ContentType 對應 FAA response 的 Content-Type header如 "application/octet-stream")。
ContentType string
// ETag 對應 FAA response 的 ETag headerFAA 端取自 storage adapter
// 若 FAA 沒帶,為空字串。
ETag string
}
// FAAClientOpts 是 NewFAAClient 的依賴注入。
//
// HTTPClient / Now / Logger 為 optionalnil 自動填預設)— 方便 unit test 注入 fake。
type FAAClientOpts struct {
// BaseURL 是 FAA base URL不帶結尾斜線
// 範例http://192.168.0.130:5081
BaseURL string
// Tokens 是 MCTokenClient注入non-nil 必填)— 用來取 service token。
Tokens MCTokenClient
// HTTPClient 為 optionalnil 用預設(含 dial / response header timeout但無整體 timeout
// 測試會注入 httptest.Server.Client()。
//
// 為什麼預設 client 不設 Timeout
// 500MB NEF 在慢網路下 download 可能 5-10 分鐘http.Client.Timeout 是「整體 timeout」
// 涵蓋「dial + response header + body 讀完」三段,會在大檔下載中途斷線。
// 改用 transport 層的 DialTimeout + ResponseHeaderTimeout10s 各自)— 連線階段卡死才算 fail
// body streaming 階段交給 ctx.Done() 控制caller 用帶 deadline 的 ctx 即可)。
HTTPClient *http.Client
// Now 為 optionalnil 用 time.Now。測試會注入 fake clock。
Now func() time.Time
// Logger 為 optionalnil 用 slog.Default()。
Logger *slog.Logger
}
// ==========================================================================
// 內部固定常數
// ==========================================================================
const (
// scopeFAADownloadRead 對齊 FAA README §「初步 API 邊界」與 FileAccessScopes.DownloadRead。
scopeFAADownloadRead = "files:download.read"
// faaDialTimeout 是 dial 階段的 timeout連 TCP / TLS 握手)。
// 連線一直建不起來通常是路由問題10s 已足夠;超過視為 FAA 不可達。
faaDialTimeout = 10 * time.Second
// faaResponseHeaderTimeout 是「送完 request → 收到 response status 行」的 timeout。
// 這段是 server-side 處理時間FAA 找檔、auth validate10s 對小檔 metadata 階段夠寬鬆。
// 注意:這個 timeout **不涵蓋 body streaming 階段**body streaming 由 ctx 控制)。
faaResponseHeaderTimeout = 10 * time.Second
// faaMaxRetries 是 Phase A 5xx / network / timeout 的最大重試次數(不含第一次)。
// 對齊 conversion.md §9.1FAA GET /files/{key} max 2 retries1s, 2s
faaMaxRetries = 2
// faaRetryBaseDelay 是指數退避的 base1s, 2s
faaRetryBaseDelay = 1 * time.Second
// objectKeyHashLen 是 log 中 object_key 的截短後 hash 長度(前 16 hex chars
objectKeyHashLen = 16
// faaErrorBodyReadCap 是失敗 response 從 body 讀進 io.Discard 的最大量4KB
// 失敗時讀少量 body 主要是讓 keep-alive 能 reuse connection避免空 body 留在 pipe。
faaErrorBodyReadCap = 4 * 1024
)
// faaEndpointKind 是 log / 錯誤分類用的 endpoint 標記(目前只有一個)。
const faaEndpointKind = "faa_get_file"
// ==========================================================================
// 構造 + 內部實作
// ==========================================================================
// faaClient 是 FAAClient 的預設實作。
//
// 套件內 unexported structcaller 拿 interface讓未來換實作不影響 caller。
type faaClient struct {
baseURL string
tokens MCTokenClient
http *http.Client
now func() time.Time
logger *slog.Logger
}
// NewFAAClient 建立一個 FAAClient 實例。
//
// 必填BaseURL / Tokens。其他 optional。
// 注意constructor 不會驗 BaseURL 連線,第一次 GetFile 才會打網路。
func NewFAAClient(opts FAAClientOpts) FAAClient {
httpClient := opts.HTTPClient
if httpClient == nil {
httpClient = newDefaultFAAHTTPClient()
}
now := opts.Now
if now == nil {
now = time.Now
}
logger := opts.Logger
if logger == nil {
logger = slog.Default()
}
return &faaClient{
baseURL: strings.TrimRight(opts.BaseURL, "/"),
tokens: opts.Tokens,
http: httpClient,
now: now,
logger: logger,
}
}
// newDefaultFAAHTTPClient 建一個適合 streaming download 的預設 http.Client。
//
// 為什麼自訂 transport
// - http.Client.Timeout 不適用大檔下載(會中斷 body streaming
// - 需要分別控制 dial / response header timeoutbody streaming 不限制(由 ctx 控)
//
// transport 其餘參數沿用 net/http DefaultTransport 的合理預設MaxIdleConns 等)。
func newDefaultFAAHTTPClient() *http.Client {
transport := &http.Transport{
DialContext: (&net.Dialer{
Timeout: faaDialTimeout,
KeepAlive: 30 * time.Second,
}).DialContext,
ResponseHeaderTimeout: faaResponseHeaderTimeout,
// 沿用 DefaultTransport 的合理預設
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
return &http.Client{
Transport: transport,
// **不設 Timeout** — body streaming 階段由 ctx 控制
}
}
// ==========================================================================
// GetFile — Phase A retryPhase B 不 retry 的 streaming pull
// ==========================================================================
// GetFile 實作 FAAClient.GetFile。
//
// 流程:
// 1. 取 service token透過 MCTokenClient其錯誤透傳不重新分類
// 2. 組 URL + 建 request
// 3. doWithRetrymax (1 + faaMaxRetries) attempts每 attempt 重新 c.http.Do
// - 拿到 200直接 return *FAAFile不 close body
// - 拿到 4xxclose body 後依 status mapping 對應 sentinel不 retry
// - 拿到 5xxclose body等 backoff 後 retry
// - network / dial / responseHeader timeout等 backoff 後 retry
// - ctx cancel / deadline立即 return ctx.Err()
func (c *faaClient) GetFile(ctx context.Context, objectKey string) (*FAAFile, error) {
if objectKey == "" {
return nil, fmt.Errorf("conversion/faa_client: object_key is required")
}
keyHash := hashObjectKey(objectKey)
// 1. 取 service token
// ServiceToken 內部已依 §6 mapping 失敗ErrServiceClientUnauthorized / ErrIDPMisconfigured /
// ErrIDPUnavailable— 這裡用 fmt.Errorf("%w") 透傳,不再二次包裝(避免錯誤碼被「升級」
// 成 ErrFAAUnavailable 而失去原本的 i18n 區分 idp_misconfig vs idp_down
token, err := c.tokens.ServiceToken(ctx, scopeFAADownloadRead)
if err != nil {
return nil, fmt.Errorf("conversion: get service token for faa download: %w", err)
}
// 2. 組 endpoint。注意 FAA 的 object_key 可能含路徑分隔符(如 "tenant/jobs/abc/output.nef")—
// 用 ResolveReference 處理net/http 內部會做 path escape避免 "../" 等問題。
endpoint, err := c.buildFileURL(objectKey)
if err != nil {
return nil, fmt.Errorf("%w: build faa url: %v", ErrFAAUnavailable, err)
}
// 3. 進 retry loopPhase A only
return c.doWithRetry(ctx, keyHash, endpoint, token)
}
// doWithRetry 是 GetFile 的 Phase A retry 執行器。
//
// 與 mc_token_client.doWithRetry / converter_client.doWithRetry 結構類似,但有以下差異:
// - 成功路徑回傳 *FAAFile含未 close 的 streaming body不是 []byte
// - 沒有「每次 attempt 重新建 request」需求 — GET 沒 bodyrequest 物件可重用,
// 但為了讓 ctx-aware 行為一致ctx cancel 後不重用舊 request這裡每次都新建一個
// - reqBuilder 不接 token 參數 — token 在 GetFile 取一次retry 期間沿用同一 token
// retry window 短max 1+2+3=6stoken 不會在這段期間過期)
//
// 為什麼 retry 期間不重新取 token
// - 簡化:避免 token 取失敗 vs HTTP 失敗 兩種錯誤交織的處理
// - 安全401 在這層被分類為「不可 retry」不會走到「token expired 中途要 refresh」場景
// - 效能cache hit 情境下成本低但仍多一次 mutex6s window 內 token 不會 expire
func (c *faaClient) doWithRetry(
ctx context.Context,
keyHash, endpoint, token string,
) (*FAAFile, error) {
var lastErr error
for attempt := 0; attempt <= faaMaxRetries; attempt++ {
// retry 前等待退避ctx cancel 立即中斷
if attempt > 0 {
select {
case <-ctx.Done():
// ctx cancel/deadline → 立即 return不 retry不包成 sentinel
return nil, ctx.Err()
case <-time.After(faaRetryBackoff(attempt)):
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
// 建 request 失敗極罕見URL parse 異常)— 不可 retry
return nil, fmt.Errorf("%w: build faa request: %v", ErrFAAUnavailable, err)
}
req.Header.Set("Accept", "application/octet-stream")
req.Header.Set("Authorization", "Bearer "+token)
file, classifiedErr, retryable := c.doOnce(req, keyHash, attempt)
if classifiedErr == nil {
// 成功 — file 含未 close 的 body由 caller 接手
return file, nil
}
lastErr = classifiedErr
if !retryable {
// 4xx / 401-403 / 404 / ctx cancel直接 return不再 retry
return nil, classifiedErr
}
// retryable 5xx / network / timeout繼續下一輪
}
// 用完 retry 額度
c.logger.Warn("conversion.faa.retry_exhausted",
slog.String("endpoint", faaEndpointKind),
slog.String("object_key_hash", keyHash),
slog.Int("attempts", faaMaxRetries+1))
return nil, lastErr
}
// doOnce 執行一次 Phase A發 request → 等 response header → 分類結果。
//
// 回傳:
// - 成功2xxfile != nil含未 close 的 streaming body, classifiedErr=nil, retryable=false
// - 失敗file=nil, classifiedErr 為 sentinel-wrapped error, retryable 表示是否該重試
//
// 重要:成功時 callerdoWithRetry會直接把 file 透傳出去 — 這層**不 close body**。
// 失敗時這層**會 close body**(讀少量讓 keep-alive reuse connection
func (c *faaClient) doOnce(
req *http.Request,
keyHash string,
attempt int,
) (file *FAAFile, err error, retryable bool) {
startedAt := c.now()
res, doErr := c.http.Do(req)
duration := c.now().Sub(startedAt)
if doErr != nil {
// network / dial / response header timeout / ctx cancel
if errors.Is(doErr, context.Canceled) || errors.Is(doErr, context.DeadlineExceeded) {
c.logger.Warn("conversion.faa.ctx_cancelled",
slog.String("endpoint", faaEndpointKind),
slog.String("object_key_hash", keyHash),
slog.Int("attempt", attempt+1),
slog.Duration("duration", duration))
return nil, doErr, false
}
c.logger.Warn("conversion.faa.network_error",
slog.String("endpoint", faaEndpointKind),
slog.String("object_key_hash", keyHash),
slog.Int("attempt", attempt+1),
slog.Duration("duration", duration),
// err.Error() 不會含 secrethttp.Client 錯誤訊息只有 URL + 連線層 errno
// 但仍 truncate 防 log 爆量
slog.String("err", truncate(doErr.Error(), 200)))
return nil, fmt.Errorf("%w: faa network error: %v", ErrFAAUnavailable, doErr), true
}
// 成功2xx直接把 res.Body 透傳給 caller streaming 消費 — **不在這裡 close**
// 注意:成功路徑沒 defer res.Body.Close() — body 的所有權交給 *FAAFile.Body。
if res.StatusCode >= 200 && res.StatusCode < 300 {
c.logger.Info("conversion.faa.get_success",
slog.String("endpoint", faaEndpointKind),
slog.String("object_key_hash", keyHash),
slog.Int("status", res.StatusCode),
slog.Int("attempt", attempt+1),
slog.Int64("content_length", res.ContentLength),
slog.Duration("duration", duration))
return &FAAFile{
Body: res.Body, // caller 責任 Close
ContentLength: res.ContentLength,
ContentType: res.Header.Get("Content-Type"),
ETag: res.Header.Get("ETag"),
}, nil, false
}
// 失敗(非 2xx讀少量 body 做 log避免 5xx 帶大 body 爆 log然後 close
// 讀進 io.Discard 而不是真的存下來:
// - 不寫進 logFAA 錯誤 body 可能含 requestId / 路徑等內部資訊)
// - 只是讓 keep-alive 能 reuse connectionread-to-EOF or close
defer res.Body.Close()
_, _ = io.CopyN(io.Discard, res.Body, faaErrorBodyReadCap)
c.logger.Warn("conversion.faa.endpoint_error",
slog.String("endpoint", faaEndpointKind),
slog.String("object_key_hash", keyHash),
slog.Int("status", res.StatusCode),
slog.Int("attempt", attempt+1),
slog.Duration("duration", duration))
mappedErr, isRetryable := c.mapGetFileError(res.StatusCode)
return nil, mappedErr, isRetryable
}
// mapGetFileError 把 FAA `GET /files/{key}` 的非 2xx 對應到 sentinel + 是否 retryable。
//
// 對齊 FAA Program.cs MapGet("/files/{**objectKey}") 的失敗回應:
// - 401 invalid_token / validation_unavailable → ErrServiceClientUnauthorized不 retry — secret 設定錯)
// - 403 tenant_mismatch / object_key_mismatch / method_mismatch → ErrServiceClientUnauthorized不 retry
// - 404 file_not_found → ErrFAAFileNotFound不 retry — object 不存在)
// - 400 invalid_object_key → ErrFAAUnavailable不 retry — visionA 端 object_key 命名 bug
// - 其他 4xx → ErrFAAUnavailable不 retry
// - 5xx → ErrFAAUnavailable**可 retry**FAA / 下游 storage 暫時失常)
func (c *faaClient) mapGetFileError(status int) (err error, retryable bool) {
switch {
case status == http.StatusUnauthorized || status == http.StatusForbidden:
return fmt.Errorf("%w: faa get file %d", ErrServiceClientUnauthorized, status), false
case status == http.StatusNotFound:
return fmt.Errorf("%w: faa get file %d", ErrFAAFileNotFound, status), false
case status >= 400 && status < 500:
// 400 / 其他 4xx不可 retry
return fmt.Errorf("%w: faa get file %d", ErrFAAUnavailable, status), false
default:
// 5xx可 retry
return fmt.Errorf("%w: faa get file %d", ErrFAAUnavailable, status), true
}
}
// faaRetryBackoff 回傳第 n 次 retryn 從 1 開始)的等待時間。
// 1 → 1s, 2 → 2s對齊 conversion.md §9.1
//
// 不加 jitter — Phase 0.8 同時打 FAA 的 caller 數量有限promote-to-models 流程是
// 序列式 per-job 觸發併發競爭機率低jitter 的邊際效益低。
func faaRetryBackoff(attempt int) time.Duration {
if attempt < 1 {
return faaRetryBaseDelay
}
return faaRetryBaseDelay * time.Duration(attempt)
}
// buildFileURL 用 url.Parse + ResolveReference 組 GET /files/{objectKey} 的完整 URL。
//
// 為什麼用 ResolveReference 而不是 string concat
// - object_key 可能含路徑分隔符("tenant/jobs/abc/output.nef"
// - 直接 concat 容易踩 trailing-slash / encoding 雷
// - net/url 會做必要的 percent-escape保留 '/' 為 path separator
func (c *faaClient) buildFileURL(objectKey string) (string, error) {
base, err := url.Parse(c.baseURL)
if err != nil {
return "", fmt.Errorf("parse base url: %w", err)
}
// 用 url.URL{Path: ...} 避免手動 escapenet/url 會處理 path encoding。
// 注意base.Path 可能為空或結尾帶 "/"ResolveReference 會處理。
ref := &url.URL{Path: "/files/" + objectKey}
return base.ResolveReference(ref).String(), nil
}
// hashObjectKey 把 object_key 算 SHA-256 後取前 16 hex chars當 log 用的穩定 hash。
//
// 為什麼不直接 log object_key
// - object_key 可能含路徑("tenant/jobs/uuid/output.nef")— 過長
// - 目前 visionA 的 object_key 不直接含 user 敏感資訊,但保險起見統一 hash
// - 16 chars hex64-bit對 visionA 內部 job 數量來說碰撞機率極低,足以追蹤單一 request
func hashObjectKey(objectKey string) string {
sum := sha256.Sum256([]byte(objectKey))
return hex.EncodeToString(sum[:])[:objectKeyHashLen]
}

View File

@ -0,0 +1,622 @@
// FAA Client 單元測試。
//
// 測試策略:
// - 用 httptest.Server mock FAA 的 GET /files/{key} 端點
// - 用 stub MCTokenClient直接回 token / 注入錯誤),不耦合真實 mc_token_client 邏輯
// - 用 atomic counter 驗 retry 行為Phase A retrymax 3 attempts = 1 + 2 retries
// - streaming 驗證用較大但合理大小10MB— 真 100MB 會拖慢 test runner 太多
//
// 測試範疇對應 conversion.md §9.1FAA GET /files retry max 2 次, 1s/2s
// - GetFile_Success / GetFile_Streaming / GetFile_AuthHeader
// - GetFile_404_NoRetry / GetFile_401_Unauthorized / GetFile_403_Unauthorized
// - GetFile_5xx_RetryThenSuccess / GetFile_5xx_Exhausted
// - GetFile_Network_RetryThenSuccess / GetFile_Network_Exhausted
// - GetFile_ContextCancel / GetFile_ContextCancel_DuringRetry
// - GetFile_ServiceTokenFailure_Propagated / GetFile_EmptyObjectKey
// - GetFile_400_GenericError / HashObjectKey_StableAndLength
//
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.3 + §9.1)
package conversion
import (
"context"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ==========================================================================
// FAA mock server helpers
// ==========================================================================
// newFAAClientForTest 建立指向 mock server 的 FAAClient使用快速 retry backoff 加速 test
//
// 注意:這個 helper 用較短 backoff10ms 起跳)讓 retry test 不會跑很久。
// 真實 production 走 §9.1 的 1s/2s在 NewFAAClient 預設)。
func newFAAClientForTest(t *testing.T, baseURL string, tokens MCTokenClient) FAAClient {
t.Helper()
return NewFAAClient(FAAClientOpts{
BaseURL: baseURL,
Tokens: tokens,
// 用一個簡單的 http.Clienthttptest.Server.Client 也可以但這樣更貼近真實情境,
// 用較短 timeout 加速 test。注意 streaming test 不能用整體 Timeout所以另外覆寫。
HTTPClient: &http.Client{Timeout: 5 * time.Second},
Logger: silentLogger(),
})
}
// ==========================================================================
// 成功路徑
// ==========================================================================
// TestGetFile_Successmock 回 200 + binary stream驗 ContentLength / ETag / ContentType 解析。
func TestGetFile_Success(t *testing.T) {
t.Parallel()
tokens := newStubTokenClient("svc-tok")
payload := []byte("binary payload here")
var receivedAuth string
mux := http.NewServeMux()
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
receivedAuth = r.Header.Get("Authorization")
require.Equal(t, http.MethodGet, r.Method)
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("ETag", "\"etag-abc-123\"")
w.Header().Set("Content-Length", "19")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(payload)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
fc := newFAAClientForTest(t, srv.URL, tokens)
file, err := fc.GetFile(context.Background(), "tenant/jobs/abc/output.nef")
require.NoError(t, err)
require.NotNil(t, file)
require.NotNil(t, file.Body)
t.Cleanup(func() { _ = file.Body.Close() })
assert.Equal(t, "application/octet-stream", file.ContentType)
assert.Equal(t, "\"etag-abc-123\"", file.ETag)
assert.Equal(t, int64(19), file.ContentLength)
// caller 確實能 streaming 讀到完整 body
body, readErr := io.ReadAll(file.Body)
require.NoError(t, readErr)
assert.Equal(t, payload, body)
assert.Equal(t, "Bearer svc-tok", receivedAuth, "Bearer service token 必須透傳")
assert.Equal(t, 1, tokens.calls(scopeFAADownloadRead))
}
// TestGetFile_Streamingmock 回 10MB bodyconfirm caller 能 streaming 讀(不 buffer 全 RAM
//
// 與 InitJob streaming test 對稱:用 io.LimitReader + zerosReader確認 reader 被多次 Read
// (而非一次性全讀)。但 net/http 端 download 的 streaming 由 res.Body 提供,這裡的關鍵是:
// - faa_client 必須**不 io.ReadAll** 把 body 提前讀完
// - caller 用 io.Copy 慢慢讀時server 端不需要先把全部 buffer 完成
func TestGetFile_Streaming(t *testing.T) {
t.Parallel()
tokens := newStubTokenClient("svc-tok")
const totalSize = int64(10 * 1024 * 1024) // 10MB
mux := http.NewServeMux()
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Length", "10485760")
w.WriteHeader(http.StatusOK)
// streaming write — 用 io.Copy from zerosReader避免一次配 10MB buffer
_, _ = io.CopyN(w, zerosReader{}, totalSize)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
// streaming download 不能用 http.Client.Timeout會中斷 body streaming
fc := NewFAAClient(FAAClientOpts{
BaseURL: srv.URL,
Tokens: tokens,
// 這裡用無 timeout 的 clienttest 自己控)
HTTPClient: &http.Client{},
Logger: silentLogger(),
})
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
file, err := fc.GetFile(ctx, "big.nef")
require.NoError(t, err)
require.NotNil(t, file)
t.Cleanup(func() { _ = file.Body.Close() })
assert.Equal(t, totalSize, file.ContentLength)
// 用 countingReader 包 file.Body — 但 countingReader 是 io.Reader
// 這裡換成 wrap 一下:直接 io.Copy 到 io.Discardconfirm 全 download 完成。
written, copyErr := io.Copy(io.Discard, file.Body)
require.NoError(t, copyErr)
assert.Equal(t, totalSize, written, "streaming download 必須拿到完整 body")
}
// TestGetFile_AuthHeader驗 Bearer token 透傳,且取 token scope 為 files:download.read。
func TestGetFile_AuthHeader(t *testing.T) {
t.Parallel()
tokens := newStubTokenClient("specific-token-xyz")
var receivedAuth string
var receivedAccept string
mux := http.NewServeMux()
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
receivedAuth = r.Header.Get("Authorization")
receivedAccept = r.Header.Get("Accept")
w.Header().Set("Content-Type", "application/octet-stream")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
fc := newFAAClientForTest(t, srv.URL, tokens)
file, err := fc.GetFile(context.Background(), "key")
require.NoError(t, err)
defer file.Body.Close()
_, _ = io.ReadAll(file.Body)
assert.Equal(t, "Bearer specific-token-xyz", receivedAuth)
assert.Equal(t, "application/octet-stream", receivedAccept)
assert.Equal(t, 1, tokens.calls(scopeFAADownloadRead),
"必須用 files:download.read scope 取 service token")
}
// ==========================================================================
// 失敗映射(不 retry 類)
// ==========================================================================
// TestGetFile_404_NoRetrymock 回 404 → 立即 return ErrFAAFileNotFound不 retry。
func TestGetFile_404_NoRetry(t *testing.T) {
t.Parallel()
tokens := newStubTokenClient("svc-tok")
var attempts atomic.Int32
mux := http.NewServeMux()
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
attempts.Add(1)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":{"code":"file_not_found","message":"File not found."}}`))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
fc := newFAAClientForTest(t, srv.URL, tokens)
file, err := fc.GetFile(context.Background(), "missing.nef")
require.Error(t, err)
require.Nil(t, file, "失敗時不應回 FAAFile避免 caller 誤用 nil body")
assert.True(t, errors.Is(err, ErrFAAFileNotFound),
"404 → ErrFAAFileNotFoundcaller 可精細處理)")
assert.Equal(t, int32(1), attempts.Load(),
"404 不應 retryobject 不存在 retry 也沒用)")
// 對外仍應 mask 成 faa_unavailable避免揭露 object_key 不存在)
assert.Equal(t, "faa_unavailable", ErrorCode(err))
assert.Equal(t, 502, HTTPStatus(err))
}
// TestGetFile_401_Unauthorizedmock 回 401 → 不 retryreturn ErrServiceClientUnauthorized。
func TestGetFile_401_Unauthorized(t *testing.T) {
t.Parallel()
tokens := newStubTokenClient("svc-tok")
var attempts atomic.Int32
mux := http.NewServeMux()
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
attempts.Add(1)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":{"code":"invalid_token"}}`))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
fc := newFAAClientForTest(t, srv.URL, tokens)
file, err := fc.GetFile(context.Background(), "k")
require.Error(t, err)
require.Nil(t, file)
assert.True(t, errors.Is(err, ErrServiceClientUnauthorized),
"401 → ErrServiceClientUnauthorizedclient 認證設定錯)")
assert.Equal(t, int32(1), attempts.Load(),
"401 不應 retrysecret 設定錯retry 也是 401")
}
// TestGetFile_403_UnauthorizedFAA 端 tenant_mismatch / object_key_mismatch 等 403 都同類處理。
func TestGetFile_403_Unauthorized(t *testing.T) {
t.Parallel()
tokens := newStubTokenClient("svc-tok")
var attempts atomic.Int32
mux := http.NewServeMux()
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
attempts.Add(1)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"error":{"code":"tenant_mismatch"}}`))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
fc := newFAAClientForTest(t, srv.URL, tokens)
_, err := fc.GetFile(context.Background(), "k")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrServiceClientUnauthorized))
assert.Equal(t, int32(1), attempts.Load(), "403 不應 retry")
}
// TestGetFile_400_GenericErrorFAA 400如 invalid_object_key→ ErrFAAUnavailable不 retry。
func TestGetFile_400_GenericError(t *testing.T) {
t.Parallel()
tokens := newStubTokenClient("svc-tok")
var attempts atomic.Int32
mux := http.NewServeMux()
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
attempts.Add(1)
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":{"code":"invalid_object_key"}}`))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
fc := newFAAClientForTest(t, srv.URL, tokens)
_, err := fc.GetFile(context.Background(), "invalid//key")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrFAAUnavailable),
"400非 401/403/404→ ErrFAAUnavailable")
// 應該不會被 mis-classified 成 ErrFAAFileNotFound
assert.False(t, errors.Is(err, ErrFAAFileNotFound))
assert.Equal(t, int32(1), attempts.Load(), "400 不應 retryvisionA 端的 bug")
}
// ==========================================================================
// Phase A retry 驗證5xx / network
// ==========================================================================
// TestGetFile_5xx_RetryThenSuccessmock 連續 500 兩次後回 200 → 共 3 次 attempt + 成功。
//
// 對齊 §9.1max 2 retries1s, 2s— 1 + 2 = 3 attempts第 3 次成功就 return。
// 注意test 用真實 backoff1s + 2s = 3s— 為了驗 §9.1 退避時序,可接受。
func TestGetFile_5xx_RetryThenSuccess(t *testing.T) {
t.Parallel()
tokens := newStubTokenClient("svc-tok")
var attempts atomic.Int32
payload := []byte("recovered after retry")
mux := http.NewServeMux()
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
n := attempts.Add(1)
if n < 3 {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":{"code":"internal_error"}}`))
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Length", "21")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(payload)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
fc := newFAAClientForTest(t, srv.URL, tokens)
start := time.Now()
file, err := fc.GetFile(context.Background(), "k")
duration := time.Since(start)
require.NoError(t, err)
require.NotNil(t, file)
t.Cleanup(func() { _ = file.Body.Close() })
got, _ := io.ReadAll(file.Body)
assert.Equal(t, payload, got, "第 3 次成功的 body 應正確透傳")
assert.Equal(t, int32(3), attempts.Load(),
"5xx 應 retrymax 2 retries → 3 attempts")
// 驗時序:兩次 retry 退避 1s + 2s至少花 3s容忍輕微誤差用 ≥2.5s
assert.GreaterOrEqual(t, duration, 2500*time.Millisecond,
"§9.1 退避序列 1s + 2s 應至少耗 2.5s")
}
// TestGetFile_5xx_Exhaustedmock 持續 500 → 用完 max retry 後 return ErrFAAUnavailable。
func TestGetFile_5xx_Exhausted(t *testing.T) {
t.Parallel()
tokens := newStubTokenClient("svc-tok")
var attempts atomic.Int32
mux := http.NewServeMux()
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
attempts.Add(1)
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":{"code":"internal_error"}}`))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
fc := newFAAClientForTest(t, srv.URL, tokens)
_, err := fc.GetFile(context.Background(), "k")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrFAAUnavailable),
"5xx exhausted → ErrFAAUnavailable")
assert.Equal(t, int32(faaMaxRetries+1), attempts.Load(),
"5xx 應跑滿 max retries1 + 2 = 3 attempts")
}
// TestGetFile_Network_RetryThenSuccess前 2 次 connection refused第 3 次成功。
//
// 用 dynamic listener swap 實作:先用一個 free port 不開 listenerdial fail
// 第 3 次 attempt 之前才 swap 到真的 mock server。實作上比較複雜 — 改用
// proxy handler 在 mock server 內部對前 N 次「立刻 hijack 後 close」模擬 dial fail
// 不行連線已建好改用「server 端 force-close connection 不送任何 byte」
// 來模擬 transport 層失敗。
//
// 簡化版:用一個 proxy server前 2 次直接 hijack + close 連線client 看到 EOF
// 第 3 次正常回 200。
func TestGetFile_Network_RetryThenSuccess(t *testing.T) {
t.Parallel()
tokens := newStubTokenClient("svc-tok")
var attempts atomic.Int32
payload := []byte("recovered from net error")
mux := http.NewServeMux()
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
n := attempts.Add(1)
if n < 3 {
// hijack + close 模擬 connection 中斷client 端會看到 unexpected EOF / read error
hj, ok := w.(http.Hijacker)
if !ok {
t.Fatal("server does not support hijacking")
}
conn, _, err := hj.Hijack()
if err != nil {
t.Fatalf("hijack failed: %v", err)
}
_ = conn.Close()
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(payload)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
fc := newFAAClientForTest(t, srv.URL, tokens)
file, err := fc.GetFile(context.Background(), "k")
require.NoError(t, err)
require.NotNil(t, file)
t.Cleanup(func() { _ = file.Body.Close() })
got, _ := io.ReadAll(file.Body)
assert.Equal(t, payload, got)
assert.Equal(t, int32(3), attempts.Load(),
"network error 應 retrymax 2 retries → 3 attempts 後成功")
}
// TestGetFile_Network_Exhausteddial 失敗持續發生 → 用完 max retry 後 ErrFAAUnavailable。
//
// 用一個 listen 後立刻 close 的 socket 製造 connection refused每次 attempt 都失敗)。
func TestGetFile_Network_Exhausted(t *testing.T) {
t.Parallel()
tokens := newStubTokenClient("svc-tok")
// 拿一個 free port 立刻關掉dial 必失敗)
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
addr := ln.Addr().String()
require.NoError(t, ln.Close())
fc := NewFAAClient(FAAClientOpts{
BaseURL: "http://" + addr,
Tokens: tokens,
// 用較短 timeout但仍要大於 retry 退避總和1s + 2s = 3s— 設 10s 安全
HTTPClient: &http.Client{Timeout: 10 * time.Second},
Logger: silentLogger(),
})
start := time.Now()
_, err = fc.GetFile(context.Background(), "k")
duration := time.Since(start)
require.Error(t, err)
assert.True(t, errors.Is(err, ErrFAAUnavailable),
"network exhausted → ErrFAAUnavailable")
// retry1 + 2 retries = 3 attempts2 次退避 = 1s + 2s = 3s 起跳
assert.GreaterOrEqual(t, duration, 2500*time.Millisecond,
"network retry 應走完 §9.1 退避序列")
}
// ==========================================================================
// Context cancel
// ==========================================================================
// TestGetFile_ContextCancelcaller cancel ctx → 立即 return ctx.Err()(不包成 sentinel
func TestGetFile_ContextCancel(t *testing.T) {
t.Parallel()
tokens := newStubTokenClient("svc-tok")
// mock serverhandler 故意 sleep讓 ctx cancel 在 server response 前發生)
mux := http.NewServeMux()
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
select {
case <-r.Context().Done():
case <-time.After(2 * time.Second):
}
w.WriteHeader(http.StatusOK)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
fc := newFAAClientForTest(t, srv.URL, tokens)
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(50 * time.Millisecond)
cancel()
}()
_, err := fc.GetFile(ctx, "k")
require.Error(t, err)
// ctx cancel → 透傳 ctx.Err()(不包成 ErrFAAUnavailable
assert.True(t,
errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded),
"ctx cancel 應透傳,不應包成 ErrFAAUnavailable")
}
// TestGetFile_ContextCancel_DuringRetryctx cancel 發生在 retry sleep 中 → 立即中斷。
//
// 流程:
// - mock server 持續 500觸發 retry
// - 在第 1 次 retry 退避1s的中間500mscancel ctx
// - 期望GetFile 立即 return ctx.Err(),不等完 1s 退避也不繼續第 2 次 retry
func TestGetFile_ContextCancel_DuringRetry(t *testing.T) {
t.Parallel()
tokens := newStubTokenClient("svc-tok")
var attempts atomic.Int32
mux := http.NewServeMux()
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
attempts.Add(1)
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":{"code":"internal_error"}}`))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
fc := newFAAClientForTest(t, srv.URL, tokens)
ctx, cancel := context.WithCancel(context.Background())
go func() {
// 等第 1 次 attempt 跑完 + 進 retry sleep 後再 cancel
// 第 1 次 attempt 約 < 100ms第 1 次 retry 退避 1s在 500ms cancel
time.Sleep(500 * time.Millisecond)
cancel()
}()
start := time.Now()
_, err := fc.GetFile(ctx, "k")
duration := time.Since(start)
require.Error(t, err)
assert.True(t,
errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded),
"retry sleep 中 cancel → 透傳 ctx.Err()")
// 應在 cancel 後立即中斷(< 1s 整體時間)— 不該等完 1s 退避或進入第 2 次 retry
assert.Less(t, duration, 900*time.Millisecond,
"ctx cancel 應立即中斷 retry sleep不等完退避")
// attempts 應為 1第 1 次 attempt 後進 retry sleep 就被 cancel
assert.Equal(t, int32(1), attempts.Load(),
"cancel 後不應再嘗試第 2 次 attempt")
}
// ==========================================================================
// Token 失敗透傳
// ==========================================================================
// TestGetFile_ServiceTokenFailure_PropagatedMCTokenClient 失敗 → 透傳原 sentinel。
//
// 對應 mc_token_client.go 的 ErrIDPMisconfigured / ErrServiceClientUnauthorized / ErrIDPUnavailable
// 不應被 faa_client 升級成 ErrFAAUnavailable會丟失 i18n 區分 idp_misconfig vs idp_down vs faa_down
func TestGetFile_ServiceTokenFailure_Propagated(t *testing.T) {
t.Parallel()
cases := []struct {
name string
tokenErr error
}{
{"idp_misconfigured", ErrIDPMisconfigured},
{"service_client_unauthorized", ErrServiceClientUnauthorized},
{"idp_unavailable", ErrIDPUnavailable},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
tokens := newStubTokenClient("")
tokens.setError(tc.tokenErr)
// server 不應被打token 取不到就 fail
var serverHit atomic.Int32
mux := http.NewServeMux()
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
serverHit.Add(1)
w.WriteHeader(http.StatusOK)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
fc := newFAAClientForTest(t, srv.URL, tokens)
_, err := fc.GetFile(context.Background(), "k")
require.Error(t, err)
assert.True(t, errors.Is(err, tc.tokenErr),
"token 錯誤應透傳;不應包成 ErrFAAUnavailable")
assert.Equal(t, int32(0), serverHit.Load(),
"token 取不到時不應打 FAA")
})
}
}
// ==========================================================================
// 額外empty object_key validation
// ==========================================================================
// TestGetFile_EmptyObjectKey保護性 validation — 空字串 object_key 應立即 fail。
func TestGetFile_EmptyObjectKey(t *testing.T) {
t.Parallel()
tokens := newStubTokenClient("svc-tok")
fc := NewFAAClient(FAAClientOpts{
BaseURL: "http://invalid",
Tokens: tokens,
Logger: silentLogger(),
})
_, err := fc.GetFile(context.Background(), "")
require.Error(t, err)
// 不需走網路就應該 failtoken 沒被呼叫)
assert.Equal(t, 0, tokens.calls(scopeFAADownloadRead),
"empty object_key 應立即 fail不該打 token endpoint")
}
// ==========================================================================
// hashObjectKey unit testlog 用 hash 函式的穩定性)
// ==========================================================================
// TestHashObjectKey_StableAndLength同 input 應產生同 output長度固定 16。
func TestHashObjectKey_StableAndLength(t *testing.T) {
t.Parallel()
h1 := hashObjectKey("tenant/jobs/abc/output.nef")
h2 := hashObjectKey("tenant/jobs/abc/output.nef")
h3 := hashObjectKey("tenant/jobs/xyz/output.nef")
assert.Equal(t, h1, h2, "同 object_key 應產生同 hashlog 可追蹤同一 request")
assert.NotEqual(t, h1, h3, "不同 object_key hash 應不同")
assert.Len(t, h1, objectKeyHashLen, "hash 長度固定")
}

View File

@ -0,0 +1,940 @@
// Flow — Service interface 的具體實作T6 整合層)。
//
// 整合 T2 (mc_token_client) / T3 (converter_client) / T4 (faa_client) / T5 (ownership)
// 成為對 handler 暴露的單一 Service。對齊
// - .autoflow/04-architecture/conversion.md §2.7 整體流程協調 + §4.3.1/§4.3.2
// - .autoflow/04-architecture/api/api-conversion.md5 個 endpoint 規格)
// - .autoflow/04-architecture/adr/adr-014-conversion-integration.md
//
// 設計原則:
// - flow 不直接 import internal/model / internal/storage
// 改用 narrow interfaceModelStore / Storage— 避免 import cycle
// 讓 main.go 在 wire 時做 adapter符合 Go 慣例accept interfaces, return structs
// - 所有 method 第一步都做 ownership 檢查trust boundary§7.2
// - 多次 promote 冪等:以 modelStore 已有對應 source_job_id 為「已處理」
// 的 source-of-truth避免重複 promote / 重複建 model record
//
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.7)
package conversion
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"log/slog"
"mime"
"mime/multipart"
"net/url"
"path"
"strings"
"time"
)
// ==========================================================================
// Narrow interfaces避免 import cyclecaller 在 main.go 做 adapter
// ==========================================================================
// ModelStore 是 flow 對 internal/model.Repository 的最小依賴子集。
//
// 設計選擇FAANG 慣例consumer 定義介面,不直接 import internal/model
// main.go 在 wire 時把 *model.InMemoryRepository或未來的 PostgresRepository
// 包成 adapter 傳進來。這樣:
// - flow_test.go 可以用 in-package stub 測試,不必拉 model package
// - 未來 model.Repository 介面再擴充也不影響 flow除非 flow 真的要用新 method
// - 不引入 import cyclemodel 不需 import conversion
//
// 具體 method 對應 internal/model.Repository
// - Save: model.Repository.Save
// - FindBySourceJobID: 既有 List + filter SourceJobIDadapter 在 main.go 寫)
// - GenerateID: 由 adapter 注入model_id 命名邏輯沿用既有專案規則)
type ModelStore interface {
// Save 新增或更新一筆 Model 紀錄。對齊 model.Repository.Save semantics。
Save(ctx context.Context, m *ModelRecord) error
// FindBySourceJobID 查找該 user 是否已有對應某 conversion job 的 model record。
// 用於 PromoteToModels 冪等檢查:同 jobID 重複 promote 直接回既有 model。
//
// 找不到回 (nil, nil);找到回 (*ModelRecord, nil);其他錯誤回 err。
FindBySourceJobID(ctx context.Context, ownerUserID, sourceJobID string) (*ModelRecord, error)
// GenerateID 產一個新的 model_id對齊既有 model package 的命名)。
GenerateID() string
}
// ModelRecord 是 flow 與 ModelStore 之間的 DTO避免 flow 直接 import internal/model。
//
// adapter在 main.go負責 ModelRecord ↔ model.Model 的轉換。
//
// 欄位對齊 internal/model.Model 的子集Phase 0.8 promote-to-models 寫入需要的)。
type ModelRecord struct {
ID string
OwnerUserID string
Name string
Description string
StorageKey string
FileSize int64
FileChecksum string
TargetChip string
Source string // 永遠 "converted"
SourceJobID string
CreatedAt time.Time
UpdatedAt time.Time
}
// Storage 是 flow 對 internal/storage.Store 的最小依賴子集。
//
// Phase 0.8 promote-to-models 流程只需要 Putstreaming 寫進 storage
// 其他 methodGet / List / Presigned由 internal/api/models.go 既有 handler 處理。
type Storage interface {
// Put streaming 寫一個 object。實作對齊 internal/storage.Store.Put
// - r 為 streaming reader實作不應 ReadAll 進記憶體
// - size 為預期大小bytes若未知傳 -1
// - meta 可為 nil
Put(ctx context.Context, key string, r io.Reader, size int64, meta map[string]string) error
}
// ==========================================================================
// Service 實作
// ==========================================================================
// flow 是 Service interface 的預設實作(不對外 exportcaller 拿 interface
type flow struct {
converter ConverterClient
faa FAAClient
mcToken MCTokenClient
ownership Ownership
modelStore ModelStore
storage Storage
tenantID string
faaBaseURL string
defaultJobExpiryDuration time.Duration
delegatedTTLSeconds int
logger *slog.Logger
now func() time.Time
}
// FlowOpts 是 NewService 的依賴注入。
//
// 必填Converter / FAA / MCToken / Ownership / ModelStore / Storage / TenantID / FAABaseURL。
// 其他 optionalnil/0 自動填合理預設)。
type FlowOpts struct {
// 4 個 clientT2-T5
Converter ConverterClient
FAA FAAClient
MCToken MCTokenClient
Ownership Ownership
// 既有 visionA 套件的 narrow adapter
ModelStore ModelStore
Storage Storage
// MC delegated download 用的 tenant idvisionA 在 MC 的 tenant 識別)
TenantID string
// FAA base URL組 download URL 用http://192.168.0.130:5081 等)。
// 不帶結尾斜線constructor 自動 trim。
FAABaseURL string
// converter 沒回 expires_at 時自行推算的 fallback duration預設 7 天)。
DefaultJobExpiryDuration time.Duration
// MC delegated download token TTL。0 → 預設 3005 分鐘)。
// 對齊 conversion.md §10.2,建議範圍 60-900。
DelegatedTTLSeconds int
Logger *slog.Logger
Now func() time.Time
}
// NewService 建立一個 Service 實例。
//
// 回傳 interface 而非 concrete structDI 友善 + 未來實作替換不影響 caller
func NewService(opts FlowOpts) (Service, error) {
if opts.Converter == nil {
return nil, errors.New("conversion: FlowOpts.Converter is required")
}
if opts.FAA == nil {
return nil, errors.New("conversion: FlowOpts.FAA is required")
}
if opts.MCToken == nil {
return nil, errors.New("conversion: FlowOpts.MCToken is required")
}
if opts.Ownership == nil {
return nil, errors.New("conversion: FlowOpts.Ownership is required")
}
if opts.ModelStore == nil {
return nil, errors.New("conversion: FlowOpts.ModelStore is required")
}
if opts.Storage == nil {
return nil, errors.New("conversion: FlowOpts.Storage is required")
}
if opts.TenantID == "" {
return nil, errors.New("conversion: FlowOpts.TenantID is required")
}
if opts.FAABaseURL == "" {
return nil, errors.New("conversion: FlowOpts.FAABaseURL is required")
}
expiry := opts.DefaultJobExpiryDuration
if expiry <= 0 {
expiry = 7 * 24 * time.Hour // 對齊 converter 7 天 GC§2.6.2
}
ttl := opts.DelegatedTTLSeconds
if ttl <= 0 {
ttl = 300
}
logger := opts.Logger
if logger == nil {
logger = slog.Default()
}
nowFn := opts.Now
if nowFn == nil {
nowFn = time.Now
}
return &flow{
converter: opts.Converter,
faa: opts.FAA,
mcToken: opts.MCToken,
ownership: opts.Ownership,
modelStore: opts.ModelStore,
storage: opts.Storage,
tenantID: opts.TenantID,
faaBaseURL: strings.TrimRight(opts.FAABaseURL, "/"),
defaultJobExpiryDuration: expiry,
delegatedTTLSeconds: ttl,
logger: logger,
now: nowFn,
}, nil
}
// 編譯時檢查:確保 *flow 實作 Service interface。
var _ Service = (*flow)(nil)
// ==========================================================================
// InitJob — 對應 POST /api/conversion/init
// ==========================================================================
// InitJob 對齊 conversion.md §4.2 streaming proxy + §2.7 整體流程。
//
// 實作流程:
// 1. ownership.EnsureRebuilt避免 cache 殘留 / 重啟後該 user 第一次進)
// 2. 同 user active job pre-check有 → 回 *ActiveJobError 帶 active job 細節
// 3. 用 io.Pipe + multipart.Reader/Writer 重組 multipart body
// - 黑名單 client 帶來的 user_id field§4.2 / §7.3
// - 注入 visionA-backend 從 OIDC sub 取得的 UserID
// 4. converter.InitJob 同步等到 201不 early-return對齊 §4.3.1 選項 A
// 5. 寫 ownership.Set(jobID, userID)
// 6. 失敗時的 cleanup 行為§4.3.2
// - converter Phase 1 **沒有實作** `POST /api/v1/jobs/{id}/cancel` endpoint
// 已驗證apps/task-scheduler 的 routes/v1/jobs.js 只有 POST '/'、GET '/'、
// GET '/:id'、POST '/:id/download-tokens'、DELETE '/:id')。
// - Phase 0.8 採「socket close 自然 abort」策略streaming body 中斷時
// converter multer 拋錯 → 該 job 留 `failed` 狀態 + error_code=invalid_multipart
// → converter 對 active_job 邏輯視為已結束 → 下次 init 不會撞 409。
// - flow.go 不主動發 cancel沒有對應 endpoint 可發);只在 InitJob 失敗時 log。
// - **Phase 1+ 升級**:當 converter 補上 `/cancel` 後T3 ConverterClient
// 新增 `CancelJob(ctx, jobID) error`flow.go 在 InitJob 失敗時開獨立 5s
// timeout context不繼承已 cancel 的 ctx做 best-effort 主動 cancel。
// 見 conversion.md §4.3.2 + ./05-implementation/phase-0.8-T6.md follow-ups。
func (f *flow) InitJob(ctx context.Context, in InitJobInput) (*Job, error) {
if in.UserID == "" {
return nil, errors.New("conversion: InitJob requires UserID")
}
if in.Body == nil {
return nil, errors.New("conversion: InitJob requires Body")
}
if in.ContentType == "" {
return nil, errors.New("conversion: InitJob requires ContentType (must contain multipart boundary)")
}
// 1. ownership lazy rebuild — 確保該 user 的 active jobs 有從 converter 拉回來
if err := f.ownership.EnsureRebuilt(ctx, in.UserID); err != nil {
// rebuild 失敗:不 hard failconverter 可能短暫不可達),讓 pre-check 走 stale cache
// — 後面真正打 converter.InitJob 時若 converter 已恢復則照常通過;若仍異常會回 502。
// 但需要記 log方便除錯。
f.logger.WarnContext(ctx, "conversion.flow.init_ownership_rebuild_failed",
slog.String("user_hash", hashUserID(in.UserID)),
slog.String("err", err.Error()),
)
}
// 2. 同 user active job pre-check§9.3
// 避免 visionA 已知 active 但仍打 converter 浪費一次 round-trip
if existing, err := f.checkActiveJob(ctx, in.UserID); err != nil {
return nil, err
} else if existing != nil {
return nil, &ActiveJobError{Job: existing}
}
// 3. 重組 multipart注入 user_id、黑名單 client 帶來的 user_id§4.2 / §7.3
pr, pw := io.Pipe()
mw := multipart.NewWriter(pw)
// goroutine 解析 client multipart 並重寫到 pwconverter 端從 pr 讀
//
// Close 順序Reviewer M-2
// 單一 close 路徑、根據 rebuild err 決定 pw.Close vs pw.CloseWithError —
// 不可用 `defer pw.Close()` 配 `pw.CloseWithError(err)`defer LIFO 會在
// CloseWithError 之後跑,把 err 蓋成 nil EOFconverter 端拿到截斷 stream
// 而不是 rebuild 錯誤訊號)
// - mw.Close 必須先(送 final boundary 給 reader再用 err 決定關 pw 的方式
// - rebuildErrCh 在 close 之後送,確保主流程拿到 err 時 pipe 已收尾
rebuildErrCh := make(chan error, 1)
go func() {
err := rebuildMultipart(in.UserID, in.ContentType, in.Body, mw)
// mw.Close 寫 final boundary即使 rebuild 失敗也要關(避免 mw 內部 buffer 殘留)
if mwErr := mw.Close(); mwErr != nil && err == nil {
err = fmt.Errorf("close multipart writer: %w", mwErr)
}
// 用單一路徑決定 pw 怎麼關
if err != nil {
_ = pw.CloseWithError(err)
} else {
_ = pw.Close()
}
rebuildErrCh <- err
}()
// 4. POST converter — 同步等到 201streaming proxy不 early-return對齊 §4.3.1
cj, err := f.converter.InitJob(ctx, InitConverterJobReq{
UserID: in.UserID,
Body: pr,
BodyContentType: mw.FormDataContentType(),
})
// 等 goroutine 結束pw.Close 已觸發 EOFrebuild 邏輯已 write 完)
rebuildErr := <-rebuildErrCh
// 若 converter 沒回 error但 rebuild goroutine 失敗 → 也視為 init 失敗
if err == nil && rebuildErr != nil {
err = fmt.Errorf("%w: rebuild multipart: %v", ErrConverterUnavailable, rebuildErr)
}
if err != nil {
// converter 4xx / 5xx / network → 已分類成 sentinel
// Cleanup 策略§4.3.2,已驗證 converter Phase 1 沒實作 /cancel endpoint
// 不主動打 cancel —— 靠 converter multer 收 socket close 自然 abort
// streaming 中斷 → multer 拋錯 → job 留 failed → 下次 init 不會撞 409
// Phase 1+ 等 converter 補 /cancel 後再升級為 best-effort 主動 cancel。
f.logger.WarnContext(ctx, "conversion.flow.init_failed",
slog.String("user_hash", hashUserID(in.UserID)),
slog.String("err", err.Error()),
)
return nil, err
}
// 5. 寫 ownership
f.ownership.Set(cj.JobID, in.UserID)
job := f.toJob(cj)
f.logger.InfoContext(ctx, "conversion.flow.init_success",
slog.String("user_hash", hashUserID(in.UserID)),
slog.String("job_id", cj.JobID),
slog.String("status", cj.Status),
slog.String("source_filename", cj.SourceFilename),
)
return job, nil
}
// rebuildMultipart 解 client 端 multipart重新寫到 mw。
//
// 規則§4.2 / §7.3
// 1. 先寫 user_id field從 visionA-backend 注入,唯一可信來源)
// 2. client 帶來的 user_id field 一律忽略(黑名單)
// 3. 其他 form field / file part 透傳
func rebuildMultipart(userID, contentType string, body io.Reader, mw *multipart.Writer) error {
// 解析 boundary
_, params, err := mime.ParseMediaType(contentType)
if err != nil {
return fmt.Errorf("parse content type: %w", err)
}
boundary := params["boundary"]
if boundary == "" {
return errors.New("missing multipart boundary")
}
// 先寫 user_id重點在 file part 之前§4.2 註解說明:避免 converter multer
// 解析時 user_id 還沒到就拒絕)
if err := mw.WriteField("user_id", userID); err != nil {
return fmt.Errorf("write user_id field: %w", err)
}
mr := multipart.NewReader(body, boundary)
for {
part, err := mr.NextPart()
if err == io.EOF {
return nil
}
if err != nil {
return fmt.Errorf("read next part: %w", err)
}
name := part.FormName()
// 黑名單 user_id忽略 client 自己塞的§4.2
if name == "user_id" {
_ = part.Close()
continue
}
if part.FileName() == "" {
// form field直接複製
fw, err := mw.CreateFormField(name)
if err != nil {
_ = part.Close()
return fmt.Errorf("create form field %q: %w", name, err)
}
if _, err := io.Copy(fw, part); err != nil {
_ = part.Close()
return fmt.Errorf("copy form field %q: %w", name, err)
}
} else {
// file partstreaming copy不 buffer 全 RAM
fw, err := mw.CreateFormFile(name, part.FileName())
if err != nil {
_ = part.Close()
return fmt.Errorf("create form file %q: %w", name, err)
}
if _, err := io.Copy(fw, part); err != nil {
_ = part.Close()
return fmt.Errorf("copy form file %q: %w", name, err)
}
}
_ = part.Close()
}
}
// checkActiveJob 看 user 是否已有 active jobpre-check
//
// 流程:
// 1. ownership.ActiveJobOf — 反查 cache 中該 user 的 jobs
// 2. 取第一個Phase 0.8 同 user 最多 1 個),用 converter.GetJob 確認狀態
// - 若狀態為 created/running → return 該 Job給 caller 包成 ActiveJobError
// - 若 converter 回 404 / 該 job 已 completed / failed → 視為無 active先清 cache 再 return nil
//
// 沒 active job 回 (nil, nil)。
func (f *flow) checkActiveJob(ctx context.Context, userID string) (*Job, error) {
jobIDs := f.ownership.ActiveJobOf(userID)
if len(jobIDs) == 0 {
return nil, nil
}
jobID := jobIDs[0]
cj, err := f.converter.GetJob(ctx, jobID)
if err != nil {
if errors.Is(err, ErrJobNotFound) {
// converter 已 GC7d 過期)— 清 cache 後視為無 active
f.ownership.Delete(jobID)
return nil, nil
}
// 其他錯誤5xx / network— 對 caller 透傳caller 決定 502
return nil, err
}
// 只有 created / running 視為 active
switch cj.Status {
case "completed", "failed":
// 已結束的 job 不算 active不清 ownershipGetJob / Download 仍需要這個對應)
return nil, nil
default:
return f.toJob(cj), nil
}
}
// ==========================================================================
// GetJob — 對應 GET /api/conversion/{job_id}
// ==========================================================================
// GetJob 對齊 conversion.md §2.7 + api-conversion.md §2。
//
// 流程:
// 1. ownership.EnsureRebuilt確保 cache 已 lazy rebuild
// 2. ownership.Get(jobID) — 比對 owner不符 → ErrJobNotFound避免洩漏 job 存在性)
// 3. converter.GetJob(jobID)
// 4. 若 expires_at 為零,補 created_at + DefaultJobExpiryDuration
//
// 設計選擇ownership 不符不回 forbidden而是 not_found
// - 避免讓攻擊者用「forbidden vs not_found」差異枚舉合法 job_id
// - 對齊 §7.2 安全考量
func (f *flow) GetJob(ctx context.Context, userID, jobID string) (*Job, error) {
if userID == "" {
return nil, errors.New("conversion: GetJob requires userID")
}
if jobID == "" {
return nil, ErrJobNotFound
}
if err := f.ownership.EnsureRebuilt(ctx, userID); err != nil {
// rebuild 失敗:不視為 fatal繼續走 cache可能 stalefail-soft
f.logger.WarnContext(ctx, "conversion.flow.get_ownership_rebuild_failed",
slog.String("user_hash", hashUserID(userID)),
slog.String("err", err.Error()),
)
}
owner, ok := f.ownership.Get(jobID)
if !ok || owner != userID {
// 不符 → 視為 not_found避免洩漏存在性
return nil, ErrJobNotFound
}
cj, err := f.converter.GetJob(ctx, jobID)
if err != nil {
return nil, err
}
return f.toJob(cj), nil
}
// ==========================================================================
// ActiveJob — 對應 GET /api/conversion/active
// ==========================================================================
// ActiveJob 對齊 conversion.md §2.6.1 lazy rebuild + api-conversion.md §5。
//
// 流程:
// 1. ownership.EnsureRebuilt從 converter ListInProgressJobs 重建 cache
// 2. ownership.ActiveJobOf — 反查
// 3. 沒有 → return (nil, nil)(不視為 error對齊 has_active=false 語意)
// 4. 取 [0]Phase 0.8 ≤ 1→ converter.GetJob 拿即時狀態
// 5. converter 回 404job 已過期被 GC→ 清 cache + return (nil, nil)
//
// 重啟恢復場景visionA-backend in-memory cache 全空時EnsureRebuilt 會打
// converter ListInProgressJobs 把該 user 的 active job 重建進來,使用者看不出差別。
func (f *flow) ActiveJob(ctx context.Context, userID string) (*Job, error) {
if userID == "" {
return nil, errors.New("conversion: ActiveJob requires userID")
}
// 1. lazy rebuild這個路徑不 fail-softrebuild 失敗 = 無法回答 has_active 問題,
// 必須 propagate 給 caller 知道)
if err := f.ownership.EnsureRebuilt(ctx, userID); err != nil {
return nil, err
}
// 2. 反查
jobIDs := f.ownership.ActiveJobOf(userID)
if len(jobIDs) == 0 {
return nil, nil
}
// 3. 取第一個,問 converter 即時狀態
jobID := jobIDs[0]
cj, err := f.converter.GetJob(ctx, jobID)
if err != nil {
if errors.Is(err, ErrJobNotFound) {
// converter 已 GC → 清 cache + 視為無 active
f.ownership.Delete(jobID)
return nil, nil
}
return nil, err
}
// 已 completed / failed 的 job 也不算 activehas_active=false
if cj.Status == "completed" || cj.Status == "failed" {
return nil, nil
}
return f.toJob(cj), nil
}
// ==========================================================================
// PromoteToModels — 對應 POST /api/conversion/{job_id}/promote-to-models
// ==========================================================================
// PromoteToModels 對齊 conversion.md §1 Stage 3a + §2.5 + api-conversion.md §3。
//
// 流程:
// 1. ownership 驗(不符 → ErrJobNotFound
// 2. converter.GetJob — 確認 status=completed否則 ErrJobNotCompleted
// 3. 冪等檢查modelStore.FindBySourceJobID — 已有 model 直接回(避免重複 promote
// 4. converter.Promote — 拿到 target_object_key
// 5. faa.GetFile(target_object_key) — streaming pull NEF
// 6. storage.Put — streaming 寫進 visionA storage不 ReadAll
// 7. modelStore.Save — 建 model recordSource="converted"、SourceJobID=jobID
// 8. return PromoteResult
//
// 名稱caller 從 wireframe §7.1 的 import Dialog 拿;空字串 fallback 為
// `<source_filename_stem>_<target_chip_lower>`(對齊 api-conversion.md §3
func (f *flow) PromoteToModels(ctx context.Context, userID, jobID, name string) (*PromoteResult, error) {
if userID == "" {
return nil, errors.New("conversion: PromoteToModels requires userID")
}
if jobID == "" {
return nil, ErrJobNotFound
}
// 1. ownership rebuild + 驗
if err := f.ownership.EnsureRebuilt(ctx, userID); err != nil {
f.logger.WarnContext(ctx, "conversion.flow.promote_ownership_rebuild_failed",
slog.String("user_hash", hashUserID(userID)),
slog.String("err", err.Error()),
)
}
owner, ok := f.ownership.Get(jobID)
if !ok || owner != userID {
return nil, ErrJobNotFound
}
// 2. converter.GetJob 確認 completed
cj, err := f.converter.GetJob(ctx, jobID)
if err != nil {
return nil, err
}
if cj.Status != "completed" {
return nil, fmt.Errorf("%w: status=%s", ErrJobNotCompleted, cj.Status)
}
// 3. 冪等檢查
if existing, err := f.modelStore.FindBySourceJobID(ctx, userID, jobID); err != nil {
f.logger.WarnContext(ctx, "conversion.flow.promote_find_existing_failed",
slog.String("user_hash", hashUserID(userID)),
slog.String("job_id", jobID),
slog.String("err", err.Error()),
)
// 查 model store 失敗不 hard fail —— 仍嘗試 promote最壞結果是重複建一個 model record
} else if existing != nil {
f.logger.InfoContext(ctx, "conversion.flow.promote_idempotent_hit",
slog.String("user_hash", hashUserID(userID)),
slog.String("job_id", jobID),
slog.String("model_id", existing.ID),
)
return modelRecordToPromoteResult(existing), nil
}
// 4. converter.Promote — 組目標 object_keyFAA 內部命名規則由 visionA 決定)
finalName := name
if finalName == "" {
finalName = defaultModelName(cj)
}
targetObjectKey := buildTargetObjectKey(userID, jobID)
promoteRes, err := f.converter.Promote(ctx, jobID, PromoteReq{
UserID: userID,
Source: promoteDefaultSource, // "nef"
TargetObjectKey: targetObjectKey,
})
if err != nil {
return nil, err
}
// 5. faa.GetFile streaming pull
file, err := f.faa.GetFile(ctx, promoteRes.TargetObjectKey)
if err != nil {
return nil, err
}
defer file.Body.Close()
// 6. storage.Put streaming write
modelID := f.modelStore.GenerateID()
storageKey := buildStorageKey(userID, modelID)
storageMeta := map[string]string{
"source": "converted",
"source_job_id": jobID,
"target_chip": normalizeTargetChip(cj.Platform),
}
if err := f.storage.Put(ctx, storageKey, file.Body, file.ContentLength, storageMeta); err != nil {
f.logger.WarnContext(ctx, "conversion.flow.promote_storage_put_failed",
slog.String("user_hash", hashUserID(userID)),
slog.String("job_id", jobID),
slog.String("storage_key", storageKey),
slog.String("err", err.Error()),
)
// visionA 自家 storage 失敗disk full / S3 5xx / 權限錯誤)
// — 不是 FAA / converter 問題,用獨立 sentinel 讓 SRE alarm 打對 team
// (對齊 Reviewer M-1
return nil, fmt.Errorf("%w: storage.Put %s: %v", ErrStorageUnavailable, storageKey, err)
}
// 7. modelStore.Save
now := f.now().UTC()
rec := &ModelRecord{
ID: modelID,
OwnerUserID: userID,
Name: finalName,
StorageKey: storageKey,
FileSize: promoteRes.Size,
FileChecksum: promoteRes.Checksum,
TargetChip: normalizeTargetChip(cj.Platform),
Source: "converted",
SourceJobID: jobID,
CreatedAt: now,
UpdatedAt: now,
}
if err := f.modelStore.Save(ctx, rec); err != nil {
f.logger.WarnContext(ctx, "conversion.flow.promote_model_save_failed",
slog.String("user_hash", hashUserID(userID)),
slog.String("job_id", jobID),
slog.String("model_id", modelID),
slog.String("err", err.Error()),
)
// model store save 失敗in-memory 不會失敗;未來 Postgres 才會觸發)
// — 不是 converter / FAA 問題,用獨立 sentinel 對齊 SRE alarm 分類Reviewer M-1
// 已寫進 storage 但無 record 對應 → 等同孤立檔案Phase 1 加 GC 機制清掃
return nil, fmt.Errorf("%w: modelStore.Save model_id=%s: %v", ErrModelStoreUnavailable, modelID, err)
}
f.logger.InfoContext(ctx, "conversion.flow.promote_success",
slog.String("user_hash", hashUserID(userID)),
slog.String("job_id", jobID),
slog.String("model_id", modelID),
slog.Int64("file_size", promoteRes.Size),
)
return modelRecordToPromoteResult(rec), nil
}
// ==========================================================================
// DownloadRedirectURL — 對應 GET /api/conversion/{job_id}/download
// ==========================================================================
// DownloadRedirectURL 對齊 conversion.md §1 Stage 3b + §3.1 + api-conversion.md §4。
//
// 流程:
// 1. ownership 驗(不符 → ErrJobNotFound
// 2. converter.GetJob — 確認 status=completed
// 3. ensurePromoted — 自動觸發 promote若還沒 promote 過),拿到 target_object_key
// - 設計選擇task spec 詢問點自動觸發。理由api-conversion.md §4 註解說
// 「兩條路徑promote-to-models / download都拿同一個 target_object_key」+
// 「不會與 promote-to-models 衝突;兩者內部都會 ensurePromoted冪等」—
// 要求 user 先按 promote-to-models 才能下載會違背「下載」按鈕的直覺語意。
// 4. mcToken.IssueDelegatedDownload — 換 opaque token (TTL 5min 預設)
// 5. 組 https://<faa>/files/<key>?access_token=<token>
//
// 安全§10.4
// - token 不出現在任何 JSON responsecaller 走 server-side 302 redirect
// - object_key 不對 frontend 揭露
func (f *flow) DownloadRedirectURL(ctx context.Context, userID, jobID string) (string, error) {
if userID == "" {
return "", errors.New("conversion: DownloadRedirectURL requires userID")
}
if jobID == "" {
return "", ErrJobNotFound
}
// 1. ownership 驗
if err := f.ownership.EnsureRebuilt(ctx, userID); err != nil {
f.logger.WarnContext(ctx, "conversion.flow.download_ownership_rebuild_failed",
slog.String("user_hash", hashUserID(userID)),
slog.String("err", err.Error()),
)
}
owner, ok := f.ownership.Get(jobID)
if !ok || owner != userID {
return "", ErrJobNotFound
}
// 2. converter.GetJob 確認 completed
cj, err := f.converter.GetJob(ctx, jobID)
if err != nil {
return "", err
}
if cj.Status != "completed" {
return "", fmt.Errorf("%w: status=%s", ErrJobNotCompleted, cj.Status)
}
// 3. ensurePromoted — 自動觸發 promote 拿 target_object_key
// Phase 0.8 不 cache promoted_object_keyconverter 端 promote 是冪等的,
// 重複呼叫成本可接受 — 反正 download 路徑 user 主動觸發頻率不高)
targetObjectKey, err := f.ensurePromoted(ctx, userID, jobID, cj)
if err != nil {
return "", err
}
// 4. mcToken 換 delegated download token
delegated, err := f.mcToken.IssueDelegatedDownload(ctx, IssueDownloadReq{
TenantID: f.tenantID,
UserID: userID,
ObjectKey: targetObjectKey,
ExpiresInSeconds: f.delegatedTTLSeconds,
})
if err != nil {
return "", err
}
// 5. 組 URLFAA base + /files/<key>?access_token=<token>
// - object_key 用 url.PathEscape 處理(含路徑分隔符的 key 安全 escape
// - token 用 url.QueryEscape雖 opaque token 通常不含特殊字元,仍 escape 防呆)
downloadURL := fmt.Sprintf("%s/files/%s?access_token=%s",
f.faaBaseURL,
escapeObjectKeyPath(targetObjectKey),
url.QueryEscape(delegated.Token),
)
f.logger.InfoContext(ctx, "conversion.flow.download_url_issued",
slog.String("user_hash", hashUserID(userID)),
slog.String("job_id", jobID),
slog.String("object_key_hash", hashObjectKey(targetObjectKey)),
slog.Int("ttl_sec", f.delegatedTTLSeconds),
)
return downloadURL, nil
}
// ensurePromoted 取 target_object_key — 若已 promote 過model record 已存在)用 cache
// 否則打 converter.Promote 拿。
//
// 用 modelStore.FindBySourceJobID 當 source-of-truth若已有 model record 表示
// PromoteToModels 已成功跑過,可直接從 record 拿 storage_key 反推 target_object_key
// ✗ 不行storage_key 是 visionA storage 的 key不是 FAA 的 object_key。
//
// 改用 converter.Promote 冪等性§2.7「promote 動作是冪等的converter 端對同一
// job 重複 promote 接受」)— 直接打 converter重複呼叫成本低同步等 1-2s
//
// 為什麼不用 sync.Map cachePhase 0.8 download 路徑 user 主動觸發頻率不高(每 job 1-N 次),
// 簡單性 > 微優化。Phase 1 量大再加 cacheprogress.md 已記)。
func (f *flow) ensurePromoted(ctx context.Context, userID, jobID string, cj *ConverterJob) (string, error) {
targetObjectKey := buildTargetObjectKey(userID, jobID)
res, err := f.converter.Promote(ctx, jobID, PromoteReq{
UserID: userID,
Source: promoteDefaultSource,
TargetObjectKey: targetObjectKey,
})
if err != nil {
return "", err
}
return res.TargetObjectKey, nil
}
// ==========================================================================
// helpers
// ==========================================================================
// toJob 把 ConverterJobclient 層中介 type轉成對外的 Jobresponse shape
//
// 補 expires_at fallbackconverter 沒給 → created_at + DefaultJobExpiryDuration§2.6.2)。
func (f *flow) toJob(cj *ConverterJob) *Job {
if cj == nil {
return nil
}
job := &Job{
JobID: cj.JobID,
Status: cj.Status,
Stage: cj.Stage,
CreatedAt: cj.CreatedAt,
UpdatedAt: cj.UpdatedAt,
ExpiresAt: cj.ExpiresAt,
SourceFilename: cj.SourceFilename,
TargetChip: cj.Platform,
ErrorCode: cj.ErrorCode,
ErrorMessage: cj.ErrorMessage,
}
if cj.Progress != nil {
job.Progress = *cj.Progress
}
if cj.StageProgress != nil {
job.StageProgress = *cj.StageProgress
}
if job.ExpiresAt.IsZero() && !cj.CreatedAt.IsZero() {
job.ExpiresAt = cj.CreatedAt.Add(f.defaultJobExpiryDuration)
}
return job
}
// modelRecordToPromoteResult 把 ModelRecord 轉成對外的 PromoteResult。
func modelRecordToPromoteResult(rec *ModelRecord) *PromoteResult {
if rec == nil {
return nil
}
return &PromoteResult{
ModelID: rec.ID,
Source: rec.Source,
SourceJobID: rec.SourceJobID,
Name: rec.Name,
TargetChip: rec.TargetChip,
FileSize: rec.FileSize,
Status: "ready", // visionA model 既有 statuspromote 完即 ready
CreatedAt: rec.CreatedAt,
}
}
// buildTargetObjectKey 產 FAA 的 object_keyvisionA 端命名規則)。
//
// 命名models/{user_id}/{job_id}.nef
// 用 user_id 隔離job_id 唯一性由 converter 保證UUID
//
// 對齊 conversion.md §10.4「object_key 不對 frontend 揭露」— 命名只在 server-side 用。
func buildTargetObjectKey(userID, jobID string) string {
// 注意:這裡不對 userID/jobID 做 escape — callervisionA-backend handler
// 已從 OIDC sub / converter response 拿,皆為合法 ID 字元UUID / OIDC sub
return fmt.Sprintf("models/%s/%s.nef", userID, jobID)
}
// buildStorageKey 產 visionA storage 的 key不是 FAA 的)。
//
// 沿用 internal/storage 既有命名慣例models/{user_id}/{model_id}.nef
// storage.md §2 範例)。
func buildStorageKey(userID, modelID string) string {
return fmt.Sprintf("models/%s/%s.nef", userID, modelID)
}
// escapeObjectKeyPath 對 object_key 做 path escape但保留 '/' 為 path separator。
//
// url.PathEscape 會把 '/' 也 escape 成 %2F — 對 FAA `/files/{**objectKey}` 來說
// 應該保留 '/' 為路徑分隔符,所以拆段後逐段 escape 再合回。
func escapeObjectKeyPath(objectKey string) string {
parts := strings.Split(objectKey, "/")
for i := range parts {
parts[i] = url.PathEscape(parts[i])
}
return strings.Join(parts, "/")
}
// normalizeTargetChip 把 converter 端 platform"520"/"720"/...)轉成 visionA model 的
// target_chip 表示法("kl520"/"kl720"/...)。
//
// 對齊 api-conversion.md §3 注解「conversion job 用 platform '720'model.target_chip 用 'kl720'」。
func normalizeTargetChip(platform string) string {
p := strings.ToLower(strings.TrimSpace(platform))
if p == "" {
return ""
}
if strings.HasPrefix(p, "kl") {
return p
}
return "kl" + p
}
// defaultModelName 產 PromoteToModels caller 沒給 name 時的 fallback。
//
// 規則:`<source_filename_stem>_<target_chip_lower>` — 對齊 api-conversion.md §3 預設值
// wireframe §7.1 import Dialog 預設)。
func defaultModelName(cj *ConverterJob) string {
// path.Base("") 會回 ".";先擋掉空 / "." / ".." 等無效 stem
var stem string
if cj.SourceFilename != "" {
base := path.Base(cj.SourceFilename)
if base != "." && base != "/" && base != ".." {
stem = strings.TrimSuffix(base, path.Ext(base))
}
}
chip := strings.ToLower(strings.TrimSpace(cj.Platform))
switch {
case stem != "" && chip != "":
return fmt.Sprintf("%s_kl%s", stem, chip)
case stem != "":
return stem
case chip != "":
return fmt.Sprintf("converted_kl%s", chip)
default:
// 兜底:用 timestamp 避免空 name
return fmt.Sprintf("converted_%d", time.Now().Unix())
}
}
// generateRandomID — 不對外暴露,用於測試或 ModelStore.GenerateID adapter 沒提供時的 fallback。
//
// 16 hex chars (64-bit)。
//
//nolint:unused // 保留供 main.go 的 adapter 在 fallback 時使用
func generateRandomID() string {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
// crypto/rand 失敗極為罕見;用 timestamp 兜底
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,624 @@
// MC token client — visionA-backend 對 Member Center 取兩種 token
// - service tokenclient_credentials grant自己呼叫 converter / FAA 用per-scope cache
// - delegated download token給 user 換 short-lived FAA download URL不 cache每次新簽
//
// 設計參考:
// - kneron_model_converter/apps/task-scheduler/src/auth/oauthClient.jsNode 版同模式,
// 已在 production 跑過;這裡 Go 版改用 sync.Mutex + DCL不用 promise dedup
// - 本檔案搭配 .autoflow/04-architecture/conversion.md §2.4 / §5 / §9.1 retry 矩陣
//
// 安全:
// - **絕不**把 client_secret / access_token / Authorization header 內容寫進 log
// - 錯誤訊息只揭露 status + 是否 retry不揭露 server 端細節
//
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.4 / §5)
package conversion
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// ==========================================================================
// 對外 type / interface
// ==========================================================================
// MCTokenClient 對 Member Center 取兩種 token。
//
// 兩個 method 的錯誤處理策略對齊 conversion.md §6
//
// - `ServiceToken`(打 MC `/oauth/token`client_credentials grant
// 401/403 → ErrServiceClientUnauthorized500/idp_misconfigured 對外)
// 其他 4xx → ErrIDPMisconfigured500/idp_misconfiguredi18n=idp_misconfig
// 5xx / network 持續失敗 → ErrIDPUnavailable503/idp_unavailablei18n=idp_down
//
// - `IssueDelegatedDownload`(打 MC `/file-access/download-tokens`
// 401/403 → ErrServiceClientUnauthorized
// 其他 4xx → ErrDownloadTokenFailed502/download_token_failedi18n=token_failed
// 5xx / network 持續失敗 → ErrMCTokenUnavailable502/mc_token_unavailablei18n=token_failed
//
// 兩 endpoint 的 4xx / 5xx 用不同 sentinel — 因為 §6 的 i18n 訊息設計區分了
// 「IDP 設定錯誤」「IDP 暫時不可用」「下載授權失敗」「MC 不可達」四種不同的 user-facing 提示
// (前者引導使用者「聯絡支援」,後者引導「稍後再試」)。
//
// goroutine-safecache 用 sync.MutexDCL 確保併發 fetch 只發一次 request。
type MCTokenClient interface {
// ServiceToken 取一個 access tokenclient_credentials grant可 cache 重用。
//
// scope 範例:
// "converter:job.write converter:job.read files:download.read files:download.delegate"
// (多 scope 用空白分隔,依 RFC 6749 §3.3
//
// cache 行為(見 §5.2
// - per-scope cache不同 scope 各自獨立)
// - 過期判斷now() >= exp - 15s提前 15 秒 refresh 避免邊界 race
// - 失敗不 cache下一次呼叫會重試
// - DCL 防併發爆量100 個 caller 同時要 token只 fetch 一次)
ServiceToken(ctx context.Context, scope string) (string, error)
// IssueDelegatedDownload 跟 MC 換 browser 直連 FAA 用的 opaque token。
//
// 流程:
// 1. 先取 service tokenscope=files:download.delegate— 內部呼 ServiceToken
// 2. POST {issuer}/file-access/download-tokens
// 3. 回 opaque token + 過期時間
//
// caller 通常是 flow.DownloadRedirectURL拿到後組
// https://<faa>/files/<key>?access_token=<token>
// 走 server-side 302 redirect 給 browser見 conversion.md §10.4)。
IssueDelegatedDownload(ctx context.Context, in IssueDownloadReq) (*DelegatedDownloadToken, error)
}
// IssueDownloadReq 是 IssueDelegatedDownload 的輸入。
//
// 欄位來源trust boundary 見 conversion.md §7
// - TenantID / UserID / ObjectKey 由 visionA-backend 內部產生OIDC sub + promote 結果),
// 不接受 client 傳入
// - ExpiresInSeconds 預設 3005 分鐘),可在 caller 指定(範圍由 caller 自行檢查)
type IssueDownloadReq struct {
TenantID string
UserID string
ObjectKey string
ExpiresInSeconds int // <= 0 時自動套用預設 300
}
// DelegatedDownloadToken 是 MC 簽出來的 short-lived token。
//
// Token 是 opaqueFAA 收到後再對 MC validatevisionA-backend 不解碼。
type DelegatedDownloadToken struct {
Token string
ExpiresAt time.Time
}
// MCTokenClientOpts 是 NewMCTokenClient 的依賴注入。
//
// HTTPClient / Now / Logger 為 optionalnil 自動填預設)— 方便 unit test 注入 fake。
type MCTokenClientOpts struct {
// Issuer 是 MC issuer URL不帶結尾斜線
// 會打:
// POST {Issuer}/oauth/token
// POST {Issuer}/file-access/download-tokens
Issuer string
// ClientID / ClientSecret 是 visionA service client 在 MC 的註冊資訊。
// **禁止 commit 進 repo**;由 main.go 從 env var 讀進 config 後注入。
ClientID string
ClientSecret string
// HTTPClient 為 optionalnil 用預設timeout 10s。測試會注入 httptest.Server.Client()。
HTTPClient *http.Client
// Now 為 optionalnil 用 time.Now。測試會注入 fake clock 控制 cache 過期。
Now func() time.Time
// Logger 為 optionalnil 用 slog.Default()。
Logger *slog.Logger
}
// ==========================================================================
// 內部實作
// ==========================================================================
// 內部固定常數(不對外,避免 caller hardcode
const (
// tokenRefreshSkew 是 cache 過期判斷的緩衝now() >= exp - skew 視為過期。
// 15s 對齊 conversion.md §2.4 / §5.2。
tokenRefreshSkew = 15 * time.Second
// httpTimeout 是預設 HTTP client timeoutdialer + response 整體)。
httpTimeout = 10 * time.Second
// maxRetries 是 5xx / network / timeout 的最大重試次數(不含第一次)。
// 對齊 conversion.md §9.1MC oauth/token 與 file-access/download-tokens 都 max 2 次。
maxRetries = 2
// retryBaseDelay 是指數退避的 base1s, 2s
retryBaseDelay = 1 * time.Second
// defaultDelegatedTTL 是 IssueDelegatedDownload 預設 TTLcaller 不傳就 300
defaultDelegatedTTL = 300
)
// cachedToken 是 ServiceToken cache 內部結構。
type cachedToken struct {
token string
expiresAt time.Time
}
// mcTokenClient 是 MCTokenClient 的預設實作。
//
// 套件內 unexported structcaller 拿 interface讓未來換實作不影響 caller。
type mcTokenClient struct {
issuer string
clientID string
clientSecret string
http *http.Client
now func() time.Time
logger *slog.Logger
// cache 由 mu 保護key=scopemulti-scope string 直接當 key
// 不做 normalize — caller 應傳穩定排序的 scope 字串)。
mu sync.Mutex // sync.Mutex 比 RWMutex 簡單fetch 路徑 IO boundRWMutex 沒有實質好處
cache map[string]cachedToken
}
// NewMCTokenClient 建立一個 MCTokenClient 實例。
//
// 必填Issuer / ClientID / ClientSecret。其他 optional。
// 注意constructor 不會驗 Issuer 連線,第一次 ServiceToken 呼叫才會打網路。
func NewMCTokenClient(opts MCTokenClientOpts) MCTokenClient {
httpClient := opts.HTTPClient
if httpClient == nil {
httpClient = &http.Client{Timeout: httpTimeout}
}
now := opts.Now
if now == nil {
now = time.Now
}
logger := opts.Logger
if logger == nil {
logger = slog.Default()
}
return &mcTokenClient{
issuer: strings.TrimRight(opts.Issuer, "/"),
clientID: opts.ClientID,
clientSecret: opts.ClientSecret,
http: httpClient,
now: now,
logger: logger,
cache: make(map[string]cachedToken),
}
}
// ==========================================================================
// ServiceToken 實作(含 DCL cache
// ==========================================================================
// ServiceToken 實作 MCTokenClient.ServiceToken。
//
// DCL 流程:
// 1. 拿鎖 → 看 cache → 還新鮮就 unlock 後 returnfast path
// 2. cache 過期 → 持鎖直接 fetch在鎖內執行 HTTP request
//
// 鎖內 fetch 的取捨:
// - 優點:實作極簡,無 in-flight Promise / sync.Once dance併發 100 個 caller 全部
// 在同一個 mutex 上排隊,第一個 fetch 完寫 cache 後,後續 caller 走 fast path
// - 缺點fetch 期間(最多 10s timeout + 2 retries = 最壞 ~13s所有同 scope 的
// caller 全部 block不同 scope 因為共用同一個 mu也會 block比 per-scope 鎖差)
//
// 為什麼不用 per-scope 鎖:
// - Phase 0.8 同時只用 1-2 個 scopeper-scope 鎖的好處邊際
// - 簡單性 > 微優化;若未來 profiling 顯示瓶頸再改 sync.Map + per-scope mutex
//
// 為什麼不用 sync.Once
// - sync.Once 不能 resetcache 過期後要重 fetch— 不適用
func (c *mcTokenClient) ServiceToken(ctx context.Context, scope string) (string, error) {
if scope == "" {
return "", fmt.Errorf("conversion/mc_token_client: scope is required")
}
c.mu.Lock()
defer c.mu.Unlock()
// fast pathcache hit 且仍新鮮
if entry, ok := c.cache[scope]; ok && c.isStillFresh(entry) {
return entry.token, nil
}
// cache miss / 過期 → fetch在鎖內執行
token, exp, err := c.fetchServiceToken(ctx, scope)
if err != nil {
// 失敗不寫 cache下次重試
return "", err
}
c.cache[scope] = cachedToken{
token: token,
expiresAt: exp,
}
return token, nil
}
// isStillFresh 判斷 cache entry 是否還能用。
// 真正的過期時間是 expiresAt - tokenRefreshSkew提前 15s 視為過期)。
func (c *mcTokenClient) isStillFresh(entry cachedToken) bool {
if entry.token == "" {
return false
}
return c.now().Before(entry.expiresAt.Add(-tokenRefreshSkew))
}
// fetchServiceToken 真正打 MC oauth/token endpoint 取 token。
// 已 retry 過所有可重試錯誤;回傳 error 時 caller 應視為 fatal這次取不到
func (c *mcTokenClient) fetchServiceToken(ctx context.Context, scope string) (string, time.Time, error) {
tokenURL := c.issuer + "/oauth/token"
form := url.Values{}
form.Set("grant_type", "client_credentials")
form.Set("scope", scope)
body, err := c.doWithRetry(ctx, endpointKindServiceToken, scope, func() (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL,
strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
// RFC 6749 §2.3.1 推薦client credentials 走 Basic auth header比 body 安全)
req.SetBasicAuth(c.clientID, c.clientSecret)
return req, nil
})
if err != nil {
return "", time.Time{}, err
}
// 解析 token endpoint response shapeRFC 6749 §5.1
var resp struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
Scope string `json:"scope,omitempty"`
}
if err := json.Unmarshal(body, &resp); err != nil {
c.logger.Warn("conversion.mc_token.parse_failed",
slog.String("endpoint", endpointKindServiceToken),
slog.String("scope", scope),
// 不 log body可能含 access_token只 log 錯誤訊息
slog.String("err", truncate(err.Error(), 100)))
// IdP 回了 200 但 body 不是合法 JSON — 視為服務暫時失常503/idp_unavailable
return "", time.Time{}, fmt.Errorf("%w: parse service token response: %v",
ErrIDPUnavailable, err)
}
if resp.AccessToken == "" || resp.ExpiresIn <= 0 {
c.logger.Warn("conversion.mc_token.invalid_shape",
slog.String("endpoint", endpointKindServiceToken),
slog.String("scope", scope),
slog.Int("access_token_length", len(resp.AccessToken)),
slog.Int("expires_in", resp.ExpiresIn))
// IdP 回了 200 但 shape 不對 — 同上視為 503/idp_unavailable
return "", time.Time{}, fmt.Errorf("%w: invalid service token response shape",
ErrIDPUnavailable)
}
expiresAt := c.now().Add(time.Duration(resp.ExpiresIn) * time.Second)
// 不 log token 本身;只 log 長度 + 過期時間(給除錯用)
c.logger.Info("conversion.mc_token.obtained",
slog.String("endpoint", endpointKindServiceToken),
slog.String("scope", scope),
slog.Int("expires_in_sec", resp.ExpiresIn),
slog.Int("token_len", len(resp.AccessToken)))
return resp.AccessToken, expiresAt, nil
}
// ==========================================================================
// IssueDelegatedDownload 實作
// ==========================================================================
// IssueDelegatedDownload 實作 MCTokenClient.IssueDelegatedDownload。
//
// 流程:
// 1. ServiceToken(ctx, "files:download.delegate") 取 service token
// 2. POST {issuer}/file-access/download-tokens (Bearer)
// 3. 回 opaque token + 過期時間
//
// 不 cache每次都新簽— delegated token TTL 短5 分鐘預設cache 沒意義。
func (c *mcTokenClient) IssueDelegatedDownload(ctx context.Context, in IssueDownloadReq) (*DelegatedDownloadToken, error) {
if in.TenantID == "" || in.UserID == "" || in.ObjectKey == "" {
return nil, fmt.Errorf("conversion/mc_token_client: tenant_id / user_id / object_key required")
}
ttl := in.ExpiresInSeconds
if ttl <= 0 {
ttl = defaultDelegatedTTL
}
// 1. 取 service token注意這個呼叫本身可能 fetch會走 cache fast path 或 fetch + retry
// ServiceToken 內部已依 §6 mapping 失敗ErrServiceClientUnauthorized / ErrIDPMisconfigured /
// ErrIDPUnavailable— 這裡用 fmt.Errorf("%w") 透傳,不再二次包裝,避免錯誤碼被「升級」成
// ErrMCTokenUnavailable 而失去原本的 i18n 區分idp_misconfig vs idp_down
serviceToken, err := c.ServiceToken(ctx, "files:download.delegate")
if err != nil {
return nil, fmt.Errorf("conversion: get service token for delegated download: %w", err)
}
endpoint := c.issuer + "/file-access/download-tokens"
reqBody, err := json.Marshal(map[string]any{
"tenant_id": in.TenantID,
"user_id": in.UserID,
"object_key": in.ObjectKey,
"method": "GET",
"expires_in_seconds": ttl,
})
if err != nil {
// 本地 marshal 失敗(理論不會發生)— 視為 MC 不可達502/mc_token_unavailable
return nil, fmt.Errorf("%w: marshal delegated download request: %v",
ErrMCTokenUnavailable, err)
}
body, err := c.doWithRetry(ctx, endpointKindDelegatedDownload, in.ObjectKey, func() (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint,
strings.NewReader(string(reqBody)))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+serviceToken)
return req, nil
})
if err != nil {
return nil, err
}
// MC delegated download token response shape
// {"token": "<opaque>", "expires_at": "<ISO8601>"}
// 若 MC 改用 expires_in_seconds這裡 fallback 處理。
var resp struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
ExpiresInSeconds int `json:"expires_in_seconds,omitempty"`
}
if err := json.Unmarshal(body, &resp); err != nil {
c.logger.Warn("conversion.mc_token.parse_failed",
slog.String("endpoint", endpointKindDelegatedDownload),
slog.String("err", truncate(err.Error(), 100)))
// MC 回 200 但 body 不是合法 JSON — 視為 MC 不可達502/mc_token_unavailable
return nil, fmt.Errorf("%w: parse delegated download response: %v",
ErrMCTokenUnavailable, err)
}
if resp.Token == "" {
c.logger.Warn("conversion.mc_token.invalid_shape",
slog.String("endpoint", endpointKindDelegatedDownload))
// 同上shape 不對視為 502/mc_token_unavailable
return nil, fmt.Errorf("%w: invalid delegated download response shape",
ErrMCTokenUnavailable)
}
expiresAt := resp.ExpiresAt
if expiresAt.IsZero() && resp.ExpiresInSeconds > 0 {
expiresAt = c.now().Add(time.Duration(resp.ExpiresInSeconds) * time.Second)
}
if expiresAt.IsZero() {
// 都沒有 → 用 caller 傳入 ttl 推算best-effort
expiresAt = c.now().Add(time.Duration(ttl) * time.Second)
}
c.logger.Info("conversion.mc_token.delegated_obtained",
slog.String("endpoint", endpointKindDelegatedDownload),
slog.Int("ttl_sec", ttl),
slog.Int("token_len", len(resp.Token)))
return &DelegatedDownloadToken{
Token: resp.Token,
ExpiresAt: expiresAt,
}, nil
}
// ==========================================================================
// HTTP 共用retry / 錯誤分類
// ==========================================================================
// endpointKind 常數 — doWithRetry / doOnce 用來區分 4xx/5xx 該映射到哪個 sentinel。
//
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §6)
const (
endpointKindServiceToken = "service_token" // MC /oauth/token
endpointKindDelegatedDownload = "delegated_download" // MC /file-access/download-tokens
)
// errClient4xx 取得「其他 4xx非 401/403」對應的 sentinel error。
// service_token endpoint → ErrIDPMisconfiguredIDP grant 設定錯誤)
// delegated_download endpoint → ErrDownloadTokenFailed換下載 token 失敗)
func errClient4xx(endpointKind string) error {
if endpointKind == endpointKindServiceToken {
return ErrIDPMisconfigured
}
return ErrDownloadTokenFailed
}
// errServer5xxOrNetwork 取得「5xx / network / timeout」對應的 sentinel error。
// service_token endpoint → ErrIDPUnavailable認證服務暫時不可用503
// delegated_download endpoint → ErrMCTokenUnavailableMC 不可達502
func errServer5xxOrNetwork(endpointKind string) error {
if endpointKind == endpointKindServiceToken {
return ErrIDPUnavailable
}
return ErrMCTokenUnavailable
}
// doWithRetry 執行一次 HTTP request遇到 5xx / network / timeout 時依
// conversion.md §9.1 退避重試。每次 retry 之間檢查 ctx.Done()。
//
// reqBuilder 是「每次 attempt 都重新建一個 *http.Request」的 closure
// — 因為 request body 可能在 retry 時已被讀完必須重建。caller 內部用
// strings.NewReader 等可重建的 body source。
//
// 4xx 不 retry直接 mapping 後 return。
//
// endpointKind 是 log 用的標記("service_token" / "delegated_download")。
// label 給 log 額外 contextscope or object_key
func (c *mcTokenClient) doWithRetry(
ctx context.Context,
endpointKind, label string,
reqBuilder func() (*http.Request, error),
) ([]byte, error) {
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
// retry 前檢查 ctx
if attempt > 0 {
select {
case <-ctx.Done():
// ctx cancel/deadline → 立即 return不 retry不包成 ErrMCTokenUnavailable
return nil, ctx.Err()
case <-time.After(retryBackoff(attempt)):
}
}
req, err := reqBuilder()
if err != nil {
// 建 request 失敗(例如 URL parse error— 視為「打不出去」的網路類問題,
// 依 endpoint 種類映射到對應 sentinel。
return nil, fmt.Errorf("%w: build request: %v",
errServer5xxOrNetwork(endpointKind), err)
}
body, classifiedErr, retryable := c.doOnce(req, endpointKind, label, attempt)
if classifiedErr == nil {
return body, nil
}
lastErr = classifiedErr
if !retryable {
// 4xx / 401-403 / ctx cancel直接 return不再 retry
return nil, classifiedErr
}
// retryable 5xx / network / timeout繼續下一輪
}
// 用完 retry 額度
c.logger.Warn("conversion.mc_token.retry_exhausted",
slog.String("endpoint", endpointKind),
slog.String("label", label),
slog.Int("attempts", maxRetries+1))
return nil, lastErr
}
// doOnce 執行一次 HTTP request回傳 body成功時+ 分類好的 error + 是否可重試。
//
// 回傳 retryable=false 表示 caller 不應 retry
// - ctx 已 cancel
// - 4xx responseclient errorretry 沒用)
// - JSON parse 失敗只在 caller 處理,不在這裡分類
func (c *mcTokenClient) doOnce(
req *http.Request,
endpointKind, label string,
attempt int,
) (body []byte, err error, retryable bool) {
startedAt := c.now()
res, err := c.http.Do(req)
duration := c.now().Sub(startedAt)
if err != nil {
// network / timeout / context cancel
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
c.logger.Warn("conversion.mc_token.ctx_cancelled",
slog.String("endpoint", endpointKind),
slog.String("label", label),
slog.Int("attempt", attempt+1),
slog.Duration("duration", duration))
return nil, err, false
}
c.logger.Warn("conversion.mc_token.network_error",
slog.String("endpoint", endpointKind),
slog.String("label", label),
slog.Int("attempt", attempt+1),
slog.Duration("duration", duration),
// err.Error() 不會含 secrethttp.Client 錯誤訊息只有 URL + 連線層 errno
// 但仍 truncate 防 log 爆量
slog.String("err", truncate(err.Error(), 200)))
return nil, fmt.Errorf("%w: %s network error: %v",
errServer5xxOrNetwork(endpointKind), endpointKind, err), true
}
defer res.Body.Close()
bodyBytes, readErr := io.ReadAll(res.Body)
if readErr != nil {
c.logger.Warn("conversion.mc_token.body_read_failed",
slog.String("endpoint", endpointKind),
slog.String("label", label),
slog.Int("status", res.StatusCode),
slog.String("err", truncate(readErr.Error(), 200)))
// body read 失敗視為網路問題,可重試(依 endpoint 映射)
return nil, fmt.Errorf("%w: read response body: %v",
errServer5xxOrNetwork(endpointKind), readErr), true
}
// 成功 2xx
if res.StatusCode >= 200 && res.StatusCode < 300 {
c.logger.Debug("conversion.mc_token.success",
slog.String("endpoint", endpointKind),
slog.String("label", label),
slog.Int("status", res.StatusCode),
slog.Int("attempt", attempt+1),
slog.Duration("duration", duration))
return bodyBytes, nil, false
}
// 錯誤分類(不寫 body 進 log — error_description 可能含 client_id / requestId
c.logger.Warn("conversion.mc_token.endpoint_error",
slog.String("endpoint", endpointKind),
slog.String("label", label),
slog.Int("status", res.StatusCode),
slog.Int("attempt", attempt+1),
slog.Duration("duration", duration))
// 401 / 403client 認證失敗 — 不可重試(重試也會繼續 401
// 兩個 endpoint 都用同一個 sentinelcaller 可用 errors.Is 做精細處理,
// 例如 cache invalidate對外仍透過 ErrorCode mask 成 idp_misconfigured/500
if res.StatusCode == http.StatusUnauthorized || res.StatusCode == http.StatusForbidden {
return nil, fmt.Errorf("%w: %s endpoint returned %d",
ErrServiceClientUnauthorized, endpointKind, res.StatusCode), false
}
// 其他 4xx不可重試 — 依 endpoint 種類映射到對應 sentinel
// service_token → ErrIDPMisconfigured (500/idp_misconfigured)
// delegated_download → ErrDownloadTokenFailed (502/download_token_failed)
if res.StatusCode >= 400 && res.StatusCode < 500 {
return nil, fmt.Errorf("%w: %s endpoint returned %d",
errClient4xx(endpointKind), endpointKind, res.StatusCode), false
}
// 5xx可重試 — 依 endpoint 種類映射到對應 sentinel
// service_token → ErrIDPUnavailable (503/idp_unavailable)
// delegated_download → ErrMCTokenUnavailable (502/mc_token_unavailable)
return nil, fmt.Errorf("%w: %s endpoint returned %d",
errServer5xxOrNetwork(endpointKind), endpointKind, res.StatusCode), true
}
// retryBackoff 回傳第 n 次 retryn 從 1 開始)的等待時間。
// 1 → 1s, 2 → 2s對齊 conversion.md §9.1
//
// 不加 jitter — Phase 0.8 預期同時 fetch 的 caller 已被 DCL 收斂到單一執行,
// 不會有大量併發打 MCjitter 邊際效益低。
func retryBackoff(attempt int) time.Duration {
if attempt < 1 {
return retryBaseDelay
}
return retryBaseDelay * time.Duration(attempt)
}
// truncate 把字串截到 max 長度(避免 log 太長)。
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "...(truncated)"
}

View File

@ -0,0 +1,864 @@
// MC Token Client 單元測試。
//
// 測試策略:
// - 用 httptest.Server mock MCaccept counter / atomic 驗 retry / cache 行為
// - 用 fake clock 控制時間(測 cache 過期)
// - 用 silent logger 避免 test 輸出污染assert 過程仍可 inspect
//
// 對應 task 規範必含 11 個 case本檔每個都有對應 test func。
//
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.4 / §5)
package conversion
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// silentLogger 是 test 用的 no-op logger避免 test 輸出污染。
func silentLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// fakeClock 提供可控的時間源;用 atomic 操作 nano 確保 race-free。
type fakeClock struct {
nano atomic.Int64 // unix nano
}
func newFakeClock(t time.Time) *fakeClock {
c := &fakeClock{}
c.nano.Store(t.UnixNano())
return c
}
func (c *fakeClock) now() time.Time {
return time.Unix(0, c.nano.Load())
}
func (c *fakeClock) advance(d time.Duration) {
c.nano.Add(int64(d))
}
// ==========================================================================
// mock helpers — 模擬 MC oauth/token + file-access/download-tokens 兩個 endpoint
// ==========================================================================
// tokenServerOpts 控制 mock server 行為。
type tokenServerOpts struct {
// expiresIn 是回給 caller 的 expires_in預設 3600
expiresIn int
// statusFn 控制每次 request 的 HTTP status預設 200
statusFn func(callIdx int) int
// tokenFn 控制每次 request 的 access_token 內容;預設 "tok-{idx}"
tokenFn func(callIdx int) string
// delay 是 server 回應前的等待(測 timeout / cancel 用)
delay time.Duration
// invalidJSON 為 true 時回非 JSON body測 parse error
invalidJSON bool
// emptyToken 為 true 時回 access_token=""(測 invalid shape
emptyToken bool
}
// newTokenServer 建立一個 mock MC server提供 /oauth/token endpoint。
//
// 回傳server URL、call counteratomic可用來驗 fetch 次數)、收到的 last form values。
func newTokenServer(t *testing.T, opts tokenServerOpts) (*httptest.Server, *atomic.Int32, *sync.Map) {
t.Helper()
var counter atomic.Int32
lastForm := &sync.Map{} // map[int]url.Valueskey 是 call idx
if opts.expiresIn == 0 {
opts.expiresIn = 3600
}
if opts.statusFn == nil {
opts.statusFn = func(int) int { return 200 }
}
if opts.tokenFn == nil {
opts.tokenFn = func(idx int) string { return fmt.Sprintf("tok-%d", idx) }
}
mux := http.NewServeMux()
mux.HandleFunc("/oauth/token", func(w http.ResponseWriter, r *http.Request) {
idx := int(counter.Add(1)) - 1
// 驗 Basic auth + Content-Type 都對
if _, _, ok := r.BasicAuth(); !ok {
t.Errorf("oauth/token expected Basic auth header, got none")
}
if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/x-www-form-urlencoded") {
t.Errorf("oauth/token expected form content-type, got %q", r.Header.Get("Content-Type"))
}
// 解 body 存起來給 test 檢查
_ = r.ParseForm()
// 拷一份 r.Form 進 sync.Mapr.Form 之後可能被 server 覆寫)
form := url.Values{}
for k, v := range r.Form {
form[k] = append([]string(nil), v...)
}
lastForm.Store(idx, form)
if opts.delay > 0 {
select {
case <-time.After(opts.delay):
case <-r.Context().Done():
return
}
}
status := opts.statusFn(idx)
if status != 200 {
w.WriteHeader(status)
_, _ = w.Write([]byte(`{"error":"server_error"}`))
return
}
if opts.invalidJSON {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`<not json>`))
return
}
token := opts.tokenFn(idx)
if opts.emptyToken {
token = ""
}
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(w, `{"access_token":"%s","token_type":"Bearer","expires_in":%d}`,
token, opts.expiresIn)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv, &counter, lastForm
}
// downloadServerOpts 控制 download-tokens mock 行為。
type downloadServerOpts struct {
tokenStatusFn func(callIdx int) int // /oauth/token 端的 status預設 200
downloadStatusFn func(callIdx int) int // /file-access/download-tokens 的 status預設 200
respBody string // /file-access/download-tokens 的回應 body預設 happy path
}
// newDownloadServer 同時 mock /oauth/token + /file-access/download-tokens。
//
// 回傳server URL、download endpoint call counter、收到的 last download body解 JSON 後)。
func newDownloadServer(t *testing.T, opts downloadServerOpts) (
srv *httptest.Server,
tokenCounter, downloadCounter *atomic.Int32,
lastDownloadBody *string,
) {
t.Helper()
var tCounter, dCounter atomic.Int32
var bodyMu sync.Mutex
var lastBody string
if opts.tokenStatusFn == nil {
opts.tokenStatusFn = func(int) int { return 200 }
}
if opts.downloadStatusFn == nil {
opts.downloadStatusFn = func(int) int { return 200 }
}
mux := http.NewServeMux()
mux.HandleFunc("/oauth/token", func(w http.ResponseWriter, r *http.Request) {
idx := int(tCounter.Add(1)) - 1
status := opts.tokenStatusFn(idx)
if status != 200 {
w.WriteHeader(status)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"svc-tok","token_type":"Bearer","expires_in":3600}`))
})
mux.HandleFunc("/file-access/download-tokens", func(w http.ResponseWriter, r *http.Request) {
idx := int(dCounter.Add(1)) - 1
// 把收到的 body 存起來給 test 驗 shape
body, _ := io.ReadAll(r.Body)
bodyMu.Lock()
lastBody = string(body)
bodyMu.Unlock()
// 驗 Bearer token 有送
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
t.Errorf("download endpoint expected Bearer auth, got %q", auth)
}
status := opts.downloadStatusFn(idx)
if status != 200 {
w.WriteHeader(status)
return
}
body2 := opts.respBody
if body2 == "" {
// happy path: 回一個 future expires_at
body2 = fmt.Sprintf(`{"token":"opaque-tok-%d","expires_at":"%s"}`,
idx, time.Now().UTC().Add(5*time.Minute).Format(time.RFC3339))
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(body2))
})
srv = httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv, &tCounter, &dCounter, func() *string {
bodyMu.Lock()
defer bodyMu.Unlock()
s := lastBody
return &s
}()
}
// newClient 建一個測試用的 mcTokenClient注入 fake clock 與 silent logger。
func newClient(srv *httptest.Server, clock *fakeClock) MCTokenClient {
opts := MCTokenClientOpts{
Issuer: srv.URL,
ClientID: "visiona-svc-id",
ClientSecret: "visiona-svc-secret",
HTTPClient: srv.Client(),
Logger: silentLogger(),
}
if clock != nil {
opts.Now = clock.now
}
return NewMCTokenClient(opts)
}
// ==========================================================================
// ServiceToken — cache / fetch / retry 系列
// ==========================================================================
func TestServiceToken_FirstCall_Fetches(t *testing.T) {
t.Parallel()
srv, counter, lastForm := newTokenServer(t, tokenServerOpts{})
c := newClient(srv, nil)
tok, err := c.ServiceToken(context.Background(), "converter:job.write")
require.NoError(t, err)
assert.Equal(t, "tok-0", tok)
assert.Equal(t, int32(1), counter.Load(), "第一次呼叫應該真的打 MC")
// 驗 form values 對齊 RFC 6749 §4.4
if v, ok := lastForm.Load(0); ok {
form := v.(url.Values)
assert.Equal(t, "client_credentials", form.Get("grant_type"))
assert.Equal(t, "converter:job.write", form.Get("scope"))
} else {
t.Fatal("server did not record form")
}
}
func TestServiceToken_CacheHit(t *testing.T) {
t.Parallel()
srv, counter, _ := newTokenServer(t, tokenServerOpts{expiresIn: 3600})
c := newClient(srv, nil)
scope := "converter:job.write"
tok1, err := c.ServiceToken(context.Background(), scope)
require.NoError(t, err)
tok2, err := c.ServiceToken(context.Background(), scope)
require.NoError(t, err)
tok3, err := c.ServiceToken(context.Background(), scope)
require.NoError(t, err)
assert.Equal(t, tok1, tok2)
assert.Equal(t, tok2, tok3)
assert.Equal(t, int32(1), counter.Load(), "後續呼叫應走 cache不打 MC")
}
func TestServiceToken_Expired_Refetch(t *testing.T) {
t.Parallel()
clock := newFakeClock(time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC))
srv, counter, _ := newTokenServer(t, tokenServerOpts{expiresIn: 60}) // 60s TTL
c := newClient(srv, clock)
scope := "converter:job.write"
tok1, err := c.ServiceToken(context.Background(), scope)
require.NoError(t, err)
assert.Equal(t, int32(1), counter.Load())
// 推進到 exp - skew 之後60s - 15s = 45s應視為過期
clock.advance(46 * time.Second)
tok2, err := c.ServiceToken(context.Background(), scope)
require.NoError(t, err)
assert.NotEqual(t, tok1, tok2, "過期後應拿到新 token")
assert.Equal(t, int32(2), counter.Load(), "過期後應重 fetch")
}
func TestServiceToken_DifferentScope_DifferentCache(t *testing.T) {
t.Parallel()
srv, counter, _ := newTokenServer(t, tokenServerOpts{expiresIn: 3600})
c := newClient(srv, nil)
tokA1, err := c.ServiceToken(context.Background(), "scope-a")
require.NoError(t, err)
tokB1, err := c.ServiceToken(context.Background(), "scope-b")
require.NoError(t, err)
tokA2, err := c.ServiceToken(context.Background(), "scope-a")
require.NoError(t, err)
tokB2, err := c.ServiceToken(context.Background(), "scope-b")
require.NoError(t, err)
assert.Equal(t, tokA1, tokA2, "同 scope 應走 cache")
assert.Equal(t, tokB1, tokB2)
assert.NotEqual(t, tokA1, tokB1, "不同 scope 應有不同 token")
assert.Equal(t, int32(2), counter.Load(), "兩個 scope 各 fetch 一次")
}
// TestServiceToken_Concurrent_OnlyOneFetch — 100 個 goroutine 同時要 tokenDCL 確保只 fetch 一次。
//
// 實作細節mock server 回應有 50ms delay確保第一個 fetch 還沒回前所有 caller 都已進來;
// DCL 應讓他們全部 block 在 mu.Lock(),第一個 fetch 完寫 cache 後,後續 caller 走 fast path。
func TestServiceToken_Concurrent_OnlyOneFetch(t *testing.T) {
t.Parallel()
srv, counter, _ := newTokenServer(t, tokenServerOpts{
expiresIn: 3600,
delay: 50 * time.Millisecond,
})
c := newClient(srv, nil)
const N = 100
var wg sync.WaitGroup
wg.Add(N)
tokens := make([]string, N)
errs := make([]error, N)
start := make(chan struct{})
for i := 0; i < N; i++ {
go func(idx int) {
defer wg.Done()
<-start
tok, err := c.ServiceToken(context.Background(), "converter:job.write")
tokens[idx] = tok
errs[idx] = err
}(i)
}
close(start)
wg.Wait()
for _, e := range errs {
require.NoError(t, e)
}
for i := 1; i < N; i++ {
assert.Equal(t, tokens[0], tokens[i], "所有 goroutine 應拿到同一個 token")
}
assert.Equal(t, int32(1), counter.Load(), "DCL 應確保 100 個 caller 只打一次 MC")
}
func TestServiceToken_Server4xx_NoRetry(t *testing.T) {
t.Parallel()
srv, counter, _ := newTokenServer(t, tokenServerOpts{
statusFn: func(int) int { return 401 },
})
c := newClient(srv, nil)
_, err := c.ServiceToken(context.Background(), "converter:job.write")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrServiceClientUnauthorized),
"401 應 mapping 到 ErrServiceClientUnauthorized, got %v", err)
assert.False(t, errors.Is(err, ErrMCTokenUnavailable),
"401 不應同時掛 ErrMCTokenUnavailable")
assert.Equal(t, int32(1), counter.Load(), "401 不應 retry")
}
func TestServiceToken_Server403_NoRetry(t *testing.T) {
t.Parallel()
srv, counter, _ := newTokenServer(t, tokenServerOpts{
statusFn: func(int) int { return 403 },
})
c := newClient(srv, nil)
_, err := c.ServiceToken(context.Background(), "converter:job.write")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrServiceClientUnauthorized))
assert.Equal(t, int32(1), counter.Load(), "403 不應 retry")
}
func TestServiceToken_Server400_NoRetry(t *testing.T) {
t.Parallel()
srv, counter, _ := newTokenServer(t, tokenServerOpts{
statusFn: func(int) int { return 400 },
})
c := newClient(srv, nil)
_, err := c.ServiceToken(context.Background(), "converter:job.write")
require.Error(t, err)
// §6MC token endpoint 4xx (非 401/403) → idp_misconfigured / 500
assert.True(t, errors.Is(err, ErrIDPMisconfigured),
"service_token 4xx 應 mapping 到 ErrIDPMisconfigured§6, got %v", err)
assert.False(t, errors.Is(err, ErrServiceClientUnauthorized),
"400 不應掛 ErrServiceClientUnauthorized限 401/403")
assert.False(t, errors.Is(err, ErrMCTokenUnavailable),
"service_token 4xx 不應掛 ErrMCTokenUnavailable§6 該 sentinel 限 delegated 5xx 用)")
assert.Equal(t, int32(1), counter.Load(), "400 不應 retry")
}
func TestServiceToken_Server5xx_Retry(t *testing.T) {
t.Parallel()
// 前兩次 500、第三次 200
srv, counter, _ := newTokenServer(t, tokenServerOpts{
statusFn: func(idx int) int {
if idx < 2 {
return 500
}
return 200
},
})
// 把 retryBaseDelay 暫時縮短,避免 test 等太久(用環境變數無法 — 改用 dial-down opts
// 這裡選擇接受真實 1s + 2s = 3s 的等待test 內可接受)
c := newClient(srv, nil)
tok, err := c.ServiceToken(context.Background(), "converter:job.write")
require.NoError(t, err)
assert.Equal(t, "tok-2", tok, "第三次成功的 token")
assert.Equal(t, int32(3), counter.Load(), "5xx 應 retry 兩次後第三次成功")
}
func TestServiceToken_Server5xx_Exhausted(t *testing.T) {
t.Parallel()
srv, counter, _ := newTokenServer(t, tokenServerOpts{
statusFn: func(int) int { return 500 },
})
c := newClient(srv, nil)
_, err := c.ServiceToken(context.Background(), "converter:job.write")
require.Error(t, err)
// §6MC token endpoint 5xx / network 持續失敗 → idp_unavailable / 503
assert.True(t, errors.Is(err, ErrIDPUnavailable),
"service_token 連續 5xx 應 mapping 到 ErrIDPUnavailable§6, got %v", err)
assert.False(t, errors.Is(err, ErrMCTokenUnavailable),
"service_token 5xx 不應掛 ErrMCTokenUnavailable§6 該 sentinel 限 delegated 5xx 用)")
// 第一次 + 2 次 retry = 3 次 attempt
assert.Equal(t, int32(3), counter.Load(), "5xx 應 attempt 3 次")
}
func TestServiceToken_ContextCancel_NoRetry(t *testing.T) {
t.Parallel()
// server 回應有 500ms delay給我們時間 cancel
srv, counter, _ := newTokenServer(t, tokenServerOpts{
delay: 500 * time.Millisecond,
})
c := newClient(srv, nil)
ctx, cancel := context.WithCancel(context.Background())
// 50ms 後 cancel在 server response 之前)
go func() {
time.Sleep(50 * time.Millisecond)
cancel()
}()
_, err := c.ServiceToken(ctx, "converter:job.write")
require.Error(t, err)
// ctx cancel 在 service_token endpoint
// - http.Client 端攔到 ctx cancel → 透傳 context.Canceled不包 sentinel
// - 透過 fmt.Errorf("%w") 包過 → ErrIDPUnavailable§6 service_token network 失敗映射)
// 兩者擇一即為合法
assert.True(t,
errors.Is(err, context.Canceled) || errors.Is(err, ErrIDPUnavailable),
"ctx cancel 應立即 returncontext.Canceled 或 ErrIDPUnavailable wrapgot %v", err)
// counter 可能是 1server 收到了但 client 在等回應時 cancel不應該 retry
assert.LessOrEqual(t, counter.Load(), int32(1),
"ctx cancel 不應 retrycounter <= 1")
}
func TestServiceToken_InvalidJSON_TreatedAsError(t *testing.T) {
t.Parallel()
srv, _, _ := newTokenServer(t, tokenServerOpts{invalidJSON: true})
c := newClient(srv, nil)
_, err := c.ServiceToken(context.Background(), "converter:job.write")
require.Error(t, err)
// §6service_token endpoint 回 200 但 body 不合法 — 視為 IDP 暫時不可用503/idp_unavailable
assert.True(t, errors.Is(err, ErrIDPUnavailable),
"service_token JSON parse error 應 mapping 到 ErrIDPUnavailable§6, got %v", err)
}
func TestServiceToken_EmptyTokenInResponse_TreatedAsError(t *testing.T) {
t.Parallel()
srv, _, _ := newTokenServer(t, tokenServerOpts{emptyToken: true})
c := newClient(srv, nil)
_, err := c.ServiceToken(context.Background(), "converter:job.write")
require.Error(t, err)
// §6service_token endpoint shape 不對 — 同 IdP 失常503/idp_unavailable
assert.True(t, errors.Is(err, ErrIDPUnavailable),
"空 access_token 應 mapping 到 ErrIDPUnavailable§6, got %v", err)
}
func TestServiceToken_FailureNotCached(t *testing.T) {
t.Parallel()
// 第一次 500 (+2 retry 都 500),第四次(即第二次 ServiceToken 呼叫的第一個 attempt成功
var phase atomic.Int32
srv, counter, _ := newTokenServer(t, tokenServerOpts{
statusFn: func(idx int) int {
if phase.Load() == 0 {
return 500
}
return 200
},
})
c := newClient(srv, nil)
_, err := c.ServiceToken(context.Background(), "converter:job.write")
require.Error(t, err, "第一次預期失敗")
assert.Equal(t, int32(3), counter.Load())
// 切換到 success phase
phase.Store(1)
tok, err := c.ServiceToken(context.Background(), "converter:job.write")
require.NoError(t, err, "第二次應成功(之前的失敗不應 cache")
assert.NotEmpty(t, tok)
assert.Equal(t, int32(4), counter.Load(), "第二次 ServiceToken 應重新打 MC")
}
// ==========================================================================
// IssueDelegatedDownload 系列
// ==========================================================================
func TestIssueDelegatedDownload_Success(t *testing.T) {
t.Parallel()
srv, _, dCounter, _ := newDownloadServer(t, downloadServerOpts{})
c := newClient(srv, nil)
dl, err := c.IssueDelegatedDownload(context.Background(), IssueDownloadReq{
TenantID: "tenant-x",
UserID: "user-y",
ObjectKey: "promoted/job-1.nef",
ExpiresInSeconds: 600,
})
require.NoError(t, err)
require.NotNil(t, dl)
assert.Contains(t, dl.Token, "opaque-tok-")
assert.True(t, dl.ExpiresAt.After(time.Now()), "expires_at 應在未來")
assert.Equal(t, int32(1), dCounter.Load())
}
// TestIssueDelegatedDownload_RequestBodyShape 驗 POST /file-access/download-tokens 的 body shape
// 對齊 conversion.md §1 + §2.4。
func TestIssueDelegatedDownload_RequestBodyShape(t *testing.T) {
t.Parallel()
// 自訂 server 收 body 後驗 shape
var lastBody string
mux := http.NewServeMux()
mux.HandleFunc("/oauth/token", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"svc-tok","token_type":"Bearer","expires_in":3600}`))
})
mux.HandleFunc("/file-access/download-tokens", func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
lastBody = string(body)
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
assert.True(t, strings.HasPrefix(r.Header.Get("Authorization"), "Bearer svc-tok"),
"應帶 service token 為 Bearer auth")
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(w, `{"token":"opaque","expires_at":"%s"}`,
time.Now().UTC().Add(5*time.Minute).Format(time.RFC3339))
})
srv := httptest.NewServer(mux)
defer srv.Close()
c := NewMCTokenClient(MCTokenClientOpts{
Issuer: srv.URL,
ClientID: "id",
ClientSecret: "sec",
HTTPClient: srv.Client(),
Logger: silentLogger(),
})
_, err := c.IssueDelegatedDownload(context.Background(), IssueDownloadReq{
TenantID: "tenant-z",
UserID: "user-a",
ObjectKey: "a/b/c.nef",
ExpiresInSeconds: 300,
})
require.NoError(t, err)
// 驗 body shape — JSON 含必要欄位
assert.Contains(t, lastBody, `"tenant_id":"tenant-z"`)
assert.Contains(t, lastBody, `"user_id":"user-a"`)
assert.Contains(t, lastBody, `"object_key":"a/b/c.nef"`)
assert.Contains(t, lastBody, `"method":"GET"`)
assert.Contains(t, lastBody, `"expires_in_seconds":300`)
}
func TestIssueDelegatedDownload_DefaultTTL(t *testing.T) {
t.Parallel()
var lastBody string
mux := http.NewServeMux()
mux.HandleFunc("/oauth/token", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"svc-tok","token_type":"Bearer","expires_in":3600}`))
})
mux.HandleFunc("/file-access/download-tokens", func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
lastBody = string(body)
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(w, `{"token":"opaque","expires_at":"%s"}`,
time.Now().UTC().Add(5*time.Minute).Format(time.RFC3339))
})
srv := httptest.NewServer(mux)
defer srv.Close()
c := NewMCTokenClient(MCTokenClientOpts{
Issuer: srv.URL,
ClientID: "id",
ClientSecret: "sec",
HTTPClient: srv.Client(),
Logger: silentLogger(),
})
// 不傳 ExpiresInSeconds=0應自動套 default 300
_, err := c.IssueDelegatedDownload(context.Background(), IssueDownloadReq{
TenantID: "t",
UserID: "u",
ObjectKey: "k",
})
require.NoError(t, err)
assert.Contains(t, lastBody, `"expires_in_seconds":300`,
"ExpiresInSeconds 為 0 時應 fallback 到 default 300")
}
func TestIssueDelegatedDownload_Server4xx_PropagateError(t *testing.T) {
t.Parallel()
srv, _, dCounter, _ := newDownloadServer(t, downloadServerOpts{
downloadStatusFn: func(int) int { return 400 },
})
c := newClient(srv, nil)
_, err := c.IssueDelegatedDownload(context.Background(), IssueDownloadReq{
TenantID: "t",
UserID: "u",
ObjectKey: "k",
})
require.Error(t, err)
// §6MC delegated download 4xx → download_token_failed / 502
assert.True(t, errors.Is(err, ErrDownloadTokenFailed),
"delegated 4xx 應 mapping 到 ErrDownloadTokenFailed§6, got %v", err)
assert.False(t, errors.Is(err, ErrMCTokenUnavailable),
"delegated 4xx 不應掛 ErrMCTokenUnavailable§6 該 sentinel 限 5xx 用)")
assert.Equal(t, int32(1), dCounter.Load(), "4xx 不應 retry")
}
func TestIssueDelegatedDownload_Server5xx_RetryThenFail(t *testing.T) {
t.Parallel()
srv, _, dCounter, _ := newDownloadServer(t, downloadServerOpts{
downloadStatusFn: func(int) int { return 500 },
})
c := newClient(srv, nil)
_, err := c.IssueDelegatedDownload(context.Background(), IssueDownloadReq{
TenantID: "t",
UserID: "u",
ObjectKey: "k",
})
require.Error(t, err)
// §6MC delegated download 5xx / network 持續失敗 → mc_token_unavailable / 502不變
assert.True(t, errors.Is(err, ErrMCTokenUnavailable),
"delegated 5xx 應 mapping 到 ErrMCTokenUnavailable§6, got %v", err)
assert.False(t, errors.Is(err, ErrDownloadTokenFailed),
"delegated 5xx 不應掛 ErrDownloadTokenFailed§6 該 sentinel 限 4xx 用)")
assert.Equal(t, int32(3), dCounter.Load(), "5xx 應 attempt 3 次")
}
func TestIssueDelegatedDownload_Server401_PropagateUnauthorized(t *testing.T) {
t.Parallel()
srv, _, dCounter, _ := newDownloadServer(t, downloadServerOpts{
downloadStatusFn: func(int) int { return 401 },
})
c := newClient(srv, nil)
_, err := c.IssueDelegatedDownload(context.Background(), IssueDownloadReq{
TenantID: "t",
UserID: "u",
ObjectKey: "k",
})
require.Error(t, err)
assert.True(t, errors.Is(err, ErrServiceClientUnauthorized),
"download 401 應 mapping 到 ErrServiceClientUnauthorized, got %v", err)
assert.Equal(t, int32(1), dCounter.Load(), "401 不應 retry")
}
func TestIssueDelegatedDownload_ServiceTokenFailure_Propagated(t *testing.T) {
t.Parallel()
srv, tCounter, dCounter, _ := newDownloadServer(t, downloadServerOpts{
tokenStatusFn: func(int) int { return 500 }, // service token 完全取不到
})
c := newClient(srv, nil)
_, err := c.IssueDelegatedDownload(context.Background(), IssueDownloadReq{
TenantID: "t",
UserID: "u",
ObjectKey: "k",
})
require.Error(t, err)
// §6失敗源頭是 service_token endpoint 5xx → ErrIDPUnavailable
// IssueDelegatedDownload 用 fmt.Errorf("%w") 透傳,不會升級成 ErrMCTokenUnavailable
// 確保前端 i18n 能正確顯示「認證服務暫時無法使用」而非「無法取得下載授權」。
assert.True(t, errors.Is(err, ErrIDPUnavailable),
"service token 5xx 透傳 → ErrIDPUnavailable§6, got %v", err)
assert.False(t, errors.Is(err, ErrMCTokenUnavailable),
"不應被升級成 ErrMCTokenUnavailable否則 i18n 訊息會錯")
assert.Equal(t, int32(3), tCounter.Load(), "service token 5xx 應 attempt 3 次")
assert.Equal(t, int32(0), dCounter.Load(), "service token 失敗時不應打 download endpoint")
}
// TestIssueDelegatedDownload_ServiceTokenAuthFailure_Propagated — service_token 401/403 透傳。
//
// §6 mapping401/403 用 ErrServiceClientUnauthorized對外仍 mask 成 idp_misconfigured/500
// 確認 IssueDelegatedDownload 用 fmt.Errorf("%w") 透傳後errors.Is 仍能命中。
func TestIssueDelegatedDownload_ServiceTokenAuthFailure_Propagated(t *testing.T) {
t.Parallel()
srv, tCounter, dCounter, _ := newDownloadServer(t, downloadServerOpts{
tokenStatusFn: func(int) int { return 401 },
})
c := newClient(srv, nil)
_, err := c.IssueDelegatedDownload(context.Background(), IssueDownloadReq{
TenantID: "t",
UserID: "u",
ObjectKey: "k",
})
require.Error(t, err)
assert.True(t, errors.Is(err, ErrServiceClientUnauthorized),
"service token 401 透傳 → ErrServiceClientUnauthorized§5.2, got %v", err)
assert.Equal(t, int32(1), tCounter.Load(), "401 不應 retry")
assert.Equal(t, int32(0), dCounter.Load(), "service token 401 時不應打 download endpoint")
}
// TestIssueDelegatedDownload_ServiceToken4xxNonAuth_Propagated — service_token 400 透傳成 IDP 設定錯誤。
//
// §6 mappingservice_token 4xx (非 401/403) → ErrIDPMisconfigured500/idp_misconfigured
// 這是「IDP grant 設定錯」而非「下載授權失敗」— 區分 i18n 訊息。
func TestIssueDelegatedDownload_ServiceToken4xxNonAuth_Propagated(t *testing.T) {
t.Parallel()
srv, tCounter, dCounter, _ := newDownloadServer(t, downloadServerOpts{
tokenStatusFn: func(int) int { return 400 },
})
c := newClient(srv, nil)
_, err := c.IssueDelegatedDownload(context.Background(), IssueDownloadReq{
TenantID: "t",
UserID: "u",
ObjectKey: "k",
})
require.Error(t, err)
assert.True(t, errors.Is(err, ErrIDPMisconfigured),
"service token 400 透傳 → ErrIDPMisconfigured§6, got %v", err)
assert.False(t, errors.Is(err, ErrDownloadTokenFailed),
"不應掛 ErrDownloadTokenFailed那是 delegated endpoint 4xx 的錯誤碼)")
assert.Equal(t, int32(1), tCounter.Load(), "400 不應 retry")
assert.Equal(t, int32(0), dCounter.Load(), "service token 4xx 時不應打 download endpoint")
}
func TestIssueDelegatedDownload_RequiredFieldsValidation(t *testing.T) {
t.Parallel()
c := NewMCTokenClient(MCTokenClientOpts{
Issuer: "http://localhost:9999", // 不會真的打到
ClientID: "id",
ClientSecret: "sec",
Logger: silentLogger(),
})
cases := []struct {
name string
in IssueDownloadReq
}{
{"empty_tenant", IssueDownloadReq{UserID: "u", ObjectKey: "k"}},
{"empty_user", IssueDownloadReq{TenantID: "t", ObjectKey: "k"}},
{"empty_object_key", IssueDownloadReq{TenantID: "t", UserID: "u"}},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, err := c.IssueDelegatedDownload(context.Background(), tc.in)
require.Error(t, err, "缺必填欄位應 fail-fast")
})
}
}
// ==========================================================================
// Constructor / 邊界
// ==========================================================================
func TestNewMCTokenClient_NilOptsDefaults(t *testing.T) {
t.Parallel()
c := NewMCTokenClient(MCTokenClientOpts{
Issuer: "http://example.com/",
ClientID: "id",
ClientSecret: "sec",
})
require.NotNil(t, c)
// 透過 type assertion 檢查預設值有套用(這是內部檢查;
// 平常 caller 不該 assert 內部 struct但 test 可以)
impl, ok := c.(*mcTokenClient)
require.True(t, ok)
assert.NotNil(t, impl.http, "HTTPClient nil 時應有預設")
assert.NotNil(t, impl.now, "Now nil 時應有預設")
assert.NotNil(t, impl.logger, "Logger nil 時應有預設")
assert.Equal(t, "http://example.com", impl.issuer, "issuer 結尾斜線應被移除")
}
func TestServiceToken_EmptyScope_ReturnsError(t *testing.T) {
t.Parallel()
c := NewMCTokenClient(MCTokenClientOpts{
Issuer: "http://localhost:9999",
ClientID: "id",
ClientSecret: "sec",
Logger: silentLogger(),
})
_, err := c.ServiceToken(context.Background(), "")
require.Error(t, err)
assert.Contains(t, err.Error(), "scope is required")
}

View File

@ -0,0 +1,314 @@
// Ownership store — visionA-backend 對 conversion job 的擁有權追蹤。
//
// 動機:
// - converter 端只認 user_idOIDC sub不認 visionA 的 OIDC cookie session
// - visionA-backend 處於 trust boundary每個 GET / promote / download / promote-to-models
// 都必須先檢查「這個 jobID 是不是當前 userID 的」,不符 → 403 forbidden
// - 對齊 conversion.md §7.2 ownership 檢查 + §2.6.1 lazy rebuild
//
// 設計:
// - in-memory mapjob_id → user_id
// - 重啟即失接受的取捨MVP 階段 — 見 conversion.md §9.2 graceful degradation
// - 重啟後第一次某 user 進 GET /api/conversion/active 或 GET /{job_id} →
// 從 converter 的 GET /api/v1/jobs?user_id=&status=in_progress 拿 in-progress jobs
// 重建該 user 的 ownershiplazy rebuild不啟動時 batch
//
// 為什麼 lazy 而非 startup batch
// - startup batch 對 converter 是 hammer重啟頻繁時尤甚且大部分 jobs 重啟期間
// 使用者根本沒在等
// - lazy 的 cost 對應 user 行為cost 上限 = 線上同時在 /conversion 頁面的 user 數
//
// Phase 1 follow-up
// - DB-backedPostgres / Redis讓重啟不失資料 — progress.md 已記
// - 加 user → []job_id 的反向索引ActiveJobOf O(1)
//
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.6.1)
package conversion
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"log/slog"
"sync"
"time"
)
// ==========================================================================
// 對外 interface
// ==========================================================================
// Ownership 是 visionA-backend 對 conversion job 的擁有權追蹤。
//
// In-memory map: job_id → user_id。重啟即失需 lazy rebuild
// 第一次某 user 進 GET /api/conversion/{id} 或 /active 時,若 cache 沒
// 該 user 任何項,從 converter list 拿 in_progress jobs 重建。
//
// goroutine-safe
// - jobToUser map 用 RWMutex高頻 Get / 低頻 Set / Delete
// - rebuilt 用 per-user mutexDCL pattern仿 mc_token_client 但不同 user 不互相阻塞)
type Ownership interface {
// Set 註冊 job 屬於 userinit 完成時呼叫)。
Set(jobID, userID string)
// Get 查 job 屬於誰;不在 cache 回 ("", false)。
Get(jobID string) (userID string, ok bool)
// Delete 從 cache 移除reset / job expired 時)。
Delete(jobID string)
// EnsureRebuilt 確保該 user 的 ownership 已從 converter rebuild 過。
//
// 第一次呼叫該 user 時,從 converter list 拿所有 in_progress jobs 寫進 cache。
// 後續呼叫該 user 是 noopfast path
//
// 失敗處理:
// - converter 5xx / network → 回傳 error不標 rebuilt下次重試
// - ctx cancel → 立即 return ctx.Err()
// - rebuild 內部對 converter 的呼叫帶 5s timeout用 context.WithTimeout 包裝
// ctx避免單一 caller 無限阻塞同 user 其他 caller
EnsureRebuilt(ctx context.Context, userID string) error
// ActiveJobOf 列出該 user 目前有的 active job_id從 cache不重新 rebuild
//
// caller 應先呼叫 EnsureRebuilt 確保 cache 有資料。
//
// Phase 0.8 同 user 同時最多 1 個 active jobreturn slice 通常是 0 or 1。
// Phase 1 加反向 user → []job_id 索引讓這變成 O(1)。
ActiveJobOf(userID string) []string
}
// ==========================================================================
// 內部常數
// ==========================================================================
const (
// rebuildTimeout 是單次 rebuild 對 converter 呼叫的 timeout 上限。
// 防止某個 caller 卡死同 user 其他 callerper-user mutex 的 head-of-line blocking
// 對齊 conversion.md §9.1 retry 矩陣的 list endpointmax 1 retry + 0.5s 退避,
// 加上 converter HTTP timeout 10s最壞約 ~10.5s — 設 5s 是因為 lazy rebuild 在
// frontend pre-check 路徑上UX 可接受失敗 + retry。caller (flow.go) 失敗時會 fallback。
rebuildTimeout = 5 * time.Second
)
// ==========================================================================
// 預設實作
// ==========================================================================
// ownership 是 Ownership 的 in-memory 實作。
//
// 兩組鎖分離:
// - mu: 保護 jobToUser mapSet/Get/Delete/ActiveJobOf
// - perUserLocks: 每個 user 一把 mutex避免 EnsureRebuilt 互相阻塞
//
// 為什麼不用單一全域 rebuiltMu
// - 全域 rebuiltMu 會讓 user A 的 rebuild block user B 的 rebuildrebuild 包 5s
// timeout最壞 100 user 同時進來變 500s 排隊)
// - per-user mutex 用 sync.Map 自動 lazy-init不同 user 並行進 rebuild 互不干擾
type ownership struct {
mu sync.RWMutex
jobToUser map[string]string // job_id → user_id
// perUserLocks: user_id → *sync.Mutex
// 用 sync.Map 自動處理 lazy init + 移除Phase 0.8 不主動 evict — user 量級小)
perUserLocks sync.Map
// rebuilt: user_id → 已 rebuild 過?
// 用獨立 mutex 而非 sync.Map.LoadOrStore — 因為 set 與 fetch converter 必須原子
// fetch 失敗不 setsync.Map 的 LoadOrStore 不適合這個語意
rebuiltMu sync.RWMutex
rebuilt map[string]bool
converter ConverterClient
logger *slog.Logger
}
// NewOwnership 建立一個 Ownership 實例。
//
// converter 必填lazy rebuild 依賴logger 為 optionalnil 用 slog.Default()。
func NewOwnership(converter ConverterClient, logger *slog.Logger) Ownership {
if logger == nil {
logger = slog.Default()
}
return &ownership{
jobToUser: make(map[string]string),
rebuilt: make(map[string]bool),
converter: converter,
logger: logger,
}
}
// ==========================================================================
// Set / Get / Delete — RWMutex 標準 map 保護
// ==========================================================================
// Set 寫入 ownership空字串視為 no-op防呆
func (o *ownership) Set(jobID, userID string) {
if jobID == "" || userID == "" {
return
}
o.mu.Lock()
o.jobToUser[jobID] = userID
o.mu.Unlock()
}
// Get 讀取 ownership不存在回 ("", false)。
func (o *ownership) Get(jobID string) (string, bool) {
if jobID == "" {
return "", false
}
o.mu.RLock()
userID, ok := o.jobToUser[jobID]
o.mu.RUnlock()
return userID, ok
}
// Delete 移除 ownership不存在 no-op。
func (o *ownership) Delete(jobID string) {
if jobID == "" {
return
}
o.mu.Lock()
delete(o.jobToUser, jobID)
o.mu.Unlock()
}
// ==========================================================================
// EnsureRebuilt — DCL + per-user mutex
// ==========================================================================
// EnsureRebuilt 確保 userID 的 ownership 已從 converter rebuild。
//
// DCLdouble-checked locking流程
// 1. fast path先用 RLock 看 rebuilt[userID],已 rebuild → 直接 return nil
// 2. 取該 user 的 per-user mutex不同 user 並行;同 user 序列化)
// 3. slow path拿 mutex 後再次 check rebuilt[userID](其他 caller 可能剛 rebuild 完)
// 4. 真正 fetch converter帶 rebuildTimeout
// 5. 成功 → 寫 jobToUser + 標 rebuilt[userID]=true
// 6. 失敗 → 不標,下次重試
//
// 為什麼 fast path 不直接 returnsync.Map.Load 比 RWMutex.RLock 快但 race 條件需小心;
// 這裡用 RWMutex 對 rebuilt map 一致 protect與 slow path 寫入互斥)。
func (o *ownership) EnsureRebuilt(ctx context.Context, userID string) error {
if userID == "" {
return errors.New("conversion/ownership: userID is required")
}
// fast path
o.rebuiltMu.RLock()
done := o.rebuilt[userID]
o.rebuiltMu.RUnlock()
if done {
return nil
}
// 取該 user 的 per-user mutexlazy init via sync.Map
mu := o.lockForUser(userID)
mu.Lock()
defer mu.Unlock()
// 進入 critical section 前再 check ctxcaller 可能已 cancel
if err := ctx.Err(); err != nil {
return err
}
// slow path 內部再 check其他 caller 可能剛 rebuild 完)
o.rebuiltMu.RLock()
done = o.rebuilt[userID]
o.rebuiltMu.RUnlock()
if done {
return nil
}
// 真正 fetch converter帶 rebuild timeout避免單 caller 無限阻塞同 user 其他 caller
fetchCtx, cancel := context.WithTimeout(ctx, rebuildTimeout)
defer cancel()
jobs, err := o.converter.ListInProgressJobs(fetchCtx, userID)
if err != nil {
// 失敗不標 rebuilt — 下次重試
o.logger.WarnContext(ctx, "ownership: lazy rebuild failed",
slog.String("user_hash", hashUserID(userID)),
slog.String("err", err.Error()),
)
return err
}
// 寫入 jobToUser拿 jobToUser 的 write lock
o.mu.Lock()
for _, j := range jobs {
if j == nil || j.JobID == "" {
continue
}
o.jobToUser[j.JobID] = userID
}
o.mu.Unlock()
// 標 rebuilt
o.rebuiltMu.Lock()
o.rebuilt[userID] = true
o.rebuiltMu.Unlock()
o.logger.InfoContext(ctx, "ownership: lazy rebuild done",
slog.String("user_hash", hashUserID(userID)),
slog.Int("jobs_found", len(jobs)),
)
return nil
}
// lockForUser 取(或 lazy 建立)該 user 的 mutex。
//
// 用 sync.Map.LoadOrStore併發 100 個 goroutine 同時對同 user 取 mutex
// LoadOrStore 保證所有 goroutine 拿到同一個 *sync.Mutex 實例(其他丟棄)。
func (o *ownership) lockForUser(userID string) *sync.Mutex {
if existing, ok := o.perUserLocks.Load(userID); ok {
return existing.(*sync.Mutex)
}
// LoadOrStore若不存在則寫入新建的回傳現存或新建的
// loaded=true 代表已有他人寫入,我們新建的這把丟棄
actual, _ := o.perUserLocks.LoadOrStore(userID, &sync.Mutex{})
return actual.(*sync.Mutex)
}
// ==========================================================================
// ActiveJobOf — 反查 jobToUser
// ==========================================================================
// ActiveJobOf 從 jobToUser map 反查 user 擁有的 jobID 清單。
//
// O(N) 掃描Phase 0.8 同 user 最多 1 active job、整體 jobToUser 規模也不大(內部
// 使用者 < 100 並發可接受。Phase 1 加反向索引變 O(1)。
//
// caller 應先呼叫 EnsureRebuilt這裡不主動 rebuild避免雙寫競態
func (o *ownership) ActiveJobOf(userID string) []string {
if userID == "" {
return nil
}
o.mu.RLock()
defer o.mu.RUnlock()
// 預先 alloc 0 cap 的 slice — 大多數 user 是 0 或 1 個 job
result := make([]string, 0, 1)
for jobID, uid := range o.jobToUser {
if uid == userID {
result = append(result, jobID)
}
}
return result
}
// ==========================================================================
// helpers
// ==========================================================================
// hashUserID 對 user_id 做 SHA-256 取前 8 hex char給 log 用PII 保護)。
//
// 不存原始 user_id 進 log避免 log file 洩漏 OIDC sub。
func hashUserID(userID string) string {
if userID == "" {
return ""
}
sum := sha256.Sum256([]byte(userID))
return hex.EncodeToString(sum[:])[:8]
}

View File

@ -0,0 +1,631 @@
// Ownership store 單元測試。
//
// 測試策略:
// - Set/Get/Delete 用 race detector 驗 concurrent safety
// - EnsureRebuilt 用 stub ConverterClientatomic counter 紀錄 fetch 次數)
// 驗first-call fetches / second-call noop / per-user 並行 / thundering herd 收斂
// - 失敗路徑驗error 不標 rebuilt → 下次再 fetch
//
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.6.1)
package conversion
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"strconv"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ==========================================================================
// stub ConverterClient — 只實作 ListInProgressJobs其他 panic測試不用
// ==========================================================================
// stubConverterClient 是 test 用的 fake ConverterClient。
//
// 只實作 ListInProgressJobs其他 method 測試不用panic 防呆);用 atomic counter
// 紀錄各 user 被呼叫次數。
type stubConverterClient struct {
mu sync.Mutex
// jobsByUser: user_id → 該 user 的 in_progress jobs若 nil → 空 slice
jobsByUser map[string][]*ConverterJob
// errByUser: user_id → 強制回傳的錯誤(用在失敗路徑測試)
errByUser map[string]error
// callCountByUser: user_id → ListInProgressJobs 被呼叫次數atomic counter
callCountByUser sync.Map // map[string]*atomic.Int32
// fetchDelay 模擬慢 fetch讓併發測試有機會競態
fetchDelay time.Duration
// blockSignal 若非 nil每次 ListInProgressJobs 進入時發 signal用在 timeout 測試)
blockSignal chan struct{}
// blockUntil 若非 nil會 block 在 ctx.Done 或這個 channel 任一觸發
blockUntil chan struct{}
}
func newStubConverterClient() *stubConverterClient {
return &stubConverterClient{
jobsByUser: make(map[string][]*ConverterJob),
errByUser: make(map[string]error),
}
}
func (s *stubConverterClient) setJobs(userID string, jobs []*ConverterJob) {
s.mu.Lock()
defer s.mu.Unlock()
s.jobsByUser[userID] = jobs
}
func (s *stubConverterClient) setError(userID string, err error) {
s.mu.Lock()
defer s.mu.Unlock()
s.errByUser[userID] = err
}
// callCount 取某個 user 被呼叫的次數。
func (s *stubConverterClient) callCount(userID string) int32 {
v, ok := s.callCountByUser.Load(userID)
if !ok {
return 0
}
return v.(*atomic.Int32).Load()
}
func (s *stubConverterClient) ListInProgressJobs(ctx context.Context, userID string) ([]*ConverterJob, error) {
// atomic counter
cnt, _ := s.callCountByUser.LoadOrStore(userID, &atomic.Int32{})
cnt.(*atomic.Int32).Add(1)
// 通知 caller 已進入(給 thundering herd 測試用)
if s.blockSignal != nil {
select {
case s.blockSignal <- struct{}{}:
default:
}
}
// 若有 blockUntil等到 signal 或 ctx.Done 才 return模擬慢 / cancel
if s.blockUntil != nil {
select {
case <-s.blockUntil:
case <-ctx.Done():
return nil, ctx.Err()
}
}
if s.fetchDelay > 0 {
select {
case <-time.After(s.fetchDelay):
case <-ctx.Done():
return nil, ctx.Err()
}
}
s.mu.Lock()
err := s.errByUser[userID]
jobs := s.jobsByUser[userID]
s.mu.Unlock()
if err != nil {
return nil, err
}
if jobs == nil {
jobs = []*ConverterJob{}
}
return jobs, nil
}
// 其他 method panic測試不會呼叫撞到 panic 反而好 debug
func (s *stubConverterClient) InitJob(ctx context.Context, req InitConverterJobReq) (*ConverterJob, error) {
panic("stubConverterClient.InitJob: not used in ownership_test")
}
func (s *stubConverterClient) GetJob(ctx context.Context, jobID string) (*ConverterJob, error) {
panic("stubConverterClient.GetJob: not used in ownership_test")
}
func (s *stubConverterClient) Promote(ctx context.Context, jobID string, req PromoteReq) (*ConverterPromoteResult, error) {
panic("stubConverterClient.Promote: not used in ownership_test")
}
// 確保 stubConverterClient 滿足 ConverterClient interface編譯期驗
var _ ConverterClient = (*stubConverterClient)(nil)
// ==========================================================================
// helper建立靜默 logger避免測試 stdout 噪音)
// ==========================================================================
func newSilentLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// ==========================================================================
// 基本 Set / Get / Delete
// ==========================================================================
// TestSet_Get_Delete_Basicwrite / read / delete 標準操作。
func TestSet_Get_Delete_Basic(t *testing.T) {
t.Parallel()
stub := newStubConverterClient()
o := NewOwnership(stub, newSilentLogger())
// Set + Get
o.Set("job-1", "alice")
uid, ok := o.Get("job-1")
assert.True(t, ok)
assert.Equal(t, "alice", uid)
// 覆寫
o.Set("job-1", "bob")
uid, _ = o.Get("job-1")
assert.Equal(t, "bob", uid, "Set 同 jobID 應覆寫")
// Delete
o.Delete("job-1")
_, ok = o.Get("job-1")
assert.False(t, ok, "Delete 後 Get 應回 false")
// 不存在的 jobID
_, ok = o.Get("ghost")
assert.False(t, ok)
// 防呆:空字串不寫入
o.Set("", "alice")
o.Set("job-empty-uid", "")
_, ok = o.Get("")
assert.False(t, ok)
_, ok = o.Get("job-empty-uid")
assert.False(t, ok, "空 userID 不應寫入")
}
// TestDelete_RemovesFromCacheDelete 後 Get 回 false規範必含
func TestDelete_RemovesFromCache(t *testing.T) {
t.Parallel()
stub := newStubConverterClient()
o := NewOwnership(stub, newSilentLogger())
o.Set("job-1", "alice")
o.Delete("job-1")
_, ok := o.Get("job-1")
assert.False(t, ok)
// 重複 Delete 不該 panic
o.Delete("job-1")
o.Delete("never-existed")
}
// TestSet_Concurrent100 goroutine 同時 Set 不同 job → race detector 通過。
//
// 規範必含:跑 go test -race -count=3 必綠。
func TestSet_Concurrent(t *testing.T) {
t.Parallel()
stub := newStubConverterClient()
o := NewOwnership(stub, newSilentLogger())
const N = 100
var wg sync.WaitGroup
wg.Add(N)
for i := 0; i < N; i++ {
go func(idx int) {
defer wg.Done()
jobID := "job-" + strconv.Itoa(idx)
userID := "user-" + strconv.Itoa(idx%10) // 10 種 user
o.Set(jobID, userID)
// 立即 Get 驗 not lost
uid, ok := o.Get(jobID)
assert.True(t, ok)
assert.Equal(t, userID, uid)
}(i)
}
wg.Wait()
// 驗 100 個都進去了
for i := 0; i < N; i++ {
jobID := "job-" + strconv.Itoa(i)
_, ok := o.Get(jobID)
assert.True(t, ok)
}
}
// TestSet_Get_Delete_Concurrent_Mixed併發 mixed write/read/deleterace detector 驗。
func TestSet_Get_Delete_Concurrent_Mixed(t *testing.T) {
t.Parallel()
stub := newStubConverterClient()
o := NewOwnership(stub, newSilentLogger())
const N = 50
var wg sync.WaitGroup
wg.Add(N * 3)
for i := 0; i < N; i++ {
jobID := "job-" + strconv.Itoa(i)
go func() { defer wg.Done(); o.Set(jobID, "alice") }()
go func() { defer wg.Done(); _, _ = o.Get(jobID) }()
go func() { defer wg.Done(); o.Delete(jobID) }()
}
wg.Wait()
// 不驗結果race 驗 deadlock / 共享 state corruption 即可)
}
// ==========================================================================
// EnsureRebuilt
// ==========================================================================
// TestEnsureRebuilt_FirstCall_Fetches第一次該 user 真的打 converter規範必含
func TestEnsureRebuilt_FirstCall_Fetches(t *testing.T) {
t.Parallel()
stub := newStubConverterClient()
stub.setJobs("alice", []*ConverterJob{
{JobID: "j-1", Status: "running"},
})
o := NewOwnership(stub, newSilentLogger())
err := o.EnsureRebuilt(context.Background(), "alice")
require.NoError(t, err)
assert.Equal(t, int32(1), stub.callCount("alice"), "首次應打 converter 1 次")
// 驗 jobToUser 已寫入
uid, ok := o.Get("j-1")
assert.True(t, ok)
assert.Equal(t, "alice", uid)
}
// TestEnsureRebuilt_SecondCall_NoOp第二次該 user noopatomic counter 驗,規範必含)。
func TestEnsureRebuilt_SecondCall_NoOp(t *testing.T) {
t.Parallel()
stub := newStubConverterClient()
stub.setJobs("alice", []*ConverterJob{{JobID: "j-1"}})
o := NewOwnership(stub, newSilentLogger())
require.NoError(t, o.EnsureRebuilt(context.Background(), "alice"))
require.NoError(t, o.EnsureRebuilt(context.Background(), "alice"))
require.NoError(t, o.EnsureRebuilt(context.Background(), "alice"))
assert.Equal(t, int32(1), stub.callCount("alice"),
"成功 rebuild 後同 user 後續呼叫應 noop")
}
// TestEnsureRebuilt_DifferentUsers_EachFetch不同 user 各自 fetch 一次(規範必含)。
func TestEnsureRebuilt_DifferentUsers_EachFetch(t *testing.T) {
t.Parallel()
stub := newStubConverterClient()
stub.setJobs("alice", []*ConverterJob{{JobID: "j-a"}})
stub.setJobs("bob", []*ConverterJob{{JobID: "j-b"}})
stub.setJobs("carol", []*ConverterJob{})
o := NewOwnership(stub, newSilentLogger())
require.NoError(t, o.EnsureRebuilt(context.Background(), "alice"))
require.NoError(t, o.EnsureRebuilt(context.Background(), "bob"))
require.NoError(t, o.EnsureRebuilt(context.Background(), "carol"))
assert.Equal(t, int32(1), stub.callCount("alice"))
assert.Equal(t, int32(1), stub.callCount("bob"))
assert.Equal(t, int32(1), stub.callCount("carol"))
// 二次呼叫 noop
require.NoError(t, o.EnsureRebuilt(context.Background(), "alice"))
require.NoError(t, o.EnsureRebuilt(context.Background(), "bob"))
assert.Equal(t, int32(1), stub.callCount("alice"))
assert.Equal(t, int32(1), stub.callCount("bob"))
}
// TestEnsureRebuilt_Concurrent_OnlyOneFetch同 user 100 goroutine 同時 EnsureRebuilt
// → atomic counter 驗只 fetch 一次(規範必含 — thundering herd 收斂)。
func TestEnsureRebuilt_Concurrent_OnlyOneFetch(t *testing.T) {
t.Parallel()
stub := newStubConverterClient()
stub.setJobs("alice", []*ConverterJob{{JobID: "j-1"}})
stub.fetchDelay = 50 * time.Millisecond // 故意讓 fetch 慢,放大 race window
o := NewOwnership(stub, newSilentLogger())
const N = 100
var wg sync.WaitGroup
wg.Add(N)
errs := make(chan error, N)
for i := 0; i < N; i++ {
go func() {
defer wg.Done()
if err := o.EnsureRebuilt(context.Background(), "alice"); err != nil {
errs <- err
}
}()
}
wg.Wait()
close(errs)
for err := range errs {
t.Errorf("EnsureRebuilt 失敗: %v", err)
}
assert.Equal(t, int32(1), stub.callCount("alice"),
"同 user 100 個併發 caller 應只 fetch 1 次DCL 收斂)")
}
// TestEnsureRebuilt_Concurrent_DifferentUsers_NotBlocked不同 user 並行 rebuild
// 互不阻塞per-user mutex 設計驗證)。
func TestEnsureRebuilt_Concurrent_DifferentUsers_NotBlocked(t *testing.T) {
t.Parallel()
stub := newStubConverterClient()
stub.fetchDelay = 200 * time.Millisecond
const N = 10
for i := 0; i < N; i++ {
stub.setJobs("u-"+strconv.Itoa(i), []*ConverterJob{})
}
o := NewOwnership(stub, newSilentLogger())
start := time.Now()
var wg sync.WaitGroup
wg.Add(N)
for i := 0; i < N; i++ {
uid := "u-" + strconv.Itoa(i)
go func() {
defer wg.Done()
_ = o.EnsureRebuilt(context.Background(), uid)
}()
}
wg.Wait()
elapsed := time.Since(start)
// 若 per-user mutex 失效退化成全域鎖N=10 * 200ms = 2s
// 並行情況:應該接近單次 fetch 200ms加上少量 schedule overhead
// 用 1s 當判斷線(給 CI 足夠寬裕)
assert.Less(t, elapsed, time.Second,
"不同 user rebuild 應並行per-user mutexelapsed=%v", elapsed)
}
// TestEnsureRebuilt_ConverterError_NotMarkedRebuiltconverter 5xx → 不標 rebuilt
// → 下次再 fetch規範必含
func TestEnsureRebuilt_ConverterError_NotMarkedRebuilt(t *testing.T) {
t.Parallel()
stub := newStubConverterClient()
stub.setError("alice", ErrConverterUnavailable)
o := NewOwnership(stub, newSilentLogger())
// 第一次 fetch 失敗
err := o.EnsureRebuilt(context.Background(), "alice")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrConverterUnavailable))
assert.Equal(t, int32(1), stub.callCount("alice"))
// 第二次仍會 fetch不標 rebuilt
err = o.EnsureRebuilt(context.Background(), "alice")
require.Error(t, err)
assert.Equal(t, int32(2), stub.callCount("alice"),
"上次失敗後應再次 fetch")
// 第三次成功 → 後續才會 noop
stub.setError("alice", nil)
stub.setJobs("alice", []*ConverterJob{{JobID: "j-1"}})
require.NoError(t, o.EnsureRebuilt(context.Background(), "alice"))
assert.Equal(t, int32(3), stub.callCount("alice"))
require.NoError(t, o.EnsureRebuilt(context.Background(), "alice"))
assert.Equal(t, int32(3), stub.callCount("alice"), "成功後才標 rebuilt")
}
// TestEnsureRebuilt_ContextCancelctx cancel 立即 return規範必含
func TestEnsureRebuilt_ContextCancel(t *testing.T) {
t.Parallel()
stub := newStubConverterClient()
stub.blockUntil = make(chan struct{}) // 永遠不放 → 強迫等 ctx
stub.setJobs("alice", []*ConverterJob{})
o := NewOwnership(stub, newSilentLogger())
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
done <- o.EnsureRebuilt(ctx, "alice")
}()
// 等 50ms 確保 goroutine 已進到 fetchblock 在 blockUntil
time.Sleep(50 * time.Millisecond)
cancel()
select {
case err := <-done:
require.Error(t, err, "ctx cancel 應 return error")
assert.True(t,
errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded),
"err 應為 context.Canceled 或 DeadlineExceededgot: %v", err,
)
case <-time.After(2 * time.Second):
t.Fatal("ctx cancel 後 EnsureRebuilt 沒有及時 return")
}
// 不標 rebuilt — 下次重試
close(stub.blockUntil) // 解除 block
stub.blockUntil = nil // 後續不再 block
stub.setJobs("alice", []*ConverterJob{{JobID: "j-1"}})
require.NoError(t, o.EnsureRebuilt(context.Background(), "alice"))
}
// TestEnsureRebuilt_Timeoutrebuild 內部 timeoutconverter 慢 > 5s→ return
// timeout error不標 rebuilt。
//
// 為避免測試本身跑 5s+,把 fetchDelay 設 100ms 但用 ctx WithTimeout 50ms 模擬同樣語意:
// 驗 ctx cancel path 即可ownership.go 的 rebuildTimeout 邏輯與此相同)。
func TestEnsureRebuilt_ParentCtxTimeout(t *testing.T) {
t.Parallel()
stub := newStubConverterClient()
stub.fetchDelay = 200 * time.Millisecond
stub.setJobs("alice", []*ConverterJob{})
o := NewOwnership(stub, newSilentLogger())
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
err := o.EnsureRebuilt(ctx, "alice")
require.Error(t, err)
assert.True(t, errors.Is(err, context.DeadlineExceeded),
"parent ctx timeout 應透傳, got: %v", err)
}
// TestEnsureRebuilt_EmptyUserID空 userID return error。
func TestEnsureRebuilt_EmptyUserID(t *testing.T) {
t.Parallel()
stub := newStubConverterClient()
o := NewOwnership(stub, newSilentLogger())
err := o.EnsureRebuilt(context.Background(), "")
require.Error(t, err)
}
// ==========================================================================
// ActiveJobOf
// ==========================================================================
// TestActiveJobOf_AfterRebuildrebuild 後從 jobToUser 反查到 in_progress 的 job_id規範必含
func TestActiveJobOf_AfterRebuild(t *testing.T) {
t.Parallel()
stub := newStubConverterClient()
stub.setJobs("alice", []*ConverterJob{
{JobID: "j-active-1", Status: "running"},
})
o := NewOwnership(stub, newSilentLogger())
// rebuild 前 ActiveJobOf 應空cache 沒資料)
jobs := o.ActiveJobOf("alice")
assert.Len(t, jobs, 0)
require.NoError(t, o.EnsureRebuilt(context.Background(), "alice"))
// rebuild 後反查
jobs = o.ActiveJobOf("alice")
require.Len(t, jobs, 1)
assert.Equal(t, "j-active-1", jobs[0])
}
// TestActiveJobOf_Empty_NoJobsuser 沒任何 job → 空 slice規範必含
func TestActiveJobOf_Empty_NoJobs(t *testing.T) {
t.Parallel()
stub := newStubConverterClient()
stub.setJobs("alice", []*ConverterJob{}) // 沒 active job
o := NewOwnership(stub, newSilentLogger())
require.NoError(t, o.EnsureRebuilt(context.Background(), "alice"))
jobs := o.ActiveJobOf("alice")
assert.NotNil(t, jobs, "回非 nil 空 slice 給 caller 安全 range")
assert.Len(t, jobs, 0)
}
// TestActiveJobOf_OtherUser_NotIncluded反查只回該 user 的,不會混到別 user。
func TestActiveJobOf_OtherUser_NotIncluded(t *testing.T) {
t.Parallel()
stub := newStubConverterClient()
o := NewOwnership(stub, newSilentLogger())
o.Set("j-alice", "alice")
o.Set("j-bob", "bob")
o.Set("j-alice-2", "alice")
aliceJobs := o.ActiveJobOf("alice")
assert.ElementsMatch(t, []string{"j-alice", "j-alice-2"}, aliceJobs)
bobJobs := o.ActiveJobOf("bob")
assert.ElementsMatch(t, []string{"j-bob"}, bobJobs)
// 不存在的 user
jobs := o.ActiveJobOf("nobody")
assert.Len(t, jobs, 0)
// 空 user_id
jobs = o.ActiveJobOf("")
assert.Nil(t, jobs)
}
// TestActiveJobOf_AfterDeleteDelete 後反查不回該 job。
func TestActiveJobOf_AfterDelete(t *testing.T) {
t.Parallel()
stub := newStubConverterClient()
o := NewOwnership(stub, newSilentLogger())
o.Set("j-1", "alice")
o.Set("j-2", "alice")
assert.Len(t, o.ActiveJobOf("alice"), 2)
o.Delete("j-1")
jobs := o.ActiveJobOf("alice")
require.Len(t, jobs, 1)
assert.Equal(t, "j-2", jobs[0])
}
// ==========================================================================
// 壓力測試 — 全 method 併發 race + 不死鎖
// ==========================================================================
// TestStress_AllMethods_Concurrent所有 method 同時跑race detector 驗 + 完成不 timeout。
func TestStress_AllMethods_Concurrent(t *testing.T) {
t.Parallel()
stub := newStubConverterClient()
for i := 0; i < 5; i++ {
uid := "u-" + strconv.Itoa(i)
stub.setJobs(uid, []*ConverterJob{
{JobID: fmt.Sprintf("j-%d-a", i)},
})
}
o := NewOwnership(stub, newSilentLogger())
const ROUNDS = 50
var wg sync.WaitGroup
for i := 0; i < ROUNDS; i++ {
uid := "u-" + strconv.Itoa(i%5)
jobID := "set-" + strconv.Itoa(i)
wg.Add(5)
go func() { defer wg.Done(); o.Set(jobID, uid) }()
go func() { defer wg.Done(); _, _ = o.Get(jobID) }()
go func() { defer wg.Done(); _ = o.EnsureRebuilt(context.Background(), uid) }()
go func() { defer wg.Done(); _ = o.ActiveJobOf(uid) }()
go func() { defer wg.Done(); o.Delete(jobID) }()
}
doneCh := make(chan struct{})
go func() { wg.Wait(); close(doneCh) }()
select {
case <-doneCh:
// ok
case <-time.After(5 * time.Second):
t.Fatal("壓力測試 5s 沒結束 — 疑似 deadlock")
}
}