依 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)。
742 lines
32 KiB
Markdown
742 lines
32 KiB
Markdown
# 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<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`)
|
||
|
||
```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 |
|