本文档面向客户端(Web/移动端),用于说明消息接收、私信、通知、会话聚合、WebSocket 通信、联系人查询、应用中心(快捷导航)等接口。
Authorization: Bearer <JWT_TOKEN>{{API_BASE_URL}}detail 字段常见错误码:
401:未登录或 Token 失效403:无权限404:资源不存在422:参数校验失败推荐客户端采用:
GET {{API_BASE_URL}}/messages/conversationsGET {{API_BASE_URL}}/messages/history/{other_user_id}POST {{API_BASE_URL}}/messages/type = MESSAGEMESSAGE / 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": "立即处理"
}
GET {{API_BASE_URL}}/messages/{message_id}/callback-url响应示例:
{
"callback_url": "https://app.example.com/callback?ticket=abc123&next=https://biz.example.com/todo/123"
}
GET {{API_BASE_URL}}/messages/conversationsunread_count。unread_count:请在进入聊天界面/打开会话时执行“一键已读”,保持服务端计数与客户端一致。一键已读接口(当前可用,接口标记为 deprecated):
PUT {{API_BASE_URL}}/messages/read-all
Authorization: Bearer <JWT_TOKEN>
响应示例:
{ "updated_count": 10 }
本地未读统计的建议口径:
NEW_MESSAGE:
unread += 1unread = 0unread 清零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
}
]
GET {{API_BASE_URL}}/messages/history/{other_user_id}?skip=0&limit=50other_user_id > 0:私信会话other_user_id < 0:按应用拆分的系统通知会话other_user_id = 0:兼容历史统一系统会话ws://YOUR_DOMAIN/api/v1/ws/messages?token=JWT_TOKENwss://pingpong{
"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 |
只看 data 即可,不需要额外接口:
优先看 data.type
data.type === "MESSAGE":这是 私信data.type === "NOTIFICATION":这是 通知(应用或系统)通知里区分“哪个应用”的通知
data.type === "NOTIFICATION" 且 data.app_id != null:某个应用的通知(可用 app_name 展示)data.type === "NOTIFICATION" 且 data.app_id == null:历史兼容的统一系统通知(不绑定应用)辅助判断(可选):sender_id
sender_id 为对方用户 ID(或多端同步场景下为当前用户 ID)sender_id === null为了让客户端“会话列表”与 GET /messages/conversations 的聚合口径一致,建议在收到 WS 消息后用以下规则更新本地会话:
chat:<other_user_id>
other_user_id 由 sender_id 与当前用户 ID 推导得到:sender_id === currentUserId:说明是“我从其它端发送/发给自己”的回显,other_user_id 应该取“对方”ID(客户端需要结合当前窗口或最近发送目标)sender_id !== currentUserId:other_user_id = sender_idapp:<app_id> 或 system
data.app_id != null:app:<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)
}
conversations 和当前窗口 history下面示例展示:
ping)NEW_MESSAGEcallback-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
}
}
GET {{API_BASE_URL}}/users/search?q=关键词&limit=20响应示例:
[
{
"id": 2048,
"mobile": "13800138000",
"name": "张三",
"english_name": "zhangsan",
"status": "ACTIVE"
}
]
GET {{API_BASE_URL}}/users/?skip=0&limit=20&keyword=张三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": "办公协同"
}
]
}
客户端在“快捷导航”点击应用后,调用统一 SSO 登录接口获取 redirect_url 并跳转。
conversationshistory,同时处理未读callback-url,不要缓存旧 ticket