本文档面向客户端(Web/移动端),用于说明消息接收、私信、通知、会话聚合、WebSocket 通信、联系人查询、应用中心(快捷导航)、个人身份二维码、短信验证码重置密码(忘记密码,无需登录)等接口。
Authorization: Bearer <JWT_TOKEN>{{API_BASE_URL}}detail 字段常见错误码:
401:未登录或 Token 失效403:无权限404:资源不存在422:参数校验失败NEW_MESSAGE 后,应触发对话列表刷新,不要仅靠推送拼完整列表。GET {{API_BASE_URL}}/messages/unread-count:全局未读总数(角标)。GET {{API_BASE_URL}}/messages/conversations:会话列表,每行含 unread_count。user_id 作为 other_user_id,调用 GET .../history/{other_user_id} 拉记录;进入会话后调用 PUT .../history/{other_user_id}/read-all 将该会话内你作为接收方的未读标为已读。PUT {{API_BASE_URL}}/messages/read-all,将整个收件箱未读全部标为已读(与按会话已读不同)。下列四个 HTTP 接口共用路径参数约定 other_user_id(与 conversations[].user_id 相同)。认证均为 Authorization: Bearer <JWT_TOKEN>。
| 接口 | 作用 |
|---|---|
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 |
仅该会话范围内、你是接收方且未读的消息标为已读。 |
other_user_id(与 conversations[].user_id 一致)| 取值 | 含义 |
|---|---|
> 0 |
与某用户的私信会话(对方用户 ID)。 |
= 0 |
兼容的「全部系统通知」视图。 |
< 0 |
某应用系统通知会话,值为 -applications.id(应用表主键取负)。 |
GET /messages/unread-count| 项目 | 说明 |
|---|---|
| 方法 / 路径 | GET {{API_BASE_URL}}/messages/unread-count |
| 请求头 | Authorization: Bearer <JWT_TOKEN> |
| 查询参数 | 无 |
| 成功响应 | 200,Body 为 JSON 整数(如 12),表示你是接收方且 is_read=false 的消息总数。 |
| 说明 | 正式接口,用于角标等场景。 |
请求示例:
GET {{API_BASE_URL}}/messages/unread-count HTTP/1.1
Authorization: Bearer <JWT_TOKEN>
响应示例:
HTTP/1.1 200 OK
Content-Type: application/json
12
GET /messages/conversations| 项目 | 说明 |
|---|---|
| 方法 / 路径 | GET {{API_BASE_URL}}/messages/conversations |
| 请求头 | Authorization: Bearer <JWT_TOKEN> |
| 查询参数 | 无 |
| 成功响应 | 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 | 应用名称。 |
请求与响应示例:
GET {{API_BASE_URL}}/messages/conversations HTTP/1.1
Authorization: Bearer <JWT_TOKEN>
[
{
"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系统"
}
]
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 <JWT_TOKEN> |
| 成功响应 | 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 |
应用信息(通知等)。 |
GET {{API_BASE_URL}}/messages/history/2048?skip=0&limit=50 HTTP/1.1
Authorization: Bearer <JWT_TOKEN>
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 <JWT_TOKEN> |
| 请求体 | 无 |
| 成功响应 | 200,{"updated_count": <number>} |
PUT {{API_BASE_URL}}/messages/history/2048/read-all HTTP/1.1
Authorization: Bearer <JWT_TOKEN>
{ "updated_count": 3 }
PUT /messages/read-all(收件箱全部已读)| 项目 | 说明 |
|---|---|
| 方法 / 路径 | PUT {{API_BASE_URL}}/messages/read-all |
| 请求头 | Authorization: Bearer <JWT_TOKEN> |
| 请求体 | 无 |
| 成功响应 | 200,{"updated_count": <number>},全部你是接收方的未读标为已读。 |
| 说明 | 与 §2.6 区别为不按会话筛选。单条已读 PUT /messages/{id}/read 仍标记为废弃,优先用按会话已读或本接口。 |
ws://<host>/api/v1/ws/messages?token=<JWT>(HTTPS 用 wss://)。ping,收 pong。type: "NEW_MESSAGE" 后:执行 §2.3 + §2.4 刷新(或至少刷新会话列表)。POST {{API_BASE_URL}}/messages/type = MESSAGEMESSAGE / 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 <JWT_TOKEN>;仅允许 type = MESSAGE,禁止 NOTIFICATION |
| 应用调用 | X-App-Id(字符串 app_id)、X-Timestamp(Unix 秒)、X-Sign;签名:HMAC-SHA256(app_secret, 待签名字符串),待签名字符串为按键名字母序拼接的 app_id=...×tamp=...(不含 sign 本身) |
| 字段 | 必填 | 说明 |
|---|---|---|
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(校验失败,如缺接收者)。
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
依赖:pip install requests。将 API_BASE、APP_ID、APP_SECRET、用户示例中的 ACCESS_TOKEN 换成实际值。
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..."))
用户私信示例:
POST {{API_BASE_URL}}/messages/
Authorization: Bearer xxx
Content-Type: application/json
{
"receiver_id": 2048,
"type": "MESSAGE",
"content_type": "TEXT",
"title": "私信",
"content": "你好,这是一条私信"
}
响应示例(MessageResponse):
{
"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):
POST {{API_BASE_URL}}/messages/
X-App-Id: your_app_id
X-Timestamp: 1708848000
X-Sign: <HMAC-SHA256>
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,如重新提醒):
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 投递):
POST {{API_BASE_URL}}/messages/
X-App-Id: your_app_id_string
X-Timestamp: 1708848000
X-Sign: <HMAC-SHA256 十六进制>
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):
POST {{API_BASE_URL}}/messages/
X-App-Id: your_app_id_string
X-Timestamp: 1708848000
X-Sign: <HMAC-SHA256 十六进制>
Content-Type: application/json
{
"type": "NOTIFICATION",
"is_broadcast": true,
"content_type": "TEXT",
"title": "系统公告",
"content": "这是一条全员通知"
}
通知示例(应用签名 + 已知平台 receiver_id):
POST {{API_BASE_URL}}/messages/
X-App-Id: your_app_id_string
X-Timestamp: 1708848000
X-Sign: <HMAC-SHA256 十六进制>
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,与上例类似):
{
"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
}
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"
}
会话列表、全局未读、聊天历史、按会话已读、收件箱全部已读的完整请求参数、响应字段与推荐调用顺序见第 2 节。
本节仅作补充说明:
app_id 的旧数据可归入 user_id = 0 的「系统通知」会话。NEW_MESSAGE 且当前未打开该会话时,可本地会话 unread += 1;进入会话后本地清零,并调用 PUT .../history/{other_user_id}/read-all 与服务端对齐。unread_count 时:进入会话后优先按会话已读;需要时再 PUT /messages/read-all。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 / 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 |
只看 data 即可,不需要额外接口:
优先看 data.type
data.type === "MESSAGE":这是 私信(若 data.content_type === "USER_NOTIFICATION",为私信中的「申请通知」样式,仍按对方用户会话归类)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' | '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)
}
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' | '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<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}}/users/?skip=0&limit=20&keyword=张三 HTTP/1.1
Authorization: Bearer <JWT_TOKEN>
响应示例:
{
"total": 100,
"items": [
{
"id": 2048,
"mobile": "13800138000",
"name": "张三",
"english_name": "zhangsan",
"status": "ACTIVE",
"role": "ORDINARY_USER",
"is_deleted": 0
}
]
}
GET {{API_BASE_URL}}/simple/me/launchpad-appsGET {{API_BASE_URL}}/apps/?search=关键字(受权限约束)。响应示例:
{
"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 并跳转。
SSO 登录请求示例:
POST {{API_BASE_URL}}/simple/sso-login
Authorization: Bearer <JWT_TOKEN>
Content-Type: application/json
{
"app_id": "oa_system",
"username": "",
"password": ""
}
响应示例:
{
"redirect_url": "https://oa.example.com/callback?ticket=abc123"
}
conversationshistory,同时处理未读callback-url,不要缓存旧 ticket适用于用户忘记旧密码、无法在已登录状态下提供旧密码的场景。通过图形验证码 → 短信验证码 → 设置新密码完成重置。无需 Authorization 头。
与「已登录且知道旧密码」的改密接口不同:后者为 POST {{API_BASE_URL}}/simple/me/change-password(需 Authorization: Bearer <JWT_TOKEN>,见平台其他文档或 Swagger)。
GET {{API_BASE_URL}}/utils/captchaPOST {{API_BASE_URL}}/open/sms/send(校验图形验证码后下发短信)POST {{API_BASE_URL}}/open/pwd/reset(校验短信后更新 password_hash)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。
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 如 图形验证码无效。
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 |
密码强度不足 |
GET {{API_BASE_URL}}/utils/captcha HTTP/1.1
POST {{API_BASE_URL}}/open/sms/send HTTP/1.1
Content-Type: application/json
{
"mobile": "13800138000",
"captcha_id": "<来自 captcha 响应>",
"captcha_code": "abcd"
}
POST {{API_BASE_URL}}/open/pwd/reset HTTP/1.1
Content-Type: application/json
{
"mobile": "13800138000",
"sms_code": "123456",
"new_password": "newSecret1"
}
用于「当前登录用户生成加密身份令牌 → 前端将令牌绘制成二维码 → 核验端扫码后调用接口换明文身份」的流程。令牌为 AES-256-GCM 加密后的 Base64 URL-safe 字符串,短时效(默认约 1 分钟,以服务端 IDENTITY_QR_VALID_SECONDS 配置为准)。
| 项目 | 说明 |
|---|---|
| 方法 / 路径 | GET {{API_BASE_URL}}/identity-qr/ |
| 认证 | Authorization: Bearer <JWT_TOKEN>(需为已激活用户) |
成功:200,JSON:
| 字段 | 类型 | 说明 |
|---|---|---|
token |
string | AES-GCM 密文,直接作为二维码内容;客户端不要尝试解密 |
expires_at |
string(ISO8601,UTC) | 过期时间,可用于展示剩余有效时间 |
| 项目 | 说明 |
|---|---|
| 方法 / 路径 | POST {{API_BASE_URL}}/identity-qr/verify |
| Content-Type | application/json |
认证(二选一)
Authorization: Bearer <JWT_TOKEN>X-App-Access-Token: <应用 Access Token>(与平台为该应用分配的 access_token 一致)请求体(JSON):
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
token |
string | 是 | 扫码得到的完整字符串(与 §10.1 的 token 一致) |
成功:200,JSON:
| 字段 | 类型 | 说明 |
|---|---|---|
id |
number | 用户 ID |
name |
string | null | 姓名 |
mobile |
string | 手机号 |
| HTTP | 说明 |
|---|---|
401 |
未提供有效 JWT 或 X-App-Access-Token |
400 |
令牌无法解密、已过期、载荷无效、用户非激活、或 token 内手机号与库中不一致等(detail 为文本) |
404 |
对应用户不存在或已删除 |
令牌有效期较短,生成后应尽快展示并完成核验;过期后需用户重新打开「我的二维码」以获取新 token。