Explorar o código

更新接口文档

liuq hai 1 mes
pai
achega
5be38fa91b

+ 572 - 0
frontend/public/docs/client_api_guide.md

@@ -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
+

+ 3 - 0
frontend/src/views/Help.vue

@@ -71,6 +71,7 @@ import MessageIntegration from './help/MessageIntegration.vue'
 import MinIOFilePermissions from './help/MinIOFilePermissions.vue'
 import AccountManagement from './help/AccountManagement.vue'
 import OidcIntegration from './help/OidcIntegration.vue'
+import ClientApi from './help/ClientApi.vue'
 import Tbd from './help/Tbd.vue'
 
 interface Tab {
@@ -95,6 +96,7 @@ const tabOptions = [
   { label: '账号同步 (M2M)', value: 'account-sync' },
   { label: '全量用户同步', value: 'user-sync-pull' },
   { label: '消息中心对接', value: 'message-integration' },
+  { label: '客户端接口 API', value: 'client-api' },
   { label: '文件存储权限控制', value: 'minio-file-permissions' },
   { label: '平台账号管理', value: 'account-management' },
   { label: 'OIDC 集成指南', value: 'oidc-integration' },
@@ -111,6 +113,7 @@ const componentMap: Record<string, any> = {
   'account-sync': AccountSync,
   'user-sync-pull': UserSyncPull,
   'message-integration': MessageIntegration,
+  'client-api': ClientApi,
   'minio-file-permissions': MinIOFilePermissions,
   'account-management': AccountManagement,
   'oidc-integration': OidcIntegration,

+ 376 - 0
frontend/src/views/help/ClientApi.vue

@@ -0,0 +1,376 @@
+<template>
+  <div class="help-content">
+    <div class="content-header">
+      <h2>客户端接口 API 指南</h2>
+      <el-button type="primary" size="small" plain @click="downloadDoc('/docs/client_api_guide.md', 'Client_API_Guide.md')">
+        <el-icon style="margin-right: 5px"><Download /></el-icon>
+        下载 开发文档
+      </el-button>
+    </div>
+    <p class="intro">
+      本文档面向客户端(Web/移动端),说明消息接收、私信、通知、会话聚合、WebSocket 通信、联系人查询、应用中心(快捷导航)等接口。
+    </p>
+
+    <div class="section">
+      <h3>1. 认证与调用约定</h3>
+      <ul>
+        <li><strong>认证方式</strong>:使用 <code>Authorization: Bearer &lt;JWT_TOKEN&gt;</code>。</li>
+        <li><strong>接口前缀</strong>:统一使用 <code>/api/v1</code>。</li>
+        <li><strong>错误处理</strong>:建议统一处理 <code>401/403/404/422</code>,并展示业务友好提示。</li>
+      </ul>
+    </div>
+
+    <div class="section">
+      <h3>2. 接收消息(拉取 + 推送)</h3>
+      <p>推荐双通道方案:先拉取会话/历史,再建立 WebSocket 接收实时消息,断线后补拉历史兜底。</p>
+      <ul>
+        <li><strong>会话列表</strong>:<code>GET /api/v1/messages/conversations</code></li>
+        <li><strong>历史消息</strong>:<code>GET /api/v1/messages/history/{other_user_id}</code></li>
+        <li><strong>实时推送</strong>:<code>ws(s)://YOUR_DOMAIN/api/v1/ws/messages?token=JWT_TOKEN</code></li>
+      </ul>
+
+      <h4>示例:拉取历史消息</h4>
+      <div class="code-block">
+        <pre>
+GET /api/v1/messages/history/2048?skip=0&amp;limit=50 HTTP/1.1
+Authorization: Bearer &lt;JWT_TOKEN&gt;</pre>
+      </div>
+      <div class="code-block">
+        <pre>
+[
+  {
+    "id": 501,
+    "sender_id": 2048,
+    "receiver_id": 10001,
+    "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
+  }
+]</pre>
+      </div>
+    </div>
+
+    <div class="section">
+      <h3>3. 私信与通知接口</h3>
+      <ul>
+        <li><strong>发送入口</strong>:<code>POST /api/v1/messages/</code></li>
+        <li><strong>私信</strong>:使用 <code>type: "MESSAGE"</code> + <code>receiver_id</code></li>
+        <li><strong>通知</strong>:使用 <code>type: "NOTIFICATION"</code>(用户侧无权限发送系统通知)</li>
+        <li><strong>按外部账号发送</strong>:<code>app_id + app_user_id</code> 组合解析接收者</li>
+      </ul>
+
+      <h4>示例:发送私信(用户 Token)</h4>
+      <div class="code-block">
+        <pre>
+POST /api/v1/messages/ HTTP/1.1
+Authorization: Bearer &lt;JWT_TOKEN&gt;
+Content-Type: application/json
+
+{
+  "receiver_id": 2048,
+  "type": "MESSAGE",
+  "content_type": "TEXT",
+  "title": "私信",
+  "content": "你好,这是一条私信"
+}</pre>
+      </div>
+
+      <h4>示例:发送应用通知(应用签名)</h4>
+      <div class="code-block">
+        <pre>
+POST /api/v1/messages/ HTTP/1.1
+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": "立即处理"
+}</pre>
+      </div>
+    </div>
+
+    <div class="section">
+      <h3>4. 通知回调接口</h3>
+      <ul>
+        <li><strong>接口</strong>:<code>GET /api/v1/messages/{message_id}/callback-url</code></li>
+        <li><strong>用途</strong>:点击通知按钮时,实时生成带 ticket 的可跳转 URL</li>
+        <li><strong>权限</strong>:仅消息接收者可调用</li>
+      </ul>
+
+      <h4>示例:获取 callback URL(点击通知按钮时调用)</h4>
+      <div class="code-block">
+        <pre>
+GET /api/v1/messages/501/callback-url HTTP/1.1
+Authorization: Bearer &lt;JWT_TOKEN&gt;</pre>
+      </div>
+      <div class="code-block">
+        <pre>
+{
+  "callback_url": "https://app.example.com/callback?ticket=abc123&amp;next=https://biz.example.com/todo/123"
+}</pre>
+      </div>
+    </div>
+
+    <div class="section">
+      <h3>5. 对话列表聚合接口</h3>
+      <ul>
+        <li><strong>接口</strong>:<code>GET /api/v1/messages/conversations</code></li>
+        <li><strong>聚合规则</strong>:私信按用户聚合,系统通知按应用拆分会话</li>
+        <li><strong>关键字段</strong>:<code>unread_count</code>、<code>last_message</code>、<code>updated_at</code>、<code>is_system</code></li>
+        <li><strong>未读建议</strong>:优先本地统计未读;如读取 <code>unread_count</code>,进入聊天界面建议调用 <code>PUT /api/v1/messages/read-all</code> 一键已读保持一致。</li>
+      </ul>
+
+      <h4>示例:拉取会话列表(聚合)</h4>
+      <div class="code-block">
+        <pre>
+GET /api/v1/messages/conversations HTTP/1.1
+Authorization: Bearer &lt;JWT_TOKEN&gt;</pre>
+      </div>
+      <div class="code-block">
+        <pre>
+[
+  {
+    "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
+  }
+]</pre>
+      </div>
+
+      <h4>示例:进入聊天界面后一键已读(如你读取/展示 unread_count)</h4>
+      <div class="code-block">
+        <pre>
+PUT /api/v1/messages/read-all HTTP/1.1
+Authorization: Bearer &lt;JWT_TOKEN&gt;</pre>
+      </div>
+      <div class="code-block">
+        <pre>
+{ "updated_count": 10 }</pre>
+      </div>
+    </div>
+
+    <div class="section">
+      <h3>6. WebSocket 通信接口详情</h3>
+      <ul>
+        <li><strong>连接地址</strong>:<code>/api/v1/ws/messages?token=JWT_TOKEN</code></li>
+        <li><strong>心跳</strong>:客户端每 30 秒发送 <code>ping</code>,服务端回复 <code>pong</code></li>
+        <li><strong>消息类型</strong>:服务端推送 <code>NEW_MESSAGE</code></li>
+        <li><strong>格式与归类</strong>:WS 推送体包含 <code>data.type</code> / <code>data.app_id</code>,用于区分私信与应用通知并做会话归类(详见下载文档第 5 章)。</li>
+        <li><strong>重连建议</strong>:指数退避重连,并在重连成功后补拉会话和历史</li>
+      </ul>
+
+      <h4>示例:建立连接与心跳(浏览器)</h4>
+      <div class="code-block">
+        <pre>
+const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
+const wsUrl = `${protocol}//${window.location.host}/api/v1/ws/messages?token=${encodeURIComponent(token)}`
+const ws = new WebSocket(wsUrl)
+
+ws.onopen = () =&gt; {
+  // 每 30 秒 ping 一次
+  setInterval(() =&gt; {
+    if (ws.readyState === WebSocket.OPEN) ws.send('ping')
+  }, 30000)
+}
+
+ws.onmessage = (event) =&gt; {
+  if (event.data === 'pong') return
+  const msg = JSON.parse(event.data)
+  if (msg.type === 'NEW_MESSAGE') {
+    // msg.data 即消息体
+  }
+}</pre>
+      </div>
+
+      <h4>示例:客户端收到的推送(区分私信 / 应用通知)</h4>
+      <div class="code-block">
+        <pre>
+// 私信(MESSAGE)
+{
+  "type": "NEW_MESSAGE",
+  "data": {
+    "id": 1024,
+    "type": "MESSAGE",
+    "content_type": "TEXT",
+    "title": "私信",
+    "content": "你好",
+    "sender_id": 2048,
+    "sender_name": "用户私信",
+    "created_at": "2026-03-18T10:10:00",
+    "app_id": null,
+    "app_name": null
+  }
+}
+
+// 应用通知(NOTIFICATION)
+{
+  "type": "NEW_MESSAGE",
+  "data": {
+    "id": 20480,
+    "type": "NOTIFICATION",
+    "content_type": "TEXT",
+    "title": "审批通知",
+    "content": "您有一条待审批任务",
+    "sender_id": null,
+    "sender_name": "系统通知",
+    "created_at": "2026-03-18T10:12:00",
+    "action_url": "/api/v1/simple/sso/jump?app_id=oa_system&amp;redirect_to=https%3A%2F%2Fbiz.example.com%2Ftodo%2F123",
+    "action_text": "立即处理",
+    "app_id": 101,
+    "app_name": "OA系统"
+  }
+}</pre>
+      </div>
+    </div>
+
+    <div class="section">
+      <h3>7. 联系人查询接口</h3>
+      <ul>
+        <li><strong>推荐接口</strong>:<code>GET /api/v1/users/search?q=关键词&amp;limit=20</code></li>
+        <li><strong>搜索字段</strong>:手机号、姓名、英文名</li>
+        <li><strong>管理分页接口</strong>:<code>GET /api/v1/users/?skip=0&amp;limit=20&amp;keyword=xxx</code></li>
+      </ul>
+
+      <h4>示例:搜索联系人(推荐)</h4>
+      <div class="code-block">
+        <pre>
+GET /api/v1/users/search?q=张三&amp;limit=20 HTTP/1.1
+Authorization: Bearer &lt;JWT_TOKEN&gt;</pre>
+      </div>
+      <div class="code-block">
+        <pre>
+[
+  {
+    "id": 2048,
+    "mobile": "13800138000",
+    "name": "张三",
+    "english_name": "zhangsan",
+    "status": "ACTIVE"
+  }
+]</pre>
+      </div>
+
+      <h4>示例:用户列表分页(管理/全量检索场景)</h4>
+      <div class="code-block">
+        <pre>
+GET /api/v1/users/?skip=0&amp;limit=20&amp;keyword=张三 HTTP/1.1
+Authorization: Bearer &lt;JWT_TOKEN&gt;</pre>
+      </div>
+      <div class="code-block">
+        <pre>
+{
+  "total": 100,
+  "items": [
+    {
+      "id": 2048,
+      "mobile": "13800138000",
+      "name": "张三",
+      "english_name": "zhangsan",
+      "status": "ACTIVE"
+    }
+  ]
+}</pre>
+      </div>
+    </div>
+
+    <div class="section">
+      <h3>8. 应用中心(快捷导航)接口</h3>
+      <ul>
+        <li><strong>快捷导航列表</strong>:<code>GET /api/v1/simple/me/launchpad-apps</code></li>
+        <li><strong>返回内容</strong>:应用名称、应用ID、分类、描述、映射账号等</li>
+        <li><strong>点击进入</strong>:调用 SSO 登录接口获取 <code>redirect_url</code> 后跳转</li>
+      </ul>
+
+      <h4>示例:获取快捷导航应用列表</h4>
+      <div class="code-block">
+        <pre>
+GET /api/v1/simple/me/launchpad-apps HTTP/1.1
+Authorization: Bearer &lt;JWT_TOKEN&gt;</pre>
+      </div>
+      <div class="code-block">
+        <pre>
+{
+  "total": 1,
+  "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": "办公协同"
+    }
+  ]
+}</pre>
+      </div>
+
+      <h4>示例:点击应用进入(SSO 登录获取 redirect_url)</h4>
+      <div class="code-block">
+        <pre>
+POST /api/v1/simple/sso-login HTTP/1.1
+Authorization: Bearer &lt;JWT_TOKEN&gt;
+Content-Type: application/json
+
+{
+  "app_id": "oa_system",
+  "username": "",
+  "password": ""
+}</pre>
+      </div>
+      <div class="code-block">
+        <pre>
+{
+  "redirect_url": "https://oa.example.com/callback?ticket=abc123"
+}</pre>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { Download } from '@element-plus/icons-vue'
+import { useHelpDocs } from '../../composables/useHelpDocs'
+
+const { downloadDoc } = useHelpDocs()
+</script>
+
+<style scoped>
+@import './help.css';
+</style>