visionA/visionA-backend/cmd/api-server/conversion_adapters.go
jim800121chen cabbdde495 feat(visionA-backend): DB 接入後續 — OIDC/pairing FK 收尾 + B4 metadata + nginx healthz + 補測試
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>
2026-06-21 06:36:35 +08:00

163 lines
6.0 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,
// B4 模型 metadataoptional從 converter job analysis_info 串進來。
// 轉檔端尚未串好 analysis_info 時 rec.InputShape 等為零值nil / ""
// 這裡照樣賦值(賦 nil / "" 不影響建 modelDB 端寫 NULL / 空 INT[]。
InputShape: rec.InputShape,
Classes: rec.Classes,
Framework: rec.Framework,
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,
// B4 模型 metadataoptional冪等回傳時帶回保持 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。
//
// 目前只需要 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)
}