# 客户端接口 API 指南

本文档面向客户端（Web/移动端），用于说明消息接收、私信、通知、会话聚合、WebSocket 通信、联系人查询、应用中心（快捷导航）、**个人身份二维码**、**短信验证码重置密码（忘记密码，无需登录）**等接口。

## 1. 认证与调用约定

- **认证方式**：`Authorization: Bearer <JWT_TOKEN>`
- **接口前缀**：`{{API_BASE_URL}}`
- **返回格式**：标准 JSON；失败时返回 `detail` 字段

常见错误码：

- `401`：未登录或 Token 失效
- `403`：无权限
- `404`：资源不存在
- `422`：参数校验失败

---

## 2. 接收消息：推荐双通道模型

### 2.0 推荐用法（端到端）

1. **WebSocket 只负责提醒**：长连收到 `NEW_MESSAGE` 后，应触发**对话列表刷新**，不要仅靠推送拼完整列表。
2. **刷新列表时建议请求两个 HTTP 接口**（可并行）：
   - `GET {{API_BASE_URL}}/messages/unread-count`：全局未读**总数**（角标）。
   - `GET {{API_BASE_URL}}/messages/conversations`：会话列表，每行含 `unread_count`。
3. **用户点击某会话**：用该行 `user_id` 作为 `other_user_id`，调用 `GET .../history/{other_user_id}` 拉记录；进入会话后调用 `PUT .../history/{other_user_id}/read-all` 将该会话内你作为接收方的未读标为已读。
4. **用户点击「一键已读」**：`PUT {{API_BASE_URL}}/messages/read-all`，将整个收件箱未读全部标为已读（与按会话已读不同）。

下列四个 HTTP 接口共用路径参数约定 **`other_user_id`**（与 `conversations[].user_id` 相同）。认证均为 `Authorization: Bearer <JWT_TOKEN>`。

### 2.1 四个核心接口总览

| 接口 | 作用 |
|------|------|
| `GET {{API_BASE_URL}}/messages/unread-count` | 当前用户作为接收方的**全局**未读条数（响应为纯整数）。 |
| `GET {{API_BASE_URL}}/messages/conversations` | 会话聚合列表（含每会话 `unread_count`）。 |
| `GET {{API_BASE_URL}}/messages/history/{other_user_id}` | 某会话聊天记录（分页）。 |
| `PUT {{API_BASE_URL}}/messages/history/{other_user_id}/read-all` | 仅该会话范围内、你是接收方且未读的消息标为已读。 |

### 2.2 路径参数 `other_user_id`（与 `conversations[].user_id` 一致）

| 取值 | 含义 |
|------|------|
| `> 0` | 与某用户的私信会话（对方用户 ID）。 |
| `= 0` | 兼容的「全部系统通知」视图。 |
| `< 0` | 某应用系统通知会话，值为 `-applications.id`（应用表主键取负）。 |

### 2.3 `GET /messages/unread-count`

| 项目 | 说明 |
|------|------|
| **方法 / 路径** | `GET {{API_BASE_URL}}/messages/unread-count` |
| **请求头** | `Authorization: Bearer <JWT_TOKEN>` |
| **查询参数** | 无 |
| **成功响应** | `200`，Body 为 **JSON 整数**（如 `12`），表示你是接收方且 `is_read=false` 的消息总数。 |
| **说明** | 正式接口，用于角标等场景。 |

请求示例：

```http
GET {{API_BASE_URL}}/messages/unread-count HTTP/1.1
Authorization: Bearer <JWT_TOKEN>
```

响应示例：

```http
HTTP/1.1 200 OK
Content-Type: application/json

12
```

### 2.4 `GET /messages/conversations`

| 项目 | 说明 |
|------|------|
| **方法 / 路径** | `GET {{API_BASE_URL}}/messages/conversations` |
| **请求头** | `Authorization: Bearer <JWT_TOKEN>` |
| **查询参数** | 无 |
| **成功响应** | `200`，JSON **数组**；元素字段见下表。 |
| **说明** | 内存聚合、有条数上限；极旧会话可能不出现。`unread_count` 仅计「你是接收方且未读」。 |

响应数组元素字段（ConversationResponse）：

| 字段 | 类型 | 说明 |
|------|------|------|
| `user_id` | number | 与 `history/{other_user_id}` 的 `other_user_id` 相同。 |
| `username` | string | 展示账号。 |
| `full_name` | string \| null | 展示名称。 |
| `unread_count` | number | 该会话未读数。 |
| `last_message` | string | 最后一条预览。 |
| `last_message_type` | string | 如 `TEXT` / `IMAGE` / `USER_NOTIFICATION` 等。 |
| `updated_at` | string | 最后消息时间。 |
| `is_system` | boolean | 是否系统/应用通知会话。 |
| `app_id` | number \| null | 应用主键（通知会话时可能有）。 |
| `app_name` | string \| null | 应用名称。 |

请求与响应示例：

```http
GET {{API_BASE_URL}}/messages/conversations HTTP/1.1
Authorization: Bearer <JWT_TOKEN>
```

```json
[
  {
    "user_id": -101,
    "username": "oa_system",
    "full_name": "OA系统",
    "unread_count": 3,
    "last_message": "您有一条待审批任务",
    "last_message_type": "TEXT",
    "updated_at": "2026-03-18T10:00:00",
    "is_system": true,
    "app_id": 101,
    "app_name": "OA系统"
  }
]
```

