# 聊天室 App API 文件

給 App 開發者。Base URL：`https://pake-exam.steps.tw/api`（以下路徑都省略 `/api`）。
所有請求加 `Accept: application/json`；驗證後的請求加 `Authorization: Bearer <token>`。

> **身分說明**：聊天室的對象是 **User（員工／教師／學生帳號）**，不是考生（Candidate）。
> 考生的 `/api/candidate/*` token 不能用在這些 API（會回 401），反之亦然。

## 0. App 設定（下方導覽列）

**GET /app-config?platform=ios|android**（不需驗證，限流 60 次/分）

App 啟動時呼叫，依回傳清單決定下方導覽列要顯示哪些項目，順序即顯示順序。管理者在後台「App 導覽列」設定，分 iOS／Android 各自開關（例如送審 iOS 時關掉「練習考試」）。
```json
// 200
{ "tabs": [
  { "key": "products",  "label": "考題商品", "icon": "shopping_bag" },
  { "key": "messaging", "label": "聊天",     "icon": "chat" },
  { "key": "profile",   "label": "我的",     "icon": "person" }
] }
```
- 只會回傳「該平台目前要顯示」的項目；沒開的項目不會出現。沒帶或帶了不認得的 `platform`，只回兩個平台都開的項目。
- `key` 是 App 內建的識別字（目前預設：`products`、`purchases`、`messaging`、`practice`、`profile`）；App 不認得的 `key` 請略過，不要當錯誤。`label`、`icon` 可由後台調整。
- 請在啟動時抓一次並在前景回來時更新，**抓不到（網路失敗）時用 App 內建的預設清單**，且預設清單不要包含尚未上線的項目（如 `practice`）。
- 回應帶 `Cache-Control: no-store`，後台改完下一次請求就生效。

## 1. 驗證

| 方法 | 路徑 | 說明 |
|---|---|---|
| POST | `/auth/login` | 登入取 token（限流 10 次/分） |
| POST | `/auth/logout` | 登出（刪除目前這支 token） |
| GET | `/auth/me` | 目前使用者 |
| POST | `/auth/me` | 更新資料，`multipart/form-data`：`name`（選填）、`avatar`（圖片 ≤2MB，選填） |

**POST /auth/login**
```json
// request
{ "email": "a@x.com", "password": "...", "device_name": "iPhone 15" }   // device_name 選填，用來辨識 token
// 200
{ "token": "2|abc...", "user": { "id": 1, "name": "Alice", "email": "a@x.com", "role": "teacher", "avatar_url": null }, "password_expiring_soon": false }
```
| 狀態 | 意義 |
|---|---|
| 401 | 帳密錯誤 |
| 403 | 帳號停用／到期／密碼逾期停用；或 `code: "force_password_change"`（首次登入須先到網頁版改密碼） |
| 423 | 連續失敗 5 次，鎖定 30 分鐘 |
| 422 | 欄位驗證失敗 |

Token 不會自動過期。帳號被停用後，下一次任何 API 請求會回 403 並使 token 失效。

### 考生換聊天室 token

考生登入（`/candidate/login`）後，用考生 token 換一支聊天室專用 token，不必再登入一次。

**POST /candidate/messaging-token**（`Authorization: Bearer <考生 token>`，限流 10 次/分/IP）
```json
// 200
{ "token": "5|abc...", "user": { "id": 42, "name": "小明", "email": "a@x.com" } }
```
回傳的 token 用法同 `/auth/login` 的 token（`/auth/*`、`/messaging/*`、`/broadcasting/auth`）。

- 第一次呼叫會自動用考生 email 建立 role=`student` 的 User；學校連結的考生則對應到原本的學生 User。
- 同一支考生 token 重複呼叫，舊的聊天 token 會失效，只保留最新一支——請以最新回傳的為準。
- 考生 `/candidate/logout` 或重設密碼時，換出去的聊天 token 一併撤銷。

| 狀態 | 意義 |
|---|---|
| 401 | 考生 token 無效 |
| 403 | 綁定的聊天室帳號不是學生角色（例如被改成教職員），不發 token，請聯繫管理員 |
| 409 | 此 Email 已有非本考生的聊天室帳號，不會自動接管；需由管理員在後台「考生管理」把考生綁定到該帳號（只能綁學生角色） |
| 422 | 考生或對應的聊天室帳號已停用／到期 |

## 2. 即時連線（Reverb / Pusher 協定）

1. `GET /broadcast-config`（不需驗證）→ `{ key, host, port, scheme }`
2. 用任何 Pusher 協定 client（Flutter：`pusher_channels_flutter`、`laravel_echo`）連 `wss://{host}:{port}/app/{key}`。
3. 私有／presence 頻道授權：`POST https://pake-exam.steps.tw/broadcasting/auth`，帶 `Authorization: Bearer`，表單欄位 `socket_id`、`channel_name`。

