# API 規格 — 設備註冊 / 取消註冊(個人設備管理 P0) - **上位**:[`../feature-device-mgmt-tdd.md`](../feature-device-mgmt-tdd.md)、[`api-spec.md`](api-spec.md) §3 Devices - **狀態**:Draft(契約,contract-first single source of truth) - **最後更新**:2026-08-02 > 本檔為 register / unregister 兩個新端點的權威契約。前端對此形狀寫 normalize、後端對此形狀保證回應。既有 `GET /api/devices`、`GET /api/devices/:id`、`POST /api/devices/:id/unpair` 見 `api-spec.md` §3、**本 P0 不改**。 --- ## 通用 - **認證**:JWT/OIDC bearer(同既有 `/api/devices/*` route group)。 - **識別值**:`:id` = device UUID(雲端 DB 主鍵)。register/unregister 是純雲端 DB 操作、不路由到 local agent,故用 UUID(對齊 ADR-018 FE-A:DB 操作用 UUID、路由操作才用 serial)。 - **回應信封**:沿用既有 `{ "success": true, "data": ... }`(前端 api client 已 unwrap `data`)。錯誤為 `{ "success": false, "error": { "code", "message" } }`。 ### DeviceListItem(回應 data 形狀,沿用既有,snake_case) register/unregister 成功皆回**更新後的單筆 DeviceListItem**(與 `GET /api/devices/:id` 同形狀): ```json { "id": "uuid", "name": "KL520 #A1B2", "device_type": "kl520", "serial_number": "0x1234ABCD", "agent_id": "uuid", "registered_at": "2026-08-02T10:00:00Z", "remote_status": "online", "last_seen_at": "2026-08-02T10:00:00Z", "last_connected_at": null, "status": "connected", "tunnel_online": true, "created_at": "2026-08-01T00:00:00Z", "updated_at": "2026-08-02T10:00:00Z" } ``` - `registered_at`:register 後為 ISO 8601 時間;unregister 後為 `null`(omitempty → 欄位可能不出現,前端 normalize 皆視為 null=未註冊)。 --- ## 1. `POST /api/devices/:id/register` — 註冊設備 把裝置從「未註冊」翻成「已註冊」(`registered_at` NULL → now())。 - **Request body**:無。 - **Success**:`200 OK` + 更新後 DeviceListItem(`registered_at` 非 null)。 ### 行為順序(後端) | 步驟 | 條件 | 回應 | |------|------|------| | 1 | 缺 UserContext | 500 `INTERNAL_ERROR` | | 2 | `:id` 空 | 400 `VALIDATION_FAILED` | | 3 | device 不存在 / 已軟刪 | 404 `NOT_FOUND` | | 4 | `owner_user_id != caller` | 403 `FORBIDDEN`(IDOR 防護) | | 5 | `is_representative == true` | 409 `CONFLICT`(代表 device 不可註冊) | | 6 | `registered_at != null`(已註冊) | 409 `ALREADY_REGISTERED` | | 7 | 正常 | 200 + DeviceListItem | ### 範例 ``` POST /api/devices/7f3a.../register → 200 { "success": true, "data": { ...,"registered_at":"2026-08-02T10:00:00Z" } } (已註冊再打) → 409 { "success": false, "error": { "code":"ALREADY_REGISTERED","message":"device already registered" } } ``` --- ## 2. `POST /api/devices/:id/unregister` — 取消註冊(退回未註冊) 把裝置退回「未註冊」(`registered_at` → NULL),**保留裝置列**、不軟刪、不撤 token。 > ⚠️ **與 `unpair` 完全不同**:unpair 是軟刪整台 + cascade 撤 token(device 從清單消失);unregister 只清 `registered_at`(device 仍在清單、顯示為未註冊)。兩端點各走各的,不可合併。詳見 TDD §1。 - **Request body**:無。 - **Success**:`200 OK` + 更新後 DeviceListItem(`registered_at` = null)。 ### 行為順序(後端) | 步驟 | 條件 | 回應 | |------|------|------| | 1 | 缺 UserContext | 500 `INTERNAL_ERROR` | | 2 | `:id` 空 | 400 `VALIDATION_FAILED` | | 3 | device 不存在 / 已軟刪 | 404 `NOT_FOUND` | | 4 | `owner_user_id != caller` | 403 `FORBIDDEN` | | 5 | `is_representative == true` | 409 `CONFLICT` | | 6 | 已是未註冊(`registered_at == null`) | **200**(冪等 no-op,非錯誤) | | 7 | 正常 | 200 + DeviceListItem(registered_at=null) | ### 範例 ``` POST /api/devices/7f3a.../unregister → 200 { "success": true, "data": { ...,"registered_at": null } } (device 仍存在於 GET /api/devices) ``` --- ## 3. 錯誤碼 | code | HTTP | 情境 | 新增? | |------|------|------|-------| | `VALIDATION_FAILED` | 400 | id 空 | 既有 | | `FORBIDDEN` | 403 | 非 owner(IDOR) | 既有 | | `NOT_FOUND` | 404 | device 不存在/軟刪 | 既有 | | `CONFLICT` | 409 | representative device 不可註冊/取消 | 既有(或沿用既有 CONFLICT 碼) | | `ALREADY_REGISTERED` | 409 | register 時已註冊 | **新增** | | `INTERNAL_ERROR` | 500 | 缺 UserContext / DB 錯 | 既有(DB down 經 errors.go 映射 503) | - `ALREADY_REGISTERED` 為本功能新增錯誤碼,需在後端 errors 常數 + 前端 i18n(`devices.register.error.alreadyRegistered`)同步登記。 - representative 衝突若不想新增碼,可沿用既有 `CONFLICT` + message 區分;由 backend agent 依既有錯誤碼慣例定,前端據 message/context 提示。 --- ## 4. 前端呼叫(對齊 device-store.ts 範式) ``` // 皆用 UUID(DB 操作);不帶 serial。 registerDevice(id): POST /api/devices/{id}/register → 更新 store 該筆 registeredAt unregisterDevice(id): POST /api/devices/{id}/unregister → 清 store 該筆 registeredAt(保留列) ``` - 成功後就地更新 store 對應 device 的 `registeredAt`(避免 refetch 延遲,比照既有 unpairDevice 就地移除的範式),或 refetch 該筆。 - 409 `ALREADY_REGISTERED` → toast「已註冊」+ refetch。 - **不像 unpair 從 list 移除**——unregister 只改欄、device 留在 list。