對齊 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>
153 lines
5.4 KiB
Go
153 lines
5.4 KiB
Go
// 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.7(NewService 註解)。
|
||
//
|
||
// 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 設為 now(promote 完成 = 等同 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) B1:promote 寫入的 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) + 過濾 SourceJobID(in-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。
|
||
//
|
||
// 目前只需要 Put(streaming 寫入),meta 透傳到底層 storage(LocalFS 雛形可能忽略,
|
||
// S3 接上後會寫進 object metadata)。
|
||
type conversionStorageAdapter struct {
|
||
store storage.Store
|
||
}
|
||
|
||
// newConversionStorageAdapter 建立 adapter。
|
||
func newConversionStorageAdapter(store storage.Store) conversion.Storage {
|
||
return &conversionStorageAdapter{store: store}
|
||
}
|
||
|
||
// Put streaming 寫入 storage(meta 透傳給底層 storage 實作)。
|
||
//
|
||
// LocalFS 雛形可能忽略 meta;S3 / 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)
|
||
}
|