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

742 lines
32 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.

# 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)
- 內部 APIapi-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/ # 共用的 typesrequest/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-toolNext.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.gomiddleware 層 — 既有)
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.gohandler 層 — 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-userUser 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 只走 OIDCcookie session → UserContext詳見 [oidc-tdd.md](./oidc-tdd.md)
- ~~`StaticAuthProvider`handlerLogin 任何帳密都通過~~ → **OB5 已移除**POST /api/auth/login 一律 410 Gone使用者改用 GET /api/auth/login redirect 到 Member Center
- `StaticPairingStore` 對 env `VISIONA_PAIRING_TOKEN` 比對(格式必須是 `vAc_` + 32 hex— 仍有效
**已完成**OB1-OB5OIDC 接 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 hexWeb UI 產生15 min TTL**一次性使用**
- **Session Token**`vAs_` + 64 hexagent 首次連 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)。核心 interface2026-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 實作
- 雛形不做 ORMInMemory 用 `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<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 tokenpairing 是 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-levellocal 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-servertunnel 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`LocalFS80% 行覆蓋
- `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
- E2EPlaywright 跑主要使用者旅程
- 壓測vegeta 打 api-server自製 tunnel client 模擬 N 條連線打 remote-proxy
- 混沌測試:隨機斷開 tunnel 驗證重連
---
## §14 TODO 清單(雛形暫不實作,但已記錄)
### Auth / Security
- [ ] 真實 AuthOIDC / 自建)
- [ ] JWT / refresh token 機制
- [ ] `/tunnel/connect` IP + token rate limit
- [ ] Pairing Token DB-backed 實作(`PostgresPairingStore`
- [ ] 兩階段 tokenpairing 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
### ScalingPhase 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 pipelinelint, 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 |