jim800121chen fb7da5d180 chore(autoflow): migrate .autoflow/ 共享層文件至 docs/autoflow/
依 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)。
2026-05-04 16:55:55 +08:00

422 lines
14 KiB
Markdown
Raw 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。
---
## 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"`
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 裡的 keymodels/{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 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
---
## 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
);
-- 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(),
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`