| 頻道 | 類型 | 事件（`.` 開頭為 Echo 寫法） |
|---|---|---|
| `private-App.Models.User.{我的id}` | private | `message.sent`（別的對話的新訊息，做徽章／通知）、`call.ringing`、`call.updated` |
| `presence-online-users` | presence | 全站上線狀態（成員 payload 含 `dnd`） |
| `presence-conversation.{id}` | presence | `message.sent`、`message.updated`、`message.reacted`、`conversation.read`、`call.ringing`、`call.updated` |
| `presence-call.{callId}` | presence | 通話中才訂閱；通話 WebRTC 訊令用 client whisper |

事件 payload：
- `message.sent`：與「訊息物件」（§4）相同欄位，不含 `reactions`
- `message.updated`：`{ id, conversation_id, user_id, action: "edited"|"recalled", body, encrypted, key_version, iv, edited_at, recalled_at }`
- `message.reacted`：`{ id, reactions: [{ emoji, count, user_ids, user_names }] }`
- `conversation.read`：`{ conversation_id, user_id, last_read_message_id }`
- `call.ringing`：`{ call_id, conversation_id, initiator_id, initiator_name, type, ... }`
- `call.updated`：`{ call_id, conversation_id, status, duration_seconds, event, user_id, user_name }`

> 廣播只有 App 前景且連線存活時收得到；背景請依賴推播（§8）。

## 3. 對話 `/messaging`

| 方法 | 路徑 | 說明 |
|---|---|---|
| GET | `/messaging` | 我的對話列表 |
| POST | `/messaging` | 建立私訊／群組 |
| GET | `/messaging/unread-count` | `{ "count": 3 }` 總未讀數 |
| GET | `/messaging/{id}` | 對話詳情（成員、已讀游標、進行中通話） |
| POST | `/messaging/{id}/update` | 群組改名／換頭像（owner），`multipart`：`title`、`avatar` |
| POST / DELETE | `/messaging/{id}/hide` | 隱藏／取消隱藏對話（只影響自己） |
| POST / DELETE | `/messaging/{id}/mute` | 靜音／取消靜音（不收推播，仍收即時事件） |
| DELETE | `/messaging/{id}/clear` | 清空我的聊天記錄（只影響自己） |
| POST | `/messaging/{id}/participants` | 新增成員（owner）：`{ user_id, wraps, wrapped_by_device_id }`，`wraps` 須包含**所有**現有成員＋新成員、金鑰版本 +1 |
| DELETE | `/messaging/{id}/participants/{userId}` | 踢人（owner）或自己離開 |
| GET | `/messaging/users/search?q=` | 搜尋使用者（最多 20 筆） |
| GET | `/messaging/users/all` | 聯絡人清單（全部啟用中使用者） |
| POST / DELETE | `/messaging/users/{userId}/hide` | 隱藏／取消隱藏聯絡人 |
| DELETE | `/messaging/users/{userId}/chat` | 清空與某人的聊天記錄 |

**對話物件**
```json
{ "id": 1, "type": "direct|group", "title": "Bob", "avatar_url": null, "other_user_id": 2,
  "last_message_at": "2026-09-24T00:00:32+08:00",
  "last_message": { "body": null, "user_name": "Alice", "encrypted": true, "type": "text", "recalled": false, "meta": null },
  "unread_count": 1, "is_hidden": false, "is_muted": false, "member_count": 3 }
```
`GET /messaging/{id}` 另含：`participants`（不含自己）、`is_owner`、`members`（群組，含自己）、`read_cursors`（`[{user_id,last_read_message_id}]`，用來判斷我送出的訊息對方是否已讀：`message.id <= last_read_message_id`）、`active_call`（群組進行中通話）。

**POST /messaging（建立）**
```json
// 私訊
{ "type": "direct", "user_id": 2, "wraps": [...], "wrapped_by_device_id": 5 }
// 群組
{ "type": "group", "title": "專題小組", "user_ids": [2,3], "wraps": [...], "wrapped_by_device_id": 5 }
// wraps: [{ "user_id": 2, "device_id": 7, "wrapped_key": "..." }]   → 見 §6 加密
// 200: { "id": 1, "url": "..." }
```
私訊已存在時直接回傳既有對話（同兩人只會有一個）。

## 4. 訊息 `/messaging/{id}/messages`

