# Database — 資料模型 > **雛形階段無真實 DB**(見 ADR-005)。本文件定義 Go struct + 未來 DB schema 的映射,讓雛形程式碼直接以這些 struct 操作記憶體,Phase 1 直接按這個結構建 PostgreSQL schema。 --- ## 1. 核心實體 ER 概念圖 ``` User ─┬─ owns ── Device ─ has ── PairingToken ├─ owns ── Model ├─ owns ── Cluster ─ contains ── Device └─ has ── ConverterJob ─ produces ── Model ``` --- ## 2. Go struct 定義 ### 2.1 User(雛形 stub,Phase 1 實作) ```go // internal/user/types.go package user import "time" type User struct { ID string `json:"id"` Email string `json:"email"` Name string `json:"name,omitempty"` // Phase 1 PasswordHash string `json:"-"` OrgID string `json:"orgId,omitempty"` Roles []string `json:"roles,omitempty"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` DeletedAt *time.Time `json:"deletedAt,omitempty"` } ``` **雛形**:`StaticAuthService` 永遠回 `User{ID: "demo-user", Email: "demo@visiona.local"}`,不落盤。 ### 2.2 Device ```go // internal/device/types.go package device import "time" type Device struct { ID string `json:"id"` // UUID OwnerUserID string `json:"ownerUserId"` Name string `json:"name"` // 使用者命名 DeviceType string `json:"deviceType"` // kl520 / kl720 等 SerialNumber string `json:"serialNumber"` // 從 local agent 上報 // -------- 雙狀態模型(2026-04-22 Minor-3 新增 remoteStatus / lastSeenAt 語意釐清)-------- // // Device 同時擁有兩組狀態欄位,代表不同觀察角度: // // 1. Status(USB-level status,既有) // - 由 local agent 直接觀察到的「USB 接了什麼」 // - 值:online / offline / unknown // - 來源:local agent 呼叫 KL SDK 得到;透過 tunnel 上報雲端 // - 意義:「此刻使用者電腦上這個 KL device 插著且正常」 // // 2. RemoteStatus(tunnel-level status,新增) // - 由雲端(remote-proxy / api-server)對 tunnel 連線的觀察 // - 值:online | offline | reconnecting | error // - 來源:SessionStore 狀態 + tunnel heartbeat(見 tunnel.md §4.2) // - 意義:「雲端能不能透過 tunnel 觸達使用者電腦上的 agent」 // // 兩者可能出現 4 種組合: // | remoteStatus | status | 解讀 | // |--------------|----------|------| // | online | online | 正常:雲端可達 + USB 有裝置 | // | online | offline | agent 連著但 USB 拔掉了 | // | offline | * | agent 或網路斷:雲端完全無法觸及(status 顯示的是最後觀察值)| // | reconnecting | * | tunnel 短暫斷線、local agent 正重連中 | // // 前端應優先顯示 remoteStatus(雲端連線狀態),次要顯示 status(USB 狀態), // 詳見 TDD §10 前端消費方式。 // tunnel-level 狀態(2026-04-22 新增) RemoteStatus string `json:"remoteStatus"` // online | offline | reconnecting | error LastSeenAt *time.Time `json:"lastSeenAt,omitempty"` // 最後一次收到 tunnel 心跳時間(ISO 8601) LastConnectedAt *time.Time `json:"lastConnectedAt,omitempty"` // tunnel 最近一次建立時間 // USB-level 狀態(既有,保留) Status string `json:"status"` // online / offline / unknown(USB) CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` DeletedAt *time.Time `json:"deletedAt,omitempty"` } ``` **注意**:此「Device」記錄的是**抽象身分**(綁 user)。當瀏覽器呼叫 `GET /api/devices` 時,實際「此時 USB 上接了哪些」要透過 tunnel 問 local agent 的 `/api/devices`。雲端這張表負責「我曾經綁過哪些裝置、它們的名字、擁有者」+ 雙狀態快照。 **更新時機**: - `remoteStatus` 由 `remote-proxy` 在 tunnel 事件發生時寫入(connect → `online`、heartbeat timeout → `offline`、中間短暫斷線 → `reconnecting`、yamux 錯誤 → `error`) - `lastSeenAt` 由 `remote-proxy` 的 heartbeat 處理每 10s 更新一次(見 tunnel.md §4.2) - `status`(USB)由 local agent 上報,走既有 POC 邏輯 ### 2.3 Model ```go // internal/model/types.go package model import "time" type Model struct { ID string `json:"id"` OwnerUserID string `json:"ownerUserId"` Name string `json:"name"` Description string `json:"description,omitempty"` // 檔案資訊 StorageKey string `json:"storageKey"` // 在 Store 裡的 key(例:models/{user_id}/{id}.nef) FileSize int64 `json:"fileSize"` FileChecksum string `json:"fileChecksum"` // sha256 // 模型 metadata TargetChip string `json:"targetChip"` // kl520 / kl720 InputShape []int `json:"inputShape,omitempty"` Classes []string `json:"classes,omitempty"` Framework string `json:"framework,omitempty"` // onnx / keras 等(若有) // 來源 Source string `json:"source"` // uploaded / converted / preset SourceJobID string `json:"sourceJobId,omitempty"` // 若 source=converted CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` DeletedAt *time.Time `json:"deletedAt,omitempty"` } ``` ### 2.4 PairingToken(見 ADR-003) ```go // internal/auth/types.go package auth import "time" type PairingInfo struct { TokenHash string `json:"-"` // sha256(plaintext token),DB 只存 hash UserID string `json:"userId"` DeviceID string `json:"deviceId,omitempty"` // 綁定後才有 CreatedAt time.Time `json:"createdAt"` ExpiresAt *time.Time `json:"expiresAt,omitempty"` // pairing phase 有 15min TTL;升級為 session token 後 nil 或長期 UsedAt *time.Time `json:"usedAt,omitempty"` // 首次被 local agent 使用的時間 RevokedAt *time.Time `json:"revokedAt,omitempty"` RevokedBy string `json:"revokedBy,omitempty"` LastSeenAt *time.Time `json:"lastSeenAt,omitempty"` // Phase 1:兩階段設計 Kind string `json:"kind"` // "pairing" | "session" ParentToken string `json:"parentToken,omitempty"` // session 對應的 pairing } ``` ### 2.5 Cluster(從 POC 搬) ```go // internal/cluster/types.go package cluster type Cluster struct { ID string `json:"id"` OwnerUserID string `json:"ownerUserId"` Name string `json:"name"` Devices []DeviceMember `json:"devices"` ModelID string `json:"modelId,omitempty"` Status ClusterStatus `json:"status"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` DeletedAt *time.Time `json:"deletedAt,omitempty"` } type DeviceMember struct { DeviceID string `json:"deviceId"` Weight int `json:"weight"` Status MemberStatus `json:"status"` DeviceName string `json:"deviceName,omitempty"` DeviceType string `json:"deviceType,omitempty"` } type ClusterStatus string const ( ClusterIdle ClusterStatus = "idle" ClusterInferencing ClusterStatus = "inferencing" ClusterDegraded ClusterStatus = "degraded" ) ``` ### 2.6 ConverterJob(stub) ```go // internal/converter/types.go package converter import "time" type Job struct { ID string `json:"id"` OwnerUserID string `json:"ownerUserId"` Status string `json:"status"` // queued / running / succeeded / failed SourceKey string `json:"sourceKey"` // 原始 onnx / keras 在 storage 的 key ResultKey string `json:"resultKey,omitempty"` // 產物(.nef)的 key TargetChip string `json:"targetChip"` Params map[string]any `json:"params,omitempty"` ErrorCode string `json:"errorCode,omitempty"` ErrorMsg string `json:"errorMsg,omitempty"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` StartedAt *time.Time `json:"startedAt,omitempty"` CompletedAt *time.Time `json:"completedAt,omitempty"` } ``` ### 2.7 Session(**不落 DB**,但列出以對照) ```go // internal/session/types.go package session import "time" type SessionSummary struct { Token string // caller 已知 UserID string DeviceID string ConnectedAt time.Time LastSeenAt time.Time ProxyNodeID string // Phase 1 多節點時使用 } ``` 雛形 session 在 in-memory map,process 重啟就掉。Phase 1 考慮 Redis 存 summary(TTL + heartbeat)。 --- ## 3. Repository Interfaces ```go // internal/device/repository.go package device import "context" type Repository interface { Get(ctx context.Context, id string) (*Device, error) GetBySerial(ctx context.Context, userID, serial string) (*Device, error) List(ctx context.Context, userID string) ([]*Device, error) Save(ctx context.Context, d *Device) error Delete(ctx context.Context, id string) error } type InMemoryRepository struct { devices map[string]*Device mu sync.RWMutex } // ... ``` 每個 domain 都有: - `repository.go`:interface + `InMemoryRepository` 實作 - `types.go`:struct 定義 Phase 1:新增 `postgres_repository.go`,實作同 interface。 --- ## 4. Phase 1 PostgreSQL Schema(預覽) 雛形不建 DB,但 struct 直接對應以下 schema 的欄位: ```sql -- users CREATE TABLE users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email CITEXT UNIQUE NOT NULL, name TEXT, password_hash TEXT, org_id UUID, roles TEXT[] DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), deleted_at TIMESTAMPTZ ); -- devices(2026-04-22 Minor-3:新增 remote_status + last_seen_at 雙狀態欄位) CREATE TABLE devices ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), owner_user_id UUID NOT NULL REFERENCES users(id), name TEXT NOT NULL, device_type TEXT, serial_number TEXT, -- tunnel-level 狀態(雲端觀察) remote_status TEXT NOT NULL DEFAULT 'offline', -- online | offline | reconnecting | error last_seen_at TIMESTAMPTZ, -- 最後 tunnel heartbeat 時間 last_connected_at TIMESTAMPTZ, -- 最近 tunnel 建立時間 -- USB-level 狀態(local agent 上報) status TEXT NOT NULL DEFAULT 'unknown', -- online | offline | unknown created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), deleted_at TIMESTAMPTZ, UNIQUE (owner_user_id, serial_number) ); CREATE INDEX ON devices (owner_user_id) WHERE deleted_at IS NULL; CREATE INDEX ON devices (remote_status) WHERE deleted_at IS NULL; -- pairing_tokens CREATE TABLE pairing_tokens ( token_hash TEXT PRIMARY KEY, -- sha256(plaintext) user_id UUID NOT NULL REFERENCES users(id), device_id UUID REFERENCES devices(id), kind TEXT NOT NULL DEFAULT 'pairing', parent_token TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), expires_at TIMESTAMPTZ, used_at TIMESTAMPTZ, revoked_at TIMESTAMPTZ, revoked_by UUID, last_seen_at TIMESTAMPTZ ); CREATE INDEX ON pairing_tokens (user_id) WHERE revoked_at IS NULL; CREATE INDEX ON pairing_tokens (device_id); -- models CREATE TABLE models ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), owner_user_id UUID NOT NULL REFERENCES users(id), name TEXT NOT NULL, description TEXT, storage_key TEXT NOT NULL, file_size BIGINT NOT NULL, file_checksum TEXT, target_chip TEXT, input_shape INT[], classes TEXT[], framework TEXT, source TEXT NOT NULL, source_job_id UUID, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), deleted_at TIMESTAMPTZ ); CREATE INDEX ON models (owner_user_id) WHERE deleted_at IS NULL; -- clusters CREATE TABLE clusters ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), owner_user_id UUID NOT NULL REFERENCES users(id), name TEXT NOT NULL, model_id UUID REFERENCES models(id), status TEXT DEFAULT 'idle', devices_json JSONB NOT NULL DEFAULT '[]'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), deleted_at TIMESTAMPTZ ); -- converter_jobs CREATE TABLE converter_jobs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), owner_user_id UUID NOT NULL REFERENCES users(id), status TEXT NOT NULL DEFAULT 'queued', source_key TEXT NOT NULL, result_key TEXT, target_chip TEXT NOT NULL, params JSONB, error_code TEXT, error_msg TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), started_at TIMESTAMPTZ, completed_at TIMESTAMPTZ ); ``` --- ## 5. Migration 策略(Phase 1) - 工具:`golang-migrate` - 位置:`visionA-backend/migrations/*.sql` - 命名:`YYYYMMDDHHMMSS_description.up.sql` / `.down.sql` - 雛形:**不需要 migrations 目錄**;Phase 1 第一個 migration 建立以上所有 table --- ## 6. 資料一致性考量 | 操作 | 需要的一致性 | |------|-------------| | 使用者建立 Device + PairingToken | 同一 transaction | | 使用者刪除 Device | cascade 撤銷所有 PairingToken | | Converter job 完成 → 建立 Model | transactional upsert + webhook idempotency | 雛形 in-memory 無交易;Phase 1 用 `pgx` 的 tx。 --- **雛形實作 / 未來擴展**: - 雛形:所有 repository 用 `map + sync.RWMutex`;struct 欄位按上表定義(多出的欄位留空或 zero value) - 未來:實作 `Postgres*Repository`;加上 migration;處理 soft delete(`WHERE deleted_at IS NULL`)