jim800121chen 4c962dfec1 docs(arch): ADR-019 影片/圖片/批次改走同機 localhost 直連(契約定稿)
新增 ADR-019:影片/圖片/批次上傳從經雲端 tunnel 改為同機瀏覽器直連
local-agent 的 localhost endpoint,修補 design-doc §5.3「大檔不走 tunnel」
原則未套用到 media 的邏輯漏洞。採混合路徑(上傳走 localhost、控制面 +
MJPEG 結果 + 推論 WS 仍走 tunnel)。

已完成 security pre-implementation review(1 Critical + 3 Major)+ architect
依審查修正契約 + security confirm-only 複審 APPROVED。契約含:
- C1: route 分離(新 route /api/local/media/upload/* 一律要 token、不看 Origin),
  消除「無 Origin 免 token」後門
- M2: Host header 驗證 = loopback 升為必做(DNS rebinding 緩解)
- M3: CORS 雲端 origin 完整字串精確比對 + Allow-Credentials: false
- 議題1: serial 回 SHA-256(visiona-local-v1‖serial) 雜湊、移除 agentVersion
- M1: media size 上限(video≤500MB / batch 合計 80MB)+ temp 檔清理升為必做

ADR 維持 Proposed,待實作完成 + code-level security 複審後轉 Accepted。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 03:31:16 +08:00

482 lines
22 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.

# API Spec — 對前端的 REST + WebSocket 端點
> **base URL**`https://api.visiona.cloud`Phase 1/ `http://localhost:3001`(雛形)
> **認證**`Authorization: Bearer <JWT>`(雛形可省略,走 `StaticAuthService`
> **通用回應格式**
> ```json
> { "success": true, "data": {...} }
> { "success": false, "error": { "code": "ERR_CODE", "message": "..." } }
> ```
---
## 1. Auth雛形 stub
### POST `/api/auth/login`
- 雛形:回 `501 { code: "NOT_IMPLEMENTED" }`
- Phase 1`{ email, password }``{ user, access_token, refresh_token }`
### POST `/api/auth/register`
- 同上
### POST `/api/auth/logout`
- Phase 1清 refresh token
### GET `/api/auth/me`
- 雛形:回 `demo-user` hard-coded
- Phase 1從 JWT 取
---
## 2. Pairing
### POST `/api/pairing/token`
- Auth required雛形靜默通過
- 雛形 Response
```json
{ "success": false, "error": { "code": "NOT_IMPLEMENTED", "message": "Dev uses env VISIONA_PAIRING_TOKEN" } }
```
- Phase 1 Response
```json
{
"success": true,
"data": {
"token": "pk_AbCd1234...",
"expires_at": "2026-04-21T13:00:00Z"
}
}
```
### POST `/api/pairing/exchange`agent → 雲端public、不走 AuthMiddleware
> 呼叫方是 **local agent**不是瀏覽器前端agent 拿 Pairing Token 換 Session Token 時本身還沒有登入身份,故此端點註冊在 engine 層級、不套 auth。行為主文件見 `../visiona-agent-tdd.md` §4.3 / §7.1;本節記錄 contract 形狀與 WP-0ADR-018擴充。
- **Request**(實作對齊 `visionA-backend/internal/api/pairing.go` `PairingExchangeRequest` 與 agent 端 `internal/tunnel/pairing.go` `exchangeRequest`
```json
{
"pairing_token": "vAc_<32 hex>",
"devices": [
{
"serial_number": "0x1A2B3C4D",
"device_type": "KL520",
"firmware": "1.2.3"
}
]
}
```
- `pairing_token`**required**):一次性 Pairing Token。
- `devices`**optional**、WP-0 / migration 0004 新增agent 在 exchange 前撈本地 `GET /api/devices` 上報的實體 USB 清單(含 Kneron kn_number 序號)。陣列因應「一 agent 多 USB」`device_type` / `firmware` 為 optional 欄位(`omitempty`)。
- **Response**200通用 envelope 的 `data`
```json
{
"session_token": "vAs_...",
"account": "demo@visionA.local",
"relay_url": "wss://relay.visionA.cloud",
"expires_at": "2026-07-21T00:00:00Z"
}
```
- **錯誤碼**(走 `error.code`,對齊 TDD §7.1 四種 case`INVALID_PAIRING_TOKEN` / `PAIRING_TOKEN_EXPIRED` / `PAIRING_TOKEN_USED` / `PAIRING_TOKEN_REVOKED`。
**`devices` 欄位的雲端行為WP-0 序號地基,決策見 `../adr/adr-018-agent-device-model.md`**
| 規則 | 行為 |
|------|------|
| R1最小落地 | exchange 仍只落**一筆** device 記錄:從上報清單挑**第一顆序號可用**的裝置,把序號填進 `devices.serial_number`agent 有帶 `device_type` 時一併填入)。多顆 USB 的完整模型(一 agent N device屬 WP-B / migration 0005 範疇 |
| R2假序號 | 假序號 `0x00000000`agent 端 pyusb fallback、無 Kneron SDK 時寫死上報)與空字串序號**視同無序號**、跳過不用 → `serial_number` 寫 NULL |
| R4同序號重配 | 同 owner 已存在同 serial 的未刪除 device → **復用既有 `device_id`**(只更新 `paired_at` / `updated_at`),不新建,避免撞 partial unique index `uq_devices_owner_serial_active` |
**向下相容**`devices` 欄位缺省(舊 agent、local server 未起、撈不到清單、0 顆裝置)→ 行為與舊版**完全一致**:自建一筆 `serial_number = NULL` 的 device。序號是加值資訊配對本身不因撈不到 USB 而失敗。
### GET `/api/pairing/status`
- 查詢當前 user 的 tunnel 連線狀態
- Response:
```json
{
"success": true,
"data": {
"connected": true,
"connected_at": "2026-04-21T12:00:00Z",
"last_seen_at": "2026-04-21T12:34:56Z",
"device_id": "dev-xxx",
"agent_version": "local-tool 1.2.3"
}
}
```
### GET `/api/pairing/tokens`
- List 當前 user 的所有 tokens
- Phase 1回 array of `{ id, device_id, kind, created_at, last_seen_at }`
### DELETE `/api/pairing/tokens/:id`
- 撤銷指定 token
- Phase 1 實作;雛形 501
---
## 3. Devices
以下大部分端點**會被轉發到 local agent**。api-server 行為:
1. 檢查 user 有 tunnel 連線
2. 若 device_id 有傳,檢查 ownership
3. 透過 tunnel forward 請求到 local agent沿用 POC `handleProxy`
4. 回傳 local agent 的 response
路徑與回應格式**與 local-tool 相同**,前端改 base URL 即可。
### GET `/api/devices` — 列出當前本地掃到的裝置
### POST `/api/devices/scan` — 觸發重掃
### GET `/api/devices/:id` — 單一裝置
### POST `/api/devices/:id/connect`
### POST `/api/devices/:id/disconnect`
### POST `/api/devices/:id/flash` — 燒韌體(透過 tunnel
### POST `/api/devices/:id/inference/start`
### POST `/api/devices/:id/inference/stop`
**雲端特有(非 tunnel forward**
### GET `/api/cloud/devices` — 列出「我在雲端綁過的 Device records」
- 與 `GET /api/devices` 不同:這個是查雲端 DB不問 local agent
- 雛形:從 `InMemoryDeviceRepository` 回
- Response`[{ id, name, device_type, serial_number, status, last_seen_at }]`
### POST `/api/cloud/devices/:id/rename`
- 改雲端上的 device name
### DELETE `/api/cloud/devices/:id` — 解除綁定unpair並刪除雲端 device record
- **DB 接入後行為(塊 5**:刪除 device 會在**同一交易內** cascade 撤銷該 device 的所有 pairing token + session token`pairing_tokens` + `session_tokens` 兩張表by `device_id``UPDATE ... SET revoked_at = now()`)。對應 DB 層一致性定義見 `../database.md` §6。
- 撤銷後該 device 的既有 tunnel session 將無法續用,需重新配對。
---
## 4. Models
### GET `/api/models` — 列出 user 的 model
- 雲端模型(存 storage+ preset models硬編碼
- Response
```json
{
"success": true,
"data": [
{
"id": "abc-123",
"name": "YOLOv5 Face",
"target_chip": "kl520",
"file_size": 12345678,
"source": "uploaded",
"created_at": "..."
}
]
}
```
### GET `/api/models/:id`
- Model 詳情
### POST `/api/models/init` — 初始化上傳
- Request: `{ name, file_size, checksum, target_chip, description? }`
- Response:
```json
{
"success": true,
"data": {
"model_id": "new-id",
"upload_url": "https://...presigned-put-url...",
"upload_expires_at": "..."
}
}
```
### POST `/api/models/:id/finalize`
- 在 presigned PUT 成功後呼叫
- api-server 驗證檔案已存在、size / checksum 對 → status 改 "ready"
### DELETE `/api/models/:id`
### POST `/api/models/:id/load-to-device`
- Body`{ device_id }`
- api-server 產 presigned GET URL → 透過 tunnel 送 local agent 「下載並載入」
- 回傳 job status
---
## 5. Clusters從 POC 搬)
### GET `/api/clusters`
### POST `/api/clusters`
- Body: `{ name, device_ids: [...] }`
### GET `/api/clusters/:id`
### DELETE `/api/clusters/:id`
### POST `/api/clusters/:id/devices`
### DELETE `/api/clusters/:id/devices/:deviceId`
### PUT `/api/clusters/:id/devices/:deviceId/weight`
### POST `/api/clusters/:id/flash`
### POST `/api/clusters/:id/inference/start`
### POST `/api/clusters/:id/inference/stop`
---
## 6. Camera / Media
**路徑分流2026-07 起,見 ADR-019**Camera 與推論**結果**串流走雲端 tunnel forward影片 / 圖片 / 批次的**檔案上傳**改走**同機 localhost 直連 local-agent**(繞過 tunnel解決大檔頻寬雙倍 + nginx 100M + 300s timeout
### 6.1 走 tunnel forward與 local-tool 相同,不變)
### GET `/api/camera/list`
### POST `/api/camera/start`
### POST `/api/camera/stop`
### GET `/api/camera/stream` — MJPEG透過 tunnel streaming
### GET `/api/media/batch-images/:index` — 讀取批次結果(走 tunnel
### POST `/api/media/seek` — 影片 seek走 tunnel
> Camera 支援遠端(跨機)檢視,這是產品核心價值,不受同機限制。
### 6.2 走 localhost 直連 local-agent影片 / 圖片 / 批次上傳,見 ADR-019
以下**上傳** endpoint 由瀏覽器在**同機情境**下直接 POST 到 local-agent 的 loopback server`http://127.0.0.1:<port>`port 動態 ∈ 37213740**不經雲端 tunnel**。
> **[C1 — security review 定案route 分離]** 瀏覽器 localhost 直連走**新的、一律要求 token 的** route`/api/local/media/upload/*`**不看 Origin**;既有 tunnel 轉發路徑維持 `/api/media/upload/*` 不變。認證判準由 server 端固定 route 決定,**不**由攻擊者可控的 Origin 決定(原「無 Origin 免 token」規則已刪除——見 ADR-019 §2.4.1)。
#### 瀏覽器 localhost 直連 route新增、一律要 token
### POST `/api/local/media/upload/image` — 直連 local-agent
### POST `/api/local/media/upload/video` — 直連 local-agent大檔主要受益者
### POST `/api/local/media/upload/batch-images` — 直連 local-agent
**上傳請求契約(瀏覽器 → local-agent**
- Target`http://127.0.0.1:<port>/api/local/media/upload/{video|image|batch-images}`port 由前端探測取得,見下方 §6.3
- Header`X-Visiona-Local-Token: <token>`**一律要求,不看 Origin**;缺失 / 無效 → 401
- Host 驗證:`Host` header去 port必須 ∈ `{127.0.0.1, localhost, ::1}`,否則 `400`DNS rebinding 防護,見 §6.4
- Body既有 `multipart/form-data`(含 `deviceId` 欄位 + `file`),格式不變。此 route 內部轉呼叫既有 upload handlerhandler 零改動)。
- **size 上限必做ADR-019 §4.3.1**video 硬牆 ≤ 500MB前端正常上限 90MBbatch 合計 ≤ 80MB。size 與 token 驗證都在讀 body`FormFile`**之前**。
- 驗證失敗 → `401 { "success": false, "error": { "code": "LOCAL_TOKEN_INVALID" } }`
- 檔案過大 → `413 { "success": false, "error": { "code": "LOCAL_UPLOAD_TOO_LARGE" } }`
#### 既有 tunnel 轉發 route保留不變供雲端路徑
### POST `/api/media/upload/image`
### POST `/api/media/upload/video`
### POST `/api/media/upload/batch-images`
> tunnel client 內部轉發至此,路徑 / 行為不變。此路徑**不因本 ADR 新開放給雲端 origin**;同機直打舊 route 仍受 §6.4 Host 驗證約束Host=loopback
> 推論**結果**MJPEG 串流 `streamUrl` / 推論 WS `/ws/devices/:id/inference`)仍走雲端 tunnel。這是「上傳走 localhost、結果走雲端」的混合路徑ADR-019 §2.1)。
### 6.3 localhost 直連的支援 endpointlocal-agent 新增,見 ADR-019
以下 endpoint 為 localhost 直連機制所需,**僅存在於 local-agent 本機 server**,不對應雲端路由:
#### GET `/api/local/hello` — 同機偵測 + 身分驗證
- Authbootstrap 需要——拿 token 前需先知道 portserial 以雜湊揭露、最小化 bootstrap 洩露,見下方裁決)
- Response 200
```json
{
"success": true,
"data": {
"serialHashes": ["<hex>", "..."],
"supportsLocalUpload": true
}
}
```
- **serialHashes 契約security review 議題 1 裁決,必守)**
- 每個元素 = `SHA-256(salt || fullSerial)` 的 **hex** 字串lowercase hex。演算法固定 **SHA-256**(非 MD5 / SHA-1非 bcrypt/argon2——目的是「前端可重算比對」而非「防暴力還原」
- **salt = 固定公開常數 `visiona-local-v1`**(前後端共用寫死的 app-level 常數、**非** server 私有隨機值)。前端用 Web Crypto `crypto.subtle.digest('SHA-256', utf8("visiona-local-v1" + fullSerial))` 獨立重算比對。
- **明確不要做**:不得用 server 啟動時 `crypto/rand` 生成的私有 salt前端算不出、破壞比對不得 per-request 隨機 salt。
- 用途:前端掃描 37213740比對 `SHA-256("visiona-local-v1" || selectedDevice.serialNumber) ∈ serialHashes` 判定同機(沿用 ADR-018 serial 路由慣例)。
- 誠實揭露殘留風險:序號熵低 + salt 公開 → 雜湊**不能**真正隱藏序號可枚舉回推它擋的是「順手抓完整序號」的被動洩露非「防定向還原」。security 判定與威脅相稱(序號非 credential、控制裝置仍需 token+OIDC不需過度設計。
- **`agentVersion` 已移除**security review 議題 1完整 build 版本 = 給攻擊者精準 CVE 對照A06。前端只需判斷「支不支援 local upload」→ `supportsLocalUpload: true` 布林已足夠。
- **最小揭露**hello **不回**任何其他欄位deviceId 明文 / 裝置型號 / driver 版本 / 機器名一律不回)。
- CORS需回完整 ACA* + PNA header`Access-Control-Allow-Private-Network: true`Host 驗證 = loopback§6.4)。
#### POST `/api/local/issue-token` — 產 one-time upload token
- 取得路徑:僅經既有 tunnel 由 api-server 轉發呼叫api-server 已驗 OIDC session + 裝置歸屬,見下方 `/api/devices/:serial/local-upload-ticket`)。**不對瀏覽器 CORS 白名單開放**(非 `/api/local/media/upload/*` 直連 route受 §6.4 Host 驗證約束。
- 註C1 一致性):不再用「無 Origin 即放行」作判準(該反模式已於 ADR-019 §2.4.1 刪除。token 只是縱深防禦一層——同機惡意程序的攻擊面由 C1 route 分離 + Host 驗證處理issue-token 產出的 token 仍綁 deviceId + one-time + 120s TTL即使被同機程序取得也僅能對已歸屬裝置發一次短期上傳。
- Request`{ "serial": "string" }`
- Response 200`{ "success": true, "data": { "token": "string", "expiresAt": <unix_ms>, "ttlSeconds": 120 } }`
- Response 429token 上限32 個未使用)已滿 → `{ "error": { "code": "LOCAL_TOKEN_LIMIT" } }`
- Token 設計見 ADR-019 §2.4,核心設計已經 security agent 審通過review 議題 2
#### POST `/api/devices/:serial/local-upload-ticket` — 雲端 ticket新增雲端 endpoint
- 位置:**api-server雲端**,非 local-agent
- 驗 OIDC session + 裝置歸屬 → 經既有 tunnel 打 local-agent 的 `/api/local/issue-token` → 回傳 token 給瀏覽器
- 這是 token 的取得路徑,走既有已認證的 tunnel控制面
### 6.4 CORS / Host 驗證 / PNA 要求local-agent見 ADR-019 §2.5
- Origin 白名單來源env `VISIONA_CLOUD_ORIGINS`(逗號分隔完整 originstage 預設含雙入口 `https://stage-9527.innovedus.com:9527` + `http://192.168.0.130:9527`(純 HTTP 內網入口)+ dev `http://localhost:3000`
- loopback 舊白名單(`middleware.go:17-22`)保留不動
- **[M3 — 必做] 雲端 origin 採「完整 origin 精確比對」**scheme+host+port **全等**逐字比對),**不可**沿用既有 `isAllowedOrigin` 的 hostname-only + 任意 port + 放寬 scheme 邏輯。白名單存完整 origin 字串、整串相等才通過(否則會變成「該網域任意 port / 任意 scheme 都放行」)。
- Response雲端 origin 通過):**`Access-Control-Allow-Credentials: false`M3 必做,不可對雲端 origin 回 true**、`Access-Control-Allow-Headers: Content-Type, X-Visiona-Local-Token`、`Access-Control-Max-Age: 600`、`Vary: Origin`
- **[M2 — 必做] Host header 驗證 = loopback**:對所有 `/api/local/*`(含 `/api/local/media/upload/*`)與舊 tunnel-path media route`Host`(去 port必須 ∈ `{127.0.0.1, localhost, ::1}`,否則 `400 Bad Request`DNS rebinding 獨立第二道防護tunnel 轉發的 Host 本就是 `127.0.0.1:<port>` 故不受影響)。
- **PNA必做**preflight 帶 `Access-Control-Request-Private-Network: true` 且通過白名單 → 回 `Access-Control-Allow-Private-Network: true`
### 6.5 錯誤碼(跨模組統一)
| 錯誤碼 | HTTP | 情境 | 誰產生 |
|--------|------|------|--------|
| `LOCAL_AGENT_NOT_FOUND` | —(前端內部狀態) | 掃描無回應(非同機 / agent 沒跑) | frontend |
| `LOCAL_AGENT_MISMATCH` | —(前端內部狀態) | 掃描有回應但 serial 不符 | frontend |
| `LOCAL_TOKEN_INVALID` | 401 | token 不存在 / 過期 / 已使用 / 缺失(直連 route 一律要 token | local-agent |
| `LOCAL_TOKEN_LIMIT` | 429 | 未使用 token 達 32 上限 | local-agent |
| `LOCAL_UPLOAD_TOO_LARGE` | 413 | 上傳超過 size 上限video ≤500MB / batch 合計 ≤80MBM1 | local-agent |
| Host 驗證失敗) | 400 | `Host` header 非 loopbackDNS rebinding 防護M2 | local-agent |
---
## 7. System
### GET `/api/system/health`
- 雲端側:回 api-server 自己的健康 + tunnel 連線狀態
```json
{
"success": true,
"data": {
"api_server": "ok",
"tunnel_connected": true,
"agent_last_seen_at": "..."
}
}
```
### GET `/api/system/info`
- 版本資訊
### GET `/healthz` — liveness / readiness給 load balancer
- 純基礎設施健康檢查端點(非 `/api/*` 前綴),供 LB / orchestrator probe。
- **DB 接入後行為(塊 5**PostgreSQL / Redis **啟用時會 ping**,任一 ping 失敗 → 回 **503**(讓 load balancer 知道此實例不健康、停止導流)。**未啟用的依賴略過檢查**(雛形未配 DB/Redis 時,這些依賴視為 not-applicable不影響健康判定
- Response健康`200 { "status": "ok" }`
- Response不健康`503 { "status": "unavailable", "failed": ["postgres"] }``failed` 列出 ping 失敗的依賴)
- 與 `GET /api/system/health` 的差異:`/healthz` 是基礎設施 probe含 DB/Redis ping`/api/system/health` 是業務層健康api-server 自身 + tunnel 連線狀態)。
---
## 8. Converter
### 8.1 Phase 1 stub既有保留
> 雛形 stub 路由Phase 0.8 的真實整合改走 §8.2 `/api/conversion/*`,下列路由保留為 placeholder 待 Phase 1 視需要 supersede。
#### POST `/api/converter/jobs`
- Body`{ source_model_key, target_chip, params? }`
- Response`{ job_id, status: "queued" }`
#### GET `/api/converter/jobs`
- List user 的 jobs
#### GET `/api/converter/jobs/:id`
- Job 狀態
#### GET `/api/converter/jobs/:id/download`
- 下載產物presigned URL redirect
**詳細契約** → [`api-converter-contract.md`](api-converter-contract.md)
### 8.2 Phase 0.8 — `/api/conversion/*`(轉檔功能整合)
正式對接 kneron_model_converter scheduler + FAA delegated download
- `POST /api/conversion/init` — multipart streaming proxy 到 converter建 job
- `GET /api/conversion/{job_id}` — 查狀態HTTP pollingfrontend 間隔 2s
- `POST /api/conversion/{job_id}/promote-to-models` — 「加到模型庫」
- `POST /api/conversion/{job_id}/download-token` — 換 browser 直連 FAA 的 delegated URL
- `GET /api/conversion/active` — 查當前 user 是否有 active job
**詳細契約** → [`api-conversion.md`](api-conversion.md)
**內部設計** → [`../conversion.md`](../conversion.md)
**ADR** → [`../adr/adr-014-conversion-integration.md`](../adr/adr-014-conversion-integration.md)
---
## 9. WebSocket
### WS `/ws/devices/events`
- 訂閱「裝置上下線」事件
- Server push
```json
{ "type": "device.connected", "device_id": "xxx", "at": "..." }
{ "type": "device.disconnected", "device_id": "xxx", "at": "..." }
```
### WS `/ws/devices/:id/flash-progress`
- 燒錄進度(透過 tunnel 從 local agent 取)
### WS `/ws/devices/:id/inference`
- 推論結果串流
### WS `/ws/server-logs`
- log broadcast沿用 local-tool 的 broadcaster
### WS `/ws/system`
- 系統事件server:shutdown-imminent 等)
### WS `/ws/clusters/:id/inference`
### WS `/ws/clusters/:id/flash-progress`
### WS `/ws/pairing/status`(新)
- 訂閱 tunnel 連線狀態變化
- Server push
```json
{ "type": "tunnel.connected", "connected_at": "..." }
{ "type": "tunnel.disconnected", "reason": "network_error", "at": "..." }
```
---
## 10. Storage雛形 LocalFS 代理)
### GET `/storage/*filepath?expires=...&signature=...`
- LocalFS 的假 presigned GET
- 驗簽後讀檔回傳
### PUT `/storage/*filepath?expires=...&signature=...`
- LocalFS 的假 presigned PUT
- 驗簽後收 body 寫檔
**Phase 1**:直接由 S3 提供,不走 api-server。
---
## 11. 錯誤碼清單
| Code | HTTP | 說明 |
|------|------|------|
| `UNAUTHORIZED` | 401 | 未認證或 token 無效 |
| `FORBIDDEN` | 403 | 權限不足 |
| `NOT_FOUND` | 404 | 資源不存在 |
| `VALIDATION_FAILED` | 400 | 輸入驗證失敗 |
| `CONFLICT` | 409 | 唯一性衝突(如重複註冊 active device serial、email 已存在。DB unique violation 映射到此碼。|
| `TUNNEL_DISCONNECTED` | 502 | Local agent 未連線 |
| `TUNNEL_ERROR` | 502 | Tunnel 傳輸錯誤 |
| `NOT_IMPLEMENTED` | 501 | 雛形尚未實作 |
| `RATE_LIMITED` | 429 | 請求過快Phase 1|
| `INTERNAL_ERROR` | 500 | 未預期錯誤 |
| `SERVICE_UNAVAILABLE` | 503 | 後端依賴PostgreSQL / Redis連線失敗時的 fail-fast。持久資料相關 APImodel / device / token在 PG 不可用時回此碼,不回假資料。|
> **DB 接入後的降級策略fail-fast2026-06-20 使用者拍板)**
> - **PostgreSQL 掉** → 持久資料相關 APImodel / device / token回 `503 SERVICE_UNAVAILABLE`**不回假資料、不 fallback in-memory**(避免回傳過期/不一致資料)。
> - **Redis 掉** → session 驗證失敗(請求視為未認證 → `401 UNAUTHORIZED`**不自動 fallback in-memory session**(避免多機部署下各實例 session 不同步)。
> - DB unique violation → `409 CONFLICT`(而非 500讓前端能區分「衝突」與「未預期錯誤」。
---
## 12. Pagination
對會變大的 listmodels、devices、jobs用 cursor-based
```
GET /api/models?limit=50&cursor=...
Response:
{ "data": [...], "next_cursor": "..." | null }
```
雛形可先簡單回全部in-memoryPhase 1 接 DB 時實作 cursor。
---
**雛形 MVP 清單**(必須有):
- `GET /api/system/health`
- `GET /api/pairing/status`
- `GET /api/devices` + 透過 tunnel forward
- `GET /api/models` + `POST /api/models/init` + `/finalize`LocalFS
- `/storage/*` 代理
- WS `/ws/devices/events`
- WS `/ws/pairing/status`
其他可以先 501 或 stub。