依 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)。
14 KiB
14 KiB
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 實作)
// 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
// 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
// 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)
// 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 搬)
// 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)
// 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,但列出以對照)
// 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
// 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 的欄位:
-- 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)