依 autoflow-agent workspace v2 設計把 PRD / 設計 / 架構 / 交付類 共享文件從個人層 .autoflow/(ignored)搬到 docs/autoflow/(進 git), 讓團隊可共享產品與架構文件,個人層只留 progress / review / testing 等 per-branch 筆記。 - 02-prd/ 21 個檔(PRD、features、market-analysis 等) - 03-design/ 18 個檔(design-spec、wireframes、flows 等) - 04-architecture/ 31 個檔(TDD、design-doc、ADR×14、API 規格等) - 07-delivery/ 3 個檔(project-summary、phase-0.6-handover、stage-deployment-setup) 合計 73 檔。原檔已從 .autoflow/ 移除(migration 工具執行 git mv, 但因 .autoflow/ 在 .gitignore 中、git 將此操作視為新增、無 rename history)。
422 lines
14 KiB
Markdown
422 lines
14 KiB
Markdown
# 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`)
|