|
|
@@ -0,0 +1,572 @@
|
|
|
+# 客户端接口 API 指南
|
|
|
+
|
|
|
+本文档面向客户端(Web/移动端),用于说明消息接收、私信、通知、会话聚合、WebSocket 通信、联系人查询、应用中心(快捷导航)等接口。
|
|
|
+
|
|
|
+## 1. 认证与调用约定
|
|
|
+
|
|
|
+- **认证方式**:`Authorization: Bearer <JWT_TOKEN>`
|
|
|
+- **接口前缀**:`{{API_BASE_URL}}`
|
|
|
+- **返回格式**:标准 JSON;失败时返回 `detail` 字段
|
|
|
+
|
|
|
+常见错误码:
|
|
|
+
|
|
|
+- `401`:未登录或 Token 失效
|
|
|
+- `403`:无权限
|
|
|
+- `404`:资源不存在
|
|
|
+- `422`:参数校验失败
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 2. 接收消息:推荐双通道模型
|
|
|
+
|
|
|
+推荐客户端采用:
|
|
|
+
|
|
|
+1. 首屏拉取会话列表:`GET {{API_BASE_URL}}/messages/conversations`
|
|
|
+2. 进入会话拉取历史:`GET {{API_BASE_URL}}/messages/history/{other_user_id}`
|
|
|
+3. 建立 WebSocket 连接接收实时消息
|
|
|
+4. WebSocket 重连成功后补拉会话和历史,防止漏消息
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 3. 私信 / 通知接口
|
|
|
+
|
|
|
+### 3.1 发送消息
|
|
|
+
|
|
|
+- **接口**:`POST {{API_BASE_URL}}/messages/`
|
|
|
+- **说明**:
|
|
|
+ - 用户调用:仅允许 `type = MESSAGE`
|
|
|
+ - 应用调用:支持 `MESSAGE` / `NOTIFICATION` / 广播
|
|
|
+
|
|
|
+用户私信示例:
|
|
|
+
|
|
|
+```http
|
|
|
+POST {{API_BASE_URL}}/messages/
|
|
|
+Authorization: Bearer xxx
|
|
|
+Content-Type: application/json
|
|
|
+
|
|
|
+{
|
|
|
+ "receiver_id": 2048,
|
|
|
+ "type": "MESSAGE",
|
|
|
+ "content_type": "TEXT",
|
|
|
+ "title": "私信",
|
|
|
+ "content": "你好,这是一条私信"
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+通知示例(应用签名场景):
|
|
|
+
|
|
|
+```http
|
|
|
+POST {{API_BASE_URL}}/messages/
|
|
|
+X-App-Id: your_app_id
|
|
|
+X-Timestamp: 1700000000
|
|
|
+X-Sign: your_hmac_sign
|
|
|
+Content-Type: application/json
|
|
|
+
|
|
|
+{
|
|
|
+ "type": "NOTIFICATION",
|
|
|
+ "content_type": "TEXT",
|
|
|
+ "title": "审批通知",
|
|
|
+ "content": "您有一条待审批任务",
|
|
|
+ "auto_sso": true,
|
|
|
+ "target_url": "https://biz.example.com/todo/123",
|
|
|
+ "action_text": "立即处理"
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 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. 对话列表聚合接口
|
|
|
+
|
|
|
+### 4.1 会话列表
|
|
|
+
|
|
|
+- **接口**:`GET {{API_BASE_URL}}/messages/conversations`
|
|
|
+- **聚合规则**:
|
|
|
+ - 私信按用户聚合
|
|
|
+ - 系统通知按应用拆分会话
|
|
|
+
|
|
|
+#### 未读数统计建议(重要)
|
|
|
+
|
|
|
+- **推荐做法(客户端本地统计)**:客户端在本地维护未读数(基于 WS 推送 + 进入会话拉取历史),优先展示本地统计的未读数;**先不要依赖**聚合接口返回的 `unread_count`。
|
|
|
+- **如果你仍然读取/展示 `unread_count`**:请在**进入聊天界面/打开会话时**执行“一键已读”,保持服务端计数与客户端一致。
|
|
|
+
|
|
|
+一键已读接口(当前可用,接口标记为 deprecated):
|
|
|
+
|
|
|
+```http
|
|
|
+PUT {{API_BASE_URL}}/messages/read-all
|
|
|
+Authorization: Bearer <JWT_TOKEN>
|
|
|
+```
|
|
|
+
|
|
|
+响应示例:
|
|
|
+
|
|
|
+```json
|
|
|
+{ "updated_count": 10 }
|
|
|
+```
|
|
|
+
|
|
|
+本地未读统计的建议口径:
|
|
|
+
|
|
|
+- **收到 WS 推送 `NEW_MESSAGE`**:
|
|
|
+ - 若消息不属于当前打开会话:对应会话 `unread += 1`
|
|
|
+ - 若属于当前会话:追加到消息列表,并保持该会话 `unread = 0`
|
|
|
+- **进入聊天界面/打开会话**:
|
|
|
+ - 将该会话本地 `unread` 清零
|
|
|
+ - 若你展示服务端 `unread_count`:调用 `PUT /messages/read-all` 同步服务端已读状态
|
|
|
+
|
|
|
+响应示例:
|
|
|
+
|
|
|
+```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系统"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "user_id": 2048,
|
|
|
+ "username": "13800138000",
|
|
|
+ "full_name": "张三",
|
|
|
+ "unread_count": 0,
|
|
|
+ "last_message": "[IMAGE]",
|
|
|
+ "last_message_type": "IMAGE",
|
|
|
+ "updated_at": "2026-03-18T09:20:00",
|
|
|
+ "is_system": false,
|
|
|
+ "app_id": null,
|
|
|
+ "app_name": null
|
|
|
+ }
|
|
|
+]
|
|
|
+```
|
|
|
+
|
|
|
+### 4.2 聊天历史
|
|
|
+
|
|
|
+- **接口**:`GET {{API_BASE_URL}}/messages/history/{other_user_id}?skip=0&limit=50`
|
|
|
+- **约定**:
|
|
|
+ - `other_user_id > 0`:私信会话
|
|
|
+ - `other_user_id < 0`:按应用拆分的系统通知会话
|
|
|
+ - `other_user_id = 0`:兼容历史统一系统会话
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 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` |
|
|
|
+| `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.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'
|
|
|
+ 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'
|
|
|
+ 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=张三`
|
|
|
+- **用途**:管理端完整分页检索
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 7. 应用中心(快捷导航)接口
|
|
|
+
|
|
|
+### 7.1 快捷导航列表
|
|
|
+
|
|
|
+- **接口**:`GET {{API_BASE_URL}}/simple/me/launchpad-apps`
|
|
|
+- **说明**:返回当前用户可见且已激活的快捷导航应用(包含分类和描述)
|
|
|
+
|
|
|
+响应示例:
|
|
|
+
|
|
|
+```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` 并跳转。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 8. 实战接入建议
|
|
|
+
|
|
|
+1. 登录后立即拉取 `conversations`
|
|
|
+2. 初始化 WebSocket 并保持心跳
|
|
|
+3. 进入会话时拉取 `history`,同时处理未读
|
|
|
+4. 发送消息成功后本地追加,收到 WS 消息时做增量合并
|
|
|
+5. 点击通知按钮时调用 `callback-url`,不要缓存旧 ticket
|
|
|
+
|