### 2.5 `GET /messages/history/{other_user_id}`

| 项目 | 说明 |
|------|------|
| **方法 / 路径** | `GET {{API_BASE_URL}}/messages/history/{other_user_id}` |
| **路径参数** | `other_user_id`：见 §2.2。 |
| **查询参数** | `skip`（默认 0）、`limit`（默认 50） |
| **请求头** | `Authorization: Bearer <JWT_TOKEN>` |
| **成功响应** | `200`，JSON 数组，元素为消息对象（MessageResponse），按创建时间降序。 |

单条消息（MessageResponse）主要字段：

| 字段 | 说明 |
|------|------|
| `id` | 消息 ID。 |
| `sender_id` | 发送方用户 ID；通知可能为 `null`。 |
| `receiver_id` | 接收方用户 ID。 |
| `type` | `MESSAGE` / `NOTIFICATION`。 |
| `content_type` | `TEXT` / `IMAGE` / `VIDEO` / `FILE` / `USER_NOTIFICATION` 等。 |
| `title` / `content` | 标题与正文。 |
| `action_url` / `action_text` | 通知按钮；可配合 `GET /messages/{id}/callback-url`。 |
| `is_read` / `read_at` | 已读状态。 |
| `app_id` / `app_name` | 应用信息（通知等）。 |

```http
GET {{API_BASE_URL}}/messages/history/2048?skip=0&limit=50 HTTP/1.1
Authorization: Bearer <JWT_TOKEN>
```

### 2.6 `PUT /messages/history/{other_user_id}/read-all`

| 项目 | 说明 |
|------|------|
| **方法 / 路径** | `PUT {{API_BASE_URL}}/messages/history/{other_user_id}/read-all` |
| **路径参数** | `other_user_id`：当前会话的 `user_id`。 |
| **请求头** | `Authorization: Bearer <JWT_TOKEN>` |
| **请求体** | 无 |
| **成功响应** | `200`，`{"updated_count": <number>}` |

```http
PUT {{API_BASE_URL}}/messages/history/2048/read-all HTTP/1.1
Authorization: Bearer <JWT_TOKEN>
```

```json
{ "updated_count": 3 }
```

### 2.7 可选：`PUT /messages/read-all`（收件箱全部已读）

| 项目 | 说明 |
|------|------|
| **方法 / 路径** | `PUT {{API_BASE_URL}}/messages/read-all` |
| **请求头** | `Authorization: Bearer <JWT_TOKEN>` |
| **请求体** | 无 |
| **成功响应** | `200`，`{"updated_count": <number>}`，**全部**你是接收方的未读标为已读。 |
| **说明** | 与 §2.6 区别为不按会话筛选。单条已读 `PUT /messages/{id}/read` 仍标记为废弃，优先用按会话已读或本接口。 |

### 2.8 WebSocket 收到提醒后

- 连接：`ws://<host>/api/v1/ws/messages?token=<JWT>`（HTTPS 用 `wss://`）。
- 心跳：可每 30s 发送 `ping`，收 `pong`。
- 收到 `type: "NEW_MESSAGE"` 后：执行 §2.3 + §2.4 刷新（或至少刷新会话列表）。

---

## 3. 私信 / 通知接口

### 3.1 发送消息

- **接口**：`POST {{API_BASE_URL}}/messages/`
- **说明**：
  - 用户调用：仅允许 `type = MESSAGE`
  - 应用调用：支持 `MESSAGE` / `NOTIFICATION` / 广播
  - **用户申请通知**：`type = MESSAGE` 且 `content_type = USER_NOTIFICATION`，在私信会话中以通知卡片样式展示（标题 + 结构化内容 + 操作按钮）；需跳转业务页时配合 `app_id`、`auto_sso`、`target_url`（后端生成 `action_url`）

#### 调用约定

| 项目 | 说明 |
|------|------|
| **方法 / 路径** | `POST {{API_BASE_URL}}/messages/` |
| **Content-Type** | `application/json` |
| **用户调用** | `Authorization: Bearer <JWT_TOKEN>`；**仅允许** `type = MESSAGE`，禁止 `NOTIFICATION` |
| **应用调用** | `X-App-Id`（字符串 app_id）、`X-Timestamp`（Unix 秒）、`X-Sign`；签名：`HMAC-SHA256(app_secret, 待签名字符串)`，待签名字符串为按键名字母序拼接的 `app_id=...&timestamp=...`（不含 `sign` 本身） |

#### 请求体字段（MessageCreate）

| 字段 | 必填 | 说明 |
|------|------|------|
| `type` | 是 | `MESSAGE` / `NOTIFICATION` |
| `title` | 是 | 标题，最长 255 |
| `content` | 是 | 字符串或 JSON 对象（对象会序列化为 JSON 字符串落库） |
| `content_type` | 否 | 默认 `TEXT`；`IMAGE` / `VIDEO` / `FILE` / `USER_NOTIFICATION` |
| `receiver_id` | 条件 | 统一平台用户 ID；与 `app_id`+`app_user_id` 二选一（**广播时不要填**） |
| `app_id` | 条件 | 应用字符串 ID；与 `app_user_id` 联用解析接收者；用户发 `USER_NOTIFICATION` 且 `auto_sso` 时需填 |
| `app_user_id` | 条件 | 业务账号，须在映射表存在 |
| `sender_app_user_id` | 否 | 应用发信时可选：发起人业务账号，解析后写入 `sender_id` 用于审计和会话聚合；用户 JWT 发信忽略 |
| `is_broadcast` | 否 | `true` 时向全员发通知；**仅** `type = NOTIFICATION` + **应用签名**；勿填接收者 |
| `auto_sso` | 否 | 与 `target_url` 配合；`NOTIFICATION` 或 `USER_NOTIFICATION` 时可自动生成 `action_url`（jump） |
| `target_url` | 否 | 业务落地页 URL（`auto_sso = true` 时使用） |
| `action_url` / `action_text` | 否 | 自定义按钮；接收端点击若走 `callback-url`，需为后端识别的 jump 格式 |