| 方法 | 路徑 | 說明 |
|---|---|---|
| GET | `/messaging/{id}/messages?before_id=` | 一次 50 則，id 升冪；用最舊那則的 id 當 `before_id` 往前翻頁 |
| GET | `/messaging/{id}/messages/media?type=image` | 相簿（id 遞減，`type`：`image`、`video`，可逗號分隔） |
| POST | `/messaging/{id}/messages` | 送出訊息 |
| POST | `/messaging/{id}/messages/read` | 標記已讀到最新一則 |
| PUT | `/messaging/{id}/messages/{mid}` | 編輯（僅本人、文字訊息） |
| POST | `/messaging/{id}/messages/{mid}/recall` | 收回（僅本人；所有人都看不到內容） |
| DELETE | `/messaging/{id}/messages/{mid}` | 「對我刪除」（只影響自己） |
| POST | `/messaging/{id}/messages/{mid}/reactions` | 表情回應 toggle：`{ "emoji": "👍" }`，再按一次取消 |

**訊息物件**
```json
{ "id": 1, "conversation_id": 1, "user_id": 1, "user_name": "Alice", "user_avatar_url": null,
  "type": "text|image|voice|file|sticker|location|call",
  "body": "…", "meta": null, "encrypted": false, "key_version": null, "iv": null,
  "edited_at": null, "recalled_at": null,
  "reply_to": { "id": 5, "user_name": "Bob", "type": "text", "body": "…", "recalled": false, "encrypted": false, "key_version": null, "iv": null },
  "reactions": [{ "emoji": "👍", "count": 1, "user_ids": [2], "user_names": ["Bob"] }],
  "created_at": "2026-09-24T00:00:32+08:00" }
```
`meta` 依類型：image/file `{path,url,filename,mime,size}`；voice `{path,url,mime,size,duration}`；sticker `{sticker_id,sticker_pack_id,url}`；location `{lat,lng}`（加密時為 null，座標在密文裡）；call 為通話記錄。收回後 `body/meta/iv` 為 null、`reactions` 為 `[]`。

**POST 送出**：純文字用 JSON，有檔案用 `multipart/form-data`。每次只送一種內容，優先序：image → voice → file → sticker → 加密文字/位置 → 明文位置 → 文字。

| 欄位 | 規則 |
|---|---|
| `body` | 字串 ≤ 8000（加密時是 base64 密文） |
| `image` | 圖片 ≤ 5MB |
| `voice` | `audio/*` ≤ 10MB，配 `voice_duration`（秒，≤600） |
| `file` | 任意檔案 ≤ 20MB |
| `lat`, `lng` | 明文位置 |
| `sticker_id` | 須已下載該貼圖包，否則 403 |
| `reply_to_message_id` | 須為同對話的訊息 |
| `encrypted`, `key_version`, `iv`, `type` | 加密文字/位置：`encrypted=true` 時 `key_version`、`iv`、`type`（`text`\|`location`）必填 |

錯誤：422（空訊息、格式不對）、403（不是成員／沒下載貼圖）、404。

## 5. 通話 `/messaging/{id}/calls`

| 方法 | 路徑 | 說明 |
|---|---|---|
| GET | `/messaging/turn-credentials` | 時效 6 小時的 TURN 憑證 `{ username, password, urls[] }`；伺服器未設定 TURN 時回 500 |
| POST | `/messaging/{id}/calls` | 發起：`{ "type": "audio"|"video" }`（一對一 `ringing`，群組直接 `active`） |
| POST | `.../calls/{callId}/answer` | 接聽（一對一） |
| POST | `.../calls/{callId}/end` | 結束／拒接：`{ "status": "ended"|"declined"|"missed" }`，回傳含 `log_message` |
| POST | `.../calls/{callId}/join` | 加入群組通話，回 `{ roster, screen_sharing_user_id, whiteboard_active }` |
| POST | `.../calls/{callId}/leave` | 離開通話 |
| POST | `.../calls/{callId}/reconcile` | 依 `presence-call.{id}` 名單清理卡住的參與者 |
| POST | `.../calls/{callId}/screen-share/start\|stop` | 螢幕分享狀態 |
| POST | `.../calls/{callId}/whiteboard/start\|stop` | 白板狀態 |

通話物件：`{ id, conversation_id, initiator_id, initiator_name, type, status, started_at, answered_at, ended_at, duration_seconds, screen_sharing_user_id, whiteboard_active }`。
WebRTC 的 offer/answer/ICE 透過 `presence-call.{callId}` 頻道的 client whisper 交換（參考 `public/js/messaging-call.js`、`public/js/messaging.js` 的通話段落）。

## 6. 端對端加密與裝置

伺服器**只存密文與包裝過的金鑰，不解密**。文字／位置訊息可加密；圖片、語音、檔案、貼圖恆為明文。演算法（與 `public/js/messaging-crypto.js` 相同，App 需自行實作）：

