# Technical Design Document — visionA Cloud ## Metadata - **作者**:Architect Agent - **狀態**:Draft(待三方交叉審閱) - **最後更新**:2026-04-22(反映三方交叉審閱 M-1~M-8 + Minor-2~Minor-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) - **Local agent 雛形不做**:tunnel 測試端暫用 POC `edge-ai-server` 當 tunnel client - **POC 程式碼複製**:`internal/relay` / `internal/tunnel` / `internal/wsconn` 從 `edge-ai-platform` 複製後獨立演進 - **Auth 雛形**:~~`StaticAuthService` 永遠回 `demo-user` 單一使用者~~ → **已替換為 OIDC**(Phase 0.6 / OB5;接 Innovedus Member Center;詳見 [oidc-tdd.md](./oidc-tdd.md) + [adr-011](./adr/adr-011-supersede-adr-005.md)) - **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-spec.md) - 內部 API(api-server ↔ remote-proxy)→ [`api/api-internal.md`](api/api-internal.md) ### 4. 資料模型 - Go struct 定義 + Phase 1 DB schema 對應 → [`database.md`](database.md) ### 5. Pairing Token 協定 - 詳細流程 + 雛形 vs Phase 1 差異 → [`security.md`](security.md#pairing-token) ### 6. Tunnel 協定 - 詳細訊息格式、重連策略 → [`tunnel.md`](tunnel.md) ### 7. Session 管理 - `SessionStore` interface + 實作策略 → [`tunnel.md`](tunnel.md#session-management) ### 8. 儲存層介面 - `Store` interface + LocalFS 實作 → [`storage.md`](storage.md) ### 9. Converter 整合 API 契約 - visionA-backend 呼叫 converter 的 spec(給 converter 團隊)→ [`api/api-converter-contract.md`](api/api-converter-contract.md) - **Phase 0.8 轉檔功能整合**(visionA backend ↔ converter ↔ FAA ↔ MC 流程) → [`conversion.md`](conversion.md) - Phase 0.8 對前端 API → [`api/api-conversion.md`](api/api-conversion.md) - 架構決策 → [`adr/adr-014-conversion-integration.md`](adr/adr-014-conversion-integration.md) ### 10. 前端資料流與狀態管理 - 見 §10(本文件) ### 11. 建置與部署 - Makefile + Dockerfile + docker-compose → [`build-deploy.md`](build-deploy.md) ### 12. 安全考量 - 完整安全清單 → [`security.md`](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` | ```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 } ``` **雛形實作**: - ~~`StaticAuthService`(middleware)永遠回 demo-user~~ → **OB5 已移除**,AuthMiddleware 只走 OIDC(cookie session → UserContext),詳見 [oidc-tdd.md](./oidc-tdd.md) - ~~`StaticAuthProvider`(handler)Login 任何帳密都通過~~ → **OB5 已移除**;POST /api/auth/login 一律 410 Gone,使用者改用 GET /api/auth/login redirect 到 Member Center - `StaticPairingStore` 對 env `VISIONA_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`](tunnel.md#session-management)。核心 interface(2026-04-22 Minor-4 新增 `CleanupExpired`): ```go 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`](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` ```go // 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`](api/api-converter-contract.md)。 ### 2.8 `internal/storage` 見 [`storage.md`](storage.md)。 ### 2.9 `internal/wsconn` - 從 POC `server/pkg/wsconn/wsconn.go` 搬,無修改 - 作為 `net.Conn` adapter,讓 yamux 能在 WebSocket 上運作 ### 2.10 `internal/config` ```go 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`) ```ts // 雛形:base URL 從環境變數讀,不再 hardcode localhost:3721 const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'http://localhost:3001'; export async function apiFetch(path: string, init?: RequestInit): Promise { 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`) ```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 ```ts // 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`: 1. 頁面顯示「請在本機 local-tool 輸入以下 Pairing Token」 2. 從 env `NEXT_PUBLIC_DEV_PAIRING_TOKEN`(雛形)或 `POST /api/pairing/token`(Phase 1)取得 token 並顯示 3. 右側顯示即時連線狀態(訂閱 `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): ```ts interface Device { id: string; name: string; deviceType: string; // tunnel-level(雲端觀察) remoteStatus: 'online' | 'offline' | 'reconnecting' | 'error'; lastSeenAt?: string; // ISO 8601 lastConnectedAt?: string; // USB-level(local agent 上報) status: 'online' | 'offline' | 'unknown'; // ... } ``` 前端顯示邏輯: | 畫面位置 | 顯示依據 | |---------|---------| | Device 卡片主狀態 badge | **`remoteStatus`**(決定「雲端能不能操作它」)| | Device 卡片副狀態 | `status`(USB 是否插著)— 僅在 `remoteStatus === 'online'` 時顯示有意義 | | 「最後上線」時間 | `lastSeenAt`(tunnel 最後心跳) | | Inference / Flash 按鈕啟用條件 | `remoteStatus === 'online' && status === 'online'` | | 「重連中」loading | `remoteStatus === 'reconnecting'` | | 「離線,請檢查 local-tool」提示 | `remoteStatus === 'offline'` | 邏輯總結:前端永遠先看 `remoteStatus`(雲端是否可達),再看 `status`(USB 是否接著)。兩者獨立更新,前端要訂閱兩種事件: - `remoteStatus` 變化 → `WS /ws/devices/:id/remote-status`(來自 remote-proxy 轉發) - `status` 變化 → `WS /ws/devices/:id/status`(來自 local agent 的 USB 監聽事件) ### 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/connect` IP + 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 必做(為清楚起見明列) - [x] 雙 binary 骨架:`cmd/api-server` + `cmd/remote-proxy` - [x] `api-server` 端 `ProxyClientStore`(SessionStore 透過 internal HTTP 查 remote-proxy) - [x] `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 |