**接收者规则（非广播）**：必须提供 `receiver_id`，**或**同时提供 `app_id` + `app_user_id`。  
**应用 + `app_user_id`**：Body 中的 `app_id` 必须与 `X-App-Id` 一致，否则 403。

**常见错误**：`403`（无权限 / 类型不允许 / app_id 不匹配）、`404`（用户或映射不存在）、`422`（校验失败，如缺接收者）。

#### 签名生成示例（Python）

```python
import hmac, hashlib, time

app_id = "your_app_id_string"
app_secret = "your_app_secret"
timestamp = str(int(time.time()))
query_string = f"app_id={app_id}&timestamp={timestamp}"
sign = hmac.new(
    app_secret.encode("utf-8"),
    query_string.encode("utf-8"),
    hashlib.sha256,
).hexdigest()
# 请求头: X-App-Id, X-Timestamp, X-Sign=sign
```

#### Python 完整调用（requests）

依赖：`pip install requests`。将 `API_BASE`、`APP_ID`、`APP_SECRET`、用户示例中的 `ACCESS_TOKEN` 换成实际值。

```python
import hmac
import hashlib
import time
import requests

API_BASE = "https://your-host.example.com/api/v1"  # 无尾斜杠
APP_ID = "your_app_id_string"
APP_SECRET = "your_app_secret"


def app_sign_headers() -> dict:
    ts = str(int(time.time()))
    query_string = f"app_id={APP_ID}&timestamp={ts}"
    sign = hmac.new(
        APP_SECRET.encode("utf-8"),
        query_string.encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()
    return {
        "X-App-Id": APP_ID,
        "X-Timestamp": ts,
        "X-Sign": sign,
        "Content-Type": "application/json",
    }


def send_notification_by_app_user() -> dict:
    """应用签名：按 app_user_id 发送通知"""
    url = f"{API_BASE}/messages/"
    payload = {
        "app_id": APP_ID,
        "app_user_id": "zhangsan_oa",
        "type": "NOTIFICATION",
        "content_type": "TEXT",
        "title": "审批通知",
        "content": "您有一条待审批任务",
        "auto_sso": True,
        "target_url": "https://biz.example.com/todo/123",
        "action_text": "立即处理",
    }
    r = requests.post(url, headers=app_sign_headers(), json=payload, timeout=30)
    r.raise_for_status()
    return r.json()


def send_user_notification_by_app(app_user_id: str, sender_app_user_id: str = None) -> dict:
    """应用发 USER_NOTIFICATION：代发起人通知接收者（如 OA 代张三通知李四）"""
    url = f"{API_BASE}/messages/"
    payload = {
        "app_id": APP_ID,
        "app_user_id": app_user_id,
        "type": "MESSAGE",
        "content_type": "USER_NOTIFICATION",
        "title": "请假申请",
        "content": {"applyType": "LEAVE", "days": 2, "businessId": 123},
        "auto_sso": True,
        "target_url": "https://biz.example.com/leave/123",
        "action_text": "去审批",
    }
    if sender_app_user_id:
        payload["sender_app_user_id"] = sender_app_user_id
    r = requests.post(url, headers=app_sign_headers(), json=payload, timeout=30)
    r.raise_for_status()
    return r.json()


def send_user_notification(access_token: str) -> dict:
    """用户 Bearer：发送 USER_NOTIFICATION（用户本人发，如重新提醒）"""
    url = f"{API_BASE}/messages/"
    headers = {
        "Authorization": f"Bearer {access_token}",
        "Content-Type": "application/json",
    }
    payload = {
        "receiver_id": 2048,
        "type": "MESSAGE",
        "content_type": "USER_NOTIFICATION",
        "app_id": "oa_system",
        "title": "请假申请",
        "content": {"applyType": "LEAVE", "days": 2, "businessId": 123},
        "auto_sso": True,
        "target_url": "https://biz.example.com/leave/123",
        "action_text": "去审批",
    }
    r = requests.post(url, headers=headers, json=payload, timeout=30)
    r.raise_for_status()
    return r.json()


if __name__ == "__main__":
    print(send_notification_by_app_user())
    # print(send_user_notification_by_app("lisi_manager", "zhangsan_oa"))
    # print(send_user_notification("eyJhbGciOiJIUzI1NiIsInR5cCI6..."))
```

用户私信示例：

```http
POST {{API_BASE_URL}}/messages/
Authorization: Bearer xxx
Content-Type: application/json

{
  "receiver_id": 2048,
  "type": "MESSAGE",
  "content_type": "TEXT",
  "title": "私信",
  "content": "你好，这是一条私信"
}
```

响应示例（MessageResponse）：

