- feature-device-mgmt-tdd.md + api-device-mgmt.md(B 設備管理 TDD) - feature-model-sharing-tdd.md + api-model-sharing.md + PRD feature + 設計規格(C 模型共享三方規劃) - adr-020-ffmpeg-camera-indev.md(camera 三平台 indev) - PRD.md / TDD.md 索引增補 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
38 KiB
Technical Design Document — visionA Cloud
Metadata
- 作者:Architect Agent
- 狀態:Draft(待三方交叉審閱)
- 最後更新:2026-04-22(反映三方交叉審閱 M-1
M-8 + Minor-2Minor-4) - 文件角色:給工程師實作時的技術契約(不是 Test-Driven Development)
- 上位文件:
design-doc.md、adr/adr-*.md - 讀者:Frontend / Backend / DevOps / Testing Agents
雛形關鍵決策(2026-04-22 使用者裁決)
- 雛形即雙 binary:
cmd/api-server+cmd/remote-proxy,不做單進程 all-in-one - 不引入 Redis(POC 也從未用過;查證 go.mod / source / docker 皆無)
- Session state 由
remote-proxy完全持有(in-memory) api-server無狀態,透過 internal HTTP 向remote-proxy查 session / 轉發請求/internal/session/:token、/internal/forward/*在 Phase 0 雛形就必須實作(不是 Phase 1)
- Session state 由
- Local agent 雛形不做:tunnel 測試端暫用 POC
edge-ai-server當 tunnel client - POC 程式碼複製:
internal/relay/internal/tunnel/internal/wsconn從edge-ai-platform複製後獨立演進 - Auth 雛形:
→ 已替換為 OIDC(Phase 0.6 / OB5;接 Innovedus Member Center;詳見 oidc-tdd.md + adr-011)StaticAuthService永遠回demo-user單一使用者 - Session 覆蓋:沿用 POC — 同 token 後連覆蓋前連;Phase 1 再重新設計
- Pairing Token 顯示:API 回傳純 hex,前端顯示層加空格
- 三個 local-tool-only 元件雛形隱藏:
OnboardingDialog、ServerStatusDashboard、ServerLogViewer
索引(本文件為總覽 + 連結)
1. 專案骨架
- 見 §1 專案骨架(本文件)
2. Backend 模組詳細
- 見 §2 Backend 模組詳細(本文件)
3. API 規格
- REST + WebSocket 詳細端點清單 →
api/api-spec.md - 內部 API(api-server ↔ remote-proxy)→
api/api-internal.md
4. 資料模型
- Go struct 定義 + Phase 1 DB schema 對應 →
database.md
5. Pairing Token 協定
- 詳細流程 + 雛形 vs Phase 1 差異 →
security.md
6. Tunnel 協定
- 詳細訊息格式、重連策略 →
tunnel.md
7. Session 管理
SessionStoreinterface + 實作策略 →tunnel.md
8. 儲存層介面
Storeinterface + LocalFS 實作 →storage.md
9. Converter 整合 API 契約
- visionA-backend 呼叫 converter 的 spec(給 converter 團隊)→
api/api-converter-contract.md - Phase 0.8 轉檔功能整合(visionA backend ↔ converter ↔ FAA ↔ MC 流程) →
conversion.md - Phase 0.8 對前端 API →
api/api-conversion.md - 架構決策 →
adr/adr-014-conversion-integration.md
9.5 模型庫存取 / 下載(FAA delegated download)
- 完整架構決策(認證鏈 / 權限模型 / object_key 斷層 / stage e2e 實證) →
adr/adr-017-model-library-access.md - 下載對接權威規格(v1.3:query-string token + redirect、推翻舊「fetch + Bearer」、解 CORS 405) →
adr/adr-017-model-library-access.md§11 - CORS 405 根因定位(個人層)→
.autoflow/04-architecture/download-cors-405-diagnosis.md
9.6 模型共享(Model Sharing — 公開對象 + 共享模型庫)
- TDD(資料模型 visibility 欄 / 權限可見性 query / 相容性 / 並行化計畫) →
feature-model-sharing-tdd.md - API 規格(
GET /api/models/library共享庫列表 /GET /api/models/:id/profile/PATCH /api/models/:id/visibility) →api/api-model-sharing.md - 沿用 ADR-017 決策 3 的
model_shares(點對點分享);本功能新增models.visibilityenum(private/tenant/public)作正交的「廣播」維度 - ⚠️ 放寬既有 owner-only 邊界(download 端點權限改動 + enumeration 防護)— 建議整體送 security agent 審
9.7 個人設備管理 — 設備註冊 / 取消註冊(P0)
- TDD(註冊 API / 取消註冊 vs unpair 區分 / 三態分色 / 排序 filter / 安全 / 並行化計畫) →
feature-device-mgmt-tdd.md - API 規格(
POST /api/devices/:id/register/POST /api/devices/:id/unregister,UUID 識別) →api/api-device-mgmt.md - 沿用 ADR-018 走向 A' 模型(
registered_at註冊軸、欄位已存在 → 不需 migration) - ⚠️ 取消註冊 = 退回未註冊(清 registered_at、保留列),與 unpair(軟刪+撤 token)分開;使用者 2026-08-02 重新拍板,覆蓋 ADR-018 §1.1 Q5「實體刪除」
10. 前端資料流與狀態管理
- 見 §10(本文件)
11. 建置與部署
- Makefile + Dockerfile + docker-compose →
build-deploy.md
12. 安全考量
- 完整安全清單 →
security.md
13. 測試策略
- 見 §13(本文件)
14. TODO 清單
- 見 §14(本文件)
§1 專案骨架
1.1 Repo 結構(Monorepo)
visionA/ # 既有 repo 根目錄
├── local-tool/ # 既有,不動
├── visionA-frontend/ # ⬅ 新增
│ ├── src/
│ │ ├── app/ # Next.js App Router
│ │ ├── components/
│ │ ├── stores/ # Zustand
│ │ ├── hooks/
│ │ ├── lib/
│ │ │ ├── api.ts # 改造:base URL = 雲端 API server
│ │ │ ├── auth.ts # stub,未來對接真實 auth
│ │ │ └── ws.ts # WebSocket 連接 util
│ │ ├── types/
│ │ └── i18n/
│ ├── public/
│ ├── package.json
│ ├── next.config.ts
│ ├── tsconfig.json
│ ├── tailwind.config.ts
│ └── .env.example # VISIONA_* / NEXT_PUBLIC_*
│
├── visionA-backend/ # ⬅ 新增
│ ├── go.mod # module "visiona-backend"
│ ├── go.sum
│ ├── Makefile
│ ├── cmd/
│ │ ├── api-server/
│ │ │ └── main.go # REST + WebSocket API(無狀態)
│ │ └── remote-proxy/
│ │ └── main.go # Tunnel server + internal HTTP API(有狀態)
│ │ # 註:雛形交付物就是這兩個 binary + docker-compose。
│ │ # 本機開發可用 `make run-dev` 平行跑(純便利工具,非交付物)。
│ │ # 不做 `cmd/dev-all-in-one`(Q1 裁決 C + design-doc.md §1.9 N10)
│ ├── internal/
│ │ ├── api/ # API handlers(給前端)
│ │ │ ├── handlers/
│ │ │ ├── middleware/
│ │ │ ├── ws/
│ │ │ └── router.go
│ │ ├── auth/ # Auth + Pairing Token
│ │ │ ├── service.go # AuthService interface
│ │ │ ├── static.go # StaticAuthService
│ │ │ ├── pairing.go # PairingStore interface
│ │ │ └── static_pairing.go # StaticPairingStore
│ │ ├── session/ # Tunnel session 管理
│ │ │ ├── store.go # SessionStore interface
│ │ │ ├── inmemory.go
│ │ │ └── types.go
│ │ ├── device/ # Device domain
│ │ │ ├── types.go
│ │ │ └── repository.go # interface + InMemory 實作
│ │ ├── model/
│ │ │ ├── types.go
│ │ │ └── repository.go
│ │ ├── cluster/ # 從 POC 複製(Q2 決策,後續獨立演進)
│ │ ├── relay/ # 從 POC 複製(同上)
│ │ ├── tunnel/ # 從 POC 複製;雛形給測試/未來 agent 用
│ │ ├── converter/ # Converter client(雛形 stub)
│ │ │ ├── client.go # interface
│ │ │ └── stub.go
│ │ ├── storage/ # S3-compat
│ │ │ ├── store.go # Store interface
│ │ │ ├── localfs.go
│ │ │ └── s3.go # 可選,雛形後期加
│ │ ├── wsconn/ # yamux over WebSocket(從 POC 複製)
│ │ ├── config/ # env config
│ │ └── logger/
│ ├── pkg/ # 對外可重用
│ │ └── protocol/ # 共用的 types(request/response schema)
│ ├── docker/
│ │ ├── Dockerfile.api-server
│ │ ├── Dockerfile.remote-proxy
│ │ └── docker-compose.yml
│ ├── scripts/
│ │ └── dev.sh
│ └── .env.example
│
├── docs/ # 共用文件(選配)
├── .autoflow/ # 文件管理(本文件所在)
└── README.md
1.2 Go module
module visiona-backend(簡短、不含 github 路徑,避免未來搬 repo 要改)- Go 1.26(與 POC 一致)
- 主要依賴:
github.com/gin-gonic/gin
github.com/gorilla/websocket
github.com/hashicorp/yamux
github.com/go-playground/validator/v10
github.com/google/uuid
github.com/aws/aws-sdk-go-v2 (可選)
1.3 Frontend Node
- Node >= 20
- npm / pnpm(沿用 local-tool 偏好)
- 主要依賴沿用 local-tool:Next.js 16、React 19、TypeScript 5、Tailwind 4、Radix UI、Zustand 5、Lucide、Recharts、driver.js、Vitest + RTL
§2 Backend 模組詳細
2.1 internal/api(api-server 用)
| 檔案 | 職責 |
|---|---|
router.go |
Gin router 組裝 |
middleware/auth.go |
從 request 解析身分並注入 UserContext 到 gin.Context |
middleware/cors.go |
CORS(沿用 local-tool) |
middleware/logger.go |
broadcaster logger(沿用 local-tool) |
handlers/device.go |
/api/devices/* — 大多轉發到 local agent |
handlers/model.go |
/api/models/* — 雲端存模型 + 轉發到 local agent 載入 |
handlers/cluster.go |
/api/clusters/* — 從 POC 搬 |
handlers/inference.go |
/api/devices/:id/inference/* |
handlers/pairing.go |
/api/pairing/* |
handlers/auth.go |
/api/auth/*(雛形 501) |
handlers/converter.go |
/api/converter/*(雛形 stub) |
handlers/storage.go |
/storage/*(雛形 LocalFS 代理) |
ws/devices.go, ws/inference.go 等 |
WebSocket upgrade + 轉發到 tunnel |
forward.go |
核心:把瀏覽器請求透過 tunnel 送到 local agent(參考 POC handleProxy) |
2.2 internal/auth(2026-04-22 M-3 修訂:介面雙層 AuthService + AuthProvider)
Auth 切分為兩層 interface,對應不同呼叫場景(詳見 security.md §2.0):
| 介面 | 層級 | 檔案 |
|---|---|---|
AuthService |
Middleware 層(每個 request 進來時解析身分) | service.go |
AuthProvider |
Handler 層(登入 / 註冊 / 登出 / token 驗證) | provider.go |
// service.go(middleware 層 — 既有)
package auth
type AuthService interface {
Authenticate(ctx context.Context, req *http.Request) (*UserContext, error)
Authorize(ctx context.Context, userCtx *UserContext, resource, action string) error
}
type UserContext struct {
UserID string
Email string
Roles []string
OrgID string
}
// static.go — 雛形(⚠️ OB5 已移除;以下程式碼僅作歷史紀錄)
// 取代為 OIDC + cookie session,見 oidc-tdd.md §4
type StaticAuthService struct {
DefaultUser UserContext
}
func (s *StaticAuthService) Authenticate(...) { return &s.DefaultUser, nil }
func (s *StaticAuthService) Authorize(...) { return nil }
// provider.go(handler 層 — 2026-04-22 新增)
package auth
import "time"
type AuthProvider interface {
Register(ctx context.Context, req *RegisterRequest) (*User, error)
Login(ctx context.Context, req *LoginRequest) (*LoginResult, error)
Logout(ctx context.Context, token string) error
ValidateToken(ctx context.Context, token string) (*UserContext, error)
GetUser(ctx context.Context, userID string) (*User, error)
}
type LoginRequest struct { Email, Password string }
type RegisterRequest struct { Email, Password, Name string }
type LoginResult struct {
User *User
AccessToken string
RefreshToken string
ExpiresAt time.Time
}
// static_provider.go — 雛形(⚠️ OB5 已移除;以下程式碼僅作歷史紀錄)
// 取代為 OIDC + cookie session,見 oidc-tdd.md §4
type StaticAuthProvider struct {
DemoUser *User // 預設 demo-user,User struct 見 database.md §2.1
}
func (p *StaticAuthProvider) Login(ctx context.Context, req *LoginRequest) (*LoginResult, error) {
// 無論 email / password 是什麼都通過
return &LoginResult{
User: p.DemoUser,
AccessToken: "demo-access-token",
RefreshToken: "demo-refresh-token",
ExpiresAt: time.Now().Add(24 * time.Hour),
}, nil
}
func (p *StaticAuthProvider) Register(ctx, req) (*User, error) { return nil, ErrNotImplemented }
func (p *StaticAuthProvider) Logout(ctx, token) error { return nil }
func (p *StaticAuthProvider) ValidateToken(ctx, token) (*UserContext, error) {
if token == "demo-access-token" {
return &UserContext{UserID: p.DemoUser.ID, Email: p.DemoUser.Email}, nil
}
return nil, ErrInvalidToken
}
func (p *StaticAuthProvider) GetUser(ctx, userID) (*User, error) {
if userID == p.DemoUser.ID { return p.DemoUser, nil }
return nil, ErrNotFound
}
// pairing.go(不變)
type PairingStore interface {
Validate(ctx context.Context, token string) (*PairingInfo, error)
MarkUsed(ctx context.Context, token string, deviceID string) error
Create(ctx context.Context, userID string, ttl time.Duration) (plain string, info *PairingInfo, err error)
Revoke(ctx context.Context, token string) error
List(ctx context.Context, userID string) ([]*PairingInfo, error)
}
type PairingInfo struct {
TokenHash string
UserID string
DeviceID string
CreatedAt time.Time
ExpiresAt *time.Time
UsedAt *time.Time
RevokedAt *time.Time
}
// static_pairing.go — 雛形
type StaticPairingStore struct {
Token string // 格式:vAc_[0-9a-f]{32}(見 security.md §1.3)
UserID string
}
func (s *StaticPairingStore) Validate(ctx, token) (*PairingInfo, error) {
if token != s.Token { return nil, ErrInvalidToken }
return &PairingInfo{UserID: s.UserID, TokenHash: sha256hex(token)}, nil
}
func (s *StaticPairingStore) Create(...) (..., error) { return "", nil, ErrNotImplemented }
雛形實作:
→ OB5 已移除,AuthMiddleware 只走 OIDC(cookie session → UserContext),詳見 oidc-tdd.mdStaticAuthService(middleware)永遠回 demo-user→ OB5 已移除;POST /api/auth/login 一律 410 Gone,使用者改用 GET /api/auth/login redirect 到 Member CenterStaticAuthProvider(handler)Login 任何帳密都通過StaticPairingStore對 envVISIONA_PAIRING_TOKEN比對(格式必須是vAc_+ 32 hex)— 仍有效
已完成(OB1-OB5):OIDC 接 Innovedus Member Center 取代 StaticAuth。
未來擴展:PostgresPairingStore、Redis-backed user session store(取代 in-memory)、Member Center webhook 接收 user 刪除/停用通知。
2.2.1 Pairing → Session Token 時序(M-2)
Phase 1 兩階段 token 完整流程見 security.md §1.2 與 adr/adr-003-pairing-token.md。關鍵原則:
- Pairing Token(
vAc_+ 32 hex):Web UI 產生,15 min TTL,一次性使用 - Session Token(
vAs_+ 64 hex):agent 首次連 tunnel 時由 remote-proxy 換發,90 天 TTL,可撤銷 - 換發發生在
WS /tunnel/connect的 upgrade 階段(一次 DB transaction 完成 insert session + mark pairing used) - local agent 從首個 yamux control frame 收到 Session Token 後持久化儲存,之後一律用 Session Token
雛形階段為簡化:只用 Pairing Token 單階段比對,無 TTL、無升級、無撤銷,僅適合 dev。
2.3 internal/session
參見 tunnel.md。核心 interface(2026-04-22 Minor-4 新增 CleanupExpired):
type SessionStore interface {
Register(ctx context.Context, token string, sess SessionHandle) error
Unregister(ctx context.Context, token string) error
Lookup(ctx context.Context, token string) (SessionHandle, error)
Exists(ctx context.Context, token string) (bool, error)
List(ctx context.Context) ([]SessionSummary, error)
Heartbeat(ctx context.Context, token string) error
// 清除 LastSeenAt 超過 expireAfter 的 session。
// 由 remote-proxy background goroutine 每 30s 呼叫。
CleanupExpired(ctx context.Context, expireAfter time.Duration) (removed int, err error)
}
type SessionHandle interface {
// remote-proxy 本地:LocalHandle wrap *yamux.Session
// api-server 遠端查詢:RemoteHandle wrap internal HTTP client
OpenStream(ctx context.Context) (net.Conn, error)
Close() error
IsClosed() bool
}
雛形部署(不用 Redis):
remote-proxy持有 唯一的InMemoryStore(session 實體就在這個進程的記憶體)api-server不持有 session state;要查 session 時,透過 internal HTTP 呼叫remote-proxy:GET /internal/session/:token— 確認 session 是否在線POST /internal/forward/http/GET /internal/forward/ws— 轉發請求
- 在
api-server端,SessionStore的實作是ProxyClientStore(wrap HTTP client,呼叫remote-proxy) - 在
remote-proxy端,SessionStore的實作是InMemoryStore(真正持有*yamux.Session)
Phase 1 多節點時再評估:多個 remote-proxy 節點間如何共享 session metadata(可能 Redis、可能 gossip、可能 service discovery)。雛形不預設方案。參見 ADR-006。
2.4 internal/device, internal/model, internal/cluster
- types.go:Go struct(見
database.md) - repository.go:interface + InMemory 實作
- 雛形不做 ORM,InMemory 用
map + sync.RWMutex - Phase 1:新增
postgres_repository.go實作同 interface
2.5 internal/relay
- 從 POC
edge-ai-platform/server/internal/relay/server.go搬過來 - 修改:
- 把
sessions map[string]*yamux.Session抽到SessionStore handleTunnel先呼叫PairingStore.Validate(token)驗證後才接受handleProxy從SessionStore.Lookup(token)取 session(而非 map 直接取)
- 把
- 不修改的部分:
proxyWebSocket的 Hijack 邏輯isWebSocketUpgrade判斷- MJPEG streaming 的
http.Flusher處理
2.6 internal/tunnel
- 從 POC
edge-ai-platform/server/internal/tunnel/client.go搬 - 在雛形中這個模組主要是給測試 / 未來 headless tunnel-only agent 用
- local-tool 既有的雲端模式 opt-in 也會 import 這個 package(未來)
- POC code 不大改;把
localAddr等參數化
2.7 internal/converter
// client.go
type Client interface {
SubmitConvert(ctx context.Context, req *ConvertRequest) (jobID string, err error)
GetJob(ctx context.Context, jobID string) (*Job, error)
ListJobs(ctx context.Context, userID string) ([]*Job, error)
DownloadResult(ctx context.Context, jobID string) (io.ReadCloser, error)
}
// stub.go — 雛形
type StubClient struct{}
func (s *StubClient) SubmitConvert(...) (string, error) {
return "stub-job-" + uuid.NewString(), nil
}
func (s *StubClient) GetJob(ctx, id) (*Job, error) {
return &Job{ID: id, Status: "queued"}, nil
}
詳細 API 契約見 api/api-converter-contract.md。
2.8 internal/storage
見 storage.md。
2.9 internal/wsconn
- 從 POC
server/pkg/wsconn/wsconn.go搬,無修改 - 作為
net.Connadapter,讓 yamux 能在 WebSocket 上運作
2.10 internal/config
type Config struct {
APIServer struct {
Port int `env:"VISIONA_API_PORT" default:"3001"`
}
RemoteProxy struct {
TunnelPort int `env:"VISIONA_TUNNEL_PORT" default:"3800"`
InternalPort int `env:"VISIONA_PROXY_INTERNAL_PORT" default:"3801"`
}
Auth struct {
Mode string `env:"VISIONA_AUTH_MODE" default:"static"` // static / oidc / ...
// static mode:
StaticUserID string `env:"VISIONA_STATIC_USER_ID" default:"demo-user"`
}
Pairing struct {
Mode string `env:"VISIONA_PAIRING_MODE" default:"static"` // static / db
Token string `env:"VISIONA_PAIRING_TOKEN"` // for static mode
}
Session struct {
// remote-proxy 端永遠是 "inmemory"(它擁有實體 yamux session)
// api-server 端永遠是 "proxy-client"(透過 internal HTTP 查 remote-proxy)
Backend string `env:"VISIONA_SESSION_BACKEND" default:"inmemory"` // inmemory / proxy-client
// api-server 要知道 remote-proxy 的 internal HTTP 位址
ProxyInternalURL string `env:"VISIONA_PROXY_INTERNAL_URL" default:"http://localhost:3801"`
}
Storage struct {
Backend string `env:"VISIONA_STORAGE_BACKEND" default:"localfs"` // localfs / s3
LocalFSRoot string `env:"VISIONA_STORAGE_LOCALFS_ROOT" default:"./data/storage"`
LocalFSBaseURL string `env:"VISIONA_STORAGE_LOCALFS_BASE_URL" default:"http://localhost:3001/storage"`
S3Endpoint string `env:"VISIONA_S3_ENDPOINT"`
S3Bucket string `env:"VISIONA_S3_BUCKET"`
S3Region string `env:"VISIONA_S3_REGION"`
S3AccessKey string `env:"VISIONA_S3_ACCESS_KEY"`
S3SecretKey string `env:"VISIONA_S3_SECRET_KEY"`
}
Converter struct {
Mode string `env:"VISIONA_CONVERTER_MODE" default:"stub"` // stub / real
URL string `env:"VISIONA_CONVERTER_URL"`
}
}
12-Factor:全部走 env;雛形 .env.example 提供範本。
§10 前端資料流與狀態管理
10.1 Zustand Stores
從 local-tool 搬來:model-store、device-preferences-store、inference-store、camera-store、flash-store、activity-store、settings-store、tour-store。
新增:
auth-store:user,token,login(),logout()(雛形 stub)session-store:pairingToken,tunnelStatus,reconnect()
10.2 API Client 改造(src/lib/api.ts)
// 雛形:base URL 從環境變數讀,不再 hardcode localhost:3721
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'http://localhost:3001';
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const token = useAuthStore.getState().token;
const res = await fetch(`${API_BASE}${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
...init?.headers,
},
credentials: 'include', // for cookie-based auth future
});
if (!res.ok) throw new ApiError(res);
return res.json();
}
移除:local-tool 原本的 X-Relay-Token header 雲端模式鉤子(雲端版不再需要,authtoken 是 user token,pairing 是 backend 內部事)。
10.3 WebSocket Hook 改造(src/hooks/use-websocket.ts)
const WS_BASE = (process.env.NEXT_PUBLIC_API_BASE ?? 'http://localhost:3001')
.replace(/^http/, 'ws');
export function useWebSocket(path: string) {
// 連 wss://api.visiona.cloud/ws/xxx
// Auth 透過 first-message 或 cookie 附帶
// ...
}
10.4 Auth 流程 stub
// src/lib/auth.ts
export const authClient = {
async login(email: string, password: string) {
// 雛形:後端回 501,前端捕獲顯示「即將推出」
return apiFetch('/api/auth/login', { method: 'POST', body: JSON.stringify({email, password}) });
},
async register(...) { /* 501 */ },
async logout() { useAuthStore.getState().clear(); },
};
10.5 Pairing 流程 UI
新頁面 /devices/pair:
- 頁面顯示「請在本機 local-tool 輸入以下 Pairing Token」
- 從 env
NEXT_PUBLIC_DEV_PAIRING_TOKEN(雛形)或POST /api/pairing/token(Phase 1)取得 token 並顯示 - 右側顯示即時連線狀態(訂閱
GET /api/pairing/status或 WS/ws/pairing/status)
Token 顯示格式(Q7 裁決):
- API 回傳值永遠是純 hex / base64url(無空格、無分隔符)— 例:
pk_a1b2c3d4e5f6g7h8... - 前端顯示層為了可讀性,每 8 個字元插入空格 — 例:
pk_a1b2c3d4 e5f6g7h8 ... - 使用者複製時:UI 提供「Copy」按鈕,複製的是原始無空格 token
- 使用者貼上時:前端允許任何空白字元,送出前正規化(
.replace(/\s/g, '')) - 這保證 API 契約乾淨,同時 UI 可讀性好
10.5.1 Device 雙狀態消費(2026-04-22 Minor-3)
Device struct 現在有兩組狀態欄位(見 database.md §2.2):
interface Device {
id: string;
name: string;
deviceType: string;
// tunnel-level(雲端觀察)
remoteStatus: 'online' | 'offline' | 'reconnecting' | 'error';
lastSeenAt?: string; // ISO 8601
lastConnectedAt?: string;
// USB-level / driver-level(local agent 上報)
// ⚠️ 型別更正(2026-07-16):舊版 TDD 誤寫為 'online'|'offline'|'unknown',
// 與實際 code 不符。實際型別為 DeviceHardwareStatus(device-store.ts:34-42),
// 以 code 為準:
status: DeviceHardwareStatus;
// ...
}
// DeviceHardwareStatus(device-store.ts:34-42)— driver 連線狀態的完整 enum
type DeviceHardwareStatus =
| 'detected' // USB 偵測到、driver 尚未 connect
| 'connecting' // driver 正在 connect(重量級操作進行中)
| 'connected' // driver 已 connect,可 flash / 推論
| 'flashing' // 正在燒錄韌體(已 connect)
| 'inferencing' // 正在推論(已 connect)
| 'error' // driver 錯誤
| 'disconnected'; // driver 未連線
前端顯示邏輯:
| 畫面位置 | 顯示依據 |
|---|---|
| Device 卡片主狀態 badge | remoteStatus(決定「雲端能不能操作它」) |
| Device 卡片副狀態 | status(driver 連線狀態、七值 enum)— 僅在 remoteStatus === 'online' 時顯示有意義 |
| 「最後上線」時間 | lastSeenAt(tunnel 最後心跳) |
| Inference / Flash 按鈕啟用條件 | remoteStatus === 'online' && status ∈ {connected, flashing, inferencing}(driver 已 connect;詳見 §10.5.1.1 兩步式 gate) |
| 「重連中」loading | remoteStatus === 'reconnecting' |
| 「離線,請檢查 local-tool」提示 | remoteStatus === 'offline' |
邏輯總結:前端永遠先看 remoteStatus(雲端是否可達),再看 status(driver 是否已 connect)。兩者獨立更新:
remoteStatus變化 →WS /ws/devices/:id/remote-status(來自 remote-proxy 轉發)status變化 → 目前輪詢式(connect / flash 完成後fetchDevice重拉)。device events WS(/ws/devices/events)end-to-end 尚未打通(backend 為 501 stub,屬 B7),故不依賴 WS 推 driver 狀態。詳見 §10.5.1.1。
10.5.1.1 Driver-level connect(兩步式操作,方案 A)
Kneron 裝置的 flash / 推論都要求 driver 先 Connect()(重量級操作,最壞 60–65s:KL520 reset + firmware reload + reconnect)。雲端前端在 device 詳情頁提供獨立「連線 / 斷線」按鈕承載這一步(對齊 local-tool POC 的兩步式:先 connect device → 再載入模型 / 推論)。
driver status vs remoteStatus 的區別(本次核心):
| 欄位 | 語意 | 值 | 決定什麼 |
|---|---|---|---|
remoteStatus |
tunnel-level(雲端能不能觸達 local agent) | online / offline / reconnecting / error |
local agent 在不在線 |
status(driver) |
driver-level(Kneron driver 有沒有 Connect()) |
DeviceHardwareStatus 七值 |
能不能 flash / 推論 |
remoteStatus === 'online' 只代表「雲端可下指令給 local agent」;能否 flash / 推論還要看 driver 是否 connected(detected = USB 偵測到但 driver 未 connect,此時 flash 會被 local agent 擋成 400 device not connected)。這是兩步式 connect 的存在理由。
-
路由:connect / disconnect 走 serial 路由(ADR-018 / WP-C)。
device-store.connectDevice(serial)/disconnectDevice(serial),pathPOST /api/devices/:serial/connect(:id段填 serial number / kn_number,非雲端 UUID)。雲端純 proxy、零邏輯。 -
gate 三分支(前提
remoteStatus === 'online' && serialNumber存在):driver status連線按鈕 FlashDialog(載入模型) 開啟工作區 detected/disconnected/error顯示「連線」(primary) disable(tooltip「請先連線裝置」) disable connecting(或本地connectingId)loading「連線中…」、disable(防重點) disable disable connected/flashing/inferencing顯示「斷線」(outline) enable 沿用 flashedModelgate -
狀態刷新(connect 60s 成本):connect 為重量級操作,最壞 60–65s。按下「連線」立即進 loading(本地
connectingId旗標,同步生效),並顯示 hint「首次連線需載入韌體,約 1 分鐘,請勿關閉頁面」承載等待體驗。connect 成功後主動fetchDevice(uuid)重拉status(輪詢式),使 badge 由connecting→connected、按鈕自動切「斷線」、FlashDialog 解鎖。不依賴 WS 推送——device events WS(/ws/devices/events)雖 local agent 端已實作(connect 成功廣播updated),但 backend proxy 該路由仍是 501 stub(pending B7),事件到不了雲端前端。B7 打通後可改 WS 即時推。 -
timeout 鏈:backend proxy 300s(
proxy.godefaultProxyRequestTimeout)> local agent 120s(device_handler.goConnectDevice)> 實際最壞 65s,層層都夠。前端無需自設 timeout;唯一需自查:api.post全域 timeout 若 < 120s,需為 connect 呼叫放寬(connect 是唯一可能 > 60s 的請求)。 -
推論前置:能進 workspace 的裝置必然已 connect(flash 前置需 connect、開啟工作區前置需
flashedModel有值 = 已 flash 過),故 camera pipeline 拿到的必為 connected driver,推論前端無需額外 connect gate。camera_handlerIsConnected()早擋為 nice-to-have(動 local agent、非本次範圍)。
10.6 local-tool-only 元件:雛形隱藏(Q6 裁決)
以下元件在 local-tool 有意義(它就是本機 server),在 visionA Cloud 無意義。雛形階段前端隱藏:
| 元件 | 原因 |
|---|---|
OnboardingDialog |
local-tool 首次啟動引導本機 server 設定;雲端版不需要 |
ServerStatusDashboard |
顯示本機 server 健康(port / process);雲端版換成 tunnel 連線狀態 |
ServerLogViewer |
顯示本機 server log;雲端版換成 device log(透過 tunnel 抓) |
實作方式:
- 沿用 local-tool 同名元件但用 feature flag 控制:
NEXT_PUBLIC_APP_MODE=cloud時不掛載 - Phase 1 視需求決定要替換、重寫,還是徹底移除
§13 測試策略
13.1 雛形期
| 類型 | 範圍 | 工具 |
|---|---|---|
| Unit | internal/* 各 package |
go test |
| Integration | api-server + remote-proxy(兩 binary)+ stub tunnel client | go test + httptest |
| E2E | 瀏覽器 → api-server → remote-proxy ← POC edge-ai-server(tunnel client) | 手動 + 簡易 Playwright |
雛形端對端驗證路徑(Q3 決策):
┌────────────────────┐ HTTPS ┌────────────────┐ internal HTTP ┌────────────────┐
│ visionA-frontend │ ──────────────► │ api-server │ ──────────────► │ remote-proxy │
│ (browser) │ │ (stateless) │ ◄────────────── │ (stateful) │
└────────────────────┘ └────────────────┘ └────────┬───────┘
│ WS + yamux
▼
┌────────────────┐
│ POC │
│ edge-ai-server │
│ (暫代 tunnel │
│ client) │
└────────────────┘
雛形 不實作 visionA/local-agent/。未來(Phase 1)建立時將從 local-tool/ 複製起步。
雛形 MVP 覆蓋目標:
internal/auth,internal/session(含InMemoryStore+ProxyClientStore),internal/storage(LocalFS):80% 行覆蓋internal/relay(tunnel accept + internal forward)、internal/tunnel:整合測試為主- api handlers:至少 happy path + error path
- Internal HTTP API(
/internal/session/:token、/internal/forward/*):雛形必須有整合測試
13.2 Phase 1
- E2E:Playwright 跑主要使用者旅程
- 壓測:vegeta 打 api-server;自製 tunnel client 模擬 N 條連線打 remote-proxy
- 混沌測試:隨機斷開 tunnel 驗證重連
§14 TODO 清單(雛形暫不實作,但已記錄)
Auth / Security
- 真實 Auth(OIDC / 自建)
- JWT / refresh token 機制
/tunnel/connectIP + token rate limit- Pairing Token DB-backed 實作(
PostgresPairingStore) - 兩階段 token(pairing short-lived + session long-lived)
- Audit log
- TLS 終止策略決定(ALB / Caddy / nginx)
Data / Persistence
- PostgreSQL + migration 機制(golang-migrate)
- 所有
InMemory*Repository→Postgres*Repository實作 - 資料備援策略
- Soft delete 行為標準化(
deleted_at)
Storage
S3Store實作 + interface conformance tests- Multipart upload for > 100MB 檔案
- Server-side encryption
Scaling(Phase 1)
- 多
remote-proxy節點間的 session metadata 共享機制(評估 Redis / gossip / service discovery — 雛形不預設方案;見 ADR-006) - api-server → proxy 跨節點路由邏輯(當一個 api-server 面對多個 proxy 節點時,需找到 session 所在節點)
- mTLS / shared secret 保護 internal HTTP endpoints(目前僅靠內網隔離)
雛形 Phase 0 必做(為清楚起見明列)
- 雙 binary 骨架:
cmd/api-server+cmd/remote-proxy api-server端ProxyClientStore(SessionStore 透過 internal HTTP 查 remote-proxy)remote-proxy端InMemoryStore+ internal HTTP endpoints(/internal/session/:token、/internal/forward/http、/internal/forward/ws)
Converter 整合
- 確認真實 converter API spec 後,替換
StubClient→HTTPClient - Job queue / webhook 機制(等 converter 團隊確認)
Observability
- 結構化 log (JSON)
- Prometheus metrics export
- OpenTelemetry trace
- SLO dashboards
- Alert rules
Local Agent
- 建立
visionA/local-agent/:從local-tool/複製起步,精簡為 tunnel-only 輕量代理(Q3 決策) - local-tool 的「雲端模式」opt-in 設定 UI(另一條線:讓既有 local-tool 也能當 tunnel client)
- 輕量 tunnel-only headless agent binary(未來針對 server / Linux 使用者)
- local agent 自動更新機制(搬 POC 的
update/?)
雛形 tunnel 驗證(過渡方案,Q3 裁決)
- 雛形 tunnel 驗證暫用 POC 的
edge-ai-server(來自edge-ai-platform)當 tunnel client - 端對端測試路徑:
visionA-frontend → api-server → remote-proxy ← POC edge-ai-server - 待
visionA/local-agent/建立後替換
DevOps
- CI pipeline(lint, test, build, push)
- Kubernetes Helm chart / Terraform module
- Staging 環境
- Blue/green or canary deploy
- 備份與災難恢復演練
Product
- Billing / 訂閱(PRD 範疇)
- 使用量統計
- 多組織(Org / Team)支援
- API key for programmatic access
待 PM / Design 確認
- pairing token UX 細節(QR code?複製貼上?WebUSB?)
- 未連線時前端呈現方式
- 模型上傳上限(容量 / 個數)
- 裝置離線時「離線模式」fallback 設計
版本記錄
| 日期 | 版本 | 變更 |
|---|---|---|
| 2026-04-21 | 0.1 | Architect Agent 初稿(待三方交叉審閱) |
| 2026-04-22 | 0.2 | 反映三方審閱 7 項使用者裁決 + ADR-006 |
| 2026-04-22 | 0.3 | 反映三方交叉審閱結果:M-1 Token 格式統一 vAc_/vAs_ hex、M-2 Token 兩階段 TTL 明確化 + 時序圖、M-3 Auth 介面雙層(AuthService + AuthProvider)、M-4 Converter API 確認 REST、M-5 心跳 10s/30s 統一、M-7 Q5 vs Multi-tab 澄清、M-8 Non-Goal 章節、Minor-2 dev-all-in-one 定位修正、Minor-3 Device 雙狀態 struct、Minor-4 SessionStore.CleanupExpired + ObjectStorage.Exists |