visionA/visionA-backend/cmd/api-server/conversion_adapters.go
jim800121chen c63886a194 feat(visionA-backend): Phase 0.9 模型庫存取 — FAA delegated download token(B1+B2)
對齊 ADR-017 v1.2:模型庫下載走 visionA 簽 MC delegated token → Client 直連 FAA。

B2 — MC download token client(internal/fileaccess):
- DownloadTokenIssuer: GetServiceToken(打 MC /oauth/token,client_credentials +
  scope files:download.delegate,含 token cache)+ IssueDownloadToken(打 MC Issue 簽 fdt_)
- secret / service token / fdt token 三層全程用 hashShort 遮罩不 log
- FileAccessConfig + VISIONA_FILE_ACCESS_* env + main.go wire(Enabled() 才接)

B1 — object_key 斷層:
- model.Model 加 FAAObjectKey(json:"-" 不揭露前端)
- PromoteToModels 寫入(用 promote response TargetObjectKey = models/{userID}/{jobID}.nef)
- 三方對映天然一致(visionA Issue / FAA path / MC validate)
- 第一階段框死只 Source=converted 類 model,上傳類 download 回 501

download endpoint:
- GET /api/models/:id/download(owner-only)→ {download_url, token, expires_at}
- 前端帶 Authorization: Bearer 直連 FAA(不經 visionA、不經 AWS)
- 401/403/404/501/502 分明,502 對外 mask 不洩漏 MC 內部狀態

測試: 13 + 8 unit test(mock MC + fake issuer,httptest 驗真 HTTP);go build/vet/test 全綠。
Reviewer: 0 Critical / 0 Major / 3 Minor / 4 Suggestion,通過。

技術債(正式上線前): 第一階段 PoC 共用 FAA service client,MC 規範禁止 client 混用
usage、secret 不共用,須 MC 配發 visionA 專屬 usage=file_api client。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 04:06:09 +08:00

153 lines
5.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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

// conversion_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,
FAAObjectKey: rec.FAAObjectKey, // ADR-017 (a) B1promote 寫入的 FAA object key
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,
FAAObjectKey: m.FAAObjectKey, // ADR-017 (a) B1
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)
}