```json
{
  "id": 501,
  "sender_id": 10001,
  "receiver_id": 2048,
  "app_id": null,
  "app_name": null,
  "type": "MESSAGE",
  "content_type": "TEXT",
  "title": "私信",
  "content": "你好，这是一条私信",
  "action_url": null,
  "action_text": null,
  "is_read": false,
  "created_at": "2026-03-18T09:59:00",
  "read_at": null
}
```

应用发 USER_NOTIFICATION 示例（应用签名，按 app_user_id 接收，可选 sender_app_user_id）：

```http
POST {{API_BASE_URL}}/messages/
X-App-Id: your_app_id
X-Timestamp: 1708848000
X-Sign: <HMAC-SHA256>
Content-Type: application/json

{
  "app_id": "oa_system",
  "app_user_id": "lisi_manager",
  "sender_app_user_id": "zhangsan_oa",
  "type": "MESSAGE",
  "content_type": "USER_NOTIFICATION",
  "title": "请假申请",
  "content": {"applyType":"LEAVE","days":2,"businessId":123},
  "auto_sso": true,
  "target_url": "https://biz.example.com/leave/123",
  "action_text": "去审批"
}
```

用户发 USER_NOTIFICATION 示例（用户 Token，如重新提醒）：

```http
POST {{API_BASE_URL}}/messages/
Authorization: Bearer xxx
Content-Type: application/json

{
  "receiver_id": 2048,
  "type": "MESSAGE",
  "content_type": "USER_NOTIFICATION",
  "app_id": "oa_system",
  "title": "请假申请",
  "content": {"applyType":"LEAVE","days":2,"businessId":123},
  "auto_sso": true,
  "target_url": "https://biz.example.com/leave/123",
  "action_text": "去审批"
}
```

- **`content_type`**：`USER_NOTIFICATION`，用于在私信界面渲染通知样式（与 `type=NOTIFICATION` 的系统通知会话不同，仍归属与对方的私信会话）。
- **`content`**：可为 JSON 对象；服务端序列化为 JSON 字符串；客户端可解析后逐项展示。
- **`auto_sso` + `target_url` + `app_id`**：后端生成 `action_url`（jump 链接）；接收者点击按钮时应调用 `GET {{API_BASE_URL}}/messages/{message_id}/callback-url` 获取带 ticket 的 `callback_url` 再打开。

响应字段与普通私信一致，`content_type` 为 `USER_NOTIFICATION`，`action_url` / `action_text` 按请求生成。

通知示例（应用签名 + 按 `app_user_id` 投递）：

```http
POST {{API_BASE_URL}}/messages/
X-App-Id: your_app_id_string
X-Timestamp: 1708848000
X-Sign: <HMAC-SHA256 十六进制>
Content-Type: application/json

{
  "app_id": "your_app_id_string",
  "app_user_id": "zhangsan_oa",
  "type": "NOTIFICATION",
  "content_type": "TEXT",
  "title": "审批通知",
  "content": "您有一条待审批任务",
  "auto_sso": true,
  "target_url": "https://biz.example.com/todo/123",
  "action_text": "立即处理"
}
```

广播示例（应用签名，全员 `NOTIFICATION`）：

```http
POST {{API_BASE_URL}}/messages/
X-App-Id: your_app_id_string
X-Timestamp: 1708848000
X-Sign: <HMAC-SHA256 十六进制>
Content-Type: application/json

{
  "type": "NOTIFICATION",
  "is_broadcast": true,
  "content_type": "TEXT",
  "title": "系统公告",
  "content": "这是一条全员通知"
}
```

通知示例（应用签名 + 已知平台 `receiver_id`）：

```http
POST {{API_BASE_URL}}/messages/
X-App-Id: your_app_id_string
X-Timestamp: 1708848000
X-Sign: <HMAC-SHA256 十六进制>
Content-Type: application/json

{
  "receiver_id": 2048,
  "type": "NOTIFICATION",
  "content_type": "TEXT",
  "title": "审批通知",
  "content": "您有一条待审批任务",
  "auto_sso": true,
  "target_url": "https://biz.example.com/todo/123",
  "action_text": "立即处理"
}
```

响应示例（MessageResponse，与上例类似）：

```json
{
  "id": 10001,
  "sender_id": null,
  "receiver_id": 2048,
  "app_id": 101,
  "app_name": "OA系统",
  "type": "NOTIFICATION",
  "content_type": "TEXT",
  "title": "审批通知",
  "content": "您有一条待审批任务",
  "action_url": "/api/v1/simple/sso/jump?app_id=oa_system&redirect_to=https%3A%2F%2Fbiz.example.com%2Ftodo%2F123",
  "action_text": "立即处理",
  "is_read": false,
  "created_at": "2026-03-18T10:02:00",
  "read_at": null
}
```

### 3.2 通知回调 URL

- **接口**：`GET {{API_BASE_URL}}/messages/{message_id}/callback-url`
- **用途**：通知点击时，实时生成包含 ticket 的 callback URL
- **权限**：只有消息接收者可调用

响应示例：

```json
{
  "callback_url": "https://app.example.com/callback?ticket=abc123&next=https://biz.example.com/todo/123"
}
```

---

## 4. 对话列表与未读（索引）

**会话列表、全局未读、聊天历史、按会话已读、收件箱全部已读的完整请求参数、响应字段与推荐调用顺序见第 2 节。**

本节仅作补充说明：