- 每台裝置一組 **ECDH P-256** 金鑰對，私鑰不出裝置；公鑰以 **JWK 的 JSON 字串**上傳。
- 每個對話一把 **AES-256-GCM** 對話金鑰（版本 `key_version`，新增成員時 +1 並重新包裝給所有人）。
- 包裝：`ECDH(我的私鑰, 收件裝置公鑰)` 直接推導成 AES-GCM-256 金鑰（無 HKDF），加密對話金鑰原始 bytes；`wrapped_key = base64(iv12) + "." + base64(ciphertext)`。
- 訊息：`body = base64(AES-GCM(對話金鑰, iv12, UTF-8 明文))`，`iv = base64(iv12)`；位置訊息的明文是 JSON。

| 方法 | 路徑 | 說明 |
|---|---|---|
| POST | `/messaging/devices` | 註冊這支手機為裝置：`{ public_key_jwk, device_label }` → `{ device_id, status }`。帳號第一台直接 `active`，之後的裝置為 `pending`，須由 active 裝置核准 |
| GET | `/messaging/devices` | 我的所有裝置 |
| GET | `/messaging/devices/mine?device_id=` | 這台裝置目前狀態 `{ status }`（404 = 被刪除，需重新註冊）；同時更新 last_seen |
| POST | `/messaging/devices/{id}/approve` | 核准：`{ approver_device_id, wraps: [{conversation_id, key_version, wrapped_key}] }`（由核准端把各對話金鑰重新包給新裝置） |
| POST | `/messaging/devices/{id}/deny` | 拒絕 |
| DELETE | `/messaging/devices/{id}` | 撤銷；已撤銷的再刪一次為永久刪除 |
| POST | `/messaging/devices/{id}/reactivate` | 已撤銷的裝置重新申請核准 |
| GET | `/messaging/keys?user_ids[]=2&user_ids[]=3` | 取得這些使用者所有 active 裝置公鑰：`{ "2": [{device_id, public_key_jwk}] }` |
| GET | `/messaging/{id}/keys/mine?device_id=&version=` | 我這台裝置在該對話的 wrap：`{ key_version, wrapped_key, wrapped_by_user_id, wrapped_by_device_id, wrapper_public_key_jwk }`；`version` 不帶為最新 |
| GET | `/messaging/{id}/keys/versions?device_id=` | 我這台裝置有哪些版本 `{ versions: [1,2] }` |
| POST | `/messaging/{id}/keys` | 補發金鑰（加密上線前的舊對話）：`{ wraps, wrapped_by_device_id }` |

建立加密對話流程：註冊裝置 → `GET /messaging/keys` 取對方（含自己其他裝置）公鑰 → 本機產生對話金鑰、對每台裝置各包一份 → `POST /messaging`（帶 `wraps`）→ 送訊息時帶 `encrypted=true`。

## 7. 貼圖

| 方法 | 路徑 | 說明 |
|---|---|---|
| GET | `/messaging/sticker-packs` | 我已下載的貼圖包（聊天室貼圖面板）`[{id,name,cover_url,stickers:[{id,url}]}]` |
| GET | `/messaging/sticker-packs/store?category=&sort=popular\|new` | 商店，含 `downloaded` 旗標 |
| POST | `/messaging/sticker-packs/{id}/download` | 下載（冪等） |

貼圖上架／管理僅網頁版（管理員）。

## 8. 推播、偏好、跨組織

| 方法 | 路徑 | 說明 |
|---|---|---|
| POST | `/push-tokens` | 註冊 FCM token：`{ token, platform: "android"\|"ios", device_name }`（登入後與每次啟動呼叫） |
| DELETE | `/push-tokens` | 登出時取消：`{ token }` |
| POST / DELETE | `/voip-push-tokens` | iOS PushKit VoIP token（來電喚醒）：`{ token, device_name }` |
| GET / POST | `/ui/prefs` | `chat_background`（`default\|ocean\|dark\|spring\|summer\|autumn\|winter`）、`presence_dnd`（勿擾，bool） |
| GET | `/messaging/federated-contacts`、`/search?q=` | 跨組織聯絡人（需伺服器設定 `FEDERATION_*` 才有資料） |
| POST / DELETE | `/messaging/federated-contacts/{id}` | 加入／移除跨組織聯絡人 |

推播內容為預覽句（「傳送了一則訊息」等），不含明文；`data.url` 為 `/messaging/{conversation_id}`。**伺服器需設定 `FIREBASE_CREDENTIALS`（與 iOS 的 `APNS_*`）才會真的送出**，未設定時只靜默略過。

## 9. 通用錯誤

`401` 未登入／token 無效或屬於考生；`403` 無權限（不是對話成員、非 owner…）或帳號已停用；`404` 資源不存在；`422` 驗證失敗（`{ message, errors }` 或單一 `message`）；`429` 限流。
