client_api_guide.md 17 KB

客户端接口 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 / 广播

用户私信示例:

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

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

通知示例(应用签名场景):

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
  • 权限:只有消息接收者可调用

响应示例:

{
  "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):

PUT {{API_BASE_URL}}/messages/read-all
Authorization: Bearer <JWT_TOKEN>

响应示例:

{ "updated_count": 10 }

本地未读统计的建议口径:

  • 收到 WS 推送 NEW_MESSAGE
    • 若消息不属于当前打开会话:对应会话 unread += 1
    • 若属于当前会话:追加到消息列表,并保持该会话 unread = 0
  • 进入聊天界面/打开会话
    • 将该会话本地 unread 清零
    • 若你展示服务端 unread_count:调用 PUT /messages/read-all 同步服务端已读状态

响应示例:

[
  {
    "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)

{
  "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 消息后用以下规则更新本地会话:

  • 私信会话 keychat:<other_user_id>
    • other_user_idsender_id 与当前用户 ID 推导得到:
    • 如果 sender_id === currentUserId:说明是“我从其它端发送/发给自己”的回显,other_user_id 应该取“对方”ID(客户端需要结合当前窗口或最近发送目标)
    • 如果 sender_id !== currentUserIdother_user_id = sender_id
  • 通知会话 keyapp:<app_id>system
    • data.app_id != nullapp:<app_id>
    • 否则:system

下面给一个可直接使用的伪代码(JavaScript/TypeScript 思路):

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 实例)。

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
  • 用途:发起私信时搜索联系人
  • 匹配字段:手机号、姓名、英文名

响应示例:

[
  {
    "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
  • 说明:返回当前用户可见且已激活的快捷导航应用(包含分类和描述)

响应示例:

{
  "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