- **聚合规则**：私信按用户聚合；系统通知按应用拆分会话；无 `app_id` 的旧数据可归入 `user_id = 0` 的「系统通知」会话。
- **本地未读（可选）**：收到 WS `NEW_MESSAGE` 且当前未打开该会话时，可本地会话 `unread += 1`；进入会话后本地清零，并调用 `PUT .../history/{other_user_id}/read-all` 与服务端对齐。
- **仍展示服务端 `unread_count` 时**：进入会话后优先按会话已读；需要时再 `PUT /messages/read-all`。

---

## 5. WebSocket 通信接口详情

### 5.1 建连地址

- `ws://YOUR_DOMAIN/api/v1/ws/messages?token=JWT_TOKEN`
- HTTPS 场景请使用 `wss://`

### 5.2 心跳机制

- 客户端每 30 秒发送 `ping`
- 服务端回复 `pong`

### 5.3 推送格式（Server -> Client）

```json
{
  "type": "NEW_MESSAGE",
  "data": {
    "id": 1024,
    "sender_id": 12,
    "type": "MESSAGE",
    "content_type": "TEXT",
    "title": "私信",
    "content": "你好",
    "action_url": null,
    "action_text": null,
    "sender_name": "用户私信",
    "created_at": "2026-03-18T10:10:00",
    "app_id": null,
    "app_name": null
  }
}
```

字段说明（`data`）：

| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | number | 消息 ID |
| `type` | string | **消息业务类型**：`MESSAGE`（私信）/ `NOTIFICATION`（通知） |
| `content_type` | string | 内容类型：`TEXT` / `IMAGE` / `VIDEO` / `FILE` / `USER_NOTIFICATION`（用户私信中的申请通知样式） |
| `title` | string | 标题（私信/通知均可能有） |
| `content` | string | 文本内容；若为多媒体类型，服务端会下发**预签名 URL**（或兼容历史的完整 URL） |
| `sender_id` | number \| null | 发送者用户 ID；通知通常为 `null`（系统/应用发出） |
| `sender_name` | string | 简化字段：`系统通知` / `用户私信`（可用于 UI 文案，不建议当作强逻辑） |
| `action_url` | string \| null | 通知按钮的跳转链接（通常是 jump URL，不包含 ticket） |
| `action_text` | string \| null | 通知按钮文案 |
| `created_at` | string | 创建时间（字符串化的时间戳/时间文本） |
| `app_id` | number \| null | 应用数据库整数 ID（用于通知按应用拆分会话）；私信为 `null` |
| `app_name` | string \| null | 应用名称（便于 UI 展示）；私信为 `null` |

### 5.4 如何区分“私信”和“应用通知/系统通知”

只看 `data` 即可，不需要额外接口：

1. **优先看 `data.type`**
   - `data.type === "MESSAGE"`：这是 **私信**（若 `data.content_type === "USER_NOTIFICATION"`，为私信中的「申请通知」样式，仍按对方用户会话归类）
   - `data.type === "NOTIFICATION"`：这是 **通知**（应用或系统）

2. **通知里区分“哪个应用”的通知**
   - `data.type === "NOTIFICATION"` 且 `data.app_id != null`：**某个应用的通知**（可用 `app_name` 展示）
   - `data.type === "NOTIFICATION"` 且 `data.app_id == null`：**历史兼容的统一系统通知**（不绑定应用）

3. **辅助判断（可选）：`sender_id`**
   - 私信一般 `sender_id` 为对方用户 ID（或多端同步场景下为当前用户 ID）
   - 通知一般 `sender_id === null`

### 5.5 客户端会话归类（推荐实现）

为了让客户端“会话列表”与 `GET /messages/conversations` 的聚合口径一致，建议在收到 WS 消息后用以下规则更新本地会话：

- **私信会话 key**：`chat:<other_user_id>`
  - `other_user_id` 由 `sender_id` 与当前用户 ID 推导得到：
    - 如果 `sender_id === currentUserId`：说明是“我从其它端发送/发给自己”的回显，`other_user_id` 应该取“对方”ID（客户端需要结合当前窗口或最近发送目标）
    - 如果 `sender_id !== currentUserId`：`other_user_id = sender_id`
- **通知会话 key**：`app:<app_id>` 或 `system`
  - 若 `data.app_id != null`：`app:<app_id>`
  - 否则：`system`

下面给一个可直接使用的伪代码（JavaScript/TypeScript 思路）：

```ts
type WsEvent = {
  type: 'NEW_MESSAGE'
  data: {
    id: number
    type: 'MESSAGE' | 'NOTIFICATION'
    content_type: 'TEXT' | 'IMAGE' | 'VIDEO' | 'FILE' | 'USER_NOTIFICATION'
    title: string
    content: string
    sender_id: number | null
    created_at: string
    app_id: number | null
    app_name: string | null
    action_url?: string | null
    action_text?: string | null
  }
}

function getConversationKey(evt: WsEvent, currentUserId: number, currentChatUserId?: number) {
  const m = evt.data

  // 1) 通知：按应用/系统拆分
  if (m.type === 'NOTIFICATION') {
    return m.app_id != null ? `app:${m.app_id}` : 'system'
  }

  // 2) 私信：按对方用户聚合
  // 注意：WS 下发没有 receiver_id，所以“我发出的消息”需要结合当前窗口来确定归属会话
  if (m.sender_id === null) return 'unknown'
  if (m.sender_id !== currentUserId) return `chat:${m.sender_id}`

  // sender_id === currentUserId：我自己发的（多端同步/回显）
  // 如果当前正打开与某人的对话窗口，可用 currentChatUserId 归类
  if (currentChatUserId != null) return `chat:${currentChatUserId}`
  return 'chat:me' // 兜底（也可选择直接 refresh conversations）
}
```

