把 visionA-backend 6 個 in-memory store 接到資料庫持久化,範圍=完整 (PG 全接 + session 接 Redis + 交易韌性)。interface / handler 不動, 只加 DB 實作 + 換 wiring,config 未設 DB 時保留 in-memory fallback。 - 塊 0 基礎建設:pgx/v5 連線池 + DatabaseConfig/RedisConfig + golang-migrate runner(embed)+ cmd/migrate + testcontainers 測試基礎建設 - 塊 1 model → Postgres:array 映射、upsert 保留 CreatedAt、faa_object_key、 三維 filter(owner/chip/source)、soft-delete partial index - 塊 2 device → Postgres:partial unique(已刪 serial 可重註冊)、雙狀態欄位 - 塊 3 token → Postgres:pairing_tokens + session_tokens 分表、token_hash 當 PK - 塊 4 userSession → Redis:idle + absolute 雙 TTL 取代 cleanup goroutine (tunnel session 維持 in-memory,yamux handle 不可序列化) - 塊 5 交易/韌性:WithTx helper + 刪 device cascade 撤銷 token(同 tx 原子) + /healthz ping PG/Redis(fail-fast 503)+ pgx error 統一映射(不洩漏 raw error) 降級策略(fail-fast):PG 掉 → 持久資料 API 回 503;Redis 掉 → session 失敗 不自動 fallback in-memory(避免多機 session 不同步)。 DB:PostgreSQL 14.23(gen_random_uuid 內建、無 citext → email 用 lower() unique index)。每塊經 Reviewer 審查 + 真 PG/Redis testcontainers 全量 dbtest 綠燈, in-memory fallback 未受影響。 docs: 同步更新 database.md(schema/config/migration 清單)+ api-spec.md (409/503 錯誤碼、/healthz 新行為、device unpair cascade)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
641 lines
32 KiB
Markdown
641 lines
32 KiB
Markdown
# Database — 資料模型
|
||
|
||
> **雛形階段無真實 DB**(見 ADR-005)。本文件定義 Go struct + 未來 DB schema 的映射,讓雛形程式碼直接以這些 struct 操作記憶體,Phase 1 直接按這個結構建 PostgreSQL schema。
|
||
>
|
||
> **以 code 為準原則(2026-06-20 DB 接入規劃更新)**:本文件 §2 struct 與 §4 schema 自雛形以來與實際 code 有少數漂移。DB 接入時,**欄位定義一律以 `visionA-backend/internal/*` 的 Go struct 為準**;本次更新已將 §4 schema 對齊 code 並標註修正點(搜尋「以 code 為準」)。後續實作 PostgresRepository 時若再發現不一致,亦以 code 為準並回頭修本文件。
|
||
|
||
---
|
||
|
||
## 0. DB 接入規格摘要(2026-06-20 新增 — DB 接入前文件補齊)
|
||
|
||
> 本節為 DB 接入任務(visionA-backend 6 個 in-memory store → Postgres/Redis 持久化)的開發前依循,對應 `db-integration-plan.md` §10 缺口補齊。範圍 = **完整**(PG 全接 + session 接 Redis + 交易韌性)。
|
||
|
||
### 0.1 DB 供給前提(取代任何「130 現成可用」字眼)
|
||
|
||
- **PostgreSQL**:visionA 專用實例,**credential 已取得**。visionA 端**不負責 provision**(DB 機器 / database / 角色由他人在 stage host 192.168.0.130 另開),**只負責跑 migration 與接上**。
|
||
- **Redis**:visionA 專用實例,**由使用者自行在 130 另起**(設密碼),供 `userSession` 用。visionA 端同樣不 provision、只接上。
|
||
- 130:5432 上其他 container 一律不共用;任何「130 上已有 DB 可直接用」的舊敘述皆作廢。
|
||
- 整合測試一律走 **testcontainers**(本機/CI 一次性 DB),不依賴 130;130 僅用於 stage 收尾驗證。
|
||
|
||
### 0.2 持久化分工(對齊 `db-integration-plan.md` §3)
|
||
|
||
| Store | package | 落地 | 理由 |
|
||
|-------|---------|------|------|
|
||
| `modelRepo` | `internal/model` | **Postgres** | 長期保存、List by owner/chip/source、跨重啟不掉。使用者最關心。 |
|
||
| `deviceRepo` | `internal/device` | **Postgres** | 裝置綁定身分長期保存、owner+serial unique、關聯查詢。 |
|
||
| `pairingStore` | `internal/auth` | **Postgres** | 可撤銷長期憑證、要稽核(used_at/revoked_at)、重啟不掉。 |
|
||
| `sessionTokenStore` | `internal/auth` | **Postgres** | session token 90 天長效、可撤銷、查 parent token 稽核鏈。 |
|
||
| `userSessionStore` | `internal/usersession` | **Redis** | 瀏覽器 cookie session、高頻讀、idle/absolute 雙 TTL、掉了重登即可。Redis TTL 原生支援。 |
|
||
| remote-proxy `session` | `internal/session` | **in-memory(不變)** | 特例:value 是活的 yamux Handle,不可序列化進 Redis。單節點維持 in-memory;多節點才需 Redis 存 Summary(handle 仍留本地)。本期範圍外。 |
|
||
|
||
### 0.3 第一份 migration 清單(最小範圍 = 塊 0 + 塊 1)
|
||
|
||
第一個 migration(`migrations/0001_*`)建立**模型庫持久化的最小集合**:
|
||
|
||
- **`users`** — `models.owner_user_id` / `devices.owner_user_id` / token `user_id` 的 FK 目標,必須先建(Phase 1 stub,雛形固定 demo-user)。
|
||
- **`models`** — 使用者最關心、塊 1 主體。
|
||
|
||
> device / pairing_tokens / session_tokens 表隨塊 2 / 塊 3 的 migration(`0002_*` / `0003_*`)建立,不塞進第一份 migration。決策理由:第一份 migration 聚焦「重啟後模型庫資料還在」的最小可驗收,降低首次上 DB 的風險面。
|
||
|
||
### 0.4 兩個關鍵 schema 決策(DB 接入前定案)
|
||
|
||
1. **token 共表(by `kind`)vs 分表** → **決策:分表(`pairing_tokens` + `session_tokens` 兩張)**。理由見 §4 token 段落。
|
||
2. **已 soft-delete 的 device serial 能否重註冊** → **決策:能**,用 partial unique index `WHERE deleted_at IS NULL` 達成。語意見 §4 devices 段落。
|
||
|
||
## 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"`
|
||
PairedAt *time.Time `json:"pairedAt,omitempty"` // 2026-06-20 補上 — code 有此欄位、原 schema 漏(配對完成時間)
|
||
DeletedAt *time.Time `json:"deletedAt,omitempty"`
|
||
}
|
||
```
|
||
|
||
> **以 code 為準**(2026-06-20):上方對齊 `internal/device/device.go`,新增 `PairedAt`(配對完成時間,nullable)。`SerialNumber` 在 code 為 `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,omitempty"` // sha256 hex
|
||
|
||
// FAAObjectKey(ADR-017 (a) B1):model 在 File Access Agent 上的 object key。
|
||
// 只有 Source=converted(轉檔→promote 進 FAA)類有值;上傳類留空。
|
||
// JSON tag = "-"(不對前端揭露內部 storage key,ADR-017 決策 2)。
|
||
// DB 欄位 nullable(見 §4,2026-06-20 補上 — 原 schema 漏此欄)。
|
||
FAAObjectKey string `json:"-"`
|
||
|
||
// 模型 metadata(可選)
|
||
TargetChip string `json:"targetChip,omitempty"` // kl520 / kl720
|
||
InputShape []int `json:"inputShape,omitempty"`
|
||
Classes []string `json:"classes,omitempty"`
|
||
Framework string `json:"framework,omitempty"` // onnx / keras 等(若有)
|
||
|
||
// 來源
|
||
Source Source `json:"source"` // uploaded / converted / preset(Source = string 別名)
|
||
SourceJobID string `json:"sourceJobId,omitempty"` // 若 source=converted
|
||
|
||
CreatedAt time.Time `json:"createdAt"`
|
||
UpdatedAt time.Time `json:"updatedAt"`
|
||
UploadedAt *time.Time `json:"uploadedAt,omitempty"` // 2026-06-20 補上 — code 有此欄位、原 schema 漏
|
||
DeletedAt *time.Time `json:"deletedAt,omitempty"`
|
||
}
|
||
```
|
||
|
||
> **以 code 為準**(2026-06-20):上方 struct 已對齊 `internal/model/model.go`。相對原雛形定義新增了 `FAAObjectKey`(ADR-017)與 `UploadedAt`,`FileChecksum` 改為 `omitempty`,`Source` 為 `type Source = string` 別名。
|
||
|
||
### 2.4 PairingToken / SessionToken(見 ADR-003)
|
||
|
||
> **以 code 為準**(2026-06-20):實際 code 中 pairing 與 session 是**兩個獨立 struct + 兩個獨立 Store**(`internal/auth/auth.go`),欄位不同。原雛形文件畫的單一 `PairingInfo`(含 `RevokedBy` / `LastSeenAt` / `ParentToken` 共用欄)**與 code 不符、已作廢**。下方改列 code 實際的兩個 struct,schema 也據此分表(見 §4)。
|
||
|
||
```go
|
||
// internal/auth/auth.go
|
||
|
||
// PairingToken — 短期一次性配對 token(vAc_ + 32 hex,15min TTL,一次性)
|
||
type PairingToken struct {
|
||
Plaintext string `json:"-"` // 僅建立時回傳一次
|
||
TokenHash string `json:"-"` // sha256(Plaintext),DB PK
|
||
UserID string `json:"userId"`
|
||
DeviceID string `json:"deviceId,omitempty"` // MarkUsed 綁定後才有
|
||
Kind TokenKind `json:"kind"` // "pairing"
|
||
|
||
CreatedAt time.Time `json:"createdAt"`
|
||
ExpiresAt *time.Time `json:"expiresAt,omitempty"` // 15min TTL;nil = 永不過期(測試用)
|
||
UsedAt *time.Time `json:"usedAt,omitempty"` // 一次性:MarkUsed 寫入後 Validate 失敗
|
||
RevokedAt *time.Time `json:"revokedAt,omitempty"`
|
||
}
|
||
|
||
// SessionToken — 長期可撤銷 tunnel session token(vAs_ + 64 hex,90 天 TTL)
|
||
type SessionToken struct {
|
||
Plaintext string `json:"-"`
|
||
TokenHash string `json:"-"` // DB PK
|
||
UserID string `json:"userId"`
|
||
DeviceID string `json:"deviceId"` // session token 必綁 device
|
||
ParentTokenHash string `json:"-"` // 升級來源 pairing token 的 hash(稽核鏈)
|
||
|
||
CreatedAt time.Time `json:"createdAt"`
|
||
ExpiresAt *time.Time `json:"expiresAt,omitempty"` // 90 天;nil = 永不過期
|
||
RevokedAt *time.Time `json:"revokedAt,omitempty"`
|
||
// 注意:SessionToken 無 UsedAt(非一次性)、無 Kind(型別本身即 session)
|
||
}
|
||
```
|
||
|
||
**關鍵差異(影響 §4 分表決策)**:
|
||
- `PairingToken` 有 `UsedAt`(一次性語意)、`Kind`;無 `ParentTokenHash`。
|
||
- `SessionToken` 有 `ParentTokenHash`(稽核鏈)、`DeviceID` 必填;無 `UsedAt`、無 `Kind`。
|
||
- 兩者由**不同 Store interface** 管理(`PairingStore` / `SessionTokenStore`),方法集不同(pairing 有 `MarkUsed`、`Validate`;session 有 `Get`)。
|
||
- code 中**不存在** `RevokedBy` / `LastSeenAt` / `ParentToken`(原雛形文件的臆測欄位)。
|
||
|
||
### 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)。
|
||
|
||
> **2026-06-20 補充(DB 接入)**:要區分兩種 session:
|
||
> - **`internal/usersession`(browser cookie session)→ 接 Redis**。Session struct(見 `internal/usersession/usersession.go`)含 OIDC pending state(OIDCState/Nonce/CodeVerifier)+ token snapshot(AccessToken/IDTokenRaw)+ `Extra map[string]any`。`RedisUserSessionStore` 用 Redis 雙 TTL(idle = `IdleTTL` 預設 24h、absolute = `AbsoluteTTL` 預設 168h)取代現有手動 cleanup goroutine;`Extra` 需 JSON 序列化;`OIDCCodeVerifier` 等敏感欄位照舊不可進 log。Redis 為 visionA 專用實例(使用者在 130 另起、設密碼)。
|
||
> - **`internal/session`(remote-proxy tunnel session)→ 維持 in-memory(不變)**。value 是活的 yamux Handle,**不可序列化進 Redis**。單節點維持 in-memory;多節點才需「Summary 放 Redis、handle 留本地」混合實作 —— **本期範圍外**。
|
||
|
||
---
|
||
|
||
## 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
|
||
-- 環境適配(塊 0,2026-06-20 真環境驗證):目標 PG 14.23 未安裝 citext extension,
|
||
-- email 改用 TEXT + 函式索引達成大小寫不敏感唯一,取代原 CITEXT 寫法:
|
||
-- email TEXT NOT NULL;
|
||
-- CREATE UNIQUE INDEX users_email_lower_uniq ON users (lower(email));
|
||
-- 應用層查詢一律以 lower(email) 比對。若未來環境有 citext,可回退 CITEXT 簡化。
|
||
CREATE TABLE users (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
email TEXT UNIQUE NOT NULL, -- 見上方環境適配註:PG 14.23 無 citext,實際以 lower(email) 唯一索引達成 CI 唯一
|
||
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(),
|
||
paired_at TIMESTAMPTZ, -- 2026-06-20 補:配對完成時間(code 有、原 schema 漏)
|
||
deleted_at TIMESTAMPTZ
|
||
);
|
||
-- 2026-06-20 改:table-level UNIQUE → partial unique index(soft-delete 後 serial 可重註冊)
|
||
-- 決策:已 soft-delete 的 device serial 「能」重新註冊。
|
||
-- 語意:唯一性只對「未刪除」紀錄成立;刪掉後同 (owner, serial) 可再 INSERT 一筆新的。
|
||
CREATE UNIQUE INDEX uq_devices_owner_serial_active
|
||
ON devices (owner_user_id, serial_number)
|
||
WHERE deleted_at IS NULL;
|
||
CREATE INDEX ON devices (owner_user_id) WHERE deleted_at IS NULL;
|
||
CREATE INDEX ON devices (remote_status) WHERE deleted_at IS NULL;
|
||
|
||
-- ── token 分表決策(2026-06-20)──────────────────────────────────────────
|
||
-- 決策:pairing_tokens 與 session_tokens 「分表」,不共表 by kind。
|
||
-- 理由:
|
||
-- 1. code 中是兩個獨立 struct + 兩個 Store interface(PairingStore / SessionTokenStore),
|
||
-- 欄位與方法集不同(pairing 有 used_at 一次性語意、kind;session 有 parent_token_hash、無 used_at)。
|
||
-- 2. 共表會讓 used_at / parent_token_hash 對另一類永遠為 NULL,欄位語意混淆、CHECK 約束變複雜。
|
||
-- 3. 分表後各表 schema 乾淨、index 各自最佳化,repository 一對一對映 Store,最直觀。
|
||
-- 代價:稽核「pairing→session 升級鏈」需跨表 join(session_tokens.parent_token_hash → pairing_tokens.token_hash);
|
||
-- 可接受(查詢頻率低,且 parent_token_hash 已是 hash 不需額外轉換)。
|
||
|
||
-- pairing_tokens(短期一次性配對 token;對齊 internal/auth.PairingToken)
|
||
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), -- MarkUsed 綁定後才有
|
||
kind TEXT NOT NULL DEFAULT 'pairing', -- 固定 'pairing'(保留欄位,便於觀測/未來擴充)
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
expires_at TIMESTAMPTZ, -- 15min TTL;NULL = 永不過期(測試用)
|
||
used_at TIMESTAMPTZ, -- 一次性:MarkUsed 後 Validate 失敗
|
||
revoked_at TIMESTAMPTZ
|
||
-- 2026-06-20 移除原 schema 的 parent_token / revoked_by / last_seen_at —— code 不存在這些欄位
|
||
);
|
||
CREATE INDEX ON pairing_tokens (user_id) WHERE revoked_at IS NULL;
|
||
CREATE INDEX ON pairing_tokens (device_id);
|
||
|
||
-- session_tokens(長期可撤銷 tunnel session token;對齊 internal/auth.SessionToken)
|
||
CREATE TABLE session_tokens (
|
||
token_hash TEXT PRIMARY KEY, -- sha256(plaintext)
|
||
user_id UUID NOT NULL REFERENCES users(id),
|
||
device_id UUID NOT NULL REFERENCES devices(id), -- session token 必綁 device
|
||
parent_token_hash TEXT, -- 升級來源 pairing token 的 hash(稽核鏈,可 join pairing_tokens.token_hash)
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
expires_at TIMESTAMPTZ, -- 90 天 TTL;NULL = 永不過期
|
||
revoked_at TIMESTAMPTZ
|
||
-- 注意:無 used_at(非一次性)、無 kind
|
||
);
|
||
CREATE INDEX ON session_tokens (user_id) WHERE revoked_at IS NULL;
|
||
CREATE INDEX ON session_tokens (device_id);
|
||
CREATE INDEX ON session_tokens (parent_token_hash);
|
||
|
||
-- 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,
|
||
faa_object_key TEXT, -- 2026-06-20 補:ADR-017 (a) B1,nullable(上傳類留 NULL)
|
||
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(),
|
||
uploaded_at TIMESTAMPTZ, -- 2026-06-20 補:code 有 UploadedAt、原 schema 漏
|
||
deleted_at TIMESTAMPTZ
|
||
);
|
||
CREATE INDEX ON models (owner_user_id) WHERE deleted_at IS NULL;
|
||
CREATE INDEX ON models (owner_user_id, target_chip) WHERE deleted_at IS NULL; -- List filter by chip
|
||
CREATE INDEX ON models (owner_user_id, source) WHERE deleted_at IS NULL; -- List filter by source
|
||
|
||
-- 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/v4`
|
||
- 位置:`visionA-backend/migrations/*.sql`
|
||
- 命名:`NNNN_description.up.sql` / `.down.sql`(序號式,如 `0001_create_users_models.up.sql`)
|
||
- runner:啟動時自動 migrate up(或獨立 `cmd/migrate`),由 `DatabaseConfig.Enabled()` 決定是否執行
|
||
|
||
### 5.1 Migration 清單(2026-06-20 補 — 對齊塊式範圍)
|
||
|
||
| Migration | 內容 | 對應塊 | 觸發範圍 |
|
||
|-----------|------|--------|---------|
|
||
| `0001_create_users_models` | `users` + `models`(含 faa_object_key / uploaded_at + 3 個 owner-scoped index) | 塊 1 | **最小範圍**(使用者最關心,重啟後模型庫還在) |
|
||
| `0002_create_devices` | `devices`(雙狀態欄位 + paired_at + partial unique index `uq_devices_owner_serial_active`) | 塊 2 | 持久資料範圍 |
|
||
| `0003_create_token_tables` | `pairing_tokens` + `session_tokens`(分表,見 §4 決策) | 塊 3 | 持久資料範圍 |
|
||
| `0004_*`(如需要) | `clusters` / `converter_jobs` | — | **本期範圍外**(main.go 未 wire,見 `db-integration-plan.md` §8) |
|
||
|
||
> **第一份 migration(`0001`)為何含 users**:`models.owner_user_id` 是 `REFERENCES users(id)` 的 FK,必須先有 users 表。雛形 users 為 stub(固定 demo-user),但 schema 與 FK 約束第一份就要到位,否則 models 建不起來。
|
||
>
|
||
> 決策:第一份 migration 聚焦「最小可驗收 = 重啟後模型庫資料還在」,故只含 users + models;device / token 隨各自塊的 migration 進場,降低首次上 DB 的風險面。
|
||
|
||
---
|
||
|
||
## 5.5 連線設定規格(2026-06-20 新增 — DatabaseConfig / RedisConfig)
|
||
|
||
> 對齊既有 `internal/config/config.go` 慣例:每個外部依賴一個 sub-config struct、掛在 `Config` 上、env 前綴 `VISIONA_*`、有 `Enabled()` 用「必要欄位全非空」判斷是否 wire(參照既有 `ConversionConfig.Enabled()` / `FileAccessConfig.Enabled()`)。新增欄位須同步 `.env.example`。
|
||
|
||
### 5.5.1 DatabaseConfig(PostgreSQL)
|
||
|
||
```go
|
||
// internal/config/config.go — 掛在 Config struct 上:Database DatabaseConfig
|
||
|
||
// DatabaseConfig 控制 PostgreSQL 連線(持久業務資料:model / device / token)。
|
||
//
|
||
// 啟用判定(Enabled()):Host / User / DBName 三者全非空才視為啟用;
|
||
// 任一缺 → main.go 不建連線池、6 個 repository 仍用 in-memory(local dev fallback)。
|
||
//
|
||
// 安全:Password / DSN 永遠不印 log 全文(可印 host:port/dbname);
|
||
// 部署走既有 secrets 機制(AWS Secrets Manager / Vault),禁止 commit 進 repo。
|
||
type DatabaseConfig struct {
|
||
Host string // VISIONA_DB_HOST
|
||
Port int // VISIONA_DB_PORT,預設 5432
|
||
User string // VISIONA_DB_USER
|
||
Password string // VISIONA_DB_PASSWORD(禁止 commit)
|
||
DBName string // VISIONA_DB_NAME
|
||
SSLMode string // VISIONA_DB_SSLMODE,預設 "require"(stage/prod);本機 testcontainers 用 "disable"
|
||
|
||
// 連線池(pgxpool)
|
||
MaxConns int // VISIONA_DB_MAX_CONNS,預設 10
|
||
MinConns int // VISIONA_DB_MIN_CONNS,預設 2
|
||
MaxConnLifetime time.Duration // VISIONA_DB_MAX_CONN_LIFETIME,預設 1h
|
||
ConnTimeout time.Duration // VISIONA_DB_CONN_TIMEOUT,預設 5s(建池/ping 逾時)
|
||
}
|
||
|
||
// DSN 組裝(供 pgxpool):
|
||
// postgres://{User}:{Password}@{Host}:{Port}/{DBName}?sslmode={SSLMode}&pool_max_conns={MaxConns}
|
||
func (c DatabaseConfig) Enabled() bool {
|
||
return c.Host != "" && c.User != "" && c.DBName != ""
|
||
}
|
||
```
|
||
|
||
| env | 預設 | 說明 |
|
||
|-----|------|------|
|
||
| `VISIONA_DB_HOST` | (空)| visionA 專用 PG host(credential 已取得;他人在 130 provision) |
|
||
| `VISIONA_DB_PORT` | `5432` | |
|
||
| `VISIONA_DB_USER` | (空)| app role |
|
||
| `VISIONA_DB_PASSWORD` | (空)| 禁止 commit;走 secrets 機制 |
|
||
| `VISIONA_DB_NAME` | (空)| visionA 專用 database |
|
||
| `VISIONA_DB_SSLMODE` | `require` | stage/prod 用 require/verify-full;testcontainers 用 disable |
|
||
| `VISIONA_DB_MAX_CONNS` | `10` | pgxpool 上限 |
|
||
| `VISIONA_DB_MIN_CONNS` | `2` | pgxpool 常駐 |
|
||
| `VISIONA_DB_MAX_CONN_LIFETIME` | `1h` | |
|
||
| `VISIONA_DB_CONN_TIMEOUT` | `5s` | 建池 / 啟動 ping 逾時 |
|
||
|
||
### 5.5.2 RedisConfig(userSession)
|
||
|
||
```go
|
||
// internal/config/config.go — 掛在 Config struct 上:Redis RedisConfig
|
||
|
||
// RedisConfig 控制 Redis 連線(僅 userSession:browser cookie session)。
|
||
//
|
||
// ⚠️ visionA 專用 Redis 實例:由使用者自行在 stage host(130) 另起、設密碼。
|
||
// visionA 端不 provision、只接上。
|
||
//
|
||
// 啟用判定(Enabled()):Host 非空才視為啟用;
|
||
// 未啟用 → userSession 仍用 in-memory(雛形行為,process 重啟掉 session)。
|
||
//
|
||
// 安全:Password 永遠不印 log 全文。禁止 commit 進 repo。
|
||
type RedisConfig struct {
|
||
Host string // VISIONA_REDIS_HOST
|
||
Port int // VISIONA_REDIS_PORT,預設 6379
|
||
Password string // VISIONA_REDIS_PASSWORD(visionA 專用實例必設密碼;禁止 commit)
|
||
DB int // VISIONA_REDIS_DB,預設 0(db index)
|
||
|
||
ConnTimeout time.Duration // VISIONA_REDIS_CONN_TIMEOUT,預設 5s
|
||
}
|
||
|
||
func (c RedisConfig) Enabled() bool {
|
||
return c.Host != ""
|
||
}
|
||
```
|
||
|
||
| env | 預設 | 說明 |
|
||
|-----|------|------|
|
||
| `VISIONA_REDIS_HOST` | (空)| visionA 專用 Redis(使用者在 130 另起) |
|
||
| `VISIONA_REDIS_PORT` | `6379` | |
|
||
| `VISIONA_REDIS_PASSWORD` | (空)| visionA 專用實例必設;禁止 commit |
|
||
| `VISIONA_REDIS_DB` | `0` | db index |
|
||
| `VISIONA_REDIS_CONN_TIMEOUT` | `5s` | 建連 / 啟動 ping 逾時 |
|
||
|
||
> userSession 的 idle / absolute TTL **不在 RedisConfig**,沿用既有 `UserSessionConfig.IdleTTL`(24h)/ `AbsoluteTTL`(168h);`RedisUserSessionStore` 用這兩個值設 Redis key TTL 取代手動 cleanup goroutine。
|
||
|
||
---
|
||
|
||
## 6. 資料一致性考量
|
||
|
||
| 操作 | 需要的一致性 |
|
||
|------|-------------|
|
||
| 使用者建立 Device + PairingToken | 同一 transaction |
|
||
| 使用者刪除 Device | cascade 撤銷該 device 的所有 token(**pairing_tokens + session_tokens 兩張表**,by device_id)於同一 tx |
|
||
| Converter job 完成 → 建立 Model | transactional upsert + webhook idempotency |
|
||
|
||
雛形 in-memory 無交易;Phase 1 用 `pgx` 的 tx(塊 5 的 `internal/db/tx.go` helper)。
|
||
|
||
> token 分表後(§4 決策),「刪 device cascade 撤銷 token」需在同一 tx 內對 `pairing_tokens` 與 `session_tokens` 各跑一次 `UPDATE ... SET revoked_at = now() WHERE device_id = $1 AND revoked_at IS NULL`。
|
||
|
||
---
|
||
|
||
**雛形實作 / 未來擴展**:
|
||
- 雛形:所有 repository 用 `map + sync.RWMutex`;struct 欄位按上表定義(多出的欄位留空或 zero value)
|
||
- 未來:實作 `Postgres*Repository`;加上 migration;處理 soft delete(`WHERE deleted_at IS NULL`)
|
||
|
||
**DB 接入關鍵改動提醒(2026-06-20,對齊 `db-integration-plan.md` 塊 3)**:
|
||
- pairing / session token 兩個 in-memory store 目前**以 plaintext 當 map key**;接 Postgres 時改成 **`token_hash`(`HashToken(plaintext)`)當 PK**。`Validate` / `Get` 需先 `HashToken()` 再查;務必確認所有呼叫端傳入 plaintext、由 store 內部統一 hash(漏一個呼叫端就驗不過)。code 已有 `HashToken()`(`internal/auth/token.go`)。
|
||
- model / device 的 `Save` 是 upsert by ID,須保留既有 `CreatedAt`(對齊 in-memory `Save` 的語意:existing 且未刪除時保留 CreatedAt)。
|
||
- 整合測試走 testcontainers(不依賴 130);stage 收尾才接 visionA 專用 PG / Redis。
|