DB 接入塊 0-5 上主幹後的收尾工作,讓 DB-on 模式可真人使用 + 補齊功能與測試。 OIDC / pairing FK 修復(接 DB 上線必要): - 新建 internal/user package(User + Store + InMemory + Postgres);OIDC callback 驗證 id_token 成功後 fail-closed upsert users(sub 直接當 users.id,MC sub 為 UUID) - pairing exchange 雲端自建 device(不動 local-tool)+ 同 tx 綁 session token; 自建 device 空 serial 寫 NULL(避免撞 partial unique) - device.SaveTx / session.CreateTx 新增 tx-aware 版本 B4 model metadata: - 轉檔 result 的 analysis_info(input_shape/classes/framework)串進 model: converter_client → flow → adapter → model.Model → PG → ModelResponse DTO - input_shape 優先用陣列、後備四維組 NCHW、缺一不亂組;全 optional 防禦性 - 前端詳細頁顯示(另 repo);轉檔端串接交接檔 b4-converter-handoff.md nginx healthz(部署層): - 新增 /healthz/deep 轉發 backend(ping PG+Redis、down 回 503)給 LB - 修掉 default_server return 444 短路 bug(docker healthcheck 長期 unhealthy 真因) storage error 統一映射(不洩漏 storage 後端細節)。 測試:補 internal/api(storage/errors handler)、cmd/api-server(seed/adapter)、 internal/db(redis)、relay/session 弱處,含 testcontainers integration。 DB 接入相關 package 真環境覆蓋達 88-94%。全程 Reviewer 審查 + 130 真 PG/Redis dbtest 綠。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
163 lines
6.0 KiB
Go
163 lines
6.0 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,
|
||
// B4 模型 metadata(optional):從 converter job analysis_info 串進來。
|
||
// 轉檔端尚未串好 analysis_info 時 rec.InputShape 等為零值(nil / ""),
|
||
// 這裡照樣賦值(賦 nil / "" 不影響建 model),DB 端寫 NULL / 空 INT[]。
|
||
InputShape: rec.InputShape,
|
||
Classes: rec.Classes,
|
||
Framework: rec.Framework,
|
||
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,
|
||
// B4 模型 metadata(optional):冪等回傳時帶回,保持 round-trip 一致。
|
||
InputShape: m.InputShape,
|
||
Classes: m.Classes,
|
||
Framework: m.Framework,
|
||
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)
|
||
}
|