### 5.6 客户端建议（断线、重连、补拉）

- 连接断开后自动重连（建议指数退避）
- 重连成功后主动拉取 `conversations` 和当前窗口 `history`
- WS 收到消息时仅做增量更新，避免全量刷新

### 5.7 可直接使用的前端示例（Web/SPA）

下面示例展示：

- 建立 WS 连接（自动选择 ws/wss）
- 心跳（每 30 秒 `ping`）
- 断线重连（指数退避）
- 解析 `NEW_MESSAGE`
- **区分私信 vs 应用通知** 并更新会话列表
- 点击通知按钮时调用 `callback-url` 获取最终跳转链接

> 注意：为了演示清晰，这里用 `fetch`；你也可以替换成项目中的请求封装（如 axios 实例）。

```ts
type WsPush =
  | { type: 'NEW_MESSAGE'; data: WsMessageData }
  | { type: string; data?: any }

type WsMessageData = {
  id: number
  type: 'MESSAGE' | 'NOTIFICATION'
  content_type: 'TEXT' | 'IMAGE' | 'VIDEO' | 'FILE' | 'USER_NOTIFICATION'
  title: string
  content: string
  sender_id: number | null
  sender_name?: string
  created_at: string
  app_id: number | null
  app_name: string | null
  action_url?: string | null
  action_text?: string | null
}

type Conversation = {
  key: string // chat:2048 / app:101 / system
  displayName: string
  unread: number
  lastPreview: string
  updatedAt: number
  kind: 'CHAT' | 'APP_NOTIFICATION' | 'SYSTEM_NOTIFICATION'
  meta?: any
}

// ====== 你的业务状态（示例） ======
let conversations: Conversation[] = []
let currentUserId = 10001
let currentChatKey: string | null = null // e.g. "chat:2048" / "app:101"

function contentPreview(m: WsMessageData) {
  return m.content_type === 'TEXT' ? m.content : `[${m.content_type}]`
}

function upsertConversation(partial: Partial<Conversation> & Pick<Conversation, 'key'>) {
  const idx = conversations.findIndex(c => c.key === partial.key)
  const now = Date.now()
  if (idx >= 0) {
    conversations[idx] = { ...conversations[idx], ...partial, updatedAt: partial.updatedAt ?? now }
  } else {
    conversations.unshift({
      key: partial.key,
      displayName: partial.displayName ?? partial.key,
      unread: partial.unread ?? 0,
      lastPreview: partial.lastPreview ?? '',
      updatedAt: partial.updatedAt ?? now,
      kind: partial.kind ?? 'CHAT',
      meta: partial.meta,
    })
  }

  // 置顶：按 updatedAt 倒序
  conversations = conversations.sort((a, b) => b.updatedAt - a.updatedAt)
}

function getConversationKeyByPush(m: WsMessageData): { key: string; kind: Conversation['kind']; displayName: string } {
  // 1) 通知：按应用/系统拆分
  if (m.type === 'NOTIFICATION') {
    if (m.app_id != null) {
      return { key: `app:${m.app_id}`, kind: 'APP_NOTIFICATION', displayName: m.app_name || `APP-${m.app_id}` }
    }
    return { key: 'system', kind: 'SYSTEM_NOTIFICATION', displayName: '系统通知' }
  }

  // 2) 私信：按对方用户聚合
  // WS 推送里没有 receiver_id，所以这里只能稳妥覆盖“对方发给我”的场景
  // “我自己发出的回显”建议：如果你有当前聊天窗口，就归到当前窗口；否则触发 refresh conversations
  if (m.sender_id != null && m.sender_id !== currentUserId) {
    return { key: `chat:${m.sender_id}`, kind: 'CHAT', displayName: `用户-${m.sender_id}` }
  }

  // sender_id === currentUserId（多端同步/回显）
  if (currentChatKey) {
    return { key: currentChatKey, kind: currentChatKey.startsWith('chat:') ? 'CHAT' : 'APP_NOTIFICATION', displayName: '当前会话' }
  }
  return { key: 'chat:me', kind: 'CHAT', displayName: '我的消息' }
}

function onNewMessage(m: WsMessageData) {
  const conv = getConversationKeyByPush(m)

  // 1) 更新会话预览
  upsertConversation({
    key: conv.key,
    kind: conv.kind,
    displayName: conv.displayName,
    lastPreview: contentPreview(m),
    updatedAt: Date.now(),
  })

  // 2) 未读数策略（示例口径）
  // - 私信：如果当前不在该会话，则 unread+1（且仅当 sender != currentUser）
  // - 通知：同理；如果当前在该通知会话可不加未读
  const isFromOther = m.sender_id != null && m.sender_id !== currentUserId
  const isCurrent = currentChatKey === conv.key

  if (!isCurrent && (m.type === 'NOTIFICATION' || isFromOther)) {
    const idx = conversations.findIndex(c => c.key === conv.key)
    if (idx >= 0) conversations[idx].unread += 1
  }
}

// ====== 通知点击：获取最终跳转链接 ======
async function openNotificationAction(messageId: number, token: string) {
  const res = await fetch(`{{API_BASE_URL}}/messages/${messageId}/callback-url`, {
    headers: { Authorization: `Bearer ${token}` },
  })
  if (!res.ok) throw new Error(`callback-url failed: ${res.status}`)
  const data = await res.json()
  if (!data?.callback_url) throw new Error('missing callback_url')
  window.open(data.callback_url, '_blank')
}

// ====== WebSocket：连接/心跳/重连 ======
export function startMessageWs(token: string) {
  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
  const wsUrl = `${protocol}//${window.location.host}/api/v1/ws/messages?token=${encodeURIComponent(token)}`

  let ws: WebSocket | null = null
  let heartbeatTimer: number | null = null
  let retry = 0

  const stopHeartbeat = () => {
    if (heartbeatTimer != null) {
      window.clearInterval(heartbeatTimer)
      heartbeatTimer = null
    }
  }

  const startHeartbeat = () => {
    stopHeartbeat()
    heartbeatTimer = window.setInterval(() => {
      if (ws && ws.readyState === WebSocket.OPEN) ws.send('ping')
    }, 30000)
  }

  const connect = () => {
    ws = new WebSocket(wsUrl)

    ws.onopen = () => {
      retry = 0
      startHeartbeat()
    }

    ws.onmessage = (event) => {
      if (event.data === 'pong') return

      let msg: WsPush | null = null
      try {
        msg = JSON.parse(event.data)
      } catch {
        return
      }

      if (msg && msg.type === 'NEW_MESSAGE' && msg.data) {
        onNewMessage(msg.data)
      }
    }

    ws.onclose = () => {
      stopHeartbeat()
      scheduleReconnect()
    }

    ws.onerror = () => {
      // 错误一般会紧跟 close，统一走重连
    }
  }

  const scheduleReconnect = () => {
    retry += 1
    const delay = Math.min(30000, 1000 * Math.pow(2, retry)) // 1s,2s,4s...<=30s
    window.setTimeout(() => {
      connect()
      // 建议：重连成功后补拉 conversations/history 防漏（此处略）
    }, delay)
  }

  connect()

  return () => {
    stopHeartbeat()
    if (ws) ws.close()
    ws = null
  }
}
```

---

## 6. 联系人查询接口

### 6.1 推荐：搜索接口

- **接口**：`GET {{API_BASE_URL}}/users/search?q=关键词&limit=20`
- **用途**：发起私信时搜索联系人
- **匹配字段**：手机号、姓名、英文名

响应示例：

```json
[
  {
    "id": 2048,
    "mobile": "13800138000",
    "name": "张三",
    "english_name": "zhangsan",
    "status": "ACTIVE"
  }
]
```

### 6.2 管理分页接口

- **接口**：`GET {{API_BASE_URL}}/users/?skip=0&limit=20&keyword=张三`
- **用途**：管理端完整分页检索

请求示例：

```http
GET {{API_BASE_URL}}/users/?skip=0&limit=20&keyword=张三 HTTP/1.1
Authorization: Bearer <JWT_TOKEN>
```

响应示例：

```json
{
  "total": 100,
  "items": [
    {
      "id": 2048,
      "mobile": "13800138000",
      "name": "张三",
      "english_name": "zhangsan",
      "status": "ACTIVE",
      "role": "ORDINARY_USER",
      "is_deleted": 0
    }
  ]
}
```

---

## 7. 应用中心（快捷导航）接口

### 7.1 快捷导航列表

- **接口**：`GET {{API_BASE_URL}}/simple/me/launchpad-apps`
- **说明**：返回当前用户可见且已激活的快捷导航应用（包含分类和描述）
- **说明补充**：该接口是为“快捷导航页”服务的“可见应用列表”接口，不支持按关键字检索应用。若需要应用检索，请使用 `GET {{API_BASE_URL}}/apps/?search=关键字`（受权限约束）。

响应示例：

```json
{
  "total": 2,
  "items": [
    {
      "app_name": "OA系统",
      "app_id": "oa_system",
      "protocol_type": "SIMPLE_API",
      "mapped_key": "zhangsan",
      "mapped_email": "zhangsan@corp.com",
      "is_active": true,
      "description": "办公自动化系统",
      "category_id": 1,
      "category_name": "办公协同"
    }
  ]
}
```

### 7.2 点击应用跳转

客户端在“快捷导航”点击应用后，调用统一 SSO 登录接口获取 `redirect_url` 并跳转。

SSO 登录请求示例：

```http
POST {{API_BASE_URL}}/simple/sso-login
Authorization: Bearer <JWT_TOKEN>
Content-Type: application/json

