Files
im_server/docs/IM-前后端对接文档.md
T

2996 lines
234 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# IM 前后端逐图对接协议
> 版本:v2.0
> 日期:2026-08-11
> 需求依据:[IM 产品原型需求文档](./IM-产品原型需求文档.md)
> 设计依据:[IM 系统设计文档](./IM-系统设计文档.md)
> 说明:第 1 至第 11 章的章节编号、图片标题和顺序与产品原型完全一致。每张图均给出可直接开发、联调和验收的协议。
## 0. 公共对接约定
### 0.1 实现状态
| 状态 | 含义 |
| --- | --- |
| `已实现` | 当前服务端已有能力,可按本文联调 |
| `需改造` | 已有基础能力,但字段、鉴权或业务规则需要调整 |
| `新增` | 当前服务端没有该产品接口或数据模型 |
| `纯客户端` | 界面或系统能力,不调用服务端;关联的提交协议会单独指出 |
文档中所有 `新增`、`需改造` 接口都是目标契约,不代表当前代码已经可用。前后端必须以本文字段为准实施,接口完成前可使用同结构 Mock。
### 0.2 API Key、启动入口与通道
#### 0.2.1 现有系统的 API Key 从哪里来
**API Key 不是客户端启动后向 IM 服务端申请得到的值。现有代码没有“获取 API Key”的 HTTP 或 WebSocket 接口。**
API Key 是应用级接入标识,由部署人员在发布客户端前生成并配置:
1. 新部署同时生成 API Key 和 HMAC Salt:在项目根目录执行 `go run ./keygen`。
2. 已有服务端继续使用当前 `api_key_salt` 时,执行 `go run ./keygen -sequence <新序号> -salt '<服务端 api_key_salt>'` 生成兼容的普通应用 Key。
3. 输出中的 `HMAC salt` 配置到服务端 `server/meetingim.conf` 的 `api_key_salt`;输出中的 `API key` 配置到 iOS、Android 或 Web 客户端构建配置/受控环境配置。
4. 客户端启动时从自己的应用配置读取 API Key,不调用 IM 接口获取。禁止把 `-isroot 1` 生成的 Root Key 放进客户端。
生成示意:
```text
$ go run ./keygen
API key v1 seq1 [ordinary]: <client-api-key>
HMAC salt: <server-hmac-salt>
```
API Key、企业码和用户 Token 是三个不同概念:
| 值 | 取得时间 | 作用 | 是否代表用户身份 |
| --- | --- | --- | --- |
| `api_key` | 应用发布/环境配置时预置 | 允许应用访问 MeetingIM 入口 | 否 |
| `tenant_code` | 用户输入企业码 | 将 Session 路由并绑定到企业 | 否 |
| `token` | 用户登录成功后由服务端返回 | 标识当前租户内的登录用户 | 是 |
API Key 可以被客户端应用提取,因此不能把它当成用户密码或租户授权。现有 `checkAPIKey` 只校验 Key 的格式和 HMAC 签名;最终权限仍必须依赖租户 Session、用户 Token 和 Topic/业务 ACL。
当前 `checkAPIKey` 没有按 `sequence` 吊销旧 Key 的逻辑。生成新序号不会自动让旧 Key 失效;若需要精细轮换,服务端必须增加最低可接受序号或撤销表。直接更换 `api_key_salt` 会让所有旧 Key 同时失效,必须与客户端升级协调。
#### 0.2.2 客户端怎样携带 API Key
| 通道 | 地址 | 认证 | 用途 |
| --- | --- | --- | --- |
| WebSocket(浏览器/通用方式) | `wss://{host}/{api_path}v0/channels?apikey={api_key}` | Upgrade 校验 API Key,`hi` 绑定租户,`login` 登录 | Topic、消息、回执、在线状态、通话信令 |
| WebSocket(支持自定义 Header 的原生客户端) | `wss://{host}/{api_path}v0/channels` | Upgrade Header `X-MeetingIM-APIKey` | 同上 |
| 产品 HTTP | `https://{host}/{api_path}v1/im/*` | `Authorization: Bearer <params.token>` | 好友、群、收藏、搜索、工作台和设置 |
| 文件 HTTP | `POST /v0/file/u/`、`GET /v0/file/s/{file_id}` | 当前用 `X-MeetingIM-Auth: token <base64_token>` | 文件上传下载;禁止 Query 传 Token |
| 用户条款 | `https://{host}/legal/user-terms` | 无需认证、无需 API Key | 登录、注册页通过浏览器或 WebView 打开 |
| 隐私政策 | `https://{host}/legal/privacy-policy` | 无需认证、无需 API Key | 登录、注册及设置页通过浏览器或 WebView 打开 |
HTTP 请求统一通过 `X-MeetingIM-APIKey` 携带。浏览器 WebSocket 无法设置自定义 Upgrade Header 时才使用 `apikey` Query;日志和监控必须对该参数脱敏。产品 HTTP 还必须带 `X-IM-Schema: 1` 和 `X-Request-ID`;写操作带 `Idempotency-Key`。生产环境只允许 HTTPS/WSS。Token、密码、验证码、证件号不得写入 URL、普通日志、埋点或崩溃报告。
#### 0.2.3 用户条款与隐私政策公开页面
客户端不解析或内置协议正文,只保存以下 HTTPS 地址并直接打开:
| 页面 | 通用地址 | 测试/生产域名示例 |
| --- | --- | --- |
| 用户条款 | `https://{host}/legal/user-terms` | `https://api.118im.com/legal/user-terms` |
| 隐私政策 | `https://{host}/legal/privacy-policy` | `https://api.118im.com/legal/privacy-policy` |
两个页面支持 `GET`、`HEAD`,返回 `text/html; charset=utf-8`,不要求 `X-MeetingIM-APIKey`、`X-IM-Schema` 或用户 Token。客户端登录前即可访问;链接应使用系统浏览器或支持 HTTPS 的 WebView 打开,不要把协议正文缓存为业务数据。服务端通过 ETag 和短时公共缓存控制版本刷新,协议更新并重新发布服务后客户端仍使用同一 URL。
客户端不需要硬编码协议域名。`POST /v1/im/tenants/resolve` 的 `data.endpoints` 会同步返回:
```json
{
"legal": {
"user_terms": "https://api.example.com/legal/user-terms",
"privacy_policy": "https://api.example.com/legal/privacy-policy"
}
}
```
登录、注册页点击对应文字时直接打开上述 URL,不附加 Token、API Key 或租户参数,并允许用户滚动、缩放和返回。
WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。缺少或无效时不会建立 WebSocket,直接返回 HTTP 403:
```json
{"ctrl":{"code":403,"text":"valid API key required","ts":"2026-08-11T08:30:15.123Z"}}
```
客户端应把该错误视为应用配置/环境配置错误,停止自动重连并上报脱敏诊断;它不是账号密码错误,也不能通过重新登录解决。
两种连接方式的第一个请求对照如下:
| 连接方式 | 发起第一个网络请求前已有的值 | 第一个网络请求 | 第一个响应得到的值 | 下一步 |
| --- | --- | --- | --- | --- |
| WebSocket(推荐) | `host + api_key + tenant_code` | 带 API Key Upgrade `/v0/channels` | HTTP 101 和已建立的 WebSocket;不会返回新 API Key 或 sid | 发送 `{hi tenant}` |
| Long Polling(兼容) | `host + api_key + tenant_code` | `POST /v0/channels/lp` | HTTP 201 和 `ctrl.params.sid` | 带 `sid` POST `{hi}`,再 GET 等待响应 |
现有实现对应关系:
| 源码 | 作用 |
| --- | --- |
| `keygen/keygen.go` | 生成普通/Root API Key 和服务端 HMAC Salt |
| `server/api_key.go` | `checkAPIKey` 校验 API Key 格式与签名 |
| `server/http.go` | 按 Header、Query、Form、Cookie 顺序读取 API Key |
| `server/hdl_websock.go` | WebSocket Upgrade 前校验 API Key,无效直接 403 |
| `server/hdl_longpoll.go` | 创建 Long Polling Session 并返回 `sid` |
| `server/session.go` | 处理 `{hi}` 租户绑定、`{login}` 和登录 Token 返回 |
现有 API Key 是应用级、非租户级,同一个普通应用 Key 可以进入不同企业;具体租户只能由后续 `{hi}.tenant` 决定。若将来要求每个租户独立 API Key,需要新增 Key 与租户绑定关系,不能只修改客户端配置。
### 0.3 多租户强制规则
1. 未登录阶段只传公开的 `tenant_code`;客户端永远不接收、不提交数据库 `tenant_id`。
2. WebSocket 第一条消息必须是 `{hi}`,其中 `tenant` 为企业码。服务端解析后把租户固定到 Session。
3. Token 内部绑定租户。Token 租户与当前 Session 租户不一致时返回 `TENANT_MISMATCH`,不得自动切换。
4. 切换企业必须退出登录、关闭 WebSocket、清除该租户的 Token/缓存,再重新解析企业码和握手。
5. 已登录 HTTP 请求的租户和用户只从 Token Principal 获取。请求体中的 `tenant_id`、`user_id` 不能覆盖身份。
6. 跨租户对象统一返回 `RESOURCE_NOT_FOUND` 或 `FORBIDDEN`,不得暴露对象是否存在。
### 0.4 HTTP 请求与响应
成功响应统一为:
```json
{
"code": 0,
"data": {},
"request_id": "01K...",
"server_time": "2026-08-11T08:30:15.123Z"
}
```
失败响应统一为:
```json
{
"code": 30001,
"reason": "GROUP_MUTED",
"message": "permission denied",
"details": {},
"request_id": "01K...",
"server_time": "2026-08-11T08:30:15.123Z"
}
```
客户端只使用稳定的 `reason` 分支和本地化文案,不解析 `message`。ID、Topic、Seq 游标在客户端均按字符串处理;时间统一为 UTC RFC3339 毫秒。分页统一使用 `limit` 与不透明 `cursor`,返回 `items`、`next_cursor`、`has_more`。
### 0.5 WebSocket 建连、登录和恢复顺序
#### 0.5.1 现有服务端可直接执行的冷启动流程
```text
读取客户端预置的 host 和普通 API Key
-> 用户输入/客户端读取 tenant_code
-> 可选:POST /v1/im/tenants/resolve 校验企业码并取得公开配置
-> 带 API Key 完成 WebSocket Upgrade
-> hi(tenant_code)
-> 收到 hi 的 201 ctrl,确认 tenant 与服务端限制
-> 有历史 Token 时 token login,否则 basic login/acc
-> 收到 login 的 200 ctrl,保存 user/token/expires
-> sub(me) 拉取本人资料和会话订阅
-> sub(fnd) 建立搜索 Topic
-> 订阅需要在线的会话并按 seq 增量拉取
-> 进入可发送状态
```
当前服务端已提供 `/v1/im/tenants/resolve`,用于进入企业前校验企业码并返回公开配置;但它不能代替 WebSocket 第一帧 `{hi}`。即使 HTTP 解析成功,WebSocket Upgrade 返回 `101 Switching Protocols` 后仍必须先发送下面的 `{hi}` JSON 文本帧,并使用同一个 `tenant_code` 绑定当前 Session。
`hi` 请求:
```json
{"hi":{"id":"hi-1","ver":"0.25","ua":"android/1.0.0","dev":"device-id","lang":"zh-CN","tenant":"acme"}}
```
`login` 请求使用 `basic` 或 `token`:
```json
{"login":{"id":"login-1","scheme":"basic","secret":"base64(username:password)"}}
```
```json
{"login":{"id":"login-2","scheme":"token","secret":"<上次登录响应原样返回的 token 字符串>"}}
```
`basic.secret` 是 `base64(username + ":" + password)`。Base64 只是协议编码,不是加密,Basic 注册和登录必须使用 HTTPS/WSS;服务端解析密码后使用 bcrypt 哈希,数据库不保存密码明文。Token 登录必须把上次 `params.token` 的 JSON 字符串原样填入 `secret`,不能再次 Base64 编码。登录成功 `{ctrl}` 的 `params` 至少包含 `user`、`authlvl`、`token`、`expires`。产品 HTTP 也使用同一个 `params.token`:`Authorization: Bearer <params.token>`,客户端不要解码或二次编码。Token 对客户端是不可解析的 opaque 字符串,只能存入系统安全存储。`id` 只用于匹配本次 Session 的命令响应,不是业务幂等键。
当前登录 Token 默认有效期为 14 天,不单独设置 Refresh Token 或刷新接口。每次 Basic 登录或未过期 Token 登录成功后,服务端都会返回一个新的 14 天 Token;客户端必须原子覆盖安全存储中的旧 `token/expires`。Token 已过期或校验失败时不能续期,必须回到账号密码登录。
`hi` 成功示例:
```json
{
"ctrl": {
"id": "hi-1",
"code": 201,
"text": "created",
"params": {
"ver": "0.25",
"tenant": {"code": "acme", "name": "示例企业"},
"maxFileUploadSize": 104857600,
"callTimeout": 30,
"iceServers": []
}
}
}
```
客户端只使用实际响应中存在的限制字段;未知字段忽略。`hi` 返回非 2xx 时必须关闭连接,不得继续发送 `login`。
登录成功后发送:
```json
{"sub":{"id":"sub-me-1","topic":"me","get":{"what":"desc sub tags cred"}}}
```
客户端按相同 `id` 接收 `{ctrl}` 和一个或多个 `{meta}`,从 `meta.desc` 取得本人资料和读写游标,从 `meta.sub` 取得会话 Topic、访问权限、`seq/read/recv` 与会话摘要。不要依赖 `{ctrl}`、`{meta}` 的固定先后顺序。
随后建立查找 Topic:
```json
{"sub":{"id":"sub-fnd-1","topic":"fnd"}}
```
进入具体会话时订阅并增量拉取,`since` 使用客户端已完整持久化的最后 `seq + 1`:
```json
{"sub":{"id":"sub-topic-1","topic":"grp...","get":{"what":"desc sub data del","data":{"since":125,"limit":50},"del":{"since":1,"limit":50}}}}
```
订阅成功后才允许 `{pub}`、`note read/recv` 等会话操作。断线恢复必须重新执行 `WebSocket Upgrade -> hi -> token login -> sub me -> 恢复 Topic`,同一连接内不得跳过 `hi` 或切换企业码。
##### 0.5.1.1 WebSocket 心跳与在线状态
当前 IM 长连接心跳使用 WebSocket 协议层 Ping/Pong,不使用业务 JSON 消息做保活。服务端每 30 秒发送一次 WebSocket Ping 帧(`pingPeriod=30s`),并期望客户端在 45 秒读超时窗口内返回 Pong(`idleSessionTimeout=45s`);如果没有收到 Pong,或底层读写失败,服务端会关闭当前 Session。
客户端要求:
1. 使用系统 WebSocket 库时,必须确认库会自动回复 Pong;如果库不会自动回复,需要在收到 Ping 时显式发送 Pong。
2. 不要把 `{"note":{"what":"kp"}}` 当作心跳。`kp/kpa/kpv` 只是“正在输入/正在录音/正在视频输入”等 UI 状态通知,不落库,也不能替代 WebSocket Pong。
3. 连接关闭或报错后,客户端本地应立即把 IM 长连接视为离线,并按指数退避重连。重连成功后完整执行 `WebSocket Upgrade -> hi -> token login -> sub me -> sub topics -> get data/del`,用本地最后完整持久化的 `seq + 1` 增量补消息。
4. 服务端收到任意客户端上行 JSON 消息或 WebSocket Pong 后,都会刷新在线状态。当前在线状态写入 Redis,默认 TTL 为 120 秒,用于好友在线状态、群成员在线状态等展示。
5. iOS/Android 进入后台后系统可能挂起 WebSocket。后台场景以推送唤醒和前台重连补偿为主,不要求客户端在后台无限重连。
#### 0.5.2 Long Polling 兼容流程:先取得的是 sid
如果客户端无法使用 WebSocket,可以使用现有 `/v0/channels/lp`。该模式确实需要“先请求取得一个值”,但取得的是短期 Session ID `sid`,API Key 仍然必须由客户端预先持有。
第一步,创建 Long Polling Session:
```http
POST /v0/channels/lp HTTP/1.1
Host: im.example.com
X-MeetingIM-APIKey: <预置的普通 API Key>
Content-Length: 0
```
服务端返回 HTTP 201:
```json
{"ctrl":{"code":201,"text":"created","params":{"sid":"session-id"},"ts":"2026-08-11T08:30:15.123Z"}}
```
第二步开始,所有 Long Polling 请求都同时携带 API Key 和 `sid`。发送 `hi`:
```http
POST /v0/channels/lp?sid=session-id HTTP/1.1
X-MeetingIM-APIKey: <预置的普通 API Key>
Content-Type: application/json
{"hi":{"id":"hi-1","ver":"0.25","ua":"android/1.0.0","platf":"android","lang":"zh-CN","tenant":"acme"}}
```
POST 只负责把命令送入 Session。客户端再保持一个不带 Body 的 GET 来接收下一条 `{ctrl}`、`{meta}`、`{data}`、`{pres}` 或 `{info}`:
```http
GET /v0/channels/lp?sid=session-id HTTP/1.1
X-MeetingIM-APIKey: <预置的普通 API Key>
```
收到 `hi` 成功后,以相同方式 POST `{login}`,再 GET 登录结果;登录成功后依次 POST `{sub me}`、`{sub fnd}` 和会话订阅,并持续发起下一次 GET 等待下行消息。
`sid` 无效或过期时服务端返回 HTTP 403 `invalid or expired session`,客户端必须重新创建 Long Polling Session 并从 `hi` 开始,不能只重发 `login`。Long Polling 是兼容方案,移动端和 Web 端正常网络条件下优先使用 WebSocket。
#### 0.5.3 企业解析与产品流程
第 1.1 节的 `POST /v1/im/tenants/resolve` 已实现,推荐产品入口流程为:
```text
读取预置 API Key
-> POST /v1/im/tenants/resolve 校验企业码并取得服务地址/品牌配置
-> 带同一 API Key 建立返回地址的 WebSocket
-> hi 再次绑定并校验 tenant_code
-> login -> sub me -> 恢复会话
```
企业解析接口只改善入口、服务发现和品牌配置,不能代替 `{hi}` 的租户绑定。即使 HTTP 解析成功,WebSocket 第一帧仍必须带相同 `tenant_code`。如果解析响应中的 `endpoints` 不符合当前发布环境,客户端应优先使用内置的公网环境配置,并上报脱敏诊断。
#### 0.5.4 WSS 契约输入输出示例
本节用于 WebSocket 联调。除特别说明外:
- WebSocket 地址为 `wss://{host}/{api_path}v0/channels?apikey={api_key}`;支持自定义 Header 的原生客户端也可用 `X-MeetingIM-APIKey`。
- 连接建立后第一帧必须是 `{hi}`,`hi` 成功后才能 `{login}` 或 `{acc}`。
- 每个上行命令都带客户端生成的 `id`,客户端用该 `id` 匹配 `{ctrl}`;同一连接内保持唯一即可。
- 服务端下行可能乱序到达,客户端不能假设 `{ctrl}` 一定先于 `{meta}` 或 `{data}`。
- 成功控制包一般为 `{ctrl.code=2xx}`;失败控制包一般为 `{ctrl.code=4xx/5xx}`,产品稳定错误优先读 `ctrl.params.reason`,没有时按 `code/text` 兜底。
##### 0.5.4.1 连接、握手、登录和注册
| 场景 | 客户端发送 | 服务端返回示例 |
| --- | --- | --- |
| WebSocket Upgrade | `GET /v0/channels?apikey=AQ...` | HTTP `101 Switching Protocols`;API Key 无效时 HTTP `403` |
| 租户握手 `hi` | `{"hi":{"id":"hi-1","ver":"0.25","ua":"Tinodios/1","platf":"ios","lang":"zh-CN","tenant":"default"}}` | `{"ctrl":{"id":"hi-1","code":201,"text":"created","params":{"ver":"0.25","tenant":{"code":"default","name":"Default Tenant"},"maxMessageSize":4194304,"maxFileUploadSize":33554432,"maxSubscriberCount":128}}}` |
| 账号密码登录 | `{"login":{"id":"login-1","scheme":"basic","secret":"base64(username:password)"}}` | `{"ctrl":{"id":"login-1","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` |
| Token 登录 | `{"login":{"id":"login-2","scheme":"token","secret":"上次登录返回的 token 原文"}}` | `{"ctrl":{"id":"login-2","code":200,"text":"ok","params":{"user":"usr...","authlvl":"auth","token":"new_token...","expires":"2026-09-03T09:00:00Z"}}}` |
| 用户名注册 | `{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"","invite_code":"AbCdE","secret":"base64(alice:password)","login":true,"desc":{"public":{"fn":"alice"}}}}` | `{"ctrl":{"id":"acc-1","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` |
| 手机号注册 | `{"acc":{"id":"acc-2","user":"new","scheme":"basic","username":"alice","phone":"15500000000","secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"15500000000","resp":"verification_token","params":{"challenge_id":"chg_123"}}],"desc":{"public":{"fn":"alice"}}}}` | `{"ctrl":{"id":"acc-2","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` |
| 登录失败 | `{"login":{"id":"login-bad","scheme":"basic","secret":"base64(alice:wrong)"}}` | `{"ctrl":{"id":"login-bad","code":401,"text":"authentication failed","params":{"reason":"AUTH_FAILED"}}}` |
| 租户不匹配 | `{"login":{"id":"login-tenant","scheme":"token","secret":"其他租户 token"}}` | `{"ctrl":{"id":"login-tenant","code":403,"text":"tenant mismatch","params":{"reason":"TENANT_MISMATCH"}}}` |
##### 0.5.4.2 启动订阅、会话列表和增量恢复
| 场景 | 客户端发送 | 服务端返回示例 |
| --- | --- | --- |
| 订阅本人 `me` | `{"sub":{"id":"sub-me-1","topic":"me","get":{"what":"desc sub tags cred"}}}` | `{"ctrl":{"id":"sub-me-1","code":200,"text":"ok"}}`,随后可能收到 `{"meta":{"id":"sub-me-1","topic":"me","desc":{"public":{"fn":"alice"}},"sub":[{"topic":"grp...","public":{"name":"项目群"},"seq":12,"read":10,"recv":12,"updated":"2026-08-20T09:00:00Z"}]}}` |
| 订阅查找 `fnd` | `{"sub":{"id":"sub-fnd-1","topic":"fnd"}}` | `{"ctrl":{"id":"sub-fnd-1","code":200,"text":"ok"}}` |
| 订阅会话并拉历史 | `{"sub":{"id":"sub-topic-1","topic":"grp...","get":{"what":"desc sub data del","data":{"since":1,"limit":50},"del":{"since":1,"limit":50}}}}` | `{"ctrl":{"id":"sub-topic-1","code":200,"text":"ok"}}`,随后 `{"meta":{"id":"sub-topic-1","topic":"grp...","desc":{"public":{"name":"项目群"},"seq":12},"sub":[{"user":"usr...","mode":"JRWP"}]}}` 和多条 `{data}` |
| 断线恢复增量 | `{"sub":{"id":"resume-grp-1","topic":"grp...","get":{"what":"data del","data":{"since":125,"limit":50},"del":{"since":1,"limit":50}}}}` | `{"data":{"topic":"grp...","seq":125,"from":"usr...","ts":"2026-08-20T09:00:00Z","content":{"text":"恢复后的第一条"}}}` |
| 只拉元数据 | `{"get":{"id":"get-topic-1","topic":"grp...","what":"desc sub"}}` | `{"meta":{"id":"get-topic-1","topic":"grp...","desc":{"seq":12,"public":{"name":"项目群"}},"sub":[{"user":"usr...","read":10,"recv":12}]}}` |
| 离开/取消订阅 | `{"leave":{"id":"leave-1","topic":"grp...","unsub":false}}` | `{"ctrl":{"id":"leave-1","code":200,"text":"ok"}}` |
##### 0.5.4.3 发送消息、媒体卡片、名片和转发卡片
| 类型 | 客户端发送 | 服务端返回/下行示例 |
| --- | --- | --- |
| 文本消息 | `{"pub":{"id":"pub-text-1","topic":"p2p...","noecho":false,"head":{"mime":"application/json","x-im-schema":1,"x-im-type":"text","x-im-client-mid":"mid_1"},"content":{"text":"你好"}}}` | 发送方收到 `{"ctrl":{"id":"pub-text-1","code":202,"text":"accepted","params":{"seq":12}}}`;订阅者收到 `{"data":{"topic":"p2p...","seq":12,"from":"usr...","head":{"x-im-type":"text","x-im-client-mid":"mid_1"},"content":{"text":"你好"}}}` |
| 图片消息 | `{"pub":{"id":"pub-img-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"image","x-im-client-mid":"mid_img_1"},"content":{"file_id":"file_img","thumbnail_file_id":"file_thumb","name":"a.jpg","size":12345,"mime":"image/jpeg","width":1080,"height":720}}}` | `{"ctrl":{"id":"pub-img-1","code":202,"params":{"seq":13}}}` |
| 语音消息 | `{"pub":{"id":"pub-voice-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"voice","x-im-client-mid":"mid_voice_1"},"content":{"file_id":"file_voice","duration_ms":8000,"size":4096,"mime":"audio/aac","waveform":[1,3,5,2]}}}` | `{"ctrl":{"id":"pub-voice-1","code":202,"params":{"seq":14}}}` |
| 文件消息 | `{"pub":{"id":"pub-file-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"file","x-im-client-mid":"mid_file_1"},"content":{"file_id":"file_pdf","name":"方案.pdf","size":102400,"mime":"application/pdf","extension":"pdf","checksum":"sha256:..."}}}` | `{"ctrl":{"id":"pub-file-1","code":202,"params":{"seq":15}}}` |
| 名片消息 | `{"pub":{"id":"pub-card-1","topic":"p2p...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"contact_card","x-im-client-mid":"mid_card_1"},"content":{"user_id":"usr...","display_name":"张三","avatar_file_id":"","public_account":"zhangsan"}}}` | `{"ctrl":{"id":"pub-card-1","code":202,"params":{"seq":16}}}` |
| 合并转发卡片 | `{"pub":{"id":"pub-fwd-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"forward_bundle","x-im-client-mid":"mid_fwd_1"},"content":{"bundle_id":"fbundle_1","title":"聊天记录","item_count":3,"preview":[{"from_display":"张三","summary":"你好"}]}}}` | `{"ctrl":{"id":"pub-fwd-1","code":202,"params":{"seq":17}}}` |
| @成员消息 | `{"pub":{"id":"pub-mention-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"text","x-im-client-mid":"mid_at_1","mentions":[{"user_id":"usr...","offset":0,"length":3}]},"content":{"text":"@张三 看一下"}}}` | `{"ctrl":{"id":"pub-mention-1","code":202,"params":{"seq":18}}}` |
| @所有人 | `{"pub":{"id":"pub-all-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"text","x-im-client-mid":"mid_all_1","mention_all":true},"content":{"text":"@所有人 开会"}}}` | 无权限时 `{"ctrl":{"id":"pub-all-1","code":403,"text":"permission denied","params":{"reason":"MENTION_ALL_FORBIDDEN"}}}` |
| 重复 `client_mid` 重试 | 与第一次发送完全相同,复用 `x-im-client-mid:"mid_1"` | 目标行为:返回第一次成功的同一 `seq`;不得落两条消息 |
##### 0.5.4.3.1 会话内系统提示和可点击动作消息
截图中的灰色提示、群内邀请确认说明、好友已添加提示等属于会话内系统消息,必须进入对应 P2P 或群 Topic 的消息流,占用 `seq`,参与历史拉取、重连补偿和多端同步。它们不是 `info` 实时业务事件,也不是系统通知列表记录;`info` 只负责触发刷新,真正需要在聊天窗口长期展示的内容必须以 `{data}` 下发并入库。发给被邀请人的“加入群聊”邀请卡片不写入邀请人与被邀请人的 P2P 会话,展示以 `GET /v1/im/system-notifications` 返回的系统通知记录为准。
客户端不得主动发送 `system_notice`、`system_action`;这两类消息只能由服务端受控身份生成。服务端下发时 `data.from` 可以为空,客户端以 `head.x-im-from="system"` 判断系统身份,不依赖真实用户头像。客户端收到未知 `template` 或未知 `action.type` 时,展示 `content.text`,忽略不可识别动作,不允许执行任意 URL、脚本或非白名单路由。
| 类型 | 适用场景 | 服务端下行示例 | 客户端渲染 |
| --- | --- | --- | --- |
| `system_notice` | “你已添加了安妮,现在可以开始聊天了”、撤回提示、群名修改、成员进出群等纯提示 | `{"data":{"topic":"p2p...","seq":19,"from":"","head":{"x-im-schema":1,"x-im-type":"system_notice","x-im-template":"friend_added","x-im-from":"system"},"content":{"text":"你已添加了安妮,现在可以开始聊天了。","level":"normal","template":"friend_added","actors":[{"user_id":"usr_anne","name":"安妮"}],"actions":[]}}}` | 居中灰色提示;不显示发送人头像;如 `actors.click` 为空则不可点 |
| `system_action` | 群邀请确认说明、审批入口、群设置入口、可点击人名或群名提示 | `{"data":{"topic":"grp...","seq":20,"from":"","head":{"x-im-schema":1,"x-im-type":"system_action","x-im-template":"group_invite_confirm_enabled","x-im-from":"system"},"content":{"text":"安妮邀请栗子来了加入了群聊","template":"group_invite_confirm_enabled","actors":[{"user_id":"usr_anne","name":"安妮","click":{"type":"user_profile","target_id":"usr_anne"}},{"user_id":"usr_lizi","name":"栗子来了","click":{"type":"user_profile","target_id":"usr_lizi"}}],"actions":[{"type":"open_group_invite_settings","label":"群聊邀请确认","target_id":"grp..."}]}}}` | 居中灰色或业务提示样式;可点击片段按 `actors.click` 高亮;按钮或文字入口按 `actions` 渲染 |
`content` 字段约定:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `text` | string | 是 | 降级展示文案;客户端可直接展示,也可按 `template` 重新排版 |
| `template` | string | 是 | 服务端稳定模板编码,例如 `friend_added`、`group_member_joined`、`group_invite_confirm_enabled` |
| `level` | string | 否 | `normal`、`warning`、`danger`,影响颜色和强调程度 |
| `actors` | array | 否 | 文案中的用户、群、文件等实体;支持局部高亮和点击 |
| `actions` | array | 否 | 当前消息可触发的操作入口;只允许客户端白名单动作 |
| `extra` | object | 否 | 模板扩展参数;客户端不得依赖未知字段完成关键流程 |
`actors[].click` 和 `actions[]` 的 `type` 首版白名单:
| 类型 | 参数 | 行为 |
| --- | --- | --- |
| `user_profile` | `target_id` | 打开用户资料页 |
| `group_profile` | `target_id` | 打开群资料页 |
| `open_group_invite_settings` | `target_id` | 打开群邀请确认设置 |
| `open_group_join_requests` | `target_id` | 打开群入群审批列表 |
| `open_message` | `topic,seq` | 定位到指定消息 |
##### 0.5.4.4 回执、正在输入、删除和清空记录
| 场景 | 客户端发送 | 服务端返回/下行示例 |
| --- | --- | --- |
| 送达回执 | `{"note":{"topic":"p2p...","what":"recv","seq":12}}` | 对端收到 `{"info":{"topic":"p2p...","from":"usr...","what":"recv","seq":12}}` |
| 已读回执 | `{"note":{"topic":"p2p...","what":"read","seq":12}}` | 对端收到 `{"info":{"topic":"p2p...","from":"usr...","what":"read","seq":12}}`;客户端本地把该 topic `read` 更新为 12 |
| 正在输入 | `{"note":{"topic":"p2p...","what":"kp"}}` | 对端收到 `{"info":{"topic":"p2p...","from":"usr...","what":"kp"}}`,本地超时消失 |
| 正在录音可选 | `{"note":{"topic":"grp...","what":"kpa"}}` | 对端收到 `{"info":{"topic":"grp...","from":"usr...","what":"kpa"}}`,不落库 |
| 删除单条/多条消息 | `{"del":{"id":"del-1","topic":"grp...","what":"msg","delseq":[{"low":12,"hi":13}],"hard":false}}` | `{"ctrl":{"id":"del-1","code":200,"text":"ok","params":{"del":3}}}`;其他端可能收到 `{"pres":{"topic":"grp...","what":"del","seq":3}}` |
| 清空当前用户视图 | `{"del":{"id":"del-all-1","topic":"p2p...","what":"msg","delseq":[{"low":1,"hi":126}],"hard":false}}` | `{"ctrl":{"id":"del-all-1","code":200,"text":"ok","params":{"del":4}}}` |
`kp/kpa/kpv` 只表示输入态、录音态、视频输入态等短暂 UI 状态,不能作为长连接心跳。长连接保活必须依赖 0.5.1.1 中的 WebSocket Ping/Pong。
##### 0.5.4.5 Topic 资料、个人群设置和标签
| 场景 | 客户端发送 | 服务端返回/下行示例 |
| --- | --- | --- |
| 更新群公开资料 | `{"set":{"id":"set-grp-1","topic":"grp...","desc":{"public":{"name":"新群名","avatar_file_id":"file_avatar","description":"新简介","version":2}}}}` | `{"ctrl":{"id":"set-grp-1","code":200,"text":"ok"}}`;成员收到 `{"pres":{"topic":"grp...","what":"upd"}}` |
| 更新个人资料 | `{"set":{"id":"set-me-1","topic":"me","desc":{"public":{"fn":"新昵称","avatar_file_id":"file_avatar"}}}}` | `{"ctrl":{"id":"set-me-1","code":200,"text":"ok"}}` |
| 更新会话私有设置 | `{"set":{"id":"set-private-1","topic":"grp...","sub":{"user":"usr_me","private":{"pinned":true,"mute_notifications":false,"nickname":"群昵称","version":2}}}}` | `{"ctrl":{"id":"set-private-1","code":200,"text":"ok"}}` |
| 设置搜索标签 | `{"set":{"id":"set-tags-1","topic":"me","tags":["usr:alice"]}}` | 普通用户无权限时 `{"ctrl":{"id":"set-tags-1","code":403,"text":"permission denied","params":{"what":"tags"}}}` |
##### 0.5.4.6 系统通知、好友和群业务下行
服务端主动下行分两类:MeetingIM 原生 Topic 消息使用 `{data}`、`{info}`、`{pres}` 等帧;IM 产品业务事件使用 `{info}` 帧下发,前端按 `info.what + info.event` 分发刷新。产品业务事件不占普通聊天 `seq`,不等同于系统通知列表记录;系统消息入口的未读数来自 `im_system_notifications` 和 `GET /v1/im/system-notifications`。
用户维度业务事件统一格式:
```json
{
"info": {
"topic": "usr_target",
"from": "usr_actor",
"what": "im",
"event": "friend.request.accepted",
"payload": {
"user_id": "usr_actor"
},
"ts": "2026-08-25T10:00:00Z"
}
}
```
群维度业务事件统一格式:
```json
{
"info": {
"topic": "grp_xxx",
"from": "usr_actor",
"what": "group",
"event": "group.member.joined",
"payload": {
"topic": "grp_xxx",
"user_id": "usr_xxx"
},
"ts": "2026-08-25T10:00:00Z"
}
}
```
| 事件 | 接收方 | 服务端下行示例 | 客户端动作 |
| --- | --- | --- | --- |
| 好友申请创建 | 被申请人 | `{"info":{"topic":"usr_target","from":"usr_applicant","what":"im","event":"friend.request.created","payload":{"user_id":"usr_applicant"}}}` | 刷新 `GET /v1/im/friend-requests` 和 `GET /v1/im/bootstrap` 的新朋友未处理数;系统通知列表也会新增记录 |
| 好友申请通过 | 申请人、通过人本人 | `{"info":{"topic":"usr_target","from":"usr_actor","what":"im","event":"friend.request.accepted","payload":{"user_id":"usr_actor"}}}` | 刷新新朋友列表、好友列表、P2P 会话 context;申请人当前有系统通知,通过人本人当前无系统通知 |
| 好友申请拒绝 | 申请人、拒绝人本人 | `{"info":{"topic":"usr_target","from":"usr_actor","what":"im","event":"friend.request.rejected","payload":{"user_id":"usr_actor"}}}` | 刷新新朋友列表和 `bootstrap.entry_badges`;申请人当前有系统通知,拒绝人本人当前无系统通知 |
| 删除好友 | 被删除方、操作人本人 | `{"info":{"topic":"usr_target","from":"usr_actor","what":"im","event":"friend.deleted","payload":{"user_id":"usr_actor"}}}` | 刷新好友列表、会话 context,重新判断输入区 `can_send`;被删除方当前有系统通知,操作人本人当前无系统通知 |
| 拉黑/取消拉黑 | 操作人本人 | `{"info":{"topic":"usr_actor","from":"usr_actor","what":"im","event":"relationship.blocked","payload":{"user_id":"usr_actor"}}}` | 刷新黑名单、好友列表和会话 context |
| 修改好友备注 | 操作人本人 | `{"info":{"topic":"usr_actor","from":"usr_actor","what":"im","event":"friend.remark.updated","payload":{"user_id":"usr_actor"}}}` | 刷新好友列表和会话头显示名 |
| 消息撤回 | Topic 在线成员 | `{"info":{"topic":"p2p_or_grp","from":"usr_actor","what":"im","event":"message.recalled","payload":{"topic":"p2p_or_grp","seq":12,"version":2}}}` | 按 `topic+seq+version` 重新拉取或本地标记已撤回 |
| 消息编辑 | Topic 在线成员 | `{"info":{"topic":"p2p_or_grp","from":"usr_actor","what":"im","event":"message.edited","payload":{"topic":"p2p_or_grp","seq":12,"version":2}}}` | 按 `topic+seq+version` 重新拉取消息详情或修订记录 |
| 群业务变化 | 群 Topic 在线成员 | `{"info":{"topic":"grp...","from":"usr_actor","what":"group","event":"group.member.joined","payload":{"topic":"grp...","user_id":"usr_xxx"}}}` | 按事件刷新群详情、成员列表、入群申请、公告、禁言或角色权限;除操作人外当前会写系统通知 |
| 会议变化 | 会议参会人 | `{"info":{"topic":"usr_target","from":"usr_actor","what":"im","event":"meeting.updated","payload":{"meeting_id":"mtg_1","version":2,"state":"scheduled"}}}` | 刷新会议列表/详情;除操作者外当前会写系统通知 |
| 安全风险确认 | 操作人本人 | `{"info":{"topic":"usr_actor","from":"usr_actor","what":"im","event":"security.risk.continue","payload":{"risk_challenge_id":"risk_1","decision":"continue"}}}` | 刷新安全验证页面;当前会写系统通知 |
业务事件写入 outbox 后会立即尝试投递;Hub 不可用或队列满时保持 pending,后台 poller 按在线租户重试。`registration.invite_used` 和 `security.password_reset` 是服务端内部消费事件,不作为普通业务事件下发给 App。
##### 0.5.4.7 WebRTC 通话信令
| 场景 | 客户端发送 | 服务端返回/下行示例 |
| --- | --- | --- |
| 发起语音通话 | `{"pub":{"id":"call-pub-1","topic":"p2p...","head":{"mime":"application/json","webrtc":"started","x-im-type":"call","x-im-client-mid":"mid_call_1"},"content":{"callId":"call_1","callType":"audio","audio":true,"video":false}}}` | `{"ctrl":{"id":"call-pub-1","code":202,"text":"accepted","params":{"seq":30}}}`;被叫收到同一 `{data seq=30}` |
| 被叫响铃 | `{"note":{"topic":"p2p...","what":"call","event":"ringing","seq":30,"payload":{"callId":"call_1"}}}` | 主叫收到 `{"info":{"topic":"p2p...","what":"call","event":"ringing","seq":30,"payload":{"callId":"call_1"}}}` |
| 被叫接听 | `{"note":{"topic":"p2p...","what":"call","event":"accept","seq":30,"payload":{"callId":"call_1"}}}` | 双方收到通话状态 `accept`;原消息可被服务端替换为 `head.webrtc:"accepted"` |
| SDP offer | `{"note":{"topic":"p2p...","what":"call","event":"offer","seq":30,"payload":{"callId":"call_1","sdp":"v=0..."}}}` | 对端收到 `{"info":{"topic":"p2p...","what":"call","event":"offer","seq":30,"payload":{"callId":"call_1","sdp":"v=0..."}}}` |
| SDP answer | `{"note":{"topic":"p2p...","what":"call","event":"answer","seq":30,"payload":{"callId":"call_1","sdp":"v=0..."}}}` | 对端收到 `event:"answer"` |
| ICE candidate | `{"note":{"topic":"p2p...","what":"call","event":"ice-candidate","seq":30,"payload":{"callId":"call_1","candidate":{"candidate":"candidate:...","sdpMid":"0","sdpMLineIndex":0}}}}` | 对端收到 `event:"ice-candidate"` |
| 挂断/取消 | `{"note":{"topic":"p2p...","what":"call","event":"hang-up","seq":30,"payload":{"callId":"call_1","reason":"cancelled"}}}` | 服务端替换原通话消息:`{"data":{"topic":"p2p...","seq":30,"head":{"replace":":30","webrtc":"missed","webrtc-duration":0,"x-im-type":"call"},"content":{"callId":"call_1","callType":"audio","status":"missed"}}}` |
| 异常事件 | `{"note":{"topic":"p2p...","what":"call","event":"media-state","seq":30,"payload":{"callId":"call_1"}}}` | 当前未开放时返回 `{"ctrl":{"code":400,"text":"malformed","params":{"reason":"CALL_EVENT_UNSUPPORTED"}}}` 或直接忽略,客户端不要提前依赖 |
##### 0.5.4.8 WSS 错误、重试和客户端处理
| 场景 | 服务端返回示例 | 客户端处理 |
| --- | --- | --- |
| API Key 无效 | Upgrade HTTP `403 {"ctrl":{"code":403,"text":"valid API key required"}}` | 停止重连,上报环境配置错误 |
| 未先 `hi` 就登录 | `{"ctrl":{"id":"login-1","code":400,"text":"tenant required","params":{"reason":"TENANT_REQUIRED"}}}` | 关闭连接,从 Upgrade + `hi` 重来 |
| 登录失败 | `{"ctrl":{"id":"login-1","code":401,"text":"authentication failed","params":{"reason":"AUTH_FAILED"}}}` | 清密码,保留账号,显示统一账号或密码错误 |
| Token 过期 | `{"ctrl":{"id":"login-token-1","code":401,"text":"authentication failed","params":{"reason":"TOKEN_EXPIRED"}}}` | 清当前租户 token,回登录页 |
| 无 Topic 权限 | `{"ctrl":{"id":"pub-1","code":403,"text":"permission denied","params":{"reason":"MESSAGE_NOT_ALLOWED"}}}` | 发送气泡置为失败,不伪装已发送 |
| 消息过大 | `{"ctrl":{"id":"pub-1","code":413,"text":"message too large","params":{"reason":"MESSAGE_TOO_LARGE","max_size":4194304}}}` | 禁止重试,提示压缩或拆分 |
| 断线 | WebSocket close | 指数退避重连,重新 `hi -> token login -> sub me -> sub topics`,按最后完整 `seq + 1` 增量拉取 |
##### 0.5.4.9 极光离线 Push 与重连补偿
离线 Push 只承担提醒和唤醒 App,不作为消息正文或业务状态真源。客户端收到极光 Push 后,必须重新登录 IM,通过 `topic + seq`、`call_id` 或系统通知 ID 拉取服务端状态。普通聊天、群 @、系统通知、好友/群审批、音视频来电都复用同一套 extras 分发协议。
客户端侧要求:
- App 集成 JPush SDK,初始化使用极光 `AppKey`,不接触 `MasterSecret`。
- 登录 IM 后将极光 alias 绑定为当前 `user_id` 的安全别名:`user_id.replace("-", ".").replace("_", "|")`,例如 `usrd6Ag5opa-ww` 绑定为 `usrd6Ag5opa.ww`;同一用户多设备允许绑定同一个 alias。
- SDK 返回 `registration_id` 后调用 `PUT /v1/im/devices/{device_id}/push-token` 上报,`provider` 固定传 `jpush`。
- 点击 Push 后按 `extras.type` 跳转,再按服务端数据补偿,不能只凭 Push payload 渲染最终状态。
服务端侧要求:
- `jpush_master_secret` 只保存在后台配置 `im_jpush` 分组,IM Server 通过内部配置接口读取并调用极光 REST API。
- 用户在线时优先 WebSocket 实时投递;用户离线、后台或不可投递时生成 Push 任务。
- 万人大群实时 fanout 不靠 Push;离线只对需要提醒的成员生成极光 Push,客户端重连后按 Topic seq 拉取补偿。
- Push payload 默认不带敏感正文,通知栏文案按用户隐私设置降级。
极光 Push extras 统一格式:
```json
{
"type": "im_message",
"tenant_code": "default",
"topic": "p2p...",
"seq": "128",
"sender_id": "usr_sender",
"message_type": "text"
}
```
| `extras.type` | 必填字段 | 客户端动作 |
| --- | --- | --- |
| `im_message` | `tenant_code,topic,seq` | 打开会话,按本地最后 seq 补齐消息并定位。 |
| `group_mention` | `tenant_code,topic,seq,sender_id` | 打开群会话并定位被 @ 消息。 |
| `call_invite` | `tenant_code,topic,seq,call_id,call_type,caller_id` | 打开来电页前先登录 IM 并确认通话仍为有效来电。 |
| `call_missed` | `tenant_code,topic,call_id` | 打开通话记录或对应会话。 |
| `system_notification` | `tenant_code,notification_id` | 打开系统消息列表并刷新详情。 |
| `friend_request` | `tenant_code,request_id` | 打开新朋友列表并刷新未处理数。 |
| `group_request` | `tenant_code,topic,request_id` | 打开群审批入口并刷新列表。 |
普通离线消息极光 payload 示例:
```json
{
"platform": "all",
"audience": {
"alias": ["usr_target"]
},
"notification": {
"alert": "你收到一条新消息",
"android": {
"title": "MeetingIM",
"alert": "你收到一条新消息",
"extras": {
"type": "im_message",
"tenant_code": "default",
"topic": "p2p...",
"seq": "128"
}
},
"ios": {
"alert": {
"title": "MeetingIM",
"body": "你收到一条新消息"
},
"sound": "default",
"badge": "+1",
"extras": {
"type": "im_message",
"tenant_code": "default",
"topic": "p2p...",
"seq": "128"
}
}
},
"options": {
"time_to_live": 86400,
"apns_production": false,
"classification": 1
}
}
```
音视频来电极光 payload 示例:
```json
{
"platform": "all",
"audience": {
"alias": ["usr_callee"]
},
"notification": {
"alert": "收到一通语音通话",
"android": {
"title": "语音通话",
"alert": "张三邀请你语音通话",
"extras": {
"type": "call_invite",
"tenant_code": "default",
"topic": "p2p...",
"seq": "30",
"call_id": "call_01K...",
"call_type": "audio",
"caller_id": "usr_caller"
}
},
"ios": {
"alert": {
"title": "语音通话",
"body": "张三邀请你语音通话"
},
"sound": "default",
"badge": "+1",
"extras": {
"type": "call_invite",
"tenant_code": "default",
"topic": "p2p...",
"seq": "30",
"call_id": "call_01K...",
"call_type": "audio",
"caller_id": "usr_caller"
}
}
},
"options": {
"time_to_live": 60,
"apns_production": false,
"classification": 1
}
}
```
#### 0.5.5 Long Polling 与文件基础示例
##### 0.5.5.1 WebSocket 与文件基础接口
| 接口 | 输入示例 | 成功输出示例 |
| --- | --- | --- |
| `GET /v0/channels?apikey={api_key}` | WebSocket Upgrade 后首帧 `{"hi":{"id":"hi-1","ver":"0.25","ua":"ios/1.0","lang":"zh-CN","tenant":"default"}}` | `{"ctrl":{"id":"hi-1","code":201,"text":"created","params":{"tenant":{"code":"default","name":"Default Tenant"},"ver":"0.25","maxMessageSize":4194304}}}` |
| WebSocket `login basic` | `{"login":{"id":"login-1","scheme":"basic","secret":"base64(username:password)"}}` | `{"ctrl":{"id":"login-1","code":200,"params":{"user":"usr...","token":"token...","expires":"2026-09-03T09:00:00Z","authlvl":"auth"}}}` |
| WebSocket `acc new` | `{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"15500000000","secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"15500000000","resp":"verification_token","params":{"challenge_id":"chg_123"}}]}}` | `{"ctrl":{"id":"acc-1","code":200,"params":{"user":"usr...","username":"alice","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` |
| `POST /v0/channels/lp` | Long Polling 首包 `{"hi":{"id":"hi-1","ver":"0.25","tenant":"default"}}` | `{"ctrl":{"id":"hi-1","code":201,"params":{"sid":"lp_sid_123"}}}` |
| `POST /v0/file/u/` | Multipart 文件;Header `X-MeetingIM-Auth: token <base64_token>`;业务表单可带 `purpose=chat_file&topic=grp...` | `{"ctrl":{"code":200,"params":{"url":"/v0/file/s/file123","name":"a.pdf","size":1024,"mime":"application/pdf"}}}` |
| `GET /v0/file/s/{file_id}` | Header `X-MeetingIM-Auth: token <base64_token>`,可带 `Range: bytes=0-` | 对象存储模式下鉴权通过后返回 302 到短期签名 URL;客户端跟随 Location 获取文件 |
### 0.6 Topic 与产品消息
核心命令:`sub` 订阅、`pub` 发送、`get` 拉取、`set` 更新 Topic、`leave` 离开、`del` 删除、`note` 发送回执/临时状态。发送成功为 `{ctrl code:202, params:{seq}}`,服务端下行消息为 `{data}`。
所有产品消息在 `pub.head` 中携带:
```json
{
"pub": {
"id": "pub-1",
"topic": "p2p...",
"noecho": false,
"head": {
"x-im-schema": 1,
"x-im-type": "text",
"x-im-client-mid": "01K...",
"mentions": []
},
"content": {"text":"你好"}
}
}
```
`x-im-client-mid` 由客户端生成 ULID/UUID,作用域为“租户 + 发送人”,断线重试必须复用;服务端目标实现应返回第一次写入的同一 `seq`。未知 `x-im-type` 显示“暂不支持的消息”,不得崩溃或删除消息。
消息类型首版固定为:`text`、`image`、`video`、`voice`、`file`、`contact_card`、`link`、`forward_bundle`、`call`、`system_notice`、`system_action`、`system_event`。自定义表情启用后追加 `sticker`。媒体内容只保存 `file_id`、文件元数据和缩略图 ID,不保存可长期绕过鉴权的公共 URL。`system_notice` 和 `system_action` 只能由服务端生成,普通用户构造后发送必须被服务端拒绝。
### 0.7 HTTP 幂等、重试和安全
1. 创建、提交、同意、拒绝、删除、转让、收藏、转发等写请求必须携带 `Idempotency-Key`。
2. GET、断线同步和同幂等键写请求可自动重试;密码、验证码和风控确认只按接口返回的 `retry_after_seconds` 重试。
3. `401 TOKEN_EXPIRED` 不执行刷新接口,立即清理失效 Token 并返回账号密码登录;其他 `401` 也不得无限重试。`403` 不重试,`429` 按服务端退避。
4. 服务端必须在最终写入前再次校验好友关系、群角色、禁言、消息归属、文件 ACL、租户配额和风控,不能信任前端按钮状态。
5. 普通用户禁止向系统 Topic 发布;系统通知必须由服务端受控身份生成。
## 1. 账号注册、登录与身份安全
### 1.1 企业码登录.png
<img src="images/企业码登录.png" alt="企业码登录" width="360">
**状态:企业解析接口与 WebSocket 租户握手均已实现。**
**当前系统的执行方式:**
- 前端动作:输入企业码并点击“进入企业”。只去掉首尾空格并校验非空、最长 64 字符;当前服务端没有自动转小写,客户端不得擅自改变用户输入的大小写。
- 客户端从构建/环境配置读取 `host` 和普通 API Key,可先调用 `POST /v1/im/tenants/resolve` 获取企业公开信息,再使用 `wss://{host}/{api_path}v0/channels?apikey={api_key}` 建立 WebSocket。
- Upgrade 成功后第一帧发送 `{"hi":{"id":"hi-1","ver":"0.25","ua":"android/1.0.0","platf":"android","lang":"zh-CN","tenant":"用户输入的企业码"}}`。
- 服务端通过 `im_tenant.code` 查询企业;成功返回 `{ctrl code:201}` 和 `params.tenant{code,name}`,同时固定 Session 的内部 `tenant_id`。客户端保存服务端返回的标准 `tenant.code/name`,再进入 1.2 登录。
- 当前失败表现:企业码缺失/超过 64 字符返回 400,企业不存在按存储错误返回,企业停用返回 403。当前还没有统一 `TENANT_UNAVAILABLE` 产品错误码。
**企业解析接口:**
- `POST /v1/im/tenants/resolve` 未登录可调用,但必须带 `X-MeetingIM-APIKey` 和 `X-IM-Schema: 1`;Header 传 `X-Tenant-Code: acme`,Body 可传 `{"tenant_code":"acme","platform":"android","app_version":"1.0.0"}`。
- 成功数据:`tenant{code,name,tenant_desc,branding}`、`endpoints{websocket,api,file}`、`auth_methods`、`password_policy`、`app_update`。不得返回 `tenant_id` 或 `tenant_ticket`。
- 后续:保存公开企业信息,连接响应给出的 WebSocket 地址,仍然发送 0.5 节的 `hi(tenant)` 做最终租户绑定;`hi` 成功后才进入 1.2。
- 目标失败协议:企业不存在、停用或不允许登录统一返回 `TENANT_UNAVAILABLE`;客户端不得区分不存在和停用。网络失败保留输入并允许重试。
成功响应示例:
```json
{
"code": 0,
"data": {
"tenant": {"code": "acme", "name": "示例企业", "tenant_desc": null, "branding": {}},
"endpoints": {
"websocket": "wss://im.example.com/v0/channels?apikey=AQ...",
"api": "https://im.example.com/v1/im",
"file": "https://im.example.com/v0/file"
},
"auth_methods": ["password", "sms"],
"password_policy": {"min_length": 8, "max_length": 64},
"app_update": {"type": "none"}
},
"request_id": "01K...",
"server_time": "2026-08-11T08:30:15.123Z"
}
```
### 1.1.1 企业注册-登录入口.png
<img src="images/企业注册-登录入口.png" alt="企业注册-登录入口" width="360">
**状态:已实现。**
- 页面入口:企业码登录页右上角展示“企业注册”,底部展示“遇到问题?”。两个入口均为未登录入口,但必须使用客户端预置的普通 API Key,不需要用户 Token。
- 点击“企业注册”:进入 1.1.2 企业资料提交流程。客户端保留当前已输入的 `tenant_code` 作为可选线索,但不得把它当成已解析企业身份。
- 点击“遇到问题?”:进入 1.1.4 问题反馈页。若用户已输入企业码,提交反馈时带 `tenant_code_hint`;未输入时可为空。
- 埋点和日志:可记录入口点击事件,但不得记录 API Key、完整联系方式、证照图片 URL 或上传文件真实地址。
- 入口展示:入口文案必须避开系统状态栏、安全区域和键盘遮挡;网络不可用时仍允许进入填写页,提交时再提示网络错误。
### 1.1.2 企业注册-填写注册信息.png
<img src="images/企业注册-填写注册信息.png" alt="企业注册-填写注册信息" width="360">
**状态:已实现。**
- 页面步骤:展示“填写企业信息、确定信息、提交审核”三步,当前处于第 1 步。提交前客户端在本页完成格式校验,服务端仍做最终校验。
- 必填字段:`credit_code` 18 位统一社会信用代码,客户端仅去掉首尾空格并转大写展示;提交值必须匹配 `^[0-9A-Z]{18}$`。不得提交中文、空格或短码。
- 可选字段:`license_file_id` 营业执照图片文件 ID,`enterprise_email` 企业邮箱。邮箱用于接收审核结果和后续通知,填写时必须符合邮箱格式。
- 扫描识别:扫码或 OCR 只能作为填充辅助,提交前仍以输入框中的 `credit_code` 为准;识别失败不阻塞手动填写。
- 图片上传:营业执照先调用公开用途受限上传接口,`purpose=enterprise_license`,支持 `jpg/png/webp/pdf`,单文件不超过 10MB。服务端通过当前 media handler 存储对象,生产环境必须配置 `oss` handler;上传成功返回 `file_id` 后再提交申请;未上传营业执照时 `license_file_id` 可为空。
- 提交接口:`POST /v1/im/tenant-applications`,未登录可调用,必须带 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID` 和 `Idempotency-Key`。
- 请求体:
```json
{
"credit_code": "91310000MA1K000000",
"enterprise_email": "admin@example.com",
"license_file_id": "file_01K...",
"tenant_code_hint": "acme",
"client_context": {
"platform": "android",
"app_version": "1.0.0",
"device_model": "Pixel 8"
}
}
```
- 成功响应:服务端创建企业注册申请单,状态为 `received` 或 `pending_review`。客户端进入 1.1.3,不得重复提交同一个幂等键。
```json
{
"application_id": "entapp_01K...",
"status": "received",
"estimated_review_hours": 24,
"submitted_at": "2026-08-24T08:30:15.123Z"
}
```
- 失败处理:`INVALID_CREDIT_CODE` 显示统一社会信用代码格式错误;`INVALID_EMAIL` 定位邮箱输入;`FILE_UNAVAILABLE` 允许重新上传;`APPLICATION_DUPLICATED` 提示已有申请正在审核;`RATE_LIMITED` 按服务端退避时间禁用提交按钮。
### 1.1.3 企业注册-提交成功提示.png
<img src="images/企业注册-提交成功提示.png" alt="企业注册-提交成功提示" width="360">
**状态:已实现。**
- 触发条件:1.1.2 提交接口返回成功后,在当前页展示 Toast:“提交成功,我们会尽快联系你”。
- 状态保存:客户端保存 `application_id,status,submitted_at,estimated_review_hours` 到本地短期缓存,用于返回页面时展示“已提交”状态。该缓存不代表审核结果,最终状态以服务端和邮件/通知为准。
- 防重复:提交成功后禁用提交按钮;用户返回后再次进入企业注册页,可显示已提交提示并允许重新填写新申请,但必须使用新的 `Idempotency-Key`。
- 审核通知:审核结果默认通过 `enterprise_email` 触达;如果后续后台接入站内通知或短信,客户端只按服务端下发的公开状态展示,不自行推断审核通过。
- 文案规则:页面底部审核时长应使用服务端返回的 `estimated_review_hours` 或后台配置,不写死。
### 1.1.4 企业注册-问题反馈.png
<img src="images/企业注册-问题反馈.png" alt="企业注册-问题反馈" width="360">
**状态:已实现。**
- 使用场景:企业码不可用、企业注册资料提交异常、审核通知未收到、登录入口无法进入等未登录阶段问题。
- 输入字段:`description` 必填,长度 10~1000 字符;`contact` 可选,用于客服回访;`tenant_code_hint` 可选,来自用户输入;`screenshot_file_ids` 最多 9 个。
- 截图上传:截图先调用公开用途受限上传接口,`purpose=tenant_entry_feedback`,支持 `jpg/png/webp`,单张不超过 10MB。服务端通过当前 media handler 存储对象,生产环境必须配置 `oss` handler;上传失败时允许单张重试或删除。
- 提交接口:`POST /v1/im/tenant-entry-feedback`,未登录可调用,必须带 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID` 和 `Idempotency-Key`。
- 请求体:
```json
{
"description": "企业码 acme 提示不可用,无法进入企业。",
"contact": "user@example.com",
"tenant_code_hint": "acme",
"screenshot_file_ids": ["file_01K..."],
"client_context": {
"platform": "ios",
"app_version": "1.0.0",
"network": "wifi"
}
}
```
- 成功响应:客户端展示“已收到反馈,我们会尽快处理”,并清空草稿和已提交截图引用。
```json
{
"feedback_id": "entryfb_01K...",
"status": "received",
"submitted_at": "2026-08-24T08:35:15.123Z"
}
```
- 失败处理:`DESCRIPTION_REQUIRED` 或 `DESCRIPTION_TOO_LONG` 定位描述框;`TOO_MANY_FILES` 提示最多 9 张;`FILE_UNAVAILABLE` 提示重新上传失效图片;`RATE_LIMITED` 禁用提交并展示稍后重试。
### 1.2 登录页面.png
<img src="images/登录页面.png" alt="登录页面" width="360">
**状态:基本账号密码登录已实现;WS Token 可复用于产品 HTTP。**
- 前端动作:账号、密码非空且已勾选协议后启用登录;密码仅驻留当前页面内存。
- 协议:已完成 `hi` 的 WebSocket 发送 `{login scheme:"basic"}`,`secret=base64(username:password)`;Base64 不是加密,必须通过 WSS 传输,不得先登录再传企业码。
- 成功:读取 `{ctrl params.user/token/expires/authlvl}`,Token 存 Keychain/Keystore;立即 `sub me`,再加载会话。前端不解码 Token,也不再次 Base64 编码。
- 产品 HTTP:使用同一个 `params.token` 作为 `Authorization: Bearer <params.token>`;所有 `/v1/im/*` HTTP 请求仍必须带 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`,写请求再带 `Idempotency-Key`。
- Token:当前复用 MeetingIM 单一登录 Token,默认有效期 14 天,不使用 Refresh Token。有效 Token 登录成功会签发新的 14 天 Token,客户端覆盖旧值;过期后重新账号密码登录。Token 已绑定 `tenant_id/user_id/auth_version`,修改密码、封禁账号后旧 Token 应失效。
- 密码:服务端使用 `bcrypt.DefaultCost=10` 生成单向哈希,`im_auth.secret` 只保存哈希;客户端和服务端不得记录密码或完整 Basic `secret`。
- 错误:`AUTH_FAILED` 统一显示账号或密码错误;`TENANT_MISMATCH` 清 Token 并回企业码页;`ACCOUNT_DISABLED`、`REALNAME_REQUIRED`、`YOUTH_RESTRICTED` 按专用页面处理。
登录成功示例:
```json
{
"ctrl": {
"id": "login-1",
"code": 200,
"text": "ok",
"params": {
"user": "usr...",
"authlvl": "auth",
"token": "base64-opaque-token",
"expires": "2026-08-11T10:30:15.123Z"
}
}
}
```
失败仍是同一 `{ctrl}` 结构,使用 `code` 和改造后的稳定 `params.reason` 分支;客户端不得用 `text` 做程序判断。
### 1.3 登录页面-密码错误.png
<img src="images/登录页面-密码错误.png" alt="登录页面-密码错误" width="360">
**状态:已实现基础错误,错误码和限流需改造。**
- 触发:`login` 返回 `{ctrl code:401}` 或产品错误 `AUTH_FAILED`。
- 前端:保留账号、清空密码、聚焦密码框;统一显示“账号或密码错误”,不得显示账号是否存在。
- 服务端:按 `tenant + account + device/IP` 限流,连续失败返回 `AUTH_RATE_LIMITED` 和 `retry_after_seconds`;日志仅记录账号哈希和 Request ID。
- 验收:同一不存在账号与错误密码在响应码、耗时区间、前端文案上保持一致。
### 1.4 注册页面.png
<img src="images/注册页面.png" alt="注册页面" width="360">
**状态:注册原型和服务端注册分流均已完成;用户名和手机号为独立字段,服务端已接入 username、邀请码和短信开关逻辑。**
- 页面规则:用户名和手机号是独立输入。用户名非空时必须保存为 `im_users.username`;手机号非空时必须保存为规范化后的 `im_users.phone`。两个字段同时为空时拒绝注册。
- 用户名格式:普通用户名必须匹配 `^[a-z0-9]{1,10}$`,即 1~10 位小写英文字母或数字;允许纯数字,不允许大写、中文、空格、点、下划线和其他符号。前端限制输入,服务端按原始值再次校验,失败返回 `USERNAME_INVALID_FORMAT`。
- 邀请码格式:非空时必须匹配 `^[A-Za-z]{5}$`,固定 5 位大小写英文字母并区分大小写;客户端不得自动转换大小写。服务端在当前租户内按明文精确查询。
- 邀请码可复用:同一有效邀请码可以供多个用户注册,使用后仍保持 `active`;服务端为每个注册用户记录邀请来源。只有到期或管理员撤销后才不可继续使用。
- 邀请码主数据位于 HotGo 控制库并按 `tenant_id` 隔离;注册使用明细位于当前租户业务库。客户端契约不受分库影响,仍只提交完整 5 位邀请码。
- 用户名方式:手机号为空时提交用户名、密码和邀请码,不发送短信。邀请码必须有效,注册后 `im_users.username` 保存用户名,`im_users.phone=NULL`。
- 找回提示:用户名方式提交注册前,客户端明确提示“未绑定手机号时无法自助找回密码,忘记密码需联系企业管理员重置”。
- 手机号方式:前端只接受 11 位数字。调用 `POST /v1/im/auth/verification-codes`,Body `{"scene":"register","phone":"138..."}`;后端补 `+86` 后发送,成功返回 `challenge_id`、`expires_in_seconds`、`resend_after_seconds`。当前测试环境短信供应商未接入,注册、找回密码和敏感操作验证码固定填写 `123456`,用它调用 verify 接口换取一次性 `verification_token` 或 `action_token`;生产接入短信供应商后不得继续使用固定验证码。
- 短信开关开启:手机号非空时必须完成验证码验证,邀请码不能绕过短信验证;验证成功后 `username` 保存用户填写的用户名,`phone` 保存规范化的 E.164 手机号。若用户未填写用户名,则服务端随机生成 3 位小写字母 + 6 位数字的 username。
- 短信开关关闭:发码接口仍返回正常倒计时但不调用短信服务商。手机号非空时提供有效邀请码后可无验证码注册;`username` 保存用户填写的用户名,`phone` 保存规范化手机号。若用户未填写用户名,则服务端随机生成 3 位小写字母 + 6 位数字的 username。不建立已完成的 `tel credential`。
- 提交注册:`{acc}` 增加独立的 `username`、`phone` 和 `invite_code`。短信验证成功后调用 `POST /v1/im/auth/verification-codes/{challenge_id}/verify` 获得一次性 `verification_token`。`username` 非空时,`secret` 使用 `username:password`;仅手机号注册且 username 为空时,客户端仍使用规范化手机号与密码生成 `secret`,服务端创建后返回随机 username。
- 服务端判定:`username` 非空时校验 `^[a-z0-9]{1,10}$` 并要求 `secret` 登录名与 username 一致;`username` 为空且 `phone` 非空时要求 `secret` 登录名与规范化手机号一致,随后生成随机 username。普通用户名允许纯数字,因此不能只根据 `secret` 是否为 11 位数字推断注册方式。
- 账户昵称:注册创建资料时,昵称暂时默认等于最终 username。填写用户名时昵称等于该用户名;仅手机号注册时昵称等于服务端随机 username,不使用手机号作为昵称。
- 禁止客户端提交 `tags`;手机号方式始终写入 `im_users.phone`,只有短信验证成功时才建立已完成的电话凭证。
用户名方式:
```json
{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"","secret":"base64(alice:password)","invite_code":"AbCdE","login":true,"cred":[]}}
```
手机号验证方式:
```json
{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"138...","secret":"base64(alice:password)","invite_code":"","login":true,"cred":[{"meth":"tel","val":"138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}]}}
```
仅手机号注册方式:
```json
{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"","phone":"138...","secret":"base64(+86138...:password)","invite_code":"","login":true,"cred":[{"meth":"tel","val":"138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}]}}
```
- 成功:返回新 `user`、最终 `username` 和登录 Token;客户端保存 `params.token`,随后执行 `sub me` 和会话恢复。创建用户前原子校验 `im_tenant_config.max_users`,超限返回 `TENANT_USER_QUOTA_EXCEEDED`。
- 安全:用户名、手机号分别在租户内唯一;验证码票据单次消费,邀请码在有效期内可重复使用;密码按服务端策略校验并使用强哈希保存。
### 1.5 注册页面-倒计时.png
<img src="images/注册页面-倒计时.png" alt="注册页面-倒计时" width="360">
**状态:新增。**
- 倒计时以 1.4 返回的 `resend_after_seconds` 为准,并结合 `server_time` 计算,不能只依赖本地固定 60 秒。
- 倒计时内前端禁用按钮;即使绕过按钮重复调用,服务端返回 `VERIFICATION_RATE_LIMITED` 和剩余秒数。
- App 重进页面可使用内存/安全临时存储中的 `challenge_id` 恢复;超过 `expires_in_seconds` 必须重新发送,旧验证码失效。
### 1.6 忘记密码.png
<img src="images/忘记密码.png" alt="忘记密码" width="360">
**状态:新增。**
- 适用范围:仅 `im_users.phone` 非空的账号支持短信自助找回。`phone=NULL` 的用户名账号不提供自助找回,客户端提示联系当前企业管理员重置密码。
- 发码:`POST /v1/im/auth/verification-codes`,Body `{"scene":"reset_password","phone":"..."}`。无论账号是否存在都返回相同受理响应。
- 校验:`POST /v1/im/auth/password-reset/challenges/{challenge_id}/verify`,Body `{"code":"123456"}`,成功返回短时、单次使用的 `reset_token`。
- 重置:`POST /v1/im/auth/password-reset/confirm`,Body `{"reset_token":"...","new_password":"..."}`,带幂等键。
- 凭证补建:只要账号的 `phone` 非空,成功完成 `reset_password` 短信挑战即可证明当前持有该号码;即使注册时未建立 `tel credential`,本次成功后也可补建完成状态的电话凭证。
- 成功:服务端更新密码、递增 `auth_version`,使该用户全部旧登录 Token 失效并撤销在线 Session;客户端清理本租户凭证并返回登录页。
- 失败:`PASSWORD_POLICY_VIOLATION` 返回规则明细;验证码错误不返回账号存在性,达到上限后冻结 challenge。
- 管理员路径:无手机号账号只能由当前租户管理员调用 `POST /admin/v1/tenant/users/{user_id}/reset-password` 重置;不得根据用户名跨租户查找或操作。
### 1.7 身份验证(安卓).png
<img src="images/身份验证(安卓).png" alt="身份验证(安卓)" width="360">
**状态:新增。**
- 敏感操作先调用 `POST /v1/im/security/challenges`;开启青少年模式设置独立密码时 Body 为 `{"scene":"set_independent_password","channel":"sms"}`,关闭青少年模式时 Body 为 `{"scene":"close_youth_mode","channel":"sms"}`。手机号由登录身份读取,客户端不提交目标手机号。
- 发码/校验:服务端返回 `challenge_id` 和脱敏手机号;提交 `POST /v1/im/security/challenges/{id}/verify`,Body `{"code":"..."}`。
- 重复发码:同一用户、同一手机号、同一敏感场景存在未过期 challenge 且仍在 `resend_after_seconds` 冷却期内时,服务端返回 HTTP `429 VERIFICATION_RATE_LIMITED`,`details` 带 `challenge_id` 和 `retry_after_seconds`。客户端应复用该 `challenge_id` 继续倒计时,不要当成接口不可达或重新登录。
- 小时限流:超过一小时创建次数上限时同样返回 HTTP `429 VERIFICATION_RATE_LIMITED`,`details.retry_after_seconds` 可能大于 60 秒且不一定带 `challenge_id`。客户端只展示倒计时,倒计时结束后允许用户重新获取验证码。
- 成功返回 `action_token`、`action`、`expires_at`;Token 只能用于指定动作且单次消费。设置独立密码接口通过 Body `action_token` 携带;关闭青少年模式接口通过 Header `X-Action-Token` 携带。
- 错误:`VERIFICATION_RATE_LIMITED`、`CHALLENGE_EXPIRED`、`CODE_INVALID`、`TOO_MANY_ATTEMPTS`;前端不得把短信验证码或 action token 落普通存储。
### 1.8 实名认证.png
<img src="images/实名认证.png" alt="实名认证" width="360">
**状态:新增。**
- 查询:`GET /v1/im/security/realname` 返回 `status=unverified|pending|verified|rejected`、脱敏姓名/证件号和 `can_submit`。
- 提交:`POST /v1/im/security/realname/submissions`,Body `{"name":"张三","id_type":"cn_id","id_number":"..."}`,必须带幂等键。
- 成功:返回 `submission_id`、`status:"pending"`;前端禁用重复提交,当前以 `GET /v1/im/security/realname` 查询状态为准。后续如启用 `security.realname.updated`,应按 0.5.4.6 的 `info.what="im"` 业务事件下发。
- 安全:证件号传输和存储加密、展示脱敏;普通日志、搜索索引和消息中禁止出现原值;重复身份由租户策略决定并返回稳定错误。
### 1.9 实名认证-已实名.png
<img src="images/实名认证-已实名.png" alt="实名认证-已实名" width="360">
**状态:新增。**
- 页面只调用 `GET /v1/im/security/realname`。`verified` 时返回如 `masked_name:"张*"`、`masked_id_number:"110***********1234"`、`verified_at`。
- 前端只读展示,不缓存原始证件信息,不提供直接修改接口。
- 更正信息使用独立申诉 `POST /v1/im/security/realname/appeals`,需二次身份验证;不复用本页保存按钮。
### 1.10 实名认证(安卓).png
<img src="images/实名认证(安卓).png" alt="实名认证(安卓)" width="360">
**状态:新增。**
- 登录后 `GET /v1/im/bootstrap` 返回 `security.realname{status,required,allow_skip,remind_after}`;只有 `required=true` 且未认证时显示弹窗。
- 点击“去认证”进入 1.8;点击稍后仅在 `allow_skip=true` 时本地关闭,并按 `remind_after` 控制再次提示。
- 服务端对必须实名的受限写操作仍返回 `REALNAME_REQUIRED`,前端弹窗不能作为授权依据。
### 1.11 设置独立密码(安卓).png
<img src="images/设置独立密码(安卓).png" alt="设置独立密码(安卓)" width="360">
**状态:新增。**
- 设置:`POST /v1/im/settings/independent-password`,Body `{"password":"1234","purpose":"youth_mode","action_token":"..."}`;服务端只存强哈希,不返回密码。`action_token` 为一次性 token,会在设置密码时消费;设置成功后响应会返回新的 `enable_action_token`,用于下一步开启青少年模式,前端不要复用设置密码时提交的 token。
- 验证:`POST /v1/im/settings/independent-password/verify` 返回短时 `action_token`,用于关闭青少年模式或修改密码。
- 青少年模式完整开启和关闭流程见 `docs/IM-青少年模式前后端对接文档.md`。
- 规则:四位数字、禁止明显连续/重复组合;连续错误返回 `TOO_MANY_ATTEMPTS` 和 `retry_after_seconds`。前端输入框禁截屏/自动填充,离开页面立即清空。
- 忘记密码:跳转 1.7 的短信身份验证,验证后重置,不提供明文找回。
### 1.12 接口示例
以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。
#### 1.12.1 未登录、企业与账号安全
| 接口 | 输入示例 | 成功 `data` 示例 |
| --- | --- | --- |
| `POST /v1/im/tenants/resolve` | Header `X-Tenant-Code: default`;Body `{"tenant_code":"default","platform":"ios","app_version":"1.0.0"}` | `{"tenant":{"code":"default","name":"Default Tenant","tenant_desc":null,"branding":{}},"endpoints":{"websocket":"wss://api.118im.com/v0/channels?apikey=AQ...","api":"https://api.118im.com/v1/im","file":"https://api.118im.com/v0/file"},"auth_methods":["password","sms"],"password_policy":{"min_length":8,"max_length":64},"app_update":{"type":"none"}}` |
| `POST /v1/im/public/uploads` 企业注册/反馈附件上传 | Multipart `purpose=enterprise_license\|tenant_entry_feedback`、`file=@license.png` | `{"file_id":"file_01K...","purpose":"enterprise_license","mime_type":"image/png","size":1024000,"expires_at":"2026-08-25T08:30:15Z"}` |
| `POST /v1/im/tenant-applications` 企业注册申请 | `{"credit_code":"91310000MA1K000000","enterprise_email":"admin@example.com","license_file_id":"file_01K...","tenant_code_hint":"acme","client_context":{"platform":"android","app_version":"1.0.0"}}` | `{"application_id":"entapp_01K...","status":"received","estimated_review_hours":24,"submitted_at":"2026-08-24T08:30:15.123Z"}` |
| `POST /v1/im/tenant-entry-feedback` 企业入口问题反馈 | `{"description":"企业码 acme 提示不可用,无法进入企业。","contact":"user@example.com","tenant_code_hint":"acme","screenshot_file_ids":["file_01K..."],"client_context":{"platform":"ios","app_version":"1.0.0"}}` | `{"feedback_id":"entryfb_01K...","status":"received","submitted_at":"2026-08-24T08:35:15.123Z"}` |
| `POST /v1/im/auth/verification-codes` 注册发码 | `{"scene":"register","phone":"15500000000"}` | `{"challenge_id":"chg_123","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` |
| `POST /v1/im/auth/verification-codes/{challenge_id}/verify` | `{"code":"123456"}` | `{"verification_token":"verify_token_123","expires_in_seconds":300}` |
| `POST /v1/im/auth/verification-codes` 找回密码发码 | `{"scene":"reset_password","phone":"15500000000"}` | `{"challenge_id":"chg_reset_123","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` |
| `POST /v1/im/auth/password-reset/challenges/{challenge_id}/verify` | `{"code":"123456"}` | `{"reset_token":"reset_token_123","expires_in_seconds":300}` |
| `POST /v1/im/auth/password-reset/confirm` | `{"reset_token":"reset_token_123","new_password":"NewPass123"}` | `{"status":"ok"}` |
| `POST /admin/v1/tenant/users/{user_id}/reset-password` 管理后台 | `{"new_password":"AdminReset123","reason":"用户无手机号无法自助找回"}` | `{"user_id":"usr...","status":"reset","auth_version":2}` |
| `POST /v1/im/auth/logout` | `{}` | `{"status":"ok"}` |
| `GET /v1/im/app/releases/latest?platform=ios\|android&current_version=1.0.0&channel=stable` | Query only | `{"current_version":"1.0.0","has_update":true,"force_update":false,"platform":"ios","channel":"stable","latest_version":"1.1.0","min_supported_version":"1.0.0","update_type":"recommended","package_url":"","store_url":"https://apps.apple.com/app/id...","release_notes":"修复已知问题。","remind_after_seconds":86400}` |
## 2. 消息首页、全局导航与系统通知
### 2.1 消息.png
<img src="images/消息.png" alt="消息" width="360">
**状态:会话订阅已实现;产品摘要和系统入口需改造。**
- 登录后发送 `sub me`,再通过 `get` 拉取 `sub` 元数据;每项使用 `topic`、`public`、`private`、`updated`、`seq/read/recv` 计算名称、摘要和未读数。
- 消息增量通过各 Topic 的 `{data}` 更新;未读数为 `max(seq-read,0)`,回执以服务端游标为准,不能只用本地计数。
- `GET /v1/im/bootstrap` 返回固定入口、功能开关、权限、系统 Topic、`entry_badges` 和 `entries`;搜索调用 `GET /v1/im/search/conversations?q=&cursor=`。
- 本地缓存按 `tenant_code + user_id` 隔离。切租户、退出或账号变化时不得复用另一身份的会话摘要。
### 2.2 消息-无网络状态.png
<img src="images/消息-无网络状态.png" alt="消息-无网络状态" width="360">
**状态:需改造客户端恢复流程和消息幂等。**
- 纯网络状态由客户端监听;断网时展示缓存并禁止需要在线确认的写操作,未发送消息进入本地 `pending` 队列。
- 使用指数退避加随机抖动重连;恢复后严格执行 `hi -> token login -> sub me -> 恢复 Topic -> get data`。
- 每个 Topic 按最后持久化的 `seq/read/recv` 增量拉取。重发消息复用 `x-im-client-mid`,服务端需实现去重并返回原 `seq`。
- Token 失效转登录页;租户停用返回企业不可用页;禁止无限快速重连。
### 2.3 消息-更多.png
<img src="images/消息-更多.png" alt="消息-更多" width="360">
**状态:菜单为纯客户端;权限数据新增。**
- 菜单打开/关闭不调用服务端。菜单项来自登录后 `GET /v1/im/bootstrap` 的 `features` 与 `permissions`。
- `create_group=false` 时隐藏“创建群聊”;`user_search=false` 时隐藏“添加好友”;扫码权限只影响实际扫码,不影响服务端授权。
- 各入口协议分别见 5.3、6.1、10.1;服务端在实际写接口再次鉴权,不能只依赖隐藏按钮。
### 2.4 消息页面—长按弹出.png
<img src="images/消息页面—长按弹出.png" alt="消息页面—长按弹出" width="360">
**状态:基础消息操作部分已实现;产品权限矩阵需改造。**
- 长按本身不请求。客户端根据消息的 `type/from/seq/created_at/status`、当前 Topic 权限和服务端下发的 `message_policy` 预裁剪菜单。
- 引用/复制为本地动作;收藏见 4.16;转发见 4.17~4.20;删除使用 MeetingIM `{del what:"msg",topic,delseq}`;撤回需新增 `POST /v1/im/topics/{topic}/messages/{seq}/recall`。
- 编辑使用 `POST /v1/im/topics/{topic}/messages/{seq}/revisions`,Body `{"content":...,"client_mid":"..."}`;仅文本且在时限内允许。
- 群回执调用 `GET /v1/im/topics/{topic}/messages/{seq}/receipts?cursor=`。所有最终操作由服务端验证消息归属、时限、群角色和租户。
### 2.5 系统消息.png
<img src="images/系统消息.png" alt="系统消息" width="360">
**状态:需改造。**
- `GET /v1/im/system-notifications?cursor=` 返回 `id,event_type,title,summary,created_at,read_at,action`;实时刷新以 0.5.4.6 的 WS `{info what:"im|group"}` 业务事件为准,系统通知列表记录不等同于每一条实时事件。
- 新设备登录事件内容为 `device_name,platform,ip_masked,login_at,security_action`;IP 只下发必要脱敏值。
- 已读:`POST /v1/im/system-notifications/{id}/read`,带幂等键;跳转目标只接受服务端白名单 `action.type` 和内部参数。
- 当前普通认证用户可向 `sys` Topic 发布的路径必须封禁。客户端不得渲染普通用户伪造的系统卡片为可信通知。
### 2.6 接口示例
以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。
#### 2.6.1 登录后启动、消息首页与系统通知
| 接口 | 输入示例 | 成功 `data` 示例 |
| --- | --- | --- |
| `GET /v1/im/bootstrap` | Query only | `{"features":{"create_group":true,"user_search":true,"scan_qr":true,"system_notifications":true,"message_recall":true,"message_edit":true,"audio_call":true,"video_call":true},"permissions":{"create_group":true,"user_search":true,"scan_qr":true,"audio_call":true,"video_call":true},"entries":{"new_friends":{"enabled":true,"route":"/contacts/new-friends","unread_count":0},"groups":{"enabled":true,"route":"/contacts/groups","unread_count":0},"file_assistant":{"enabled":true,"route":"/chat/file-assistant","topic":"slf","unread_count":0},"system_messages":{"enabled":true,"route":"/chat/system","topic":"sys","unread_count":0}},"entry_badges":{"new_friends_pending_count":0,"groups_unread_count":0,"file_assistant_unread_count":0,"system_unread_count":0,"workbench_total_unread":0},"system_topic":{"topic":"sys","type":"system"},"message_policy":{"recall_window_seconds":120,"edit_window_seconds":300,"supported_actions":["copy","quote","delete","recall","edit"]},"security":{"realname":{"status":"unverified","required":false,"allow_skip":true,"remind_after":"24h"}}}` |
| `GET /v1/im/search/conversations?q=&cursor=` | Query only | `{"items":[{"topic":"grp...","public":{"name":"项目群"},"private":{"pinned":false,"mute_notifications":false},"seq":12,"read":10,"recv":12,"unread":2,"updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` |
| `GET /v1/im/system-notifications?cursor=&limit=20` | Query only | `{"items":[{"id":"evt_1","event_type":"friend.request.created","title":"新的好友申请","summary":"张三请求添加你为好友","action":{"type":"friend_request","request_id":"frq_1"},"read_at":null,"created_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` |
| `POST /v1/im/system-notifications/{id}/read` | `{"read_at":"2026-08-20T09:00:00Z"}` | `{"status":"ok","id":"evt_1","read_at":"2026-08-20T09:00:00Z"}` |
## 3. 单聊会话、会话建立与消息状态
### 3.1 个人对话框(对方的状态).png
<img src="images/个人对话框(对方的状态).png" alt="个人对话框(对方的状态)" width="360">
**状态:需改造好友关系状态。**
- 进入会话先 `GET /v1/im/conversations/{topic}/context`,返回 `relationship=pending_inbound`、`can_send=false`、对方公开资料和申请 ID。
- 历史申请提示由 `relationship_event` 系统消息渲染;普通消息仍通过 Topic `{data}` 下行。
- 输入区锁定;“通过/拒绝”调用 5.2 的好友申请接口。前端不可通过直接 `pub` 绕过,服务端发布路径必须校验好友关系。
### 3.2 个人对话框(对方的状态)(1).png
<img src="images/个人对话框(对方的状态)(1).png" alt="个人对话框(对方的状态)(1)" width="360">
**状态:在线状态已具备基础能力;关系文案新增。**
- 订阅 P2P Topic 后用 `{pres}` 更新在线/离线,`last_seen` 仅在对方隐私允许时展示。
- `GET /v1/im/conversations/{topic}/context` 返回 `display_name,avatar_file_id,online_visibility,relationship`。
- 好友关系变化由服务端生成 0.5.4.6 定义的 WS 业务事件,客户端不得根据本地动作自行永久插入系统通知记录。
### 3.3 个人对话框(我的状态).png
<img src="images/个人对话框(我的状态).png" alt="个人对话框(我的状态)" width="360">
**状态:新增好友申请状态。**
- 发起申请使用 `POST /v1/im/friend-requests`,Body `{"target_user_id":"usr...","message":"我是...","source":"search"}`。
- 返回 `request_id,status:"pending",created_at`;页面以该服务端状态显示“已发送”,重复点击用同一幂等键返回原申请。
- 申请待处理时 `context.relationship=pending_outbound`、`can_send=false`,禁止重复创建和普通消息发送。
### 3.4 个人对话框(我的状态)(1).png
<img src="images/个人对话框(我的状态)(1).png" alt="个人对话框(我的状态)(1)" width="360">
**状态:发送和读回执已实现基础能力;送达状态映射需统一。**
- 本地生成 `client_mid` 后显示 `sending`;`pub` 收到 `202 + seq` 更新为 `sent`;失败保留同一 `client_mid` 重试。
- 对方 `note what:"recv"` 更新 `delivered`,`note what:"read"` 更新 `read`;多设备取服务端最大游标。
- 状态只附着在气泡元数据,不修改消息正文。服务端拒绝时映射为 `failed(reason)`,不得伪装成已发送。
### 3.5 开启沟通页面.png
<img src="images/开启沟通页面.png" alt="开启沟通页面" width="360">
**状态:新增好友审批与 Topic 联动。**
- 好友申请通过后,服务端在同一事务内建立双方关系和 P2P 订阅,并向申请人、通过人本人下发 `info.what="im", event="friend.request.accepted"`;申请人当前会写入系统通知,通过人本人当前只收到实时业务事件。
- 客户端收到事件或重新查询 `context.relationship=friend` 后解锁输入区;不得仅因本地“通过”按钮成功动画解锁。
- 首次进入订阅 P2P Topic,再按 `since` 拉取申请上下文和正常消息。
### 3.6 双方沟通状态.png
<img src="images/双方沟通状态.png" alt="双方沟通状态" width="360">
**状态:上传基础已实现;媒体消息 Schema、ACL 和恢复需改造。**
- 选择媒体后本地生成 `client_mid` 和稳定占位;调用文件上传,得到 `file_id` 后再 `pub x-im-type=image|video|voice`。
- `content` 至少含 `file_id,name,size,mime,width,height,duration_ms,thumbnail_file_id` 中适用字段;上传进度是客户端任务状态,不写成聊天消息。
- 服务端发布成功后关联文件与 Topic/消息;文件下载必须校验消息可见性。上传失败只重试上传,成功但 `pub` 失败时复用已上传 `file_id` 和 `client_mid`。
### 3.7 个人对话—拉黑状态/已读状态.png
<img src="images/个人对话—拉黑状态/已读状态.png" alt="个人对话—拉黑状态/已读状态" width="360">
**状态:黑名单产品规则新增。**
- 拉黑后 `context.relationship=blocked_by_me|blocked_by_peer`、`can_send=false`;服务端 `pub` 返回统一 `MESSAGE_NOT_ALLOWED`,不得向发送者泄露具体是对方拉黑。
- 失败消息只保存在发送端本地并显示失败,不下发给对方、不写入正常消息表。
- 已读仍按历史最大 `read` 渲染;拉黑不能回滚既有回执。解除拉黑见 5.10、5.12。
### 3.8 个人对话—删除状态.png
<img src="images/个人对话—删除状态.png" alt="个人对话—删除状态" width="360">
**状态:新增好友删除后的发送约束。**
- 删除好友使用 `DELETE /v1/im/friends/{user_id}`;成功后双方关系变为 `none`,服务端推送关系变化事件。
- 后续 `pub` 返回 `FRIEND_RELATION_REQUIRED`;前端保留失败草稿,提供“重新添加好友”入口,不自动发申请。
- 历史聊天是否保留由 Topic 权限决定,删除好友不等于删除双方历史消息。
### 3.9 个人对话-键盘收起状态.png
<img src="images/个人对话-键盘收起状态.png" alt="个人对话-键盘收起状态" width="360">
**状态:纯客户端。**
- 键盘、表情和附加面板收起不调用服务端;保持列表锚点和未发送草稿。
- 草稿默认按 `tenant + user + topic` 本地加密保存;若实现多端草稿,再单独调用 `PUT /v1/im/topics/{topic}/draft`,不得混入聊天消息。
### 3.10 个人对话-发各种文件状态.png
<img src="images/个人对话-发各种文件状态.png" alt="个人对话-发各种文件状态" width="360">
**状态:面板纯客户端;上传需改造。**
- 打开面板不请求。选择前依据 `context.capabilities` 判断图片、视频、语音、文件是否允许,调用系统选择器/相机。
- 上传前校验 `mime,size` 和 `bootstrap.limits.max_file_size`;服务端仍必须重新校验租户配额、文件类型和会话权限。
- 具体上传/发消息使用 3.6 协议;相机和麦克风授权见 10.20、10.21。
### 3.11 个人对话-发各种文件状态(1).png
<img src="images/个人对话-发各种文件状态(1).png" alt="个人对话-发各种文件状态(1)" width="360">
**状态:需改造产品消息 Schema。**
- 客户端按 `x-im-type` 选择文本、名片、文件等卡片,公共字段统一使用 `from,topic,seq,ts,head,content`。
- 每种卡片必须实现未知字段忽略、未知类型占位、发送/失败/回执状态和长按动作。
- 重新安装或换设备后仅凭服务端消息即可还原卡片,不能依赖发送端本地路径或临时 URL。
### 3.12 个人对话-发送名片状态.png
<img src="images/个人对话-发送名片状态.png" alt="个人对话-发送名片状态" width="360">
**状态:新增产品消息类型。**
- 选人:`GET /v1/im/friends?query=&cursor=`;选择后发送 `x-im-type:"contact_card"`。
- `content` 为 `{"user_id":"usr...","display_name":"张三","avatar_file_id":"...","public_account":"A1024"}`;服务端按目标用户当前公开资料生成/校正快照,禁止手机号、证件号等敏感字段。
- 点击卡片调用 `GET /v1/im/users/{identifier}/profile`;`identifier` 可传 `user_id`、`username` 或 `public_account`,用户不可见或已离职时显示失效卡片,不跨租户查询。
## 4. 消息输入、媒体内容与消息操作
### 4.1 键盘展开.png
<img src="images/键盘展开.png" alt="键盘展开" width="360">
**状态:纯客户端。**
- 键盘展开、列表上推和光标变化不调用服务端;草稿按 `tenant + user + topic` 保存。
- 点击发送时才校验 `context.can_send` 并执行 `pub text`;正文去首尾策略、最大长度从 `bootstrap.limits.max_text_length` 获取,服务端再次校验。
### 4.2 @人员—选择提醒人.png
<img src="images/@人员—选择提醒人.png" alt="@人员—选择提醒人" width="360">
**状态:新增群成员查询。**
- 输入 `@` 后调用 `GET /v1/im/groups/{topic}/members?query=&cursor=&mentionable=true`,返回 `user_id,display_name,avatar_file_id,role,mentionable`。
- `@所有人` 是虚拟项,只有响应 `permissions.can_mention_all=true` 时展示,不以特殊用户 ID 表示。
- 搜索做 300ms 防抖并取消旧请求;服务端只返回当前租户且仍在群内的成员。
### 4.3 @人员—完成.png
<img src="images/@人员—完成.png" alt="@人员—完成" width="360">
**状态:纯客户端选择;发送协议需改造。**
- 完成后在编辑器插入带范围的 Mention 节点,内部保存 `user_id`,显示昵称只是快照。
- 发送时 `pub.head.mentions=[{"user_id":"usr...","offset":0,"length":3}]`;`@所有人` 使用 `mention_all:true`,不得伪造用户 ID。
- 服务端验证成员身份和 `mention_all` 权限;无效成员可删除提醒语义但保留可读文本,越权 `@所有人` 返回 `MENTION_ALL_FORBIDDEN`。
### 4.4 @人员状态.png
<img src="images/@人员状态.png" alt="@人员状态" width="360">
**状态:需改造提醒和 Push。**
- 消息仍通过 `{data}` 下行,客户端使用结构化 `mentions` 高亮,禁止通过字符串扫描昵称判断。
- 当前用户被提及时会话摘要标记 `mentioned=true`;读到对应 `seq` 后随 `note read` 清除。
- Push 服务按结构化目标生成“有人@你”文案;已退群用户不能收到新提醒,但历史消息仍按快照渲染。
### 4.5 表情包展开(发送按钮默认状态).png
<img src="images/表情包展开(发送按钮默认状态).png" alt="表情包展开(发送按钮默认状态)" width="360">
**状态:纯客户端。**
- 面板、分类、最近使用和删除键均本地处理;未选择内容且文本为空时禁用发送。
- Unicode 表情作为普通 `text` 内容发送。自定义表情资源首版未定义,不得把本地资源路径直接发送。
### 4.6 表情包发送页面.png
<img src="images/表情包发送页面.png" alt="表情包发送页面" width="360">
**状态:Unicode 表情使用已实现文本消息;独立表情包新增。**
- 文本表情按 `pub x-im-type:text` 发送。独立自定义表情若启用,使用 `x-im-type:"sticker"`,`content={pack_id,sticker_id,file_id,width,height}`。
- 自定义表情发送前服务端校验表情包属于当前租户且可用;未知表情显示占位,不使用任意外链 URL。
### 4.7 语音—按住说话.png
<img src="images/语音—按住说话.png" alt="语音—按住说话" width="360">
**状态:录音纯客户端;上传和语音消息需改造。**
- 按下前检查麦克风权限和 `context.capabilities.voice`;权限不足进入 10.21,不发请求。
- 松开发送:编码为服务端允许的 MIME,校验最短/最长时长,上传后 `pub x-im-type:voice`,内容含 `file_id,duration_ms,size,mime,waveform`。
- 上滑取消、过短、系统来电打断时删除临时文件,不调用上传接口。
### 4.8 语音按住说话.png
<img src="images/语音按住说话.png" alt="语音按住说话" width="360">
**状态:纯客户端。**
- 音量幅度、上滑取消区域和浮层颜色来自本地录音会话,不上传实时音量。
- 进入取消区只改变本地状态;最终抬手决定删除或执行 4.7 的上传发送。
### 4.9 内容沟通对话3-语音录音中.png
<img src="images/内容沟通对话3-语音录音中.png" alt="内容沟通对话3-语音录音中" width="360">
**状态:录音纯客户端;临时状态可选改造。**
- 录音期间客户端锁定其他媒体入口。默认不向群成员广播“正在录音”,以减少隐私暴露和状态风暴。
- 若产品确认需要,可发送不落库的 `{note what:"kpa",topic}`;服务端限频,其他端超时自动消失,不能作为聊天记录。
### 4.10 内容沟通对话3-图片和语音发送中.png
<img src="images/内容沟通对话3-图片和语音发送中.png" alt="内容沟通对话3-图片和语音发送中" width="360">
**状态:客户端任务管理新增;服务端上传需改造。**
- 每个待发送项保存 `client_mid,local_uri,type,upload_id,progress,state`;同一文件可分片/断点续传时使用稳定 `upload_id`。
- 上传完成后再发布消息;多任务可并发但必须各自保持原排序号。离开页面不取消,退出账号必须暂停并隔离任务。
- 服务端返回 `FILE_TOO_LARGE`、`FILE_TYPE_NOT_ALLOWED`、`STORAGE_QUOTA_EXCEEDED` 时不可自动无限重试。
### 4.11 内容沟通对话3-语音/视频/图片状态.png
<img src="images/内容沟通对话3-语音/视频/图片状态.png" alt="内容沟通对话3-语音/视频/图片状态" width="360">
**状态:需改造媒体 Schema 和受权下载。**
- 语音渲染 `duration_ms,waveform`,未听状态保存在客户端并可选跨端同步;视频渲染 `thumbnail_file_id,duration_ms,width,height`;图片渲染缩略图和原图 ID。
- 预览前使用认证文件接口获取,服务端校验当前用户仍可访问引用消息;附件撤回或过期返回 `FILE_UNAVAILABLE`。
- 缩略图和原文件分别授权,客户端缓存键必须包含租户与用户。
### 4.12 语音图片发送状态.png
<img src="images/语音图片发送状态.png" alt="语音图片发送状态" width="360">
**状态:需改造。**
- 图片选择后立即显示本地缩略图,上传成功后只替换资源来源,不改变 `client_mid` 或列表位置。
- 失败展示重试按钮;重试复用同一上传任务和消息幂等 ID。语音播放与图片上传状态相互独立。
### 4.13 上传文件.png
<img src="images/上传文件.png" alt="上传文件" width="360">
**状态:纯客户端视觉资源。**
- 该图片仅定义统一上传图标和无障碍名称“上传文件”,不产生网络请求。
- 文件助手、群文件、会话附件、反馈证据实际上传时统一走 0.2 文件接口,并额外提交业务 `purpose` 以便服务端执行权限和保留期策略。
### 4.14 文件发送状态.png
<img src="images/文件发送状态.png" alt="文件发送状态" width="360">
**状态:上传已实现基础能力;消息引用和 ACL 需改造。**
- 上传成功后发送 `x-im-type:file`,`content={file_id,name,size,mime,extension,checksum}`;文件名做长度和控制字符清理。
- 服务端在消息落库事务中关联文件;未关联的临时上传按 TTL 清理。卡片下载见 7.9~7.11。
- 多文件逐个生成 `client_mid`,某个失败不能改变其他文件状态。
### 4.15 多选-选中对话框.png
<img src="images/多选-选中对话框.png" alt="多选-选中对话框" width="360">
**状态:选择纯客户端;批量动作新增。**
- 客户端按 `(topic,seq)` 保存已选项并显示数量;默认最多 100 条,超限在本地阻止。
- 转发、收藏、删除前把选中列表提交对应接口;服务端逐条校验仍可见、类型支持和权限,返回 `succeeded[]` 与 `failed[{seq,reason}]`。
- 不允许只因第一条可操作就假定整批可操作。
### 4.16 多选-选中对话内容-收藏成功.png
<img src="images/多选-选中对话内容-收藏成功.png" alt="多选-选中对话内容-收藏成功" width="360">
**状态:新增。**
- `POST /v1/im/favorites/batch`,Body `{"messages":[{"topic":"grp...","seq":"123"}]}`,带幂等键。
- 返回 `created_count,existing_count,failed[]`;同一用户对同一 `topic+seq` 重复收藏不新增记录。
- 收藏保存原消息引用、不可变展示快照和附件授权关系;成功 Toast 只在至少一项成功时展示,部分失败需给出汇总。
### 4.17 转发-单选.png
<img src="images/转发-单选.png" alt="转发-单选" width="360">
**状态:新增。**
- 目标列表:`GET /v1/im/forward-targets?query=&cursor=`,只返回 `can_receive_forward=true` 的会话和文件助手。
- 选中后本地进入确认页,不立即发送;服务端在最终转发接口再次校验目标 Topic、原消息和附件权限。P2P 目标可以传转发目标列表返回的 `topic`,也可以传对方 `usr...` 用户 ID,服务端会归一化为当前用户与对方的 P2P topic。
### 4.18 转发-多选.png
<img src="images/转发-多选.png" alt="转发-多选" width="360">
**状态:新增。**
- 目标查询同 4.17;多选最多取 `bootstrap.limits.max_forward_targets`,首版建议 9。
- 确认前只保存目标 Topic 字符串或对方 `usr...` 用户 ID;不得缓存或提交 `tenant_id`。跨租户 Topic 在服务端统一拒绝。
### 4.19 转发—逐条转发.png
<img src="images/转发—逐条转发.png" alt="转发—逐条转发" width="360">
**状态:新增。**
- `POST /v1/im/messages/forward`,Body `{"mode":"separate","sources":[{"topic":"p2p...","seq":12}],"target_topics":["grp..."],"comment":"..."}`。`seq` 兼容数字和字符串;P2P 源会话也可以传对方 `usr...` 用户 ID,服务端会归一化为 P2P topic。
- 返回每个目标和源消息的 `client_mid/seq/status`;`status` 取值为 `sent`、`pending`、`failed`。`pending` 表示服务端已分配 `seq/client_mid`,但 Kafka 投递结果暂不确定,客户端应按“发送中”展示,保留同一个 `Idempotency-Key` 重试或等待后续消息同步,不要立即生成第二条本地消息。服务端保持源顺序,重新生成目标消息,不能复用原 `seq`。
- 附件必须建立新的授权引用;原消息已撤回、过期或不可见时该项失败。留言作为每个目标的一条独立文本消息发送,返回项可带 `comment_client_mid/comment_seq/comment_status/comment_reason`;转发消息本身仍保持原 `head/content`,不得把留言包装进图片、文件等原消息 `content`。
### 4.20 转发—合并转发—多选状态.png
<img src="images/转发—合并转发—多选状态.png" alt="转发—合并转发—多选状态" width="360">
**状态:新增。**
- 调用 4.19 接口,`mode:"bundle"`;服务端生成一条 `forward_bundle` 目标消息,内容含 `bundle_id,title,item_count,preview[],items[],source_refs[]`。首版 Bundle 明细以内联快照随消息下发,后续如消息量增大再扩展 `GET /v1/im/forward-bundles/{bundle_id}?cursor=` 分页详情。
- `items[]` 和 `preview[]` 保留 `from` 原发送人 ID,并新增 `from_user` 用户摘要:`{"user_id":"usr...","display_name":"张三","avatar_file_id":"file_avatar"}`。头像为空时可缺省;若资料查询失败,服务端至少返回 `user_id/display_name` 兜底。
- 快照保留显示名、时间和内容摘要,对撤回/敏感字段按策略脱敏;不得允许接收端利用 Bundle 获取原会话额外权限。
- 多目标共享不可变 Bundle 内容但分别生成目标消息与授权记录。
### 4.21 会话与消息接口示例
以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。
#### 4.21.1 会话上下文、消息操作与检索
| 接口 | 输入示例 | 成功 `data` 示例 |
| --- | --- | --- |
| `GET /v1/im/conversations/{topic}/context` | Path `p2p...` 或 `grp...` | `{"topic":"p2p...","type":"p2p","relationship":"friend","can_send":true,"capabilities":{"text":true,"image":true,"file":true,"voice":true,"can_call":true},"peer":{"user_id":"usr...","display_name":"张三","avatar_file_id":""}}` |
| `POST /v1/im/conversations/p2p` 待实现 | `{"peer_user_id":"usr..."}` | `{"topic":"p2p...","relationship":"friend","can_send":true}` |
| `GET /v1/im/conversations/{topic}/settings` 待实现 | Path topic | `{"pinned":false,"mute_notifications":false,"message_retention":"default","blocked":false,"remark":"","version":1,"permissions":{"can_update":true}}` |
| `PATCH /v1/im/conversations/{topic}/settings` 待实现 | `{"pinned":true,"mute_notifications":false,"version":1}` | `{"pinned":true,"mute_notifications":false,"message_retention":"default","blocked":false,"remark":"","version":2}` |
| `GET /v1/im/conversations/{topic}/messages` 待实现 | Query `?since=12&limit=20` | `{"topic":"p2p...","items":[{"seq":13,"from":"usr...","ts":"2026-08-20T09:00:00Z","head":{"x-im-type":"text"},"content":{"text":"你好"}}],"next_cursor":""}` |
| `PUT /v1/im/topics/{topic}/draft` 待实现 | `{"content":"未发送草稿","version":1}` | `{"topic":"grp...","content":"未发送草稿","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/topics/{topic}/messages/{seq}/recall` | `{"version":1,"reason":"mistake"}` | `{"topic":"grp...","seq":12,"status":"recalled","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/topics/{topic}/messages/{seq}/revisions` | `{"content":{"text":"修改后的文本"},"client_mid":"mid_1","version":1}` | `{"topic":"grp...","seq":12,"status":"edited","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/topics/{topic}/messages/{seq}/receipts?cursor=` | Query only | `{"topic":"grp...","seq":12,"items":[{"user_id":"usr...","read_at":"2026-08-20T09:00:00Z","recv_at":"2026-08-20T08:59:00Z"}],"next_cursor":""}` |
| `GET /v1/im/topics/{topic}/messages/search?q=&type=text&cursor=` | Query only | `{"topic":"grp...","items":[{"seq":12,"from":"usr...","ts":"2026-08-20T09:00:00Z","snippet":"命中的文本","matches":[{"start":0,"end":2}]}],"next_cursor":""}` |
| `GET /v1/im/topics/{topic}/messages/search?type=file&q=&cursor=` | Query only | `{"topic":"grp...","items":[{"seq":12,"from":"usr...","ts":"2026-08-20T09:00:00Z","file":{"file_id":"file_1","name":"方案.pdf","size":1024,"mime":"application/pdf"},"availability":"available"}],"next_cursor":""}` |
| `GET /v1/im/topics/{topic}/messages/{seq}/context?before=20&after=20` | Query only | `{"topic":"grp...","anchor_seq":12,"items":[{"seq":11,"content":{"text":"上一条"}},{"seq":12,"content":{"text":"命中消息"}}]}` |
| `GET /v1/im/topics/{topic}/media?type=image&cursor=` | Query only | `{"topic":"grp...","items":[{"seq":12,"thumbnail_file_id":"file_thumb","file_id":"file_img","width":1080,"height":720,"ts":"2026-08-20T09:00:00Z","availability":"available"}],"next_cursor":""}` |
消息撤回和编辑成功后,Topic 在线成员会收到 0.5.4.6 定义的 `info.what="im"` 业务事件:`message.recalled` 或 `message.edited`。前端收到后按 `topic + seq + version` 刷新本地消息,不要等待用户重新进入会话才更新。
## 5. 通讯录、好友关系与黑名单
### 5.1 通讯录.png
<img src="images/通讯录.png" alt="通讯录" width="360">
**状态:新增产品通讯录接口。**
- 好友列表:`GET /v1/im/friends?query=&initial=&cursor=`,返回 `user_id,display_name,remark,avatar_file_id,initial,online,online_visibility`;好友展示名统一读取 `display_name`,服务端按当前登录人计算,优先级为备注名、昵称、账号。`remark` 仅用于备注编辑页回显,`nickname` 表示对方自己设置的昵称,不应直接作为好友列表或单聊标题的最终展示名。
- 分页:`next_cursor` 当前按最后一条好友的 `peer_id` 生成,客户端必须作为不透明字符串原样回传,不要自行解析、排序或拼接。
- 组织成员:`GET /v1/im/organization/members?query=&department_id=&cursor=`;好友和组织成员是两种关系,前端不得混为一个可发消息权限。
- 新朋友、群列表、文件助手和系统消息入口由 `GET /v1/im/bootstrap` 的 `entries` 控制显示,初始红点由 `entry_badges` 和各入口 `unread_count` 返回。新朋友待处理数使用业务统计;文件助手当前返回 `topic:"slf", unread_source:"topic"`;系统消息当前返回 `topic:"sys", unread_source:"business"`,未读来自系统通知业务表。列表缓存以租户和用户隔离。
### 5.2 新朋友.png
<img src="images/新朋友.png" alt="新朋友" width="360">
**状态:新增。**
- 列表:`GET /v1/im/friend-requests?direction=inbound&status=all&cursor=`,返回 `request_id,applicant,message,source,status,created_at,handled_at`。
- 同意:`POST /v1/im/friend-requests/{request_id}/accept`;拒绝:`POST /v1/im/friend-requests/{request_id}/reject`,均带幂等键和可选 `version`。
- 成功返回最终 `status` 和 P2P `topic`。重复审批返回原结果;并发冲突返回 `REQUEST_ALREADY_HANDLED` 并附当前状态,前端刷新该项。
- 主动下行:创建申请时被申请人收到 `info.what="im", event="friend.request.created"`;通过/拒绝后申请人与处理人本人都收到 `friend.request.accepted/rejected`。前端收到后刷新新朋友列表、好友列表、会话 context 和 `bootstrap.entry_badges`。当前系统通知列表只给被申请人/申请人写记录,处理人本人主要靠实时业务事件刷新。
### 5.3 添加好友.png
<img src="images/添加好友.png" alt="添加好友" width="360">
**状态:新增。**
- 账号搜索:`GET /v1/im/users/search?q=&type=account&cursor=`;扫码后调用 `POST /v1/im/qrcodes/resolve`,Body `{"token":"...","purpose":"add_friend"}`。
- 结果统一返回 `relationship=none|friend|pending_inbound|pending_outbound` 和 `can_add`;隐私不允许或未找到统一为 `USER_NOT_DISCOVERABLE`。
- 分享/二维码见 5.4;发申请见 5.6。服务端按租户配置 `allow_user_search` 和用户隐私再次拦截。
### 5.4 添加好友(1).png
<img src="images/添加好友(1).png" alt="添加好友(1)" width="360">
**状态:新增。**
- 获取二维码:`POST /v1/im/me/qrcodes`,Body `{"purpose":"add_friend","expires_in_seconds":86400}`,返回 `qr_token,qr_payload,expires_at,display_profile`。
- 二维码只含不透明、签名、带租户和用途约束的短期 Token,不直接放手机号、用户 ID 或登录 Token。
- 分享/保存图片是客户端动作;扫码解析始终调用 5.3,过期返回 `QR_CODE_EXPIRED`,篡改返回 `QR_CODE_INVALID`。
### 5.5 添加好友-输入精准用户ID.png
<img src="images/添加好友-输入精准用户ID.png" alt="添加好友-输入精准用户ID" width="360">
**状态:新增。**
- 精准查询仍调用 `GET /v1/im/users/search?q=&type=exact_account`;手机号查询仅当租户和目标隐私同时允许时使用 `type=phone`。
- 每项返回公开资料和 `action=add|chat|pending|none`。前端不得根据是否返回手机号推断账户存在。
- 搜索限流返回 `SEARCH_RATE_LIMITED`;禁止客户端批量枚举 ID。
### 5.6 添加好友验证.png
<img src="images/添加好友验证.png" alt="添加好友验证" width="360">
**状态:新增。**
- `POST /v1/im/friend-requests`,Body `{"target_user_id":"usr...","message":"验证留言","source":"search|qr|group","source_topic":"grp..."}`。
- 留言最多 50 个 Unicode 字符,服务端做敏感内容审核;`source_topic` 只能是双方当前可见的同租户群。
- 成功返回 `request_id,status:"pending"`;重复有效申请返回原记录,目标隐私关闭时返回统一 `FRIEND_REQUEST_NOT_ALLOWED`。
### 5.7 好友个人主页.png
<img src="images/好友个人主页.png" alt="好友个人主页" width="360">
**状态:新增。**
- `GET /v1/im/users/{identifier}/profile` 返回按当前查看人裁剪后的 `display_name,remark,avatar_file_id,gender,region,public_account,signature,relationship,capabilities`;`identifier` 可传 `user_id`、`username` 或 `public_account`。个人资料页头部和单聊标题统一使用 `display_name`,需要展示对方原始昵称时再使用 `nickname`。
- 查看该用户二维码调用 `POST /v1/im/users/{identifier}/qrcodes`,Body `{"purpose":"add_friend","expires_in_seconds":86400}`;前端在个人信息页只有用户名时直接把用户名作为 `identifier` 传入即可。服务端解析到真实用户后生成二维码,并按资料可见性与 `add_me_methods.qr` 做权限校验。
- 发消息使用返回的 P2P `topic`;没有 Topic 时调用 `POST /v1/im/conversations/p2p`,Body `{"peer_user_id":"..."}` 并由服务端验证好友关系。
- 音视频按钮只按 `capabilities.can_call` 展示,真正呼叫时再次鉴权。隐藏字段必须从响应中省略,不能只由前端遮挡。
### 5.8 群聊—非好友个人主页.png
<img src="images/群聊—非好友个人主页.png" alt="群聊—非好友个人主页" width="360">
**状态:新增。**
- `GET /v1/im/groups/{topic}/members/{user_id}/profile` 返回群内昵称、角色和允许公开的账号资料。`{topic}` 必须是群聊 topic(`grp...`);单聊、名片或普通用户资料页使用 `GET /v1/im/users/{identifier}/profile`,不要把 `usr...` 用户 ID 放到 groups 路径。
- `can_add_friend` 同时受群 `allow_member_add_friend`、目标隐私和租户策略控制;为 false 时隐藏添加入口。
- 从该页申请时 5.6 传 `source:"group"` 和当前 Topic;服务端校验双方仍在群内。
### 5.9 好友—聊天设置—未拉黑状态.png
<img src="images/好友—聊天设置—未拉黑状态.png" alt="好友—聊天设置—未拉黑状态" width="360">
**状态:部分 Topic 设置已实现;产品设置新增。**
- 查询:`GET /v1/im/conversations/{topic}/settings` 返回 `pinned,mute_notifications,message_retention,blocked,remark,permissions`。
- 更新置顶/免打扰/保存方式:`PATCH /v1/im/conversations/{topic}/settings`,Body 只提交变更字段并带 `version`;返回最新完整设置。
- 备注:`PATCH /v1/im/friends/{user_id}`,Body `{"remark":"..."}`;查记录见第 7 章,举报见第 11 章,删除见 5.11。
### 5.10 好友—聊天设置—拉黑状态.png
<img src="images/好友—聊天设置—拉黑状态.png" alt="好友—聊天设置—拉黑状态" width="360">
**状态:新增。**
- 开启拉黑:`PUT /v1/im/blocks/{user_id}`;解除:`DELETE /v1/im/blocks/{user_id}`,均带幂等键。
- 成功返回 `blocked,relationship,updated_at` 并向当前用户其他设备推送 `privacy.block.updated`。拉黑后服务端拦截对方消息和呼叫。
- 解除拉黑只恢复黑名单 ACL;若双方已非好友,`relationship` 仍为 `none`,不能自动恢复好友。
### 5.11 删除好友提示.png
<img src="images/删除好友提示.png" alt="删除好友提示" width="360">
**状态:确认框纯客户端;删除接口新增。**
- 确认调用 `DELETE /v1/im/friends/{user_id}`,带幂等键。是否清除本地历史由客户端在成功后独立处理,不放入删除好友请求。
- 服务端解除双向好友关系但不替对方删除历史;返回 `relationship:"none",topic`。当前用户是否删除本地/服务端可见记录是独立选择。
- 取消不请求;确认后清理页面缓存并等待关系事件校正其他设备。
### 5.12 黑名单.png
<img src="images/黑名单.png" alt="黑名单" width="360">
**状态:新增。**
- `GET /v1/im/blocks?cursor=` 返回 `user,blocked_at`;移出调用 5.10 的 DELETE。
- 批量解除:`POST /v1/im/blocks/batch-delete`,Body `{"user_ids":[...]}`,返回逐项结果;最多 100 人。
- 用户资料不可见时仍用受控占位显示黑名单记录,不能因资料删除而无法解除。
### 5.13 黑名单提示(1).png
<img src="images/黑名单提示(1).png" alt="黑名单提示(1)" width="360">
**状态:确认框纯客户端;写接口新增。**
- 确认后调用 `PUT /v1/im/blocks/{user_id}`,相同幂等键重复提交返回当前 `blocked=true`。
- 服务端在同一事务内更新黑名单和通信 ACL,并写审计;取消不调用接口。
- 若对象跨租户或不存在统一返回 `RESOURCE_NOT_FOUND`。
### 5.14 黑名单提示.png
<img src="images/黑名单提示.png" alt="黑名单提示" width="360">
**状态:图片文件名与画面语义不一致;按“清除聊天记录”实施,需改造。**
- 确认调用 MeetingIM `{del what:"msg",topic,delseq:[{"low":1,"hi":last_seq+1}],hard:false}`,仅删除当前用户视图;或封装 `DELETE /v1/im/conversations/{topic}/messages`。
- 不解除好友、不影响对方历史、不删除收藏;附件仅在无任何有效引用且过保留期后回收。
- 前端需在确认文案明确“仅清除自己的聊天记录”,成功后清本地 Topic 消息缓存并保留会话关系。
### 5.15 接口示例
以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。
#### 5.15.1 通讯录、好友、黑名单与二维码
| 接口 | 输入示例 | 成功 `data` 示例 |
| --- | --- | --- |
| `GET /v1/im/friends?query=&initial=&cursor=` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","remark":"","avatar_file_id":"","initial":"Z","topic":"p2p...","relationship":"friend","online_visibility":"unknown","capabilities":{"can_send":true,"can_call":true}}],"next_cursor":""}` |
| `GET /v1/im/friends?query=&cursor=` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","topic":"p2p...","relationship":"friend"}],"next_cursor":""}` |
| `GET /v1/im/friend-requests?direction=inbound&status=all&cursor=` | Query only | `{"items":[{"request_id":"frq_1","direction":"inbound","applicant_id":"usr_a","target_user_id":"usr_b","peer":{"user_id":"usr_a","display_name":"张三"},"message":"我是张三","source":"search","status":"pending","created_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` |
| `POST /v1/im/friend-requests` | `{"target_user_id":"usr...","message":"验证留言","source":"search","source_topic":""}` | `{"request_id":"frq_1","applicant_id":"usr_me","target_user_id":"usr...","message":"验证留言","source":"search","status":"pending","topic":"p2p...","created_at":"2026-08-20T09:00:00Z","expires_at":"2026-08-27T09:00:00Z"}` |
| `POST /v1/im/friend-requests/{request_id}/accept` | `{"version":1}` | `{"request_id":"frq_1","status":"accepted","topic":"p2p...","handled_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/friend-requests/{request_id}/reject` | `{"version":1}` | `{"request_id":"frq_1","status":"rejected","handled_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/users/search?q=&type=account&cursor=` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","avatar_file_id":"","public_account":"zhangsan","relationship":"none","can_add":true}],"next_cursor":""}` |
| `GET /v1/im/users/search?q=&type=exact_account` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","public_account":"zhangsan","relationship":"none","can_add":true}],"next_cursor":""}` |
| `GET /v1/im/users/{identifier}/profile` | Path `user_id`/`username`/`public_account` | `{"user_id":"usr...","display_name":"张三","remark":"","avatar_file_id":"","gender":"","region":"","public_account":"zhangsan","signature":"","relationship":"friend","capabilities":{"can_send":true,"can_call":true,"can_add":false},"topic":"p2p..."}` |
| `PATCH /v1/im/friends/{user_id}` | `{"remark":"老张"}` | `{"user_id":"usr...","remark":"老张","relationship":"friend","updated_at":"2026-08-20T09:00:00Z"}` |
| `DELETE /v1/im/friends/{user_id}` | Path user ID | `{"user_id":"usr...","relationship":"none","deleted":true}` |
| `GET /v1/im/blocks?cursor=` | Query only | `{"items":[{"user":{"user_id":"usr...","display_name":"张三","avatar_file_id":""},"blocked_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` |
| `PUT /v1/im/blocks/{user_id}` | `{}` | `{"user_id":"usr...","blocked":true,"blocked_at":"2026-08-20T09:00:00Z"}` |
| `DELETE /v1/im/blocks/{user_id}` | Path user ID | `{"user_id":"usr...","blocked":false}` |
| `POST /v1/im/blocks/batch-delete` | `{"user_ids":["usr_a","usr_b"]}` | `{"items":[{"user_id":"usr_a","blocked":false}],"failed":[]}` |
| `POST /v1/im/me/qrcodes` | `{"purpose":"add_friend","expires_in_seconds":86400}` | `{"qr_token":"token...","qr_payload":"im://qr?token=...&purpose=add_friend","expires_at":"2026-08-21T09:00:00Z","display_profile":{"user_id":"usr...","display_name":"张三"}}` |
| `POST /v1/im/users/{identifier}/qrcodes` | `{"purpose":"add_friend","expires_in_seconds":86400}` | `{"qr_token":"token...","qr_payload":"im://qr?token=...&purpose=add_friend","expires_at":"2026-08-21T09:00:00Z","display_profile":{"user_id":"usr...","display_name":"张三","public_account":"zhangsan"}}` |
| `POST /v1/im/qrcodes/resolve` | `{"token":"token...","purpose":"add_friend"}` | `{"purpose":"add_friend","user":{"user_id":"usr...","display_name":"张三"},"relationship":"none","can_add":true}` |
## 6. 群聊创建、资料维护与成员治理
### 6.1 群聊.png
<img src="images/群聊.png" alt="群聊" width="360">
**状态:MeetingIM 群 Topic 已实现基础能力;产品群列表与配额需改造。**
- 列表:`GET /v1/im/groups?category=owned|managed|joined&query=&cursor=`,返回 `topic,name,avatar_file_id,member_count,my_role,updated_at`。
- 创建:`POST /v1/im/groups`,Body `{"name":"...","description":"...","member_user_ids":[],"avatar_file_id":"..."}`;`description` 可省略或传空字符串,最长 200 字符;原子校验租户群数上限和群成员上限,返回 `topic,name,avatar_file_id,description,owner,member_count,version,permissions`。
- 分类由服务端 `my_role` 决定;创建成功或角色变化后通过 `info.what="group"` 业务事件刷新,不由前端自行移动分组。
### 6.2 空白页.png
<img src="images/空白页.png" alt="空白页" width="360">
**状态:纯客户端。**
- 对 6.1 任一分类返回 `items=[]` 且 `has_more=false` 时显示对应空状态,不追加空白占位数据。
- 收到 `group.membership.updated` 或创建成功后重新拉取当前分类。
### 6.3 加入群聊.png
<img src="images/加入群聊.png" alt="加入群聊" width="360">
**状态:新增邀请卡片和加入接口。**
- 邀请卡展示以群邀请接口返回和系统通知记录为准;系统通知 `event_type=group.invitation.created`,`action.type=group_invitation_accept`,`action.invitation_id` 用于点击加入,`action.topic` 为目标群。相关实时刷新使用 `info.what="group"` 群业务事件。
- 点击加入:`POST /v1/im/group-invitations/{invitation_id}/accept`,带幂等键;返回 `status=joined|pending_approval` 和 `topic`。
- 服务端校验邀请租户、有效期、群状态、成员资格和成员数上限;重复加入返回当前状态,过期返回 `INVITATION_EXPIRED`。
### 6.4 内容沟通对话3-开启进群验证与拉人状态.png
<img src="images/内容沟通对话3-开启进群验证与拉人状态.png" alt="内容沟通对话3-开启进群验证与拉人状态" width="360">
**状态:新增群成员事件。**
- 邀请:`POST /v1/im/groups/{topic}/invitations`,Body `{"user_ids":[],"message":""}`;按群策略直接加入或创建待审批申请。
- 加入、审批、拒绝等结果由服务端生成 `info.what="group"` 群业务事件,例如 `group.member.joined`、`group.join_request.accepted/rejected`。
- 客户端只渲染服务端可信事件;服务端同时更新成员表、Topic 订阅和成员数,不允许三者不一致。
### 6.5 邀请进群申请.png
<img src="images/邀请进群申请.png" alt="邀请进群申请" width="360">
**状态:新增。**
- `GET /v1/im/groups/{topic}/join-requests?status=pending&cursor=` 返回申请人、邀请人、来源、时间、状态和 `version`;申请人展示字段直接带 `display_name/avatar_file_id/user`,邀请人展示字段带 `inviter_display_name/inviter_avatar_file_id/inviter`。
- 同意:`POST /v1/im/groups/{topic}/join-requests/{id}/accept`;拒绝:`POST /v1/im/groups/{topic}/join-requests/{id}/reject`,带幂等键与 `version`。
- 仅群主/管理员可处理;同意时原子校验群成员上限。并发处理返回 `REQUEST_ALREADY_HANDLED` 和最终状态。
### 6.6 新邀请进群申请.png
<img src="images/新邀请进群申请.png" alt="新邀请进群申请" width="360">
**状态:新增。**
- `GET /v1/im/groups/{topic}/summary` 返回 `pending_join_request_count`、最新公告和权限;新申请通过 `group.join_request.created` 推送给有审批权限的成员。
- 审批完成后服务端广播新数量;前端归零时移除入口。普通成员响应中不得得到申请人敏感信息或数量。
### 6.7 群公告状态.png
<img src="images/群公告状态.png" alt="群公告状态" width="360">
**状态:新增。**
- 查询最新公告:`GET /v1/im/groups/{topic}/announcements/latest`;发布/更新:`PUT /v1/im/groups/{topic}/announcements/current`,Body `{"content":"...","version":1}`。
- 返回 `announcement_id,topic,content,author,version,published_at,permissions.can_edit`;即使暂无公告也返回空公告和编辑权限。仅有权限角色可写,使用乐观锁防覆盖。
- 发布成功后服务端生成 `group.announcement.updated` 群业务事件;顶部摘要使用接口返回数据,不解析聊天文本猜测。
### 6.8 群公告状态/邀请进群申请同时存在.png
<img src="images/群公告状态/邀请进群申请同时存在.png" alt="群公告状态/邀请进群申请同时存在" width="360">
**状态:纯客户端组合;数据接口新增。**
- 页面一次调用 `GET /v1/im/groups/{topic}/summary`,分别读取 `announcement` 和 `pending_join_request_count`,按产品固定顺序渲染两个入口。
- 处理申请只刷新计数,不清公告;更新公告只替换公告对象,不影响申请入口。
### 6.9 群公告(对方查看状态).png
<img src="images/群公告(对方查看状态).png" alt="群公告(对方查看状态)" width="360">
**状态:新增。**
- 详情:`GET /v1/im/groups/{topic}/announcements/{announcement_id}`;普通成员响应不含编辑权限。
- 已读:`POST /v1/im/groups/{topic}/announcements/{id}/read`,带幂等键,返回 `read_at`;用于清除新公告标记。
- 服务端校验当前群成员资格,退群后不可借历史 ID 读取新公告。
### 6.10 群聊设置.png
<img src="images/群聊设置.png" alt="群聊设置" width="360">
**状态:部分订阅设置已实现;产品菜单新增。**
- `GET /v1/im/groups/{topic}/settings` 返回 `my_role,permissions,profile,announcement,member_count,pinned,mute_notifications` 和群治理开关。
- 个人置顶/免打扰通过 `PATCH /v1/im/groups/{topic}/member-settings`;群级设置必须使用 6.17,不混在个人设置里。
- 退出:`POST /v1/im/groups/{topic}/leave`;群主必须先转让或解散。清记录、举报分别见 5.14、11.3。
### 6.11 群资料.png
<img src="images/群资料.png" alt="群资料" width="360">
**状态:需改造。**
- `GET /v1/im/groups/{topic}` 返回 `name,avatar_file_id,description,owner,member_count,version,permissions`。
- 更新:`PATCH /v1/im/groups/{topic}`,Body 可含 `name,avatar_file_id,description,version`;成功返回最新对象并推送 `group.profile.updated`。
- 服务端做角色、长度、敏感内容、文件归属校验;前端只展示 `permissions.can_edit_profile` 允许的入口。
### 6.12 设置群头像.png
<img src="images/设置群头像.png" alt="设置群头像" width="360">
**状态:上传基础已实现;用途绑定需改造。**
- 来源选择、裁剪是客户端动作。上传使用 `purpose=group_avatar`,服务端校验图片 MIME、尺寸、大小并返回 `file_id`。
- 上传后调用 6.11 PATCH `{"avatar_file_id":"...","version":n}`;只有 PATCH 成功才替换正式头像。
- 取消或 PATCH 失败的临时文件按 TTL 回收,不能污染群文件列表。
### 6.13 设置昵称(1).png
<img src="images/设置昵称(1).png" alt="设置昵称(1)" width="360">
**状态:新增。**
- `PUT /v1/im/groups/{topic}/members/me/profile`,Body `{"nickname":"...","version":n}`。
- 服务端校验成员资格、长度和敏感词,返回 `nickname,version,updated_at` 并推送成员资料变化。
- 该昵称仅作用当前群,不能修改全局用户昵称或其他群资料。
- 群成员列表和群成员资料中的 `display_name` 仍由服务端按当前登录人计算,好友备注优先;群内昵称单独返回为 `group_nickname`,前端不要用 `nickname/group_nickname` 覆盖 `display_name`。
### 6.14 群简介.png
<img src="images/群简介.png" alt="群简介" width="360">
**状态:同 6.11,需改造。**
- 保存调用 `PATCH /v1/im/groups/{topic}`,Body `{"description":"...","version":n}`;空字符串表示清空,省略表示不修改。
- 服务端返回 `VALIDATION_FAILED` 的 `details.field/max_length` 或 `CONTENT_REJECTED`;前端保留草稿供修正。
### 6.15 群标识.png
<img src="images/群标识.png" alt="群标识" width="360">
**状态:角色已有基础数据;产品渲染需统一。**
- 群消息下行增加服务端确认的 `sender_snapshot{display_name,role}`,角色枚举固定 `owner|admin|member`。
- 成员角色变化事件到达后刷新成员缓存;历史消息可以保留发送时角色快照,详情以当前角色接口为准。
- 客户端不得根据本地管理员列表给消息自行加可信标识。
### 6.16 群二维码页面.png
<img src="images/群二维码页面.png" alt="群二维码页面" width="360">
**状态:新增。**
- `POST /v1/im/groups/{topic}/qrcodes`,Body `{"expires_in_seconds":86400}`,返回签名 `qr_token,qr_payload,expires_at,group_snapshot`。
- 扫码调用 `POST /v1/im/qrcodes/resolve`,`purpose:"join_group"`;随后 `POST /v1/im/groups/{topic}/join-requests` 并携带 `qr_token`。
- 群解散、二维码过期或管理员撤销后必须失效;二维码不得直接承载内部群权限。
### 6.17 群组管理.png
<img src="images/群组管理.png" alt="群组管理" width="360">
**状态:新增。**
- 查询/更新:`GET/PATCH /v1/im/groups/{topic}/governance`,字段为 `join_approval,mute_all,member_upload_files,member_private_chat,member_invite` 和 `version`。
- 每个字段返回 `editable`;PATCH 仅群主或被授权管理员可用,记录操作者、旧值、新值和 Request ID。
- 解散:`DELETE /v1/im/groups/{topic}`,需二次 `X-Action-Token` 和幂等键;成功后撤销 Topic、二维码、邀请和文件新增权限。
### 6.18 设置群管理员.png
<img src="images/设置群管理员.png" alt="设置群管理员" width="360">
**状态:新增。**
- `GET /v1/im/groups/{topic}/members?role=admin&cursor=` 返回管理员列表;`GET /v1/im/groups/{topic}/role-limits` 返回 `max_admin_count,current_admin_count`。
- 只有群主可进入。群主本人以 `owner` 单独展示,不计入普通管理员列表和名额。
### 6.19 添加管理员.png
<img src="images/添加管理员.png" alt="添加管理员" width="360">
**状态:新增。**
- 候选:`GET /v1/im/groups/{topic}/members?eligible_role=admin&query=&cursor=`,返回 `eligible` 和不可选原因。
- 提交:`POST /v1/im/groups/{topic}/admins/batch`,Body `{"user_ids":[],"version":n}`;服务端原子校验群主身份、成员资格和管理员上限。
- 超限返回 `GROUP_ADMIN_LIMIT_EXCEEDED`,并附 `max/current/requested`。
### 6.20 添加管理员-设置成功.png
<img src="images/添加管理员-设置成功.png" alt="添加管理员-设置成功" width="360">
**状态:新增。**
- 6.19 成功返回 `admins,version,updated_at`;前端用响应替换列表并显示成功,不在请求完成前自行修改角色。
- 服务端推送 `group.role.updated`,新管理员收到后刷新菜单权限和群标识。
### 6.21 添加管理员-设置成功(1).png
<img src="images/添加管理员-设置成功(1).png" alt="添加管理员-设置成功(1)" width="360">
**状态:新增。**
- 提交期间按幂等键禁重复点击。批量接口返回 `succeeded[]`、`failed[{user_id,reason}]` 和最新 `admins`。
- 部分失败时保留成功角色,不做整批客户端回滚;前端展示失败原因并以服务端列表为准。
### 6.22 删除管理员-设置成功.png
<img src="images/删除管理员-设置成功.png" alt="删除管理员-设置成功" width="360">
**状态:新增。**
- `DELETE /v1/im/groups/{topic}/admins/{user_id}`,带幂等键和 `If-Match`/`version`。
- 成功后目标角色变为 `member`,保留群成员关系;服务端立即撤销管理权限并广播角色事件。
- 删除群主、非成员或最后状态冲突分别返回稳定错误,不允许前端拼接通用成功。
### 6.23 添加禁言成员.png
<img src="images/添加禁言成员.png" alt="添加禁言成员" width="360">
**状态:新增。**
- 候选:`GET /v1/im/groups/{topic}/members?mute_eligible=true&query=&cursor=`。
- 提交:`POST /v1/im/groups/{topic}/mutes/batch`,Body `{"members":[{"user_id":"usr...","expires_at":null}],"reason":""}`。
- 服务端校验操作者角色层级;管理员不能禁言群主,普通管理员互相操作规则由治理策略确定。
### 6.24 禁言的群成员.png
<img src="images/禁言的群成员.png" alt="禁言的群成员" width="360">
**状态:新增。**
- `GET /v1/im/groups/{topic}/mutes?cursor=` 返回 `user,muted_by,muted_at,expires_at`;解除 `DELETE /v1/im/groups/{topic}/mutes/{user_id}`。
- 禁言成员发送消息时服务端返回 `GROUP_MUTED` 和可选 `expires_at`;不能只禁用前端输入框。
- 退群时清理禁言记录;过期由服务端自动失效并推送更新。
### 6.25 选择新群主.png
<img src="images/选择新群主.png" alt="选择新群主" width="360">
**状态:新增。**
- `GET /v1/im/groups/{topic}/members?eligible_role=owner&query=&cursor=`,只返回状态正常的当前成员。
- 前端单选并保存 `target_user_id`;当前群主、已退出、停用账号返回 `eligible=false`。
- 此步骤不修改权限,真正转让仅在 6.26 确认后执行。
### 6.26 选择新群主提示.png
<img src="images/选择新群主提示.png" alt="选择新群主提示" width="360">
**状态:新增。**
- `POST /v1/im/groups/{topic}/owner-transfer`,Body `{"target_user_id":"usr...","version":n}`,必须带幂等键和二次认证 `X-Action-Token`。
- 服务端事务内把旧群主降为管理员或成员、目标升为群主、更新权限和审计,再生成 `group.owner.transferred` 群业务事件。
- 成功返回 `owner,previous_owner,roles,version`;并发成员变化返回 `GROUP_VERSION_CONFLICT`,前端重新确认,不自动重试。
### 6.27 群文件.png
<img src="images/群文件.png" alt="群文件" width="360">
**状态:新增群文件索引;底层文件存储已实现。**
- `GET /v1/im/groups/{topic}/files?query=&type=&cursor=` 返回 `group_file_id,file_id,name,size,mime,uploader,created_at,download_state,permissions`。
- 下载使用认证文件接口,服务端校验当前群成员和文件状态;删除 `DELETE /v1/im/groups/{topic}/files/{group_file_id}` 需上传者或管理员权限。
- 退群后不可继续使用旧 URL 下载;缓存文件是否保留由客户端隐私策略处理。
### 6.28 群文件-上传状态.png
<img src="images/群文件-上传状态.png" alt="群文件-上传状态" width="360">
**状态:需改造。**
- 先用 `purpose=group_file,topic=...` 上传,再 `POST /v1/im/groups/{topic}/files`,Body `{"file_id":"...","file_name":"方案.pdf","mime_type":"application/pdf","file_size":1024}` 建立群文件记录。
- 服务端校验 `member_upload_files`、文件是否已上传完成、`file_name` 长度不超过 255、`mime_type` 长度不超过 128、`file_size` 非负且与上传记录一致、单文件上限、租户总存储和群成员身份;成功返回完整群文件项。
- 上传失败可复用 `upload_id`;登记失败不得在列表显示,孤立文件按 TTL 回收。
### 6.29 接口示例
以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。
#### 6.29.1 群聊、群成员与群治理
| 接口 | 输入示例 | 成功 `data` 示例 |
| --- | --- | --- |
| `GET /v1/im/groups?category=owned&query=&cursor=` | Query only | `{"items":[{"topic":"grp...","name":"项目群","avatar_file_id":"","member_count":3,"my_role":"owner","updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":"","has_more":false}` |
| `GET /v1/im/groups?category=owned\|managed\|joined&query=&cursor=` | Query only | `{"items":[{"topic":"grp...","name":"项目群","avatar_file_id":"","member_count":3,"my_role":"admin","updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":"","has_more":false}` |
| `POST /v1/im/groups` | `{"name":"项目群","description":"项目沟通","member_user_ids":["usr..."],"avatar_file_id":""}` | `{"topic":"grp...","name":"项目群","avatar_file_id":"","description":"项目沟通","owner":"usr_me","member_count":2,"version":1,"my_role":"owner","permissions":{"can_edit_profile":true,"can_invite_members":true,"can_manage_members":true,"can_manage_settings":true,"can_leave":false}}` |
| `GET /v1/im/groups/{topic}` | Path topic | `{"topic":"grp...","name":"项目群","avatar_file_id":"","description":"项目沟通","owner":"usr_me","member_count":2,"version":1,"my_role":"owner","permissions":{"can_edit_profile":true}}` |
| `PATCH /v1/im/groups/{topic}` | `{"name":"新群名","avatar_file_id":"file_avatar","description":"新简介","version":1}` | `{"topic":"grp...","name":"新群名","avatar_file_id":"file_avatar","description":"新简介","version":2,"permissions":{"can_edit_profile":true}}` |
| `DELETE /v1/im/groups/{topic}` 待实现 | Header `X-Action-Token: action_token`;Body `{}` | `{"topic":"grp...","deleted":true}` |
| `GET /v1/im/groups/{topic}/settings` | Path topic | `{"topic":"grp...","profile":{"name":"项目群","description":"项目沟通"},"pinned":false,"mute_notifications":false,"member_count":2,"permissions":{"can_manage_settings":true},"governance":{"join_approval":false,"mute_all":false,"member_invite":true}}` |
| `PATCH /v1/im/groups/{topic}/member-settings` | `{"pinned":true,"mute_notifications":false,"version":1}` | `{"topic":"grp...","pinned":true,"mute_notifications":false,"version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `PUT /v1/im/groups/{topic}/members/me/profile` | `{"nickname":"我在群里的昵称","version":1}` | `{"nickname":"我在群里的昵称","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/groups/{topic}/leave` | `{}` | `{"topic":"grp...","status":"left"}` |
| `GET /v1/im/groups/{topic}/members?query=&cursor=&mentionable=true` | Query only | `{"topic":"grp...","items":[{"user_id":"usr...","display_name":"张三","group_nickname":"小张","avatar_file_id":"","role":"member","mentionable":true,"acs":{"want":"JRWP","given":"JRWP","mode":"JRWP"}}],"next_cursor":"","permissions":{"can_mention_all":true}}` |
| `GET /v1/im/groups/{topic}/members/{user_id}/profile` | Path topic/user;`topic` 必须是 `grp...`,不能传 `usr...` | `{"topic":"grp...","user_id":"usr...","display_name":"张三","group_nickname":"小张","role":"member","relationship":"friend","capabilities":{"can_send":true,"can_call":true}}` |
| `POST /v1/im/groups/{topic}/qrcodes` | `{"expires_in_seconds":86400}` | `{"qr_token":"token...","qr_payload":"im://qr?token=...&purpose=join_group","expires_at":"2026-08-21T09:00:00Z","group_snapshot":{"topic":"grp...","name":"项目群","member_count":2}}` |
| `POST /v1/im/groups/{topic}/invitations` | `{"user_ids":["usr..."],"message":"进群一起讨论"}` | `{"topic":"grp...","items":[{"user_id":"usr...","status":"joined","invitation_id":"ginv_1"}]}` |
| `POST /v1/im/group-invitations/{invitation_id}/accept` 待实现 | `{}` | `{"invitation_id":"ginv_1","status":"joined","topic":"grp..."}` |
| `POST /v1/im/groups/{topic}/join-requests` | `{"message":"申请入群","source":"qr","qr_token":"token..."}` | `{"request_id":"gjr_1","topic":"grp...","status":"pending","created_at":"2026-08-20T09:00:00Z","version":1}` |
| `GET /v1/im/groups/{topic}/join-requests?status=pending&cursor=` | Query only | `{"topic":"grp...","items":[{"request_id":"gjr_1","user_id":"usr...","display_name":"张三","avatar_file_id":"avatar.jpg","user":{"user_id":"usr...","display_name":"张三","avatar_file_id":"avatar.jpg"},"inviter_id":"usr_inviter","inviter_display_name":"李四","inviter_avatar_file_id":"inviter.jpg","inviter":{"user_id":"usr_inviter","display_name":"李四","avatar_file_id":"inviter.jpg"},"status":"pending","source":"invitation","created_at":"2026-08-20T09:00:00Z","version":1}],"next_cursor":""}` |
| `POST /v1/im/groups/{topic}/join-requests/{id}/accept` | `{"version":1}` | `{"request_id":"gjr_1","topic":"grp...","status":"accepted","handled_at":"2026-08-20T09:00:00Z","version":2}` |
| `POST /v1/im/groups/{topic}/join-requests/{id}/reject` | `{"version":1,"reason":"人数已满"}` | `{"request_id":"gjr_1","topic":"grp...","status":"rejected","handled_at":"2026-08-20T09:00:00Z","version":2}` |
| `GET /v1/im/groups/{topic}/summary` | Path topic | `{"topic":"grp...","pending_join_request_count":0,"announcement":{"announcement_id":"ann_1","content":"公告"},"permissions":{"can_manage_members":true}}` |
| `GET /v1/im/groups/{topic}/announcements/latest` | Path topic | `{"announcement_id":"ann_1","topic":"grp...","content":"公告内容","version":1,"published_at":"2026-08-20T09:00:00Z","permissions":{"can_edit":true}}` |
| `PUT /v1/im/groups/{topic}/announcements/current` | `{"content":"新的公告","version":1}` | `{"announcement_id":"ann_1","topic":"grp...","content":"新的公告","version":2,"published_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/groups/{topic}/announcements/{announcement_id}` | Path announcement | `{"announcement_id":"ann_1","topic":"grp...","content":"公告内容","version":2,"published_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/groups/{topic}/announcements/{id}/read` | `{}` | `{"announcement_id":"ann_1","read_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/groups/{topic}/governance` | Path topic | `{"join_approval":false,"mute_all":false,"member_upload_files":true,"member_private_chat":true,"member_invite":true,"version":1}` |
| `PATCH /v1/im/groups/{topic}/governance` | `{"join_approval":true,"member_invite":false,"version":1}` | `{"join_approval":true,"member_invite":false,"version":2}` |
| `GET /v1/im/groups/{topic}/role-limits` | Path topic | `{"max_admin_count":10,"current_admin_count":1}` |
| `GET /v1/im/groups/{topic}/members?role=admin&cursor=` | Query only | `{"topic":"grp...","items":[{"user_id":"usr_admin","display_name":"管理员","role":"admin"}],"next_cursor":""}` |
| `GET /v1/im/groups/{topic}/members?eligible_role=admin&query=&cursor=` | Query only | `{"topic":"grp...","items":[{"user_id":"usr...","display_name":"张三","role":"member","eligible":true}],"next_cursor":""}` |
| `POST /v1/im/groups/{topic}/admins/batch` | `{"user_ids":["usr..."],"version":1}` | `{"succeeded":[{"user_id":"usr...","role":"admin"}],"admins":[{"user_id":"usr...","role":"admin"}],"version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `DELETE /v1/im/groups/{topic}/admins/{user_id}` | Header `If-Match: 2` 或 Body version | `{"user_id":"usr...","role":"member","updated_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/groups/{topic}/members?mute_eligible=true&query=&cursor=` | Query only | `{"topic":"grp...","items":[{"user_id":"usr...","display_name":"张三","eligible":true}],"next_cursor":""}` |
| `POST /v1/im/groups/{topic}/mutes/batch` | `{"members":[{"user_id":"usr...","expires_at":null}],"reason":"刷屏"}` | `{"topic":"grp...","items":[{"user_id":"usr...","muted":true,"muted_at":"2026-08-20T09:00:00Z","expires_at":null}]}` |
| `GET /v1/im/groups/{topic}/mutes?cursor=` | Query only | `{"topic":"grp...","items":[{"user":{"user_id":"usr...","display_name":"张三"},"muted_by":"usr_admin","muted_at":"2026-08-20T09:00:00Z","expires_at":null}],"next_cursor":""}` |
| `DELETE /v1/im/groups/{topic}/mutes/{user_id}` | Path user | `{"user_id":"usr...","muted":false}` |
| `GET /v1/im/groups/{topic}/members?eligible_role=owner&query=&cursor=` | Query only | `{"topic":"grp...","items":[{"user_id":"usr...","display_name":"张三","eligible":true}],"next_cursor":""}` |
| `POST /v1/im/groups/{topic}/owner-transfer` | Header `X-Action-Token: action_token`;Body `{"target_user_id":"usr...","version":2}` | `{"owner":"usr...","previous_owner":"usr_old","version":3}` |
| `GET /v1/im/groups/{topic}/files?query=&type=&cursor=` 待修复 | Query only | `{"topic":"grp...","items":[{"group_file_id":"gfile_1","file_id":"file_1","name":"方案.pdf","size":1024,"mime":"application/pdf","uploader":{"user_id":"usr...","display_name":"张三"},"created_at":"2026-08-20T09:00:00Z","download_state":"available","permissions":{"can_delete":true}}],"next_cursor":""}` |
| `POST /v1/im/groups/{topic}/files` 待修复 | `{"file_id":"file_1"}` | `{"group_file_id":"gfile_1","file_id":"file_1","topic":"grp...","created_at":"2026-08-20T09:00:00Z"}` |
| `DELETE /v1/im/groups/{topic}/files/{group_file_id}` 待修复 | Path group file ID | `{"group_file_id":"gfile_1","deleted":true}` |
## 7. 文件下载、收藏管理与聊天记录检索
### 7.1 我的收藏.png
<img src="images/我的收藏.png" alt="我的收藏" width="360">
**状态:新增。**
- `GET /v1/im/favorites?type=all|text|image|video|voice|file|link&q=&cursor=`,返回 `favorite_id,type,snapshot,source,collected_at,availability`。
- 类型与关键词由服务端过滤,客户端不下载全部收藏再筛选。`source` 仅下发用户仍可知晓的会话摘要。
- 收藏数据属于当前租户当前用户;跨租户登录不得合并列表。
### 7.2 我的收藏(1).png
<img src="images/我的收藏(1).png" alt="我的收藏(1)" width="360">
**状态:新增。**
- 详情:`GET /v1/im/favorites/{favorite_id}`;删除:`DELETE /v1/im/favorites/{favorite_id}`;转发使用 7.8。
- 收藏创建时保存受控快照和附件引用,因此原消息对当前用户删除后仍可按保留策略展示;原资源因合规删除时返回 `availability:"removed"`。
- 删除收藏不删除原消息或已转发消息。
### 7.3 我的收藏-文件.png
<img src="images/我的收藏-文件.png" alt="我的收藏-文件" width="360">
**状态:新增。**
- 调用 7.1,固定 `type=file`;项中使用 `snapshot.file{name,size,mime,file_id}` 和 `availability`。
- 点击前调用收藏详情获得当前授权,下载仍走 7.9 文件协议;失效显示原因,不反复请求公共 URL。
### 7.4 我的收藏-文本.png
<img src="images/我的收藏-文本.png" alt="我的收藏-文本" width="360">
**状态:新增。**
- 调用 7.1,固定 `type=text`;服务端返回纯文本/结构化 Drafty 快照和安全高亮区间 `matches[]`。
- 展开、折叠、复制为本地动作;复制前遵守企业 DLP 策略 `permissions.can_copy`。
### 7.5 我的收藏-语音.png
<img src="images/我的收藏-语音.png" alt="我的收藏-语音" width="360">
**状态:新增。**
- 调用 7.1,固定 `type=voice`;播放前获取认证文件,返回 `duration_ms,waveform,availability`。
- 播放/暂停和单实例播放器是客户端状态;服务端不接收逐秒播放进度,除非后续定义跨端收听状态。
### 7.6 我的收藏—链接.png
<img src="images/我的收藏—链接.png" alt="我的收藏—链接" width="360">
**状态:新增。**
- 调用 7.1,固定 `type=link`;服务端快照返回 `url,title,summary,thumbnail_file_id,risk_level`。
- 点击前 `POST /v1/im/security/urls/check`,Body `{"url":"...","source":"favorite"}`;返回 `allow|warn|block` 和 `risk_challenge_id`。
- 客户端不得在未确认时自动打开或预加载高风险外链。
### 7.7 收藏—文件(多选).png
<img src="images/收藏—文件(多选).png" alt="收藏—文件(多选)" width="360">
**状态:新增。**
- 选择状态本地维护;批量删除 `POST /v1/im/favorites/batch-delete`,Body `{"favorite_ids":[]}`;批量转发见 7.8。
- 返回逐项成功/失败;删除必须二次确认并带幂等键。跨页选择只保存 ID,不保留失效的临时下载地址。
### 7.8 收藏文件—分享给好友页面.png
<img src="images/收藏文件—分享给好友页面.png" alt="收藏文件—分享给好友页面" width="360">
**状态:新增。**
- 目标列表使用 4.17;提交 `POST /v1/im/favorites/forward`,Body `{"favorite_ids":[],"target_topics":[],"comment":""}`。
- 服务端从收藏的受控附件引用生成新消息和新 ACL,返回每个目标的消息结果;收藏原件不变。
- 文件失效、目标禁言或无发送权限按目标逐项失败,不允许客户端直接复用旧下载 URL 发送。
### 7.9 文件-下载.png
<img src="images/文件-下载.png" alt="文件-下载" width="360">
**状态:底层下载已实现租户校验;资源 ACL 需改造。**
- 客户端使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-MeetingIM-Auth: token <base64_token>`;禁止 `?auth=&secret=`。
- 服务端除 Token 租户外,还必须校验该用户通过消息、群文件或收藏拥有有效引用,并支持 `Range`、`ETag`、内容长度和安全文件名。
- 下载前客户端校验本地空间和网络策略;`FILE_FORBIDDEN` 不自动重试,`FILE_UNAVAILABLE` 显示失效。
### 7.10 文件-下载中.png
<img src="images/文件-下载中.png" alt="文件-下载中" width="360">
**状态:客户端任务管理;服务端需支持 Range。**
- 下载任务保存 `file_id,etag,total_bytes,downloaded_bytes,temp_path,state`;恢复时携带 `Range` 与 `If-Range: <etag>`。
- ETag 改变或服务端返回完整 200 时丢弃旧分片重新下载;取消时关闭连接并清理临时文件。
- 下载中断后,如果客户端通过有效 Token 登录取得了新 Token,则使用新 Token 和原 Range 状态续传;Token 已过期或失效时暂停任务并返回登录页。不能把 Token 写入任务文件或日志。
### 7.11 文件-下载完成.png
<img src="images/文件-下载完成.png" alt="文件-下载完成" width="360">
**状态:纯客户端完成态。**
- 完成后校验长度和服务端提供的 checksum,再原子移动到受控目录;校验失败删除并标记重试。
- 打开/分享遵守系统沙箱和企业 DLP。每次展示页面检查本地文件存在性,不存在则恢复“可下载”。
### 7.12 查找聊天记录-搜索关键词选中状态(点击后跳出对应聊天内容).png
<img src="images/查找聊天记录-搜索关键词选中状态(点击后跳出对应聊天内容).png" alt="查找聊天记录-搜索关键词选中状态(点击后跳出对应聊天内容)" width="360">
**状态:新增服务端搜索。**
- `GET /v1/im/topics/{topic}/messages/search?q=&type=text&cursor=` 返回 `seq,from,ts,snippet,matches[]`。
- 点击结果后 `GET /v1/im/topics/{topic}/messages/{seq}/context?before=20&after=20`,返回锚点及上下文;客户端定位 `seq` 并高亮。
- 服务端只搜索当前用户可见且未硬删除的消息,关键词做长度/频率限制,搜索日志不得记录原文。
### 7.13 查找聊天记录-文件.png
<img src="images/查找聊天记录-文件.png" alt="查找聊天记录-文件" width="360">
**状态:新增。**
- 调用 `GET /v1/im/topics/{topic}/messages/search?type=file&q=&cursor=`,返回消息 `seq` 与文件快照、发送者、时间、`availability`。
- 预览/下载使用 7.9;撤回或无授权时显示失效。点击来源用 7.12 上下文接口定位。
### 7.14 查找聊天记录-链接.png
<img src="images/查找聊天记录-链接.png" alt="查找聊天记录-链接" width="360">
**状态:新增。**
- 调用搜索接口 `type=link`,返回 `url,title,summary,seq,ts,risk_level`;打开前执行 7.6 URL 安全检查。
- 链接预览由服务端受控抓取和缓存,客户端不直接抓取内网地址,防止 SSRF 和隐私泄露。
### 7.15 查看聊天记录-图片.png
<img src="images/查看聊天记录-图片.png" alt="查看聊天记录-图片" width="360">
**状态:新增。**
- `GET /v1/im/topics/{topic}/media?type=image&cursor=` 返回 `seq,thumbnail_file_id,file_id,width,height,ts,availability`。
- 缩略图懒加载使用认证文件请求;大图前再次授权。多选仅保存 `(topic,seq)`,操作使用第 4 章批量接口。
### 7.16 查看聊天记录-视频.png
<img src="images/查看聊天记录-视频.png" alt="查看聊天记录-视频" width="360">
**状态:新增。**
- 调用 7.15 接口,`type=video`,额外返回 `duration_ms,mime,size`。
- 播放器使用支持 Range 的认证下载/流式读取;文件失效、Token 过期和网络中断分别映射可理解状态,返回列表保持游标与滚动位置。
### 7.17 接口示例
以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。
#### 7.17.1 收藏、转发与文件安全
| 接口 | 输入示例 | 成功 `data` 示例 |
| --- | --- | --- |
| `GET /v1/im/favorites?type=all&q=&cursor=` | Query only | `{"items":[{"favorite_id":"fav_1","type":"text","snapshot":{"text":"收藏内容"},"source":{"topic":"grp...","seq":12},"collected_at":"2026-08-20T09:00:00Z","availability":"available"}],"next_cursor":""}` |
| `GET /v1/im/favorites?type=all\|text\|image\|video\|voice\|file\|link&q=&cursor=` | Query only | `{"items":[{"favorite_id":"fav_1","type":"image","snapshot":{"thumbnail_file_id":"file_thumb","file_id":"file_img"},"source":{"topic":"grp...","seq":12},"collected_at":"2026-08-20T09:00:00Z","availability":"available"}],"next_cursor":""}` |
| `POST /v1/im/favorites/batch` | `{"messages":[{"topic":"grp...","seq":"12"}]}` | `{"created_count":1,"existing_count":0,"failed":[]}` |
| `GET /v1/im/favorites/{favorite_id}` | Path favorite ID | `{"favorite_id":"fav_1","type":"file","snapshot":{"file_id":"file_1","name":"a.pdf"},"source":{"topic":"grp...","seq":12},"availability":"available"}` |
| `DELETE /v1/im/favorites/{favorite_id}` | Path favorite ID | `{"favorite_id":"fav_1","deleted":true}` |
| `POST /v1/im/favorites/batch-delete` | `{"favorite_ids":["fav_1","fav_2"]}` | `{"items":[{"favorite_id":"fav_1","deleted":true},{"favorite_id":"fav_2","deleted":true}],"failed":[]}` |
| `POST /v1/im/favorites/forward` | `{"favorite_ids":["fav_1"],"target_topics":["grp..."],"comment":"转给你"}` | `{"items":[{"favorite_id":"fav_1","target_topic":"grp...","status":"sent","seq":20,"comment_seq":19,"comment_status":"sent"}],"failed":[]}`;Kafka 投递结果不确定时返回 `status:"pending"`、`reason:"MESSAGE_PERSIST_PENDING"` |
| `GET /v1/im/forward-targets?query=&cursor=` | Query only | `{"items":[{"topic":"grp...","type":"group","display_name":"项目群","avatar_file_id":"","can_receive_forward":true},{"topic":"slf","type":"file_assistant","display_name":"文件助手","can_receive_forward":true}],"next_cursor":"","has_more":false}` |
| `POST /v1/im/messages/forward` 逐条转发 | `{"mode":"separate","sources":[{"topic":"grp...","seq":12}],"target_topics":["usr..."],"comment":"转给你"}` | `{"items":[{"source":{"topic":"grp...","seq":12},"target_topic":"p2p...","client_mid":"mid_1","seq":30,"status":"sent","comment_seq":29,"comment_status":"sent"}],"failed":[]}`;Kafka 投递结果不确定时返回 `status:"pending"`、`reason:"MESSAGE_PERSIST_PENDING"` |
| `POST /v1/im/messages/forward` 合并转发 | `{"mode":"bundle","sources":[{"topic":"grp...","seq":12},{"topic":"grp...","seq":13}],"target_topics":["usr..."],"comment":"转给你"}` | `{"items":[{"sources":[{"topic":"grp...","seq":12},{"topic":"grp...","seq":13}],"target_topic":"p2p...","client_mid":"mid_1","seq":31,"status":"sent","mode":"bundle","bundle_id":"fbundle_1","comment_seq":30,"comment_status":"sent"}],"failed":[]}`;Kafka 投递结果不确定时返回 `status:"pending"`、`reason:"MESSAGE_PERSIST_PENDING"` |
| `GET /v1/im/forward-bundles/{bundle_id}?cursor=` 待实现 | Query only | `{"bundle_id":"fbundle_1","title":"聊天记录","item_count":2,"items":[{"from_display":"张三","ts":"2026-08-20T09:00:00Z","summary":"文本摘要"}],"next_cursor":""}` |
| `POST /v1/im/security/urls/check` | `{"url":"https://example.com","source":"favorite"}` | `{"url":"https://example.com","decision":"allow","risk_level":"low","reason":"ok","source":"favorite"}` |
## 8. 实时语音与视频通话
### 8.1 各种通话提示状态.png
<img src="images/各种通话提示状态.png" alt="各种通话提示状态" width="360">
**状态:已实现基础通话记录替换;产品文案映射需统一。**
- 发起呼叫时的持久消息就是通话记录。服务端以相同 `seq` 写 replacement `{data}`,`head.replace=":{seq}"`,并把 `head.webrtc` 更新为 `accepted|finished|disconnected|missed|declined`。
- 已接通结束时 `head.webrtc-duration` 为服务端计算的毫秒数。客户端映射:`finished=已结束`、`missed=未接听/已取消`、`declined=已拒绝`、`disconnected=异常结束`。
- 双方使用原消息 `content.callId/callType` 和同一 `seq`;客户端不得另发一条“通话结果”消息或相信本地传入的 duration。
### 8.2 对方状态.png
<img src="images/对方状态.png" alt="对方状态" width="360">
**状态:在线邀请已实现;Push 与产品鉴权需改造。**
- 来电是 Topic 下行的初始 `{data}`:`head.webrtc="started"`、`head.x-im-type="call"`,`content` 至少含 `callId,callType,audio,video`。客户端保存该消息 `seq` 作为后续所有 call note 的关联 ID。
- 来电端开始响铃后发送 `{note:{topic,what:"call",event:"ringing",seq,payload:{"callId":"..."}}}`;接听发送 `event:"accept"`;拒绝发送 `event:"hang-up"`。
- 后台 Push 只携带 `callId,topic,seq,caller_display,callType,expires_at`,不含 SDP、ICE 或 Token;用户操作后必须回 WebSocket 鉴权。
### 8.3 语音通话-对方待接受状态.png
<img src="images/语音通话-对方待接受状态.png" alt="语音通话-对方待接受状态" width="360">
**状态:发起与超时已实现基础;好友/拉黑/租户开关校验需改造。**
- 音视频入口先看 `GET /v1/im/bootstrap` 的 `features.audio_call/video_call` 和 `permissions.audio_call/video_call`;这两个值来源于 Admin 后台现有配置读取链路 `im_client_feature` 分组中的 `audio_call_enabled`、`video_call_enabled`,与声网参数使用同一个配置读取方法,但不放在声网分组。具体到某个会话或用户时,再看 `capabilities.can_call`。任一为 `false` 时前端不展示对应语音/视频按钮。
- 发起前查询 `capabilities.can_call`,再发布持久邀请消息:
```json
{"pub":{"id":"call-1","topic":"p2p...","head":{"mime":"application/json","webrtc":"started","x-im-type":"call"},"content":{"callId":"01K...","callType":"audio","audio":true,"video":false}}}
```
- `{ctrl code:202,params.seq}` 的 `seq` 是后续 `ringing/accept/offer/answer/ice-candidate/hang-up` 必填关联值。服务端只允许 P2P 双方参与,并校验好友、拉黑、租户通话开关和当前 Topic 无并发呼叫。
- 发起方取消发送 `{note event:"hang-up",seq,payload:{"callId":"...","reason":"cancelled"}}`;等待超时由服务端结束并把原消息替换为 `missed`。
### 8.4 语音通话-双方语音沟通中.png
<img src="images/语音通话-双方语音沟通中.png" alt="语音通话-双方语音沟通中" width="360">
**状态:WebRTC 客户端能力;信令和服务端计时已实现。**
- 被叫发送 `accept` 后,服务端把原消息替换为 `webrtc:"accepted"` 并向主叫发送 `{info what:"call",event:"accept",seq}`。
- 双方随后用 `{note what:"call",event:"offer|answer|ice-candidate",seq,payload:{"callId":"...",...}}` 交换 SDP/ICE。ICE 配置使用 `hi.ctrl.params.iceServers`,不得在 App 中硬编码 TURN 长期凭证。
- 任一端发送 `hang-up`,服务端按接受时间计算 duration 并替换原通话消息。异常断线后按原 `topic+seq+callId` 恢复或结束,不新建第二通呼叫。
### 8.5 语音通话-麦克风与扬声器已关.png
<img src="images/语音通话-麦克风与扬声器已关.png" alt="语音通话-麦克风与扬声器已关" width="360">
**状态:纯客户端设备控制。**
- 麦克风关闭通过禁用本地音轨,扬声器关闭/听筒切换通过系统音频路由完成,不调用业务 HTTP。
- UI 必须读取实际音轨和路由状态;权限撤销时强制更新。是否告知对方静音可发送临时 `media-state`,不落聊天记录。
### 8.6 语音通话-麦克风与扬声器已开.png
<img src="images/语音通话-麦克风与扬声器已开.png" alt="语音通话-麦克风与扬声器已开" width="360">
**状态:纯客户端设备控制。**
- 开启麦克风恢复本地音轨,开启扬声器选择实际输出设备;蓝牙/耳机变化以操作系统回调为准。
- 不把设备名称、系统音量上传服务端。切换失败恢复真实按钮状态并给本地提示。
### 8.7 视频通话—等待对方接受接受邀请中.png
<img src="images/视频通话—等待对方接受接受邀请中.png" alt="视频通话—等待对方接受接受邀请中" width="360">
**状态:基础通话信令已实现;隐私约束需落实。**
- 发起协议同 8.3,`media_type:"video"`。本地预览只在设备显示,接听前媒体轨默认不发给对方,SDP 只声明能力。
- 切换摄像头纯客户端;取消发送 `hang-up:cancelled`。相机权限不足时改为音频呼叫或取消,不伪装视频已开启。
### 8.8 视频通话—等待对方接受接受邀请-摄像头关闭.png
<img src="images/视频通话—等待对方接受接受邀请-摄像头关闭.png" alt="视频通话—等待对方接受接受邀请-摄像头关闭" width="360">
**状态:纯客户端状态;媒体状态事件需改造。**
- 关闭本地视频轨并显示头像;接听后沿用 `video_enabled=false`。
- 当前服务端只允许 `ringing/accept/offer/answer/ice-candidate/hang-up`;首版应直接通过 WebRTC 轨道 mute 状态呈现。若后续新增 `media-state`,必须先修改 `server/calls.go` 事件白名单、参与者校验和限频,客户端不能提前发送未知事件。
### 8.9 双方视频通话中.png
<img src="images/双方视频通话中.png" alt="双方视频通话中" width="360">
**状态:WebRTC 客户端能力;服务端信令已具备基础。**
- 主/小窗布局、拖动和横竖屏纯客户端;WebRTC offer/answer/ICE 与 8.2~8.4 相同。
- 服务端只做身份鉴权、信令路由、TURN 配置和通话审计,不接收前端伪造的通话时长。
- 网络恶化可本地降码率或关闭视频轨;最终结束必须发 `hang-up`。服务端未支持 `media-state` 前不得发送该事件。
### 8.10 双方视频通话中-对方关闭摄像头状态.png
<img src="images/双方视频通话中-对方关闭摄像头状态.png" alt="双方视频通话中-对方关闭摄像头状态" width="360">
**状态:现阶段纯客户端;可选媒体状态事件需改造。**
- 以对端 WebRTC 视频轨的 mute/unmute 作为真实信号;关闭时显示资料缓存中的对方头像并保留音频,轨道恢复后自动显示视频。
- 不需要业务 HTTP。可选 `media-state` 必须等服务端扩展事件白名单后另行启用,且不持久化、不改变通话结果。
### 8.11 视频与语音通话悬浮状态中.png
<img src="images/视频与语音通话悬浮状态中.png" alt="视频与语音通话悬浮状态中" width="360">
**状态:纯客户端。**
- 最小化不结束 WebRTC 或 WebSocket,会话仍以同一 `call_id` 运行;点击恢复全屏不发送新 invite。
- 系统悬浮窗权限、画中画和安全区域按平台实现。收到 `hang-up`、Token 失效或媒体会话结束后立即关闭悬浮窗并落正确结果态。
### 8.12 接口示例
以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。
#### 8.12.1 实时通话与通话记录
| 接口 | 输入示例 | 成功输出示例 |
| --- | --- | --- |
| WebSocket 发起通话 | `{"pub":{"id":"call-1","topic":"p2p...","head":{"mime":"application/json","webrtc":"started","x-im-type":"call"},"content":{"callId":"call_1","callType":"audio","audio":true,"video":false}}}` | `{"ctrl":{"id":"call-1","code":202,"params":{"seq":12}}}` |
| WebSocket 通话事件 | `{"note":{"topic":"p2p...","what":"call","event":"accept","seq":12,"payload":{"callId":"call_1"}}}` | 对端收到 `{"info":{"topic":"p2p...","what":"call","event":"accept","seq":12,"payload":{"callId":"call_1"}}}` |
| `POST /v1/im/calls/rtc-join` | `{"topic":"usr_peer 或 p2p...","call_id":"call_1","seq":12,"call_type":"audio","role":"caller"}` | `{"provider":"agora","auth_mode":"token","app_id":"声网 App ID","channel_name":"call_call_1","uid":"usr...","token":"007eJx...","token_expires_at":"2026-09-02T10:00:00Z","call_id":"call_1","topic":"p2p...","seq":12,"call_type":"audio","role":"caller"}` |
| `GET /v1/im/calls?limit=20&cursor=` | Query only | `{"items":[{"call_id":"call_1","topic":"p2p...","seq":12,"call_type":"audio","status":"finished","started_at":"2026-08-20T09:00:00Z","duration_ms":60000,"peer":{"user_id":"usr...","display_name":"张三"}}],"next_cursor":""}` |
| `GET /v1/im/calls/{topic}?cursor=` | Path topic | `{"topic":"p2p...","items":[{"call_id":"call_1","seq":12,"status":"missed","call_type":"video","created_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` |
声网模式下,新客户端在发起方收到 `ctrl.params.seq` 后、被叫方点击接听后,分别调用 `POST /v1/im/calls/rtc-join` 获取 `app_id/channel_name/uid/token`。`topic` 可直接传当前会话里看到的 P2P 别名,例如发起方传被叫 `usr...`、被叫方传发起方 `usr...`;后端会归一到真实 `p2p...` 并在响应中返回。当前按正式 `token` 模式接入,后端使用 `im_shengwang` 分组中的 `agora_app_certificate` 生成 RTC Token。客户端不接触 `app_certificate`、`customer_id`、`customer_secret`,也不再通过 IM WebSocket 交换 SDP/ICE,音视频媒体协商和传输由声网 RTC SDK 完成。
## 9. 企业工作台与办公协同
### 9.1 工作台.png
<img src="images/工作台.png" alt="工作台" width="360">
**状态:新增。**
- `GET /v1/im/workbench` 返回 `modules[{code,name,icon_file_id,route,sort_order,unread_count,enabled,permission}]`、布局版本和企业配置;服务端按 `sort_order ASC` 返回,默认顺序为打卡、月报、日报、部门框架、举报信箱、会议通知、会议笔记。`meetings` 模块的 `unread_count` 由服务端按未读系统通知实时补齐。
- `GET /v1/im/bootstrap` 返回 `entry_badges.workbench_total_unread`,用于工作台一级入口总红点;当前只汇总“会议通知”模块的未读通知,会议笔记、日报、月报、打卡、部门框架和举报信箱均不产生工作台通知数。
- 服务端按当前 Token 的租户、组织角色和功能开关裁剪;前端只接受白名单内部 `route`,禁止直接执行服务端任意 URL。
- 工作台一级总未读以 `GET /v1/im/bootstrap` 和 `GET /v1/im/workbench` 返回为准;当前只有会议通知可接收移动端通知,其他工作台模块在进入页面时主动拉取数据刷新。若后续启用 `workbench.badge.updated`,应按 0.5.4.6 的 `info.what="im"` 业务事件下发。切租户必须重新加载,不复用另一企业工作台。
### 9.2 部门组织框架.png
<img src="images/部门组织框架.png" alt="部门组织框架" width="360">
**状态:新增。**
- 根节点:`GET /v1/im/organization/departments?parent_id=root&cursor=`;子部门同接口传 `parent_id`;成员 `GET /v1/im/organization/departments/{id}/members?cursor=`。
- 部门返回 `department_id,name,parent_id,child_count,member_count,version`;成员只返回当前查看人可见字段。
- 全局搜索:`GET /v1/im/organization/search?q=&types=department,user&cursor=`。服务端执行组织范围授权,离职用户标记不可联系或从结果移除。
- 组织架构可见范围由服务端按当前登录用户裁剪:上架测试账号可看全部组织;手机号注册账号默认只能看到自己与好友所在组织路径及成员;邀请码注册账号默认只能看到自己所在组织路径及本人。上架测试账号通过租户业务库 `im_org_visibility_overrides` 配置 `visibility_scope=all`,客户端不传可见范围参数。
### 9.2.1 打卡考勤
**状态:已实现基础版。**
- 打卡规则、班次、位置/Wi-Fi 约束、补卡窗口、异常判定和统计结果必须以后端为真源;客户端只负责展示、采集设备侧上下文和临时离线队列,不能把正式打卡记录或规则只保存在本地。
- 配置:`GET /v1/im/attendance/config?date=2026-08-31` 返回当前用户当天适用的规则、班次、允许打卡动作和前端展示文案。服务端按租户、部门、人员、节假日和特殊排班裁剪,前端不得自行推导是否迟到/早退/缺卡。
- 记录:`GET /v1/im/attendance/records?from=2026-08-01&to=2026-08-31&scope=mine` 返回当前用户打卡记录、异常状态和补卡状态;管理员用 `scope=managed&department_id=&user_id=&cursor=` 查询权限范围内记录。
- 打卡:`POST /v1/im/attendance/checkins`,Body 带 `date,action,rule_id,shift_id,client_time,location,wifi,device`,写操作必须带 `Idempotency-Key`。服务端校验打卡窗口、地理围栏/Wi-Fi、重复提交和设备风险,成功后返回服务端判定状态。
- 补卡/异常申诉:`POST /v1/im/attendance/appeals` 创建申请,`GET /v1/im/attendance/appeals?scope=mine|managed&status=&cursor=` 查询列表,审批动作由管理端或后续办公审批接口处理。
- 主动下行:规则变更、打卡状态变化、补卡审批变化可通过 `info.what="im"`,事件 `attendance.rule.updated/attendance.record.updated/attendance.appeal.updated` 下发;前端收到后刷新配置、记录或列表。
### 9.3 会议通知-发起人状态.png
<img src="images/会议通知-发起人状态.png" alt="会议通知-发起人状态" width="360">
**状态:新增。**
- 创建:`POST /v1/im/meetings`,Body `{"title":"...","start_at":"...","end_at":"...","location":"...","recorder_user_id":"usr_me","attendee_user_ids":[],"reminder_minutes":[]}`;`recorder_user_id` 未传时服务端默认使用当前发起人。
- 详情:`GET /v1/im/meetings/{meeting_id}` 返回会议、参会人的 `response=pending|accepted|declined|tentative`、权限和 `version`。
- 更新/取消:`PATCH /v1/im/meetings/{id}`、`POST /v1/im/meetings/{id}/cancel`;提醒未响应者 `POST /v1/im/meetings/{id}/reminders`。所有写操作带幂等键并生成通知和审计。
- 主动下行:参会人收到 `info.what="im"`,事件为 `meeting.created/updated/canceled/reminded/response.updated`,payload 包含 `meeting_id,version,state`;前端收到后刷新会议列表或会议详情。除操作者外,当前会写入系统通知列表。
### 9.4 会议通知-接收人状态.png
<img src="images/会议通知-接收人状态.png" alt="会议通知-接收人状态" width="360">
**状态:新增。**
- 详情同 9.3;响应:`PUT /v1/im/meetings/{meeting_id}/my-response`,Body `{"response":"accepted|declined|tentative","version":n}`。
- 返回我的最终响应和会议版本,并通过 `meeting.response.updated` 实时通知参会人;重复提交同状态幂等。
- 会议过期返回 `MEETING_CLOSED` 并只读展示;客户端本地日历写入是可选系统动作,不替代服务端响应。
### 9.4.1 会议笔记
**状态:新增。**
- 列表:`GET /v1/im/meeting-notes?query=&cursor=&limit=20` 返回可查看会议笔记的会议列表、`note_status/file_count/permissions`。
- 详情:`GET /v1/im/meetings/{meeting_id}/notes` 返回会议信息和 `files[]`。
- 上传并登记:调用通用文件上传 `POST /v0/file/u/`,multipart 包含 `file=@...`、`purpose=meeting_note`、`meeting_id=<meeting_id>`、`id=<本次上传幂等ID>`,请求头按文件上传鉴权携带 `X-MeetingIM-APIKey`、`X-MeetingIM-Auth`。服务端上传成功后会立即登记会议笔记文件,响应 `ctrl.params` 包含 `url,file_id,purpose,meeting_id,note_file_id,note_file_status,version`。
- 兼容绑定:若客户端先完成普通文件上传,也可以调用 `POST /v1/im/meetings/{meeting_id}/notes/files`,Body `{"file_id":"..."}`,并携带 `Idempotency-Key` 完成登记。
- 替换/删除:`PUT /v1/im/meetings/{meeting_id}/notes/files/{note_file_id}`、`DELETE /v1/im/meetings/{meeting_id}/notes/files/{note_file_id}?version=n`。只有记录人/发起人按权限可上传、替换和删除。
### 9.5 日报.png
<img src="images/日报.png" alt="日报" width="360">
**状态:新增。**
- `GET /v1/im/reports/daily?scope=mine&from=&to=&status=&cursor=` 返回 `report_id,report_date,status,draft_updated_at,submitted_at,read_state`。
- 新建草稿 `POST /v1/im/reports/daily`,Body `{"report_date":"2026-08-11"}`;同一用户同一天唯一,重复创建返回原草稿。
- `permissions.can_create` 和可补交日期由服务端返回,前端不自行判断工作日规则。
### 9.6 日报-普通用户页面.png
<img src="images/日报-普通用户页面.png" alt="日报-普通用户页面" width="360">
**状态:新增。**
- 编辑:`PUT /v1/im/reports/daily/{id}/draft`,Body `{"content":"...","attachments":[],"version":n}`;提交 `POST /v1/im/reports/daily/{id}/submit`。
- 服务端返回自动保存版本,客户端用 2~5 秒防抖且串行保存;版本冲突返回 `REPORT_VERSION_CONFLICT` 并给最新版本。
- 提交后按 `editable_until` 决定是否可修改;附件使用 `purpose=daily_report` 上传并做权限绑定。
### 9.7 日报-管理员页面.png
<img src="images/日报-管理员页面.png" alt="日报-管理员页面" width="360">
**状态:新增。**
- `GET /v1/im/reports/daily/statistics?date=&department_id=` 返回 `expected_count,submitted_count,missing_count` 和组织权限范围。
- 明细:`GET /v1/im/reports/daily?scope=managed&date=&status=submitted|missing&department_id=&cursor=`。
- 提醒:`POST /v1/im/reports/daily/reminders`,Body `{"date":"...","user_ids":[]}`;服务端过滤无管理权限对象并限流,返回逐项结果。
### 9.8 日报-查看详情.png
<img src="images/日报-查看详情.png" alt="日报-查看详情" width="360">
**状态:新增。**
- `GET /v1/im/reports/daily/{report_id}` 返回填报人、日期、内容、附件、状态、版本、修改时间和 `permissions`。
- 管理员查看时服务端记录必要的已读状态;附件下载复用第 7 章 ACL。
- 撤回或修订使用专用状态和版本,客户端必须显示最新版本与 `revised_at`,不能混合旧缓存。
### 9.9 月报(1).png
<img src="images/月报(1).png" alt="月报(1)" width="360">
**状态:新增。**
- 创建/取得当月草稿:`POST /v1/im/reports/monthly`,Body `{"month":"2026-08"}`。
- 保存:`PUT /v1/im/reports/monthly/{id}/draft`,Body `{"work_summary":"...","next_plan":"...","attachments":[],"version":n}`;提交 `POST /v1/im/reports/monthly/{id}/submit`。
- 字段必填、最大长度、提交截止时间由响应 `validation` 提供;自动保存和冲突处理同 9.6。
### 9.10 月报.png
<img src="images/月报.png" alt="月报" width="360">
**状态:新增。**
- `GET /v1/im/reports/monthly?scope=mine|managed&from_month=&to_month=&status=&cursor=` 返回月份、填报人、状态和时间。
- 管理员统计 `GET /v1/im/reports/monthly/statistics?month=&department_id=`;普通用户响应中不下发团队统计入口权限。
- 未提交项进入 9.9,已提交项进入 9.11,路由由返回 `action=edit|view` 决定。
### 9.11 月报-详情页.png
<img src="images/月报-详情页.png" alt="月报-详情页" width="360">
**状态:新增。**
- `GET /v1/im/reports/monthly/{report_id}` 返回 `month,author,work_summary,next_plan,attachments,status,submitted_at,updated_at,version,permissions`。
- `permissions.can_edit=true` 时进入草稿更新;否则只读。修改已提交月报需专用 `POST /v1/im/reports/monthly/{id}/reopen` 或审核流程,不直接覆盖。
### 9.12 总经理信箱.png
<img src="images/总经理信箱.png" alt="总经理信箱" width="360">
**状态:新增。**
- 页面配置:`GET /v1/im/executive-mailbox/config` 返回 `enabled,allow_anonymous,required_fields,max_length,attachment_policy,notice`。
- 提交:`POST /v1/im/executive-mailbox/submissions`,Body `{"subject":"...","content":"...","anonymous":false,"attachment_file_ids":[]}`,带幂等键。`subject` 最多 200 字符,`content` 最多 5000 字符;超限返回 `VALIDATION_FAILED`,不返回 500。
- 成功仅返回 `submission_id,status:"received",submitted_at`;匿名模式下业务接收方不见身份,但安全审计是否保留内部追踪按企业政策执行并提前告知。
### 9.13 总经理信箱-文案提示.png
<img src="images/总经理信箱-文案提示.png" alt="总经理信箱-文案提示" width="360">
**状态:新增配置接口。**
- 文案使用 9.12 配置中的 `notice{title,content,updated_at}`,由租户后台配置;客户端不得硬编码实名/匿名承诺。
- 提交按钮前必须展示当前版本文案;请求可携带 `notice_version`,服务端发现关键政策已变更时返回 `NOTICE_RECONFIRM_REQUIRED`。
### 9.14 会议笔记
会议笔记属于企业工作台模块,复用会议主数据和统一文件能力。服务端负责判断会议是否结束、当前用户是否与会议有关、是否为会议记录员,以及文件查看和操作权限;客户端只根据响应中的状态与 `permissions` 展示页面,不自行推导权限。
本模块遵循主文档第 0 章的公共约定:产品 HTTP 使用 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID` 和 `Authorization: Bearer <token>`;写操作额外使用 `Idempotency-Key`;成功和失败响应均使用统一包裹结构。
会议笔记不产生移动端通知、不写入会议通知未读数,也不增加工作台底部 Tab 红点。客户端进入会议笔记列表或详情页时主动请求最新数据;从上传、预览或下载页面返回后,只刷新当前会议笔记页面。
### 9.14.1 会议笔记-工作台入口.png
<img src="images/会议笔记-工作台入口.png" alt="会议笔记-工作台入口" width="360">
**状态:工作台接口需改造。**
- `GET /v1/im/workbench` 的 `modules` 增加 `code="meeting_notes"`、`route="/meeting-notes"`。服务端按租户功能开关和当前用户权限决定是否返回该入口。
- 会议笔记模块固定返回 `unread_count=0`,不参与 `GET /v1/im/bootstrap` 的 `entry_badges.workbench_total_unread` 汇总。
- 当前工作台红点只对应“会议通知”模块的未读通知。日报、月报、打卡、部门框架、举报信箱和会议笔记均不产生工作台通知数。
- 客户端点击入口后请求 `GET /v1/im/meeting-notes`。切换企业后必须清空原租户的会议笔记缓存并重新加载。
### 9.14.2 会议笔记-列表.png
<img src="images/会议笔记-列表.png" alt="会议笔记-列表" width="360">
**状态:新增。**
- 列表:`GET /v1/im/meeting-notes?query=&cursor=&limit=20`。接口只返回当前用户作为发起人、会议记录员或参会人的已结束会议,不返回未开始、进行中或已取消会议。
- `query` 支持匹配会议主题、发起人、会议记录员、参会人和地点;搜索范围仍受当前用户会议可见权限限制。
- 列表按 `end_at` 倒序排列,分页使用不透明 `cursor`。客户端不得用本地时间过滤会议状态或自行扩大搜索范围。
- 已结束但尚未上传笔记的会议仍返回,使用 `note_status="empty"`、`file_count=0`;客户端展示会议基础信息,进入详情后显示“暂无笔记”。
- `permissions.can_view=true` 决定是否允许进入详情;`permissions.can_upload` 只对当前会议记录员为 `true`。普通参会人可以进入详情,但不显示上传按钮。
- `state="ended"` 是服务端按会议状态及服务端当前时间计算或持久化的有效状态。服务端必须以 `end_at` 和取消状态为准,不能信任客户端传入的“已结束”条件。
### 9.14.3 会议笔记-发布页面.png
<img src="images/会议笔记-发布页面.png" alt="会议笔记-发布页面" width="360">
**状态:现有会议创建接口需改造。**
- 页面字段顺序为发起人、地点、会议记录员、参会人、开始时间、时长、会议主题。会议记录员默认显示当前会议发起人。
- 创建仍使用 `POST /v1/im/meetings`,请求新增 `recorder_user_id`。客户端未传或传空字符串时,服务端默认使用当前登录用户;客户端不能通过 `organizer_id` 伪造发起人。
- 服务端校验会议记录员属于当前租户且账号有效。首版允许会议记录员是发起人或参会人;若选择了非参会人,服务端自动加入参会人列表并在响应中返回最终名单。
- 服务端保存的会议必须有 `recorder_user_id`;创建请求可省略该字段以使用发起人默认值。`attendee_user_ids`、`start_at`、`end_at` 和 `title` 为必填业务数据,`location` 可为空,且 `end_at` 必须晚于 `start_at`。
- 创建成功后的会议通知仍统一归属“会议通知”模块;会议笔记模块不额外生成通知或未读数。
- `PATCH /v1/im/meetings/{meeting_id}` 支持在会议结束前修改 `recorder_user_id`,并使用 `version` 做乐观锁。会议结束后返回 `MEETING_CLOSED`。
### 9.14.4 会议笔记-详情页.png
<img src="images/会议笔记-详情页.png" alt="会议笔记-详情页" width="360">
**状态:新增。**
- 详情:`GET /v1/im/meetings/{meeting_id}/notes`,一次返回会议基础信息、发起人、会议记录员、参会人、文件列表、笔记状态和当前用户权限。
- 会议基础信息与会议通知页面共用 `im_meetings` 和 `im_meeting_participants` 数据,不复制会议标题、时间和成员快照作为另一份可编辑主数据。
- 当前用户为会议记录员且会议已结束时,`permissions.can_upload=true`,客户端展示“上传文件”;其他用户不展示该按钮。
- 上传文件先调用 `POST /v0/file/u/`,Multipart 携带 `purpose=meeting_note` 和 `meeting_id`;上传成功取得 `file_id` 后,再调用 `POST /v1/im/meetings/{meeting_id}/notes/files` 建立会议笔记文件记录。
- 文件登记成功后才进入详情列表。上传成功但登记失败的临时文件不得展示,并由服务端按临时文件 TTL 回收。
- 当前会议记录员可替换或删除自己登记的文件;所有写操作均需 `Idempotency-Key`,服务端记录操作审计。
### 9.14.5 会议笔记-详情页(普通成员).png
<img src="images/会议笔记-详情页(普通成员).png" alt="会议笔记-详情页(普通成员)" width="360">
**状态:新增权限响应。**
- 普通参会人调用与 9.14.4 相同的详情接口。响应中 `permissions.can_view=true`、`can_upload=false`、`can_replace=false`、`can_delete=false`。
- 没有已上传文件时返回 `note_status="empty"` 和空数组 `files=[]`,客户端展示“暂无笔记”,不把空状态当作请求失败。
- 有文件时普通参会人只展示文件名、大小、类型、上传人、上传时间和预览入口,不展示上传、替换、删除按钮。
- 服务端必须在文件登记、替换、删除、预览和下载接口逐次校验会议关系及角色。隐藏客户端按钮不能代替服务端鉴权。
- 与会议无关、已离开可见范围或跨租户的用户统一返回 `RESOURCE_NOT_FOUND` 或 `FORBIDDEN`,不得通过 `meeting_id`、`note_file_id` 或 `file_id` 探测资源。
### 9.14.6 会议笔记-详情页-在线预览.png
<img src="images/会议笔记-详情页-在线预览.png" alt="会议笔记-详情页-在线预览" width="360">
**状态:基础文件读取已实现;会议笔记 ACL 和预览转换新增。**
- 文件项返回 `preview.status=ready|processing|unsupported|failed`、`preview.mode=native|converted|download_only` 和可选 `preview_file_id`,客户端根据状态选择直接预览、轮询转换状态或提示暂不支持。
- 原生可预览文件使用受认证的 `GET /v0/file/s/{file_id}`;转换预览使用 `GET /v0/file/s/{preview_file_id}`。对象存储模式下服务端鉴权通过后返回 302 到短期签名 URL,客户端跟随 `Location` 获取文件内容。
- 服务端不得返回长期有效的公共下载 URL。客户端缓存键至少包含 `tenant_code + user_id + file_id + etag`,退出登录或切换企业时清理受控缓存。
- `preview.status="processing"` 时客户端可在当前详情页低频刷新,建议 2 秒后开始、最长 30 秒;离开页面立即停止轮询。
### 9.14.7 会议笔记-详情页-文件下载中.png
<img src="images/会议笔记-详情页-文件下载中.png" alt="会议笔记-详情页-文件下载中" width="360">
**状态:复用统一下载任务;会议笔记 ACL 新增。**
- 下载使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-MeetingIM-Auth: token <base64_token>`,并支持 `Range` 和 `If-Range` 断点续传;对象存储模式下客户端跟随 302 `Location`。
- 下载任务保存 `file_id,etag,total_bytes,downloaded_bytes,temp_path,state`。Token 更新后使用新 Token 续传,禁止把 Token 写入任务文件或日志。
- 服务端在每次下载及续传时重新校验会议可见权限。返回 `FILE_FORBIDDEN` 时客户端停止重试;返回 `FILE_UNAVAILABLE` 时标记文件失效并刷新详情。
- 下载进度属于客户端本地状态,不更新会议笔记业务状态、不产生工作台通知,也不写入未读数。
### 9.14.8 会议笔记-文件预览.png
<img src="images/会议笔记-文件预览.png" alt="会议笔记-文件预览" width="360">
**状态:客户端预览页;服务端复用 9.14.6 文件读取协议。**
- 预览页展示服务端返回的安全文件名和实际内容;标题不得直接信任未经清理的上传文件名。
- 图片、PDF、文本等受支持类型在应用内预览;Office 等类型优先使用受控转换文件,无法转换时按 `preview.mode="download_only"` 提示下载后打开。
- 文件被删除、审核拒绝、转换失败或权限撤销时,服务端返回稳定 `reason`,客户端关闭预览并刷新详情,不能继续显示旧的授权链接。
- 从预览页返回详情后调用 `GET /v1/im/meetings/{meeting_id}/notes` 获取最新文件状态。
### 9.14.9 接口示例
以下示例用于会议笔记 HTTP 联调快速查阅。成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`,下表只展示 `data`。
#### 9.14.9.1 工作台、会议记录员与会议笔记
| 接口 | 输入示例 | 成功 `data` 示例 |
| --- | --- | --- |
| `GET /v1/im/workbench` 需改造 | Query only | `{"layout_version":2,"modules":[{"code":"meeting_notifications","name":"会议通知","route":"/meetings","unread_count":3,"enabled":true,"permission":"enabled"},{"code":"meeting_notes","name":"会议笔记","route":"/meeting-notes","unread_count":0,"enabled":true,"permission":"enabled"}]}` |
| `POST /v1/im/meetings` 需改造 | Header `Idempotency-Key`;Body `{"title":"项目周会","start_at":"2026-09-05T10:00:00+08:00","end_at":"2026-09-05T10:30:00+08:00","location":"会议室 A","recorder_user_id":"usr_me","attendee_user_ids":["usr_2"],"reminder_minutes":[10]}` | `{"meeting_id":"mtg_1","title":"项目周会","organizer":{"user_id":"usr_me","display_name":"张三"},"recorder":{"user_id":"usr_me","display_name":"张三"},"start_at":"2026-09-05T02:00:00Z","end_at":"2026-09-05T02:30:00Z","state":"scheduled","participants":[{"user_id":"usr_me","response":"accepted"},{"user_id":"usr_2","response":"pending"}],"permissions":{"can_update":true,"can_cancel":true},"version":1}` |
| `PATCH /v1/im/meetings/{meeting_id}` 需改造 | Header `Idempotency-Key`;Body `{"recorder_user_id":"usr_2","version":1}` | `{"meeting_id":"mtg_1","recorder":{"user_id":"usr_2","display_name":"李四"},"version":2,"updated_at":"2026-09-05T01:30:00Z"}` |
| `GET /v1/im/meeting-notes?query=&cursor=&limit=20` 新增 | Query only | `{"items":[{"meeting_id":"mtg_1","title":"项目周会","organizer":{"user_id":"usr_me","display_name":"张三"},"recorder":{"user_id":"usr_me","display_name":"张三"},"start_at":"2026-09-05T02:00:00Z","end_at":"2026-09-05T02:30:00Z","location":"会议室 A","state":"ended","participant_count":2,"participant_names":["张三","李四"],"note_status":"empty","file_count":0,"permissions":{"can_view":true,"can_upload":true}}],"next_cursor":"","has_more":false}` |
| `GET /v1/im/meetings/{meeting_id}/notes` 新增 | Path meeting ID | `{"meeting":{"meeting_id":"mtg_1","title":"项目周会","organizer":{"user_id":"usr_me","display_name":"张三"},"recorder":{"user_id":"usr_me","display_name":"张三"},"start_at":"2026-09-05T02:00:00Z","end_at":"2026-09-05T02:30:00Z","location":"会议室 A","state":"ended","participants":[{"user_id":"usr_me","display_name":"张三"},{"user_id":"usr_2","display_name":"李四"}]},"note_status":"available","files":[{"note_file_id":"mnote_1","file_id":"file_1","name":"会议纪要.pdf","size":204800,"mime":"application/pdf","uploader":{"user_id":"usr_me","display_name":"张三"},"created_at":"2026-09-05T03:00:00Z","version":1,"preview":{"status":"ready","mode":"native","preview_file_id":"file_1"},"permissions":{"can_preview":true,"can_download":true,"can_replace":true,"can_delete":true}}],"permissions":{"can_view":true,"can_upload":true},"version":1}` |
| `POST /v0/file/u/` | Multipart 文件;字段 `purpose=meeting_note`、`meeting_id=mtg_1`;Header `X-MeetingIM-Auth` | `{"ctrl":{"code":200,"params":{"file_id":"file_1","url":"/v0/file/s/file_1","name":"会议纪要.pdf","size":204800,"mime":"application/pdf"}}}` |
| `POST /v1/im/meetings/{meeting_id}/notes/files` 新增 | Header `Idempotency-Key`;Body `{"file_id":"file_1"}` | `{"note_file_id":"mnote_1","meeting_id":"mtg_1","file_id":"file_1","name":"会议纪要.pdf","size":204800,"mime":"application/pdf","created_at":"2026-09-05T03:00:00Z","version":1,"preview":{"status":"processing","mode":"converted"}}` |
| `PUT /v1/im/meetings/{meeting_id}/notes/files/{note_file_id}` 新增 | Header `Idempotency-Key`;Body `{"file_id":"file_2","version":1}` | `{"note_file_id":"mnote_1","meeting_id":"mtg_1","file_id":"file_2","name":"会议纪要-v2.pdf","version":2,"updated_at":"2026-09-05T03:10:00Z"}` |
| `DELETE /v1/im/meetings/{meeting_id}/notes/files/{note_file_id}` 新增 | Header `Idempotency-Key`;Query `version=2` | `{"note_file_id":"mnote_1","deleted":true,"deleted_at":"2026-09-05T03:20:00Z"}` |
| `GET /v0/file/s/{file_id}` | Header `X-MeetingIM-Auth`;可带 `Range: bytes=0-` | 对象存储模式下鉴权通过后返回 302 到短期签名 URL;客户端跟随 Location 获取文件内容 |
### 9.14.10 字段与状态约定
| 字段 | 取值 | 前端处理 |
| --- | --- | --- |
| `meeting.state` | `scheduled`、`in_progress`、`ended`、`canceled` | 会议笔记列表只展示 `ended` |
| `note_status` | `empty`、`available` | `empty` 展示“暂无笔记”,`available` 展示文件列表 |
| `preview.status` | `ready`、`processing`、`unsupported`、`failed` | 分别进入预览、短时刷新、仅下载提示、失败提示 |
| `preview.mode` | `native`、`converted`、`download_only` | 使用原文件、转换文件或仅提供下载 |
| `permissions.can_view` | Boolean | 是否可以查看当前会议笔记 |
| `permissions.can_upload` | Boolean | 是否显示并允许使用上传按钮,仅会议记录员为 `true` |
| `permissions.can_replace` | Boolean | 是否允许替换当前笔记文件 |
| `permissions.can_delete` | Boolean | 是否允许删除当前笔记文件 |
| `permissions.can_preview` | Boolean | 是否允许预览当前文件 |
| `permissions.can_download` | Boolean | 是否允许下载当前文件;首版普通参会人若产品要求“只能预览”,该值返回 `false` |
客户端必须以服务端返回的 `permissions` 为准。特别是普通参会人“只能预览”的要求,由文件项 `can_preview=true,can_download=false` 表达;即使预览底层使用文件读取接口,客户端也不得提供导出、系统分享或保存到相册入口。
### 9.14.11 错误码与客户端处理
| `reason` | 典型 HTTP 状态 | 场景 | 客户端处理 |
| --- | --- | --- | --- |
| `MEETING_NOT_ENDED` | 409 | 未结束会议上传或登记笔记 | 提示“会议结束后可上传笔记”,刷新会议详情 |
| `MEETING_CLOSED` | 409 | 会议结束后修改记录员或会议数据 | 切换为只读并刷新详情 |
| `NOT_MEETING_RECORDER` | 403 | 非记录员上传、替换或删除文件 | 隐藏操作入口并刷新权限 |
| `MEETING_NOTE_VERSION_CONFLICT` | 409 | 替换或删除时版本过期 | 拉取最新详情,不自动覆盖 |
| `MEETING_NOTE_FILE_INVALID` | 400 | 文件未上传完成、用途不符或不属于当前租户 | 提示重新上传 |
| `FILE_PREVIEW_PROCESSING` | 409 | 转换预览尚未完成 | 保持当前页并按退避策略刷新 |
| `FILE_PREVIEW_UNSUPPORTED` | 422 | 文件类型不支持在线预览 | 根据 `can_download` 决定是否提供下载 |
| `FILE_FORBIDDEN` | 403 | 当前用户无文件访问权限 | 关闭预览或停止下载并刷新详情 |
| `FILE_UNAVAILABLE` | 404/410 | 文件被删除、失效或审核拒绝 | 标记不可用并刷新文件列表 |
| `RESOURCE_NOT_FOUND` | 404 | 会议或笔记不可见、跨租户访问 | 返回列表,不提示资源是否真实存在 |
### 9.14.12 前后端联调与验收要点
1. 发起会议时不传 `recorder_user_id`,响应中的记录员必须默认为当前发起人;指定其他记录员时,响应返回最终记录员和参会人名单。
2. 会议笔记列表只包含与当前用户有关的已结束会议;已结束但无文件的会议仍展示,详情为空状态。
3. 记录员进入详情显示上传按钮,普通参会人进入同一会议不显示上传按钮;直接调用写接口仍必须被服务端拒绝。
4. 普通参会人只允许在线预览时,响应为 `can_preview=true,can_download=false`,客户端不展示下载、分享或导出入口。
5. 文件上传成功但会议笔记登记失败时,详情列表不出现该文件;重复登记同一幂等键返回首次结果。
6. 文件替换和删除必须校验 `version`;旧版本请求返回冲突,不能覆盖新文件。
7. 进入列表、进入详情以及从上传/预览/下载页面返回时均主动刷新;断开 WebSocket 不影响会议笔记数据获取。
8. 会议笔记新增、替换、删除均不产生 Push、会议通知未读或工作台红点;工作台红点只随会议通知模块变化。
9. 跨租户、无关用户和已撤销权限用户无法通过会议 ID 或文件 ID 查看、预览、下载会议笔记。
10. 切换企业或退出登录后,会议笔记列表、详情、预览文件和下载临时数据按租户及账号隔离清理。
### 9.15 接口示例
以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。
#### 9.15.1 工作台、组织、打卡、会议、日报、月报、信箱
| 接口 | 输入示例 | 成功 `data` 示例 |
| --- | --- | --- |
| `GET /v1/im/workbench` | Query only | `{"layout_version":2,"modules":[{"code":"attendance","name":"打卡","icon_file_id":"","route":"/attendance","sort_order":10,"unread_count":0,"enabled":true,"permission":"enabled"},{"code":"monthly_reports","name":"月报","icon_file_id":"","route":"/reports/monthly","sort_order":20,"unread_count":0,"enabled":true,"permission":"enabled"},{"code":"daily_reports","name":"日报","icon_file_id":"","route":"/reports/daily","sort_order":30,"unread_count":0,"enabled":true,"permission":"enabled"},{"code":"organization","name":"部门框架","icon_file_id":"","route":"/organization","sort_order":40,"unread_count":0,"enabled":true,"permission":"enabled"},{"code":"executive_mailbox","name":"举报信箱","icon_file_id":"","route":"/executive-mailbox","sort_order":50,"unread_count":0,"enabled":true,"permission":"enabled"},{"code":"meetings","name":"会议通知","icon_file_id":"","route":"/meetings","sort_order":60,"unread_count":3,"enabled":true,"permission":"enabled"},{"code":"meeting_notes","name":"会议笔记","icon_file_id":"","route":"/meeting-notes","sort_order":70,"unread_count":0,"enabled":true,"permission":"enabled"}],"enterprise":{"tenant_code":"default","feature_flags":{"organization":true,"attendance":true,"meetings":true,"meeting_notes":true}}}` |
| `GET /v1/im/organization/departments?parent_id=root&cursor=` | Query only | `{"items":[{"department_id":"dep_1","name":"研发部","parent_id":"root","child_count":0,"member_count":12,"version":1}],"next_cursor":""}` |
| `GET /v1/im/organization/departments/{id}/members?cursor=` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","avatar_file_id":"","department_id":"dep_1","title":"工程师","can_contact":true}],"next_cursor":""}` |
| `GET /v1/im/organization/members?query=&department_id=&cursor=` 待实现/可由部门成员接口替代 | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","department_id":"dep_1","department_name":"研发部","can_contact":true}],"next_cursor":""}` |
| `GET /v1/im/organization/search?q=&types=department,user&cursor=` | Query only | `{"items":[{"type":"user","user_id":"usr...","display_name":"张三","department_name":"研发部","can_contact":true}],"next_cursor":""}` |
| `GET /v1/im/attendance/config?date=2026-08-31` | Query only | `{"date":"2026-08-31","timezone":"Asia/Shanghai","rule":{"rule_id":"att_rule_default","name":"默认考勤规则","workday":true,"location_required":false,"wifi_required":false,"allow_outside_checkin":true,"appeal_allowed":true},"shifts":[{"shift_id":"shift_default","name":"默认班次","checkin_start":"08:30","work_start":"09:00","work_end":"18:00","checkout_end":"20:00"}],"allowed_actions":["check_in","check_out"],"today_record":{"record_id":"evt_...","user_id":"usr...","date":"2026-08-31","status":"checked_in","checkins":[]},"permissions":{"can_checkin":true,"can_appeal":true,"can_view_managed":true}}` |
| `GET /v1/im/attendance/records?scope=mine&from=2026-08-01&to=2026-08-31&cursor=` | Query only | `{"items":[{"record_id":"evt_...","user_id":"usr_me","date":"2026-08-31","rule_id":"att_rule_default","shift_id":"shift_default","status":"normal","checkins":[{"checkin_id":"attc_...","action":"check_in","client_time":"2026-08-31T00:55:00Z","server_time":"2026-08-31T00:55:02Z","result":"normal","location":{"name":"上海办公室","distance_meters":38},"wifi":{"ssid":"Office-WiFi"},"device":{"platform":"ios"}}],"exceptions":[],"appeal_status":"","version":3}],"next_cursor":"","has_more":false,"statistics":{"expected_count":1,"checked_count":1,"exception_count":0,"not_checked_count":0},"permissions":{"can_checkin":true,"can_view_managed":true,"can_appeal":true}}` |
| `GET /v1/im/attendance/records?scope=managed&department_id=dep_1&date=2026-08-31&status=late&cursor=` | Query only | `{"items":[{"record_id":"evt_...","user_id":"usr...","date":"2026-08-31","status":"late","exceptions":["late"],"appeal_status":"pending","version":2}],"next_cursor":"","has_more":false,"statistics":{"expected_count":20,"checked_count":19,"exception_count":2,"not_checked_count":1}}` |
| `POST /v1/im/attendance/checkins` | Header `Idempotency-Key`;Body `{"date":"2026-08-31","action":"check_in","rule_id":"att_rule_default","shift_id":"shift_default","client_time":"2026-08-31T08:55:00+08:00","location":{"latitude":31.2304,"longitude":121.4737,"accuracy_meters":30},"wifi":{"ssid":"Office-WiFi","bssid":"00:11:22:33:44:55"},"device":{"device_id":"ios-device-1","platform":"ios"}}` | `{"record":{"record_id":"evt_...","user_id":"usr...","date":"2026-08-31","status":"checked_in","version":2,"checkins":[{"checkin_id":"attc_...","action":"check_in","result":"normal"}]},"checkin":{"checkin_id":"attc_...","action":"check_in","client_time":"2026-08-31T00:55:00Z","server_time":"2026-08-31T00:55:02Z","result":"normal","location":{"latitude":31.2304,"longitude":121.4737},"wifi":{"ssid":"Office-WiFi"},"device":{"platform":"ios"}}}` |
| `POST /v1/im/attendance/appeals` | Header `Idempotency-Key`;Body `{"record_id":"evt_...","appeal_type":"missing_checkin","reason":"外出拜访客户,忘记打卡","target_action":"check_in","target_time":"2026-08-31T09:00:00+08:00","attachment_file_ids":["file_1"]}` | `{"appeal_id":"atta_...","record_id":"evt_...","user_id":"usr...","appeal_type":"missing_checkin","reason":"外出拜访客户,忘记打卡","target_action":"check_in","target_time":"2026-08-31T01:00:00Z","attachment_file_ids":["file_1"],"status":"pending","submitted_at":"2026-08-31T03:00:00Z","version":1}` |
| `GET /v1/im/attendance/appeals?scope=mine&status=pending&cursor=` | Query only | `{"items":[{"appeal_id":"atta_...","record_id":"evt_...","user_id":"usr...","appeal_type":"missing_checkin","status":"pending","submitted_at":"2026-08-31T03:00:00Z","version":1}],"next_cursor":"","has_more":false}` |
| `GET /v1/im/meetings?cursor=` 待补列表 | Query only | `{"items":[{"meeting_id":"mtg_1","title":"周会","start_at":"2026-08-21T10:00:00Z","end_at":"2026-08-21T10:30:00Z","my_response":"pending","state":"scheduled"}],"next_cursor":""}` |
| `POST /v1/im/meetings` | `{"title":"周会","start_at":"2026-08-21T10:00:00Z","end_at":"2026-08-21T10:30:00Z","location":"线上","recorder_user_id":"usr_me","attendee_user_ids":["usr..."],"reminder_minutes":[10]}` | `{"meeting_id":"mtg_1","title":"周会","recorder":{"user_id":"usr_me","display_name":"张三"},"start_at":"2026-08-21T10:00:00Z","end_at":"2026-08-21T10:30:00Z","location":"线上","participants":[{"user_id":"usr_me","response":"accepted"},{"user_id":"usr...","response":"pending"}],"permissions":{"can_update":true,"can_cancel":true,"can_remind":true,"can_respond":false},"version":1}` |
| `GET /v1/im/meetings/{meeting_id}` | Path meeting ID | `{"meeting_id":"mtg_1","title":"周会","participants":[{"user_id":"usr...","response":"pending"}],"permissions":{"can_respond":true},"version":1}` |
| `PATCH /v1/im/meetings/{id}` | `{"title":"新标题","version":1}` | `{"meeting_id":"mtg_1","title":"新标题","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/meetings/{id}/cancel` | `{"version":2,"reason":"时间冲突"}` | `{"meeting_id":"mtg_1","state":"cancelled","version":3}` |
| `POST /v1/im/meetings/{id}/reminders` | `{}` | `{"meeting_id":"mtg_1","items":[{"user_id":"usr...","status":"sent"}]}` |
| `PUT /v1/im/meetings/{meeting_id}/my-response` | `{"response":"accepted","version":1}` | `{"meeting_id":"mtg_1","my_response":"accepted","version":2,"response_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/meeting-notes?query=&cursor=` | Query only | `{"items":[{"meeting_id":"mtg_1","title":"周会","note_status":"available","file_count":1,"permissions":{"can_upload":true,"can_download":true}}],"next_cursor":"","has_more":false}` |
| `GET /v1/im/meetings/{meeting_id}/notes` | Path meeting ID | `{"meeting_id":"mtg_1","note_status":"available","files":[{"note_file_id":"mnote_1","file_id":"file_1","file_name":"会议纪要.pdf","mime_type":"application/pdf","permissions":{"can_download":true}}]}` |
| `POST /v0/file/u/` 会议笔记上传并登记 | Multipart `purpose=meeting_note&meeting_id=mtg_1&id=upload_1&file=@note.pdf` | `{"ctrl":{"code":200,"params":{"url":"/v0/file/s/file_1.pdf","file_id":"file_1","purpose":"meeting_note","meeting_id":"mtg_1","note_file_id":"mnote_1","note_file_status":"active","version":1}}}` |
| `POST /v1/im/meetings/{meeting_id}/notes/files` | Header `Idempotency-Key`;Body `{"file_id":"file_1"}` | `{"note_file_id":"mnote_1","meeting_id":"mtg_1","file_id":"file_1","status":"active","version":1}` |
| `GET /v1/im/reports/daily?scope=mine&from=&to=&status=&cursor=` | Query only | `{"items":[{"report_id":"dr_1","report_date":"2026-08-20","content_short":"今日完成联调和问题回归...","status":"draft","draft_updated_at":"2026-08-20T09:00:00Z","submitted_at":null,"read_state":"unread"}],"next_cursor":"","permissions":{"can_create":true,"makeup_allowed_days":7}}` |
| `GET /v1/im/reports/daily?scope=managed&date=&status=submitted\|missing&department_id=&cursor=` | Query only | `{"items":[{"report_id":"dr_1","author":{"user_id":"usr...","display_name":"张三"},"report_date":"2026-08-20","content_short":"完成日报提交和项目同步...","status":"submitted","submitted_at":"2026-08-20T09:00:00Z","read_state":"unread"}],"next_cursor":"","permissions":{"can_remind_unsubmitted":true,"can_view_managed":true}}` |
| `POST /v1/im/reports/daily` | `{"report_date":"2026-08-20"}` | `{"report_id":"dr_1","report_date":"2026-08-20","content":"","attachments":[],"status":"draft","version":1,"permissions":{"can_edit":true,"can_submit":true,"can_view":true}}` |
| `PUT /v1/im/reports/daily/{id}/draft` | `{"content":"今日完成联调","attachments":[],"version":1}` | `{"report_id":"dr_1","content":"今日完成联调","attachments":[],"status":"draft","version":2,"draft_updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/reports/daily/{id}/submit` | `{"version":2}` | `{"report_id":"dr_1","status":"submitted","submitted_at":"2026-08-20T09:00:00Z","version":3}` |
| `GET /v1/im/reports/daily/statistics?date=&department_id=` | Query only | `{"expected_count":10,"submitted_count":8,"missing_count":2,"scope":{"department_id":"dep_1"}}` |
| `POST /v1/im/reports/daily/reminders` | `{"date":"2026-08-20","user_ids":["usr..."]}` | `{"date":"2026-08-20","items":[{"user_id":"usr...","status":"sent"}]}` |
| `GET /v1/im/reports/daily/{report_id}` | Path report ID | `{"report_id":"dr_1","author":{"user_id":"usr...","display_name":"张三"},"report_date":"2026-08-20","content":"日报内容","attachments":[],"status":"submitted","version":3,"permissions":{"can_view":true,"can_edit":false}}` |
| `GET /v1/im/reports/monthly?scope=mine&from_month=&to_month=&status=&cursor=` | Query only | `{"items":[{"report_id":"mr_1","month":"2026-08","author_id":"usr...","work_summary_short":"本月完成客户拜访和合同整理...","next_plan_short":"下月推进回款和方案评审","status":"draft","action":"edit","updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` |
| `GET /v1/im/reports/monthly?scope=mine\|managed&from_month=&to_month=&status=&cursor=` | Query only | `{"items":[{"report_id":"mr_1","month":"2026-08","author_id":"usr...","work_summary_short":"完成项目阶段验收...","next_plan_short":"继续推进上线准备","status":"submitted","action":"view","submitted_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` |
| `POST /v1/im/reports/monthly` | `{"month":"2026-08"}` | `{"report_id":"mr_1","month":"2026-08","work_summary":"","next_plan":"","attachments":[],"status":"draft","action":"edit","version":1,"validation":{"work_summary_required":true,"next_plan_required":true,"max_length":10000},"permissions":{"can_edit":true,"can_submit":true}}` |
| `PUT /v1/im/reports/monthly/{id}/draft` | `{"work_summary":"本月总结","next_plan":"下月计划","attachments":[],"version":1}` | `{"report_id":"mr_1","work_summary":"本月总结","next_plan":"下月计划","status":"draft","version":2}` |
| `POST /v1/im/reports/monthly/{id}/submit` | `{"version":2}` | `{"report_id":"mr_1","status":"submitted","submitted_at":"2026-08-20T09:00:00Z","version":3}` |
| `GET /v1/im/reports/monthly/statistics?month=&department_id=` | Query only | `{"expected_count":10,"submitted_count":8,"missing_count":2,"scope":{"department_id":"dep_1"}}` |
| `GET /v1/im/reports/monthly/{report_id}` | Path report ID | `{"report_id":"mr_1","month":"2026-08","author":{"user_id":"usr...","display_name":"张三"},"work_summary":"总结","next_plan":"计划","attachments":[],"status":"submitted","version":3,"permissions":{"can_view":true,"can_edit":false}}` |
| `POST /v1/im/reports/monthly/{id}/reopen` 待实现 | `{"version":3,"reason":"补充内容"}` | `{"report_id":"mr_1","status":"draft","version":4}` |
| `GET /v1/im/executive-mailbox/config` | Query only | `{"enabled":true,"allow_anonymous":true,"required_fields":["subject","content"],"max_length":1000,"attachment_policy":{"max_files":5,"purpose":"executive_mailbox"},"notice":{"title":"总经理信箱","content":"请如实反馈","updated_at":"2026-08-20T09:00:00Z"}}` |
| `POST /v1/im/executive-mailbox/submissions` | Header `Idempotency-Key`;Body `{"subject":"建议","content":"建议内容","anonymous":false,"attachment_file_ids":[]}` | `{"submission_id":"mail_1","status":"received","submitted_at":"2026-08-20T09:00:00Z"}` |
## 10. 个人资料、通用设置、隐私与青少年模式
### 10.1 我的.png
<img src="images/我的.png" alt="我的" width="360">
**状态:基础用户资料已有;产品菜单新增。**
- `GET /v1/im/me` 返回公开账号资料、实名/青少年模式摘要和资料版本;`GET /v1/im/bootstrap` 返回个人菜单与权限。
- 头像、昵称更新事件到达后刷新头部;收藏和设置入口分别进入第 7、10 章。
- 账号 ID 按字符串展示,客户端不得展示内部数据库 ID 或 tenant_id。
### 10.2 个人信息.png
<img src="images/个人信息.png" alt="个人信息" width="360">
**状态:MeetingIM `me` 资料更新已有基础;目标产品接口需改造。**
- 查询 `GET /v1/im/me/profile` 返回 `avatar_file_id,nickname,birth_date,signature,gender,region,email,public_account,version,field_rules`;`email` 当前仅为资料字段,不用于登录或找回密码。
- 更新统一使用 `PATCH /v1/im/me/profile`,仅提交变更字段并带 `version`;服务端更新后同步 MeetingIM `me.public` 并推送资料事件。
- 头像、昵称、签名、性别、地区、公开账号等资料字段以 `im_user_profiles` 为业务真源;`im_users.public` 和 `im_subscriptions.public` 只作为服务端生成的展示投影/缓存,前端不得依赖它们作为可编辑源。
- 头像先以 `purpose=user_avatar` 上传再 PATCH;任何字段的可见性由隐私策略裁剪。
### 10.3 设置昵称.png
<img src="images/设置昵称.png" alt="设置昵称" width="360">
**状态:需改造。**
- `PATCH /v1/im/me/profile`,Body `{"nickname":"新昵称","version":n}`。
- 服务端校验 Unicode 长度、全空白、控制字符和敏感内容;返回更新后的 `nickname,version,updated_at`。
- 成功刷新全局资料和联系人快照,但不得覆盖各群昵称。版本冲突先拉最新资料再由用户确认。
### 10.4 出生日期.png
<img src="images/出生日期.png" alt="出生日期" width="360">
**状态:新增产品字段。**
- 保存同 10.3,Body `{"birth_date":"2000-01-02","version":n}`;仅传 ISO 日期,不传本地化文本或时区时间戳。
- 服务端校验不晚于当前租户日期及合理范围,返回 `PROFILE_FIELD_INVALID`。展示格式由客户端本地化。
### 10.5 个性签名.png
<img src="images/个性签名.png" alt="个性签名" width="360">
**状态:需改造。**
- 保存同 10.3,Body `{"signature":"...","version":n}`;空字符串表示清空。
- 服务端按 `field_rules.signature.max_length` 和内容审核处理;失败保留本地草稿,成功刷新引用页面。
### 10.6 二维码.png
<img src="images/二维码.png" alt="二维码" width="360">
**状态:图标纯客户端;二维码协议新增。**
- 点击入口进入 5.4 并创建短期个人二维码。每次主动刷新调用 `POST /v1/im/me/qrcodes` 获取新 Token。
- 二维码不能使用静态 `user_id`、手机号或登录 Token;过期、撤销和切租户后必须重新生成。
### 10.7 设置(iOS).png
<img src="images/设置(iOS).png" alt="设置(iOS)" width="360">
**状态:菜单纯客户端;数据接口部分新增。**
- 菜单来自 `bootstrap.settings_menu`,安全、通知、隐私、通用、关于按权限展示。系统通知权限状态由 iOS API 读取,不由服务端伪造。
- 退出:`POST /v1/im/auth/logout` 将当前登录 Token 的 SHA-256 摘要写入 Redis 撤销集合,TTL 与 Token 剩余有效期一致,不影响同账号其他设备。成功后关闭 WS、清安全存储和本租户用户缓存;请求须带 `Authorization: Bearer <token>` 和 `Idempotency-Key`。
- 多设备退出使用 `DELETE /v1/im/security/sessions/{session_id}`,需二次验证。
### 10.8 设置(安卓).png
<img src="images/设置(安卓).png" alt="设置(安卓)" width="360">
**状态:菜单纯客户端;业务协议同 10.7。**
- 使用相同 `bootstrap.settings_menu` 与退出协议;Android 系统权限、通知渠道和应用更新使用平台实现。
- 服务端只保存应用内通知偏好与设备 Push Token,不把系统设置页面是否打开当作已授权。
### 10.9 清空缓存的提示.png
<img src="images/清空缓存的提示.png" alt="清空缓存的提示" width="360">
**状态:纯客户端。**
- 确认后只删除缩略图、可重下文件、临时上传下载和搜索缓存;不调用服务端删除消息、收藏或账号接口。
- 不删除安全存储中的登录凭证,除非用户选择退出登录。完成后重新计算本地占用并显示结果。
### 10.10 版本更新页面.png
<img src="images/版本更新页面.png" alt="版本更新页面" width="360">
**状态:新增 App 配置接口。**
- `GET /v1/im/app/releases/latest?platform=ios|android&current_version=1.0.0&channel=stable`,全局公共接口,不依赖租户解析。`current_version` 必传,使用数字点分版本号(如 `1.2.0`)。
- 服务端返回 `has_update`、`force_update` 和 `update_type=none|recommended|force`;iOS 客户端使用 `store_url` 跳转 App Store。
- 版本策略由 Admin 在 `hotgo.im_app_releases` 统一管理,IM Server 通过 HMAC 内部配置接口读取,不从租户库查询。
- 返回 `current_version,has_update,force_update,latest_version,min_supported_version,update_type=none|recommended|force,release_notes,store_url,package_url,checksum,published_at`。
- 客户端按平台只打开受信任商店/签名下载地址;Android 包必须校验签名和 checksum,服务端 URL 使用允许域名。
### 10.11 发现新版本.png
<img src="images/发现新版本.png" alt="发现新版本" width="360">
**状态:纯客户端弹窗;数据来自 10.10。**
- `optional` 显示稍后/更新,记录本地已忽略版本并按服务端 `remind_after` 再提示;`required` 不显示绕过入口。
- 下载和安装状态由平台管理;接口异常时不得误判为强制更新,除非本地已有有效签名的最低版本策略缓存。
### 10.12 隐私设置.png
<img src="images/隐私设置.png" alt="隐私设置" width="360">
**状态:新增。**
- `GET /v1/im/me/privacy` 返回 `add_me_methods,profile_visibility,last_seen_visibility,read_receipts,group_invite_verification,version` 和各字段 `editable`;`group_invite_verification=true` 表示别人邀请我入群时需要走入群审批。
- `PATCH /v1/im/me/privacy` 只提交变更字段和 `version`;成功返回完整最新配置并向其他设备推送 `privacy.updated`。
- 黑名单入口使用 5.12。服务端在搜索、主页、回执等实际读写路径执行隐私规则,不能只保存开关。
### 10.13 隐私设置-添加我的方式选项.png
<img src="images/隐私设置-添加我的方式选项.png" alt="隐私设置-添加我的方式选项" width="360">
**状态:新增。**
- `add_me_methods` 固定字段 `account,phone,qr,group`,每项为布尔值;更新用 10.12 PATCH,如 `{"add_me_methods":{"phone":false},"version":n}`。
- 关闭后对应搜索/二维码/群来源申请由服务端统一返回 `FRIEND_REQUEST_NOT_ALLOWED` 或 `USER_NOT_DISCOVERABLE`,不泄露具体关闭项。
- 企业强制策略可令字段 `editable=false`,前端禁用并显示服务端提供的通用说明。
### 10.14 青少年模式未开启(安卓).png
<img src="images/青少年模式未开启(安卓).png" alt="青少年模式未开启(安卓)" width="360">
**状态:新增。**
- `GET /v1/im/settings/youth-mode` 返回 `enabled,policy_version,restrictions,restriction_items,password_set,can_enable`。当前默认策略项为 `night_quiet`、`content_filter`、`payment_guard` 三条;后端现阶段完成状态持久化、开启/关闭校验和策略返回,业务接口层的深夜免打扰、内容过滤、支付守护尚未实际强制执行,`restriction_items[].enforced=false` 用于明确表达这一点。
- 点击开启先展示 10.15,再按 1.7 完成身份验证和 1.11 设置独立密码;最后调用 `POST /v1/im/settings/youth-mode/enable`,Body `{"policy_version":n,"action_token":"..."}`,其中 `action_token` 使用设置独立密码响应里的 `enable_action_token`。
- 开启链路中,短信校验返回的 `set_independent_password` token 只用于设置独立密码;设置密码成功响应中的 `enable_action_token` 才用于 `youth-mode/enable`。
- 服务端持久化到用户租户范围并推送所有设备,不能仅设置本地标志。
### 10.15 青少年模式提示(安卓).png
<img src="images/青少年模式提示(安卓).png" alt="青少年模式提示(安卓)" width="360">
**状态:弹窗纯客户端;文案由服务端配置。**
- 使用 10.14 返回的 `restriction_items` 与 `policy_version` 渲染;取消不请求,确认进入密码设置/验证。`restrictions` 仅作为兼容旧客户端的 code 数组。
- 最终 enable 请求携带用户确认的 `policy_version`;若策略更新返回 `POLICY_RECONFIRM_REQUIRED`,重新展示新内容。
### 10.16 青少年模式已开启(安卓).png
<img src="images/青少年模式已开启(安卓).png" alt="青少年模式已开启(安卓)" width="360">
**状态:新增。**
- 页面调用 10.14,`enabled=true` 时展示 `enabled_at,restrictions`。`bootstrap` 同时返回受限模块和动作。
- 后续某条规则真正接入业务拦截后,受限接口服务端返回 `YOUTH_MODE_RESTRICTED`,并把对应 `restriction_items[].enforced` 改为 `true`;前端隐藏只是体验优化,不能作为安全控制。
- 登录、App 重启和另一设备均从服务端恢复状态;收到 `youth_mode.updated` 即刷新。
### 10.17 关闭青少年模式(安卓).png
<img src="images/关闭青少年模式(安卓).png" alt="关闭青少年模式(安卓)" width="360">
**状态:新增。**
- 先调用 1.11 的独立密码验证,获得 `action_token`;再 `POST /v1/im/settings/youth-mode/disable`,Header `X-Action-Token`,带幂等键。
- 成功返回 `enabled:false,updated_at` 并广播;连续密码错误按服务端 `retry_after_seconds` 锁定。
- 忘记密码走 1.7 身份验证后重置,不能绕过密码直接修改本地状态。
### 10.18 获取推送权限(安卓).png
<img src="images/获取推送权限(安卓).png" alt="获取推送权限(安卓)" width="360">
**状态:权限引导纯客户端;设备注册需改造。**
- 用户确认后调用 Android 系统权限;拒绝状态本地记录,最终以系统回调为准。
- 获取 Push Token 后 `PUT /v1/im/devices/{device_id}/push-token`,Body `{"provider":"fcm","token":"...","app_version":"..."}`;Token 轮换时覆盖。
- 退出登录删除/解绑该用户设备 Token。Push Token 属敏感凭证,不写日志。
### 10.19 提醒(安卓).png
<img src="images/提醒(安卓).png" alt="提醒(安卓)" width="360">
**状态:纯客户端。**
- 仅在功能因系统权限不可用时显示;取消和“前往设置”不调用业务服务端。
- 回到应用后重新读取真实权限,不能因用户打开过设置页就假定授权成功。
### 10.20 相机说明(安卓).png
<img src="images/相机说明(安卓).png" alt="相机说明(安卓)" width="360">
**状态:纯客户端。**
- 首次拍照、扫码或视频通话前展示用途说明,确认后再触发系统权限;拒绝不发媒体/扫码请求。
- 永久拒绝时提供系统设置入口;允许从相册选择等替代能力。客户端不得上传系统权限状态作为服务端授权依据。
### 10.21 麦克风说明(安卓).png
<img src="images/麦克风说明(安卓).png" alt="麦克风说明(安卓)" width="360">
**状态:纯客户端。**
- 首次录音或通话前说明并申请系统权限;未授权时不得创建录音文件或发送 call invite。
- 永久拒绝后进入系统设置引导;恢复授权后重新执行 4.7 或第 8 章动作,不复用过期媒体会话。
### 10.22 接口示例
以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。
#### 10.22.1 个人资料、设置、隐私、青少年模式与设备
| 接口 | 输入示例 | 成功 `data` 示例 |
| --- | --- | --- |
| `GET /v1/im/me` | Query only | `{"user_id":"usr...","profile":{"public_account":"usr...","nickname":"张三","avatar_file_id":"","signature":"","version":1},"profile_version":1,"realname":{"status":"unverified"},"youth_mode":{"enabled":false}}` |
| `GET /v1/im/me/profile` | Query only | `{"avatar_file_id":"","nickname":"张三","birth_date":"1990-01-01","signature":"签名","gender":"male","region":"上海","email":"a@example.com","public_account":"usr...","version":1,"field_rules":{"nickname":{"min_length":1,"max_length":32},"signature":{"max_length":120}}}` |
| `PATCH /v1/im/me/profile` | `{"nickname":"新昵称","signature":"新签名","version":1}` | `{"avatar_file_id":"","nickname":"新昵称","signature":"新签名","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/me/privacy` | Query only | `{"add_me_methods":{"account":true,"phone":true,"qr":true,"group":true},"profile_visibility":"friends","last_seen_visibility":"friends","read_receipts":true,"group_invite_verification":false,"version":1,"editable":{"add_me_methods":true,"profile_visibility":true,"last_seen_visibility":true,"read_receipts":true,"group_invite_verification":true}}` |
| `PATCH /v1/im/me/privacy` | `{"add_me_methods":{"phone":false},"read_receipts":false,"group_invite_verification":true,"version":1}` | `{"add_me_methods":{"account":true,"phone":false,"qr":true,"group":true},"read_receipts":false,"group_invite_verification":true,"version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/settings/youth-mode` | Query only | `{"enabled":false,"policy_version":1,"restrictions":["night_quiet","content_filter","payment_guard"],"restriction_items":[{"code":"night_quiet","title":"深夜免打扰","description":"夜间减少消息提醒打扰,帮助用户保持健康作息。","enforced":false},{"code":"content_filter","title":"内容过滤","description":"对不适合青少年的内容进行识别和限制展示。","enforced":false},{"code":"payment_guard","title":"支付守护","description":"限制或确认支付、充值等敏感消费行为。","enforced":false}],"password_set":false,"can_enable":false,"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/settings/independent-password` | `{"password":"1234","purpose":"youth_mode","action_token":"action_token"}` | `{"status":"ok","password_set":true,"purpose":"youth_mode","enable_action_token":"enable_token","enable_action_expires_at":"2026-08-20T09:05:00Z","next_action":"enable_youth_mode"}` |
| `POST /v1/im/settings/independent-password/verify` | `{"password":"1234","purpose":"close_youth_mode"}` | `{"action_token":"action_token","action":"close_youth_mode","expires_at":"2026-08-20T09:05:00Z"}` |
| `POST /v1/im/settings/youth-mode/enable` | `{"policy_version":1,"action_token":"enable_action_token"}` | `{"enabled":true,"policy_version":1,"password_set":true,"restrictions":["night_quiet","content_filter","payment_guard"],"restriction_items":[{"code":"night_quiet","title":"深夜免打扰","description":"夜间减少消息提醒打扰,帮助用户保持健康作息。","enforced":false},{"code":"content_filter","title":"内容过滤","description":"对不适合青少年的内容进行识别和限制展示。","enforced":false},{"code":"payment_guard","title":"支付守护","description":"限制或确认支付、充值等敏感消费行为。","enforced":false}],"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/settings/youth-mode/disable` | Header `X-Action-Token: action_token`;Body `{}` | `{"enabled":false,"policy_version":1,"password_set":true,"updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/security/challenges` 开启青少年模式 | Header `Idempotency-Key`;Body `{"scene":"set_independent_password","channel":"sms"}` | `{"challenge_id":"sec_chg_1","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` |
| `POST /v1/im/security/challenges` 关闭青少年模式 | Header `Idempotency-Key`;Body `{"scene":"close_youth_mode","channel":"sms"}` | `{"challenge_id":"sec_chg_2","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` |
| `POST /v1/im/security/challenges` 限流 | 同上 | HTTP `429`:`{"code":30020,"reason":"VERIFICATION_RATE_LIMITED","message":"verification rate limited","details":{"challenge_id":"sec_chg_1","retry_after_seconds":45}}`;小时限流时 `retry_after_seconds` 可能为数百/数千秒且可不返回 `challenge_id` |
| `POST /v1/im/security/challenges/{id}/verify` 开启青少年模式 | `{"code":"123456"}` | `{"action_token":"set_password_token","action":"set_independent_password","expires_at":"2026-08-20T09:05:00Z"}` |
| `POST /v1/im/security/challenges/{id}/verify` 关闭青少年模式 | `{"code":"123456"}` | `{"action_token":"close_token","action":"close_youth_mode","expires_at":"2026-08-20T09:05:00Z"}` |
| `GET /v1/im/security/realname` | Query only | `{"status":"unverified","can_submit":true}` |
| `POST /v1/im/security/realname/submissions` | `{"name":"张三","id_type":"cn_id","id_number":"110101199001011234"}` | `{"submission_id":"rn_1","status":"pending","can_submit":false}` |
| `POST /v1/im/security/realname/appeals` 待实现 | `{"reason":"证件变更","description":"需要更正实名信息","action_token":"action_token"}` | `{"appeal_id":"rna_1","status":"pending","submitted_at":"2026-08-20T09:00:00Z"}` |
| `PUT /v1/im/devices/{device_id}/push-token` | `{"provider":"jpush","token":"registration_id","platform":"android","app_version":"1.0.0","push_env":"test","sdk_version":"5.0.0","notification_permission":"granted"}` | `{"device_id":"device_1","provider":"jpush","platform":"android","app_version":"1.0.0","updated_at":"2026-08-20T09:00:00Z"}` |
| `DELETE /v1/im/security/sessions/{session_id}` 待实现 | Header `X-Action-Token: action_token` | `{"session_id":"sess_1","revoked":true}` |
## 11. 反诈安全、举报与意见反馈
### 11.1 反诈弹窗(安卓).png
<img src="images/反诈弹窗(安卓).png" alt="反诈弹窗(安卓)" width="360">
**状态:新增风控决策协议。**
- 高风险动作在原业务接口返回 `409 RISK_CONFIRMATION_REQUIRED`,`details={risk_challenge_id,level,notice_code,notice_params,expires_at,action}`。
- 客户端用 `notice_code` 映射经过审核的本地文案,不显示服务端规则、分数或命中特征;取消不重试原操作。
- 低风险提示也可由服务端可信安全事件触发。当前风险确认/取消会按 0.5.4.6 下发 `security.risk.continue/cancel`;普通消息不得伪装成系统反诈弹窗。
### 11.2 反诈弹窗(二次提示安卓).png
<img src="images/反诈弹窗(二次提示安卓).png" alt="反诈弹窗(二次提示安卓)" width="360">
**状态:新增。**
- 确认:`POST /v1/im/security/risk-challenges/{risk_challenge_id}/confirm`,Body `{"decision":"continue|cancel"}`,带幂等键。
- `continue` 成功返回单次、短期 `risk_action_token`;客户端在重试原业务接口时以 `X-Risk-Action-Token` 携带,原请求幂等键保持不变。
- 服务端可返回 `RISK_ACTION_BLOCKED` 直接阻断;确认、取消、过期均写审计,客户端不得无限创建 challenge 绕过。
### 11.3 举报.png
<img src="images/举报.png" alt="举报" width="360">
**状态:新增。**
- 原因:`GET /v1/im/reports/abuse/reasons?target_type=user|group|message` 返回 `reason_code,label,requires_description,allows_evidence`。
- 进入页面携带受控目标:用户 `user_id`、群 `topic` 或消息 `(topic,seq)`;客户端不提交 tenant_id 或被举报者任意文本身份。
- 服务端先验证举报人能够看到目标,再返回适用原因;举报人身份不下发给被举报方。
### 11.4 举报(1).png
<img src="images/举报(1).png" alt="举报(1)" width="360">
**状态:新增。**
- 证据使用 `purpose=abuse_evidence` 上传;提交 `POST /v1/im/reports/abuse`,Body `{"target":{"type":"message","topic":"...","seq":"12"},"reason_code":"fraud","description":"...","evidence_file_ids":[]}`。
- 服务端从目标消息保存必要、受控的审核快照,校验证据数量/MIME/大小,返回 `report_id,status:"received"`。
- 重复举报按策略合并并返回原受理号或 `REPORT_RATE_LIMITED`;证据使用独立严格 ACL 和保留期。
### 11.5 意见反馈.png
<img src="images/意见反馈.png" alt="意见反馈" width="360">
**状态:新增。**
- 配置:`GET /v1/im/feedback/config` 返回类别、最大正文、附件规则、是否允许联系方式和日志授权说明。
- 提交:`POST /v1/im/feedback`,Body `{"category":"bug","content":"...","contact":"...","attachment_file_ids":[],"client_context":{"platform":"android","app_version":"1.0.0","os_version":"..."},"include_diagnostics":false}`。
- 附件用 `purpose=feedback`;只有用户明确同意 `include_diagnostics=true` 才上传经过脱敏的诊断包。成功返回 `feedback_id,status:"received"`,重复提交由幂等键去重。
### 11.6 接口示例
以下示例用于本章 HTTP 联调快速查阅。除特别说明外:
- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer <token>`。
- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。
- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。
#### 11.6.1 反诈、举报与意见反馈
| 接口 | 输入示例 | 成功 `data` 示例 |
| --- | --- | --- |
| `POST /v1/im/security/risk-challenges/{risk_challenge_id}/confirm` | `{"decision":"continue"}` | `{"risk_action_token":"risk_token","action":"send_message","expires_at":"2026-08-20T09:05:00Z"}` |
| `GET /v1/im/reports/abuse/reasons?target_type=user\|group\|message` 需修复 | Query only | `{"items":[{"reason_code":"fraud","label":"诈骗","requires_description":true,"allows_evidence":true},{"reason_code":"harassment","label":"骚扰","requires_description":false,"allows_evidence":true}]}` |
| `POST /v1/im/reports/abuse` | `{"target":{"type":"message","topic":"grp...","seq":"12"},"reason_code":"fraud","description":"疑似诈骗","evidence_file_ids":["file_1"]}` | `{"report_id":"abr_1","status":"received","submitted_at":"2026-08-20T09:00:00Z"}` |
| `GET /v1/im/feedback/config` | Query only | `{"categories":["bug","suggestion","complaint","other"],"max_length":1000,"allow_contact":true,"attachment_policy":{"max_files":5,"purpose":"feedback"},"diagnostics_notice":"仅在你同意时上传脱敏诊断信息。","updated_at":"2026-08-20T09:00:00Z"}` |
| `POST /v1/im/feedback` | `{"category":"bug","content":"页面异常","contact":"15500000000","attachment_file_ids":[],"client_context":{"platform":"android","app_version":"1.0.0","os_version":"14"},"include_diagnostics":false}` | `{"feedback_id":"fb_1","status":"received","created_at":"2026-08-20T09:00:00Z"}` |
## 12. 联调实施清单
### 12.1 当前可直接联调的基础能力
1. WebSocket `/v0/channels`、Wire Version `0.25` 和最低版本 `0.20`。
2. 第一条 `hi` 携带企业码并固定 Session 租户;响应包含公开租户信息、限制、ICE 配置和呼叫超时。
3. `basic`/`token` 登录,`sub/get/set/pub/leave/del/note` 基础 MeetingIM 协议。
4. `pub` 成功返回 `202 + seq`,Topic 消息与基础回执同步。
5. `/v0/file/u/` 和 `/v0/file/s/{file_id}` 的基础上传下载及租户存储隔离。
### 12.2 必须优先完成的服务端阻塞项
| 优先级 | 改造项 | 阻塞图片章节 |
| --- | --- | --- |
| P0 | `/v1/im` 统一认证中间件、响应封装、幂等键、租户 Principal | 全部 HTTP 产品能力 |
| P0 | 企业码解析、验证码、密码重置、二次认证、Token `auth_version`、登录换新和撤销 | 1.1~1.11、10.7~10.8 |
| P0 | 普通用户禁止发布系统 Topic;系统通知和业务事件可信生产通道 | 2.5、3.1~3.5、6.4、11.1 |
| P0 | 消息 `x-im-schema/type/client-mid` 校验和服务端幂等 | 2~4 章 |
| P0 | 文件资源级 ACL、Range、ETag、用途绑定和租户存储配额 | 3.6、4.10~4.14、6.27~7.16、9.14、11.4~11.5 |
| P1 | 好友申请、好友关系、黑名单状态机 | 第 3、5 章 |
| P1 | 群成员、角色、禁言、公告、邀请审批和配额 | 第 6 章 |
| P1 | 收藏、转发和聊天记录搜索 | 第 4、7 章 |
| P1 | 通话媒体状态、结果消息、Push 来电 | 第 8 章 |
| P2 | 工作台、组织、会议、会议笔记、日报、月报、信箱 | 第 9 章 |
| P2 | 隐私、青少年模式、反诈、举报、反馈 | 第 10、11 章 |
### 12.3 前后端联调最低验收用例
| 编号 | 场景 | 预期结果 |
| --- | --- | --- |
| T-01 | A 企业 Token 连接 B 企业 Session | 登录失败 `TENANT_MISMATCH`,不产生用户/Topic 数据 |
| T-02 | 企业停用后使用旧 Token 登录和请求 HTTP | WS/HTTP 均拒绝,客户端清除凭证并回企业入口 |
| T-03 | 同 `client_mid` 断线重发同一消息 | 只落一条消息并返回同一 `seq` |
| T-04 | 相同 HTTP 幂等键、相同请求体重复提交 | 返回第一次结果;不同请求体返回 `IDEMPOTENCY_CONFLICT` |
| T-05 | 被拉黑或删除好友后绕过按钮直接 `pub` | 服务端拒绝,对方不收到消息 |
| T-06 | 普通用户构造 `system_event` 或向系统 Topic 发布 | 服务端拒绝,系统消息页不展示可信样式 |
| T-07 | 普通群成员调用管理员、禁言、转让群主接口 | 返回 `FORBIDDEN`,群状态不变且写安全审计 |
| T-08 | 群成员数已达租户配置上限时同意两个并发申请 | 至多一个成功,成员数不超限 |
| T-09 | 非消息接收者持同租户 Token 下载文件 | 返回 `FILE_FORBIDDEN`,不能只通过 file_id 下载 |
| T-10 | 文件下载中断后以有效 Token 登录取得新 Token 并 Range 续传 | 文件完整、checksum 正确、不重复下载已完成分片 |
| T-11 | 风控二次确认后重复重放 risk token | 只有首次有效,其余返回 Token 已消费 |
| T-12 | 切换企业后查看缓存、上传任务、收藏和搜索 | 不出现上一租户数据或任务 |
### 12.4 统一错误 reason
首版客户端至少实现:`TENANT_UNAVAILABLE`、`TENANT_MISMATCH`、`AUTH_FAILED`、`TOKEN_EXPIRED`、`ACCOUNT_DISABLED`、`RATE_LIMITED`、`VALIDATION_FAILED`、`IDEMPOTENCY_CONFLICT`、`RESOURCE_NOT_FOUND`、`FORBIDDEN`、`FRIEND_RELATION_REQUIRED`、`MESSAGE_NOT_ALLOWED`、`GROUP_MUTED`、`GROUP_MEMBER_LIMIT_EXCEEDED`、`TENANT_USER_QUOTA_EXCEEDED`、`TENANT_GROUP_QUOTA_EXCEEDED`、`FILE_TOO_LARGE`、`FILE_FORBIDDEN`、`FILE_UNAVAILABLE`、`STORAGE_QUOTA_EXCEEDED`、`REALNAME_REQUIRED`、`YOUTH_MODE_RESTRICTED`、`RISK_CONFIRMATION_REQUIRED`、`RISK_ACTION_BLOCKED`。
未知 `reason` 使用通用失败文案并记录 `request_id`,不得把服务端 `message` 原样展示给用户。
### 12.5 完成定义
每个图片小节进入联调前,服务端须提供 OpenAPI/JSON Schema、错误码、权限规则和可用测试租户;前端须提供请求日志脱敏、断线/重试行为和四态页面(加载、空、成功、失败)。联调完成必须逐图勾选对应协议、至少覆盖成功、权限拒绝、重复提交、断网恢复和跨租户五类场景。