jim800121chen 4d0b870480 feat(visionA-backend): DB 接入 — 6 store 接 PostgreSQL/Redis 持久化(塊 0-5)
把 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>
2026-06-20 18:28:04 +08:00

641 lines
32 KiB
Markdown
Raw Permalink 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.

# 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不依賴 130130 僅用於 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 存 Summaryhandle 仍留本地)。本期範圍外。 |
### 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雛形 stubPhase 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. StatusUSB-level status既有
// - 由 local agent 直接觀察到的「USB 接了什麼」
// - 值online / offline / unknown
// - 來源local agent 呼叫 KL SDK 得到;透過 tunnel 上報雲端
// - 意義:「此刻使用者電腦上這個 KL device 插著且正常」
//
// 2. RemoteStatustunnel-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雲端連線狀態次要顯示 statusUSB 狀態),
// 詳見 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 / unknownUSB
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 裡的 keymodels/{user_id}/{id}.nef
FileSize int64 `json:"fileSize"`
FileChecksum string `json:"fileChecksum,omitempty"` // sha256 hex
// FAAObjectKeyADR-017 (a) B1model 在 File Access Agent 上的 object key。
// 只有 Source=converted轉檔→promote 進 FAA類有值上傳類留空。
// JSON tag = "-"(不對前端揭露內部 storage keyADR-017 決策 2
// DB 欄位 nullable見 §42026-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 / presetSource = 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 實際的兩個 structschema 也據此分表(見 §4
```go
// internal/auth/auth.go
// PairingToken — 短期一次性配對 tokenvAc_ + 32 hex15min 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 TTLnil = 永不過期(測試用)
UsedAt *time.Time `json:"usedAt,omitempty"` // 一次性MarkUsed 寫入後 Validate 失敗
RevokedAt *time.Time `json:"revokedAt,omitempty"`
}
// SessionToken — 長期可撤銷 tunnel session tokenvAs_ + 64 hex90 天 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 ConverterJobstub
```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 mapprocess 重啟就掉。Phase 1 考慮 Redis 存 summaryTTL + heartbeat
> **2026-06-20 補充DB 接入)**:要區分兩種 session
> - **`internal/usersession`browser cookie session→ 接 Redis**。Session struct見 `internal/usersession/usersession.go`)含 OIDC pending stateOIDCState/Nonce/CodeVerifier+ token snapshotAccessToken/IDTokenRaw+ `Extra map[string]any`。`RedisUserSessionStore` 用 Redis 雙 TTLidle = `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
-- 環境適配(塊 02026-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
);
-- devices2026-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 indexsoft-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 interfacePairingStore / SessionTokenStore
-- 欄位與方法集不同pairing 有 used_at 一次性語意、kindsession 有 parent_token_hash、無 used_at
-- 2. 共表會讓 used_at / parent_token_hash 對另一類永遠為 NULL欄位語意混淆、CHECK 約束變複雜。
-- 3. 分表後各表 schema 乾淨、index 各自最佳化repository 一對一對映 Store最直觀。
-- 代價稽核「pairing→session 升級鏈」需跨表 joinsession_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 TTLNULL = 永不過期(測試用)
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 天 TTLNULL = 永不過期
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) B1nullable上傳類留 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 + modelsdevice / 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 DatabaseConfigPostgreSQL
```go
// internal/config/config.go — 掛在 Config struct 上Database DatabaseConfig
// DatabaseConfig 控制 PostgreSQL 連線持久業務資料model / device / token
//
// 啟用判定Enabled()Host / User / DBName 三者全非空才視為啟用;
// 任一缺 → main.go 不建連線池、6 個 repository 仍用 in-memorylocal 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 hostcredential 已取得;他人在 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-fulltestcontainers 用 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 RedisConfiguserSession
```go
// internal/config/config.go — 掛在 Config struct 上Redis RedisConfig
// RedisConfig 控制 Redis 連線(僅 userSessionbrowser 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_PASSWORDvisionA 專用實例必設密碼;禁止 commit
DB int // VISIONA_REDIS_DB預設 0db 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不依賴 130stage 收尾才接 visionA 專用 PG / Redis。