{
  "app_id": "oa_system",
  "username": "",
  "password": ""
}
```

响应示例：

```json
{
  "redirect_url": "https://oa.example.com/callback?ticket=abc123"
}
```

---

## 8. 实战接入建议

1. 登录后立即拉取 `conversations`
2. 初始化 WebSocket 并保持心跳
3. 进入会话时拉取 `history`，同时处理未读
4. 发送消息成功后本地追加，收到 WS 消息时做增量合并
5. 点击通知按钮时调用 `callback-url`，不要缓存旧 ticket

---

## 9. 短信验证码重置密码（开放接口）

适用于**用户忘记旧密码**、无法在已登录状态下提供旧密码的场景。通过**图形验证码 → 短信验证码 → 设置新密码**完成重置。**无需** `Authorization` 头。

与「已登录且知道旧密码」的改密接口不同：后者为 `POST {{API_BASE_URL}}/simple/me/change-password`（需 `Authorization: Bearer <JWT_TOKEN>`，见平台其他文档或 Swagger）。

### 9.1 调用顺序

1. **获取图形验证码**：`GET {{API_BASE_URL}}/utils/captcha`
2. **发送短信**：`POST {{API_BASE_URL}}/open/sms/send`（校验图形验证码后下发短信）
3. **重置密码**：`POST {{API_BASE_URL}}/open/pwd/reset`（校验短信后更新 `password_hash`）

### 9.2 `GET /utils/captcha`

| 项目 | 说明 |
|------|------|
| **方法 / 路径** | `GET {{API_BASE_URL}}/utils/captcha` |
| **认证** | 无 |
| **成功响应** | `200`，JSON：`captcha_id`（string）、`image`（Base64 图片数据，常见为 `data:image/png;base64,...`）、`expire_seconds`（number） |

客户端展示 `image`，用户输入图中字符；将 `captcha_id` 与用户输入一并传给 §9.3。

### 9.3 `POST /open/sms/send`

| 项目 | 说明 |
|------|------|
| **方法 / 路径** | `POST {{API_BASE_URL}}/open/sms/send` |
| **Content-Type** | `application/json` |
| **认证** | 无 |

请求体（JSON）：

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `mobile` | string | 是 | 已注册用户手机号 |
| `captcha_id` | string | 是 | §9.2 返回的 `captcha_id` |
| `captcha_code` | string | 是 | 用户识别的图形验证码内容 |

成功：`200`，`{"message": "短信发送成功"}`。

常见失败：`400`，`detail` 如 `图形验证码无效`。

### 9.4 `POST /open/pwd/reset`

| 项目 | 说明 |
|------|------|
| **方法 / 路径** | `POST {{API_BASE_URL}}/open/pwd/reset` |
| **Content-Type** | `application/json` |
| **认证** | 无 |

请求体（JSON）：

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `mobile` | string | 是 | 与 §9.3 一致 |
| `sms_code` | string | 是 | 短信中的验证码 |
| `new_password` | string | 是 | 新密码明文（服务端会哈希存储） |

**密码强度**：须**同时包含字母与数字**（与注册等接口一致）；不满足时 `400`，`detail`：`密码强度不足，必须包含字母和数字`。

成功：`200`，`{"message": "密码重置成功"}`。

常见失败：

| HTTP | `detail` 含义（示例） |
|------|------------------------|
| `400` | 短信验证码无效 |
| `404` | 用户未找到（该手机号未注册） |
| `400` | 密码强度不足 |

### 9.5 请求示例（节选）

```http
GET {{API_BASE_URL}}/utils/captcha HTTP/1.1
```

```http
POST {{API_BASE_URL}}/open/sms/send HTTP/1.1
Content-Type: application/json

