# 客户端接口 API 指南 本文档面向客户端(Web/移动端),用于说明消息接收、私信、通知、会话聚合、WebSocket 通信、联系人查询、应用中心(快捷导航)、**个人身份二维码**、**短信验证码重置密码(忘记密码,无需登录)**等接口。 ## 1. 认证与调用约定 - **认证方式**:`Authorization: Bearer ` - **接口前缀**:`{{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 `。 ### 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 ` | | **查询参数** | 无 | | **成功响应** | `200`,Body 为 **JSON 整数**(如 `12`),表示你是接收方且 `is_read=false` 的消息总数。 | | **说明** | 正式接口,用于角标等场景。 | 请求示例: ```http GET {{API_BASE_URL}}/messages/unread-count HTTP/1.1 Authorization: Bearer ``` 响应示例: ```http HTTP/1.1 200 OK Content-Type: application/json 12 ``` ### 2.4 `GET /messages/conversations` | 项目 | 说明 | |------|------| | **方法 / 路径** | `GET {{API_BASE_URL}}/messages/conversations` | | **请求头** | `Authorization: Bearer ` | | **查询参数** | 无 | | **成功响应** | `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 ``` ```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 ` | | **成功响应** | `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 ``` ### 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 ` | | **请求体** | 无 | | **成功响应** | `200`,`{"updated_count": }` | ```http PUT {{API_BASE_URL}}/messages/history/2048/read-all HTTP/1.1 Authorization: Bearer ``` ```json { "updated_count": 3 } ``` ### 2.7 可选:`PUT /messages/read-all`(收件箱全部已读) | 项目 | 说明 | |------|------| | **方法 / 路径** | `PUT {{API_BASE_URL}}/messages/read-all` | | **请求头** | `Authorization: Bearer ` | | **请求体** | 无 | | **成功响应** | `200`,`{"updated_count": }`,**全部**你是接收方的未读标为已读。 | | **说明** | 与 §2.6 区别为不按会话筛选。单条已读 `PUT /messages/{id}/read` 仍标记为废弃,优先用按会话已读或本接口。 | ### 2.8 WebSocket 收到提醒后 - 连接:`ws:///api/v1/ws/messages?token=`(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 `;**仅允许** `type = MESSAGE`,禁止 `NOTIFICATION` | | **应用调用** | `X-App-Id`(字符串 app_id)、`X-Timestamp`(Unix 秒)、`X-Sign`;签名:`HMAC-SHA256(app_secret, 待签名字符串)`,待签名字符串为按键名字母序拼接的 `app_id=...×tamp=...`(不含 `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}×tamp={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}×tamp={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: 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: 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: 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: 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` 由 `sender_id` 与当前用户 ID 推导得到: - 如果 `sender_id === currentUserId`:说明是“我从其它端发送/发给自己”的回显,`other_user_id` 应该取“对方”ID(客户端需要结合当前窗口或最近发送目标) - 如果 `sender_id !== currentUserId`:`other_user_id = sender_id` - **通知会话 key**:`app:` 或 `system` - 若 `data.app_id != null`:`app:` - 否则:`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 & Pick) { 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 ``` 响应示例: ```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 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 `,见平台其他文档或 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 `(需为已激活用户) | 成功:`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 ` - **业务应用**:`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`。