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

14 KiB
Raw Blame History

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 實作)

// 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. 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。雲端這張表負責「我曾經綁過哪些裝置、它們的名字、擁有者」+ 雙狀態快照。

更新時機

  • remoteStatusremote-proxy 在 tunnel 事件發生時寫入connect → online、heartbeat timeout → offline、中間短暫斷線 → reconnecting、yamux 錯誤 → error
  • lastSeenAtremote-proxy 的 heartbeat 處理每 10s 更新一次(見 tunnel.md §4.2
  • statusUSB由 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 裡的 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

// 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 ConverterJobstub

// 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 mapprocess 重啟就掉。Phase 1 考慮 Redis 存 summaryTTL + 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.gointerface + InMemoryRepository 實作
  • types.gostruct 定義

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
);

-- 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.RWMutexstruct 欄位按上表定義(多出的欄位留空或 zero value
  • 未來:實作 Postgres*Repository;加上 migration處理 soft deleteWHERE deleted_at IS NULL