{
  "mobile": "13800138000",
  "captcha_id": "<来自 captcha 响应>",
  "captcha_code": "abcd"
}
```

```http
POST {{API_BASE_URL}}/open/pwd/reset HTTP/1.1
Content-Type: application/json

{
  "mobile": "13800138000",
  "sms_code": "123456",
  "new_password": "newSecret1"
}
```

---

## 10. 个人身份二维码

用于「当前登录用户生成加密身份令牌 → 前端将令牌绘制成二维码 → 核验端扫码后调用接口换明文身份」的流程。令牌为 **AES-256-GCM** 加密后的 **Base64 URL-safe** 字符串，**短时效**（默认约 1 分钟，以服务端 `IDENTITY_QR_VALID_SECONDS` 配置为准）。

### 10.1 获取二维码密文（展示端）

| 项目 | 说明 |
|------|------|
| **方法 / 路径** | `GET {{API_BASE_URL}}/identity-qr/` |
| **认证** | `Authorization: Bearer <JWT_TOKEN>`（需为已激活用户） |

成功：`200`，JSON：

| 字段 | 类型 | 说明 |
|------|------|------|
| `token` | string | AES-GCM 密文，**直接作为二维码内容**；客户端不要尝试解密 |
| `expires_at` | string（ISO8601，UTC） | 过期时间，可用于展示剩余有效时间 |

### 10.2 核验扫码结果（核验端）

| 项目 | 说明 |
|------|------|
| **方法 / 路径** | `POST {{API_BASE_URL}}/identity-qr/verify` |
| **Content-Type** | `application/json` |

**认证（二选一）**

- **用户**：`Authorization: Bearer <JWT_TOKEN>`
- **业务应用**：`X-App-Access-Token: <应用 Access Token>`（与平台为该应用分配的 `access_token` 一致）

请求体（JSON）：

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `token` | string | 是 | 扫码得到的完整字符串（与 §10.1 的 `token` 一致） |

成功：`200`，JSON：

| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | number | 用户 ID |
| `name` | string \| null | 姓名 |
| `mobile` | string | 手机号 |

### 10.3 错误与集成注意

| HTTP | 说明 |
|------|------|
| `401` | 未提供有效 JWT 或 `X-App-Access-Token` |
| `400` | 令牌无法解密、已过期、载荷无效、用户非激活、或 token 内手机号与库中不一致等（`detail` 为文本） |
| `404` | 对应用户不存在或已删除 |

令牌有效期较短，生成后应尽快展示并完成核验；过期后需用户重新打开「我的二维码」以获取新 `token`。

