From 297f1fa0b705d38f7136225725a2d6edc3a4fc27 Mon Sep 17 00:00:00 2001 From: Austin Date: Wed, 14 Oct 2026 16:49:14 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E8=A1=A5=E5=85=85=E6=B3=A8=E5=86=8C=20?= =?UTF-8?q?username=20=E8=A7=84=E5=88=99=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增服务端 username 强校验规则,包括长度、字符限制、自动生成规则及错误返回详情,明确最小长度来自部署配置、上限为 10。 --- docs/IM-前后端对接文档.md | 2 + docs/对接文档最新/IM-前后端对接文档.md | 275 +++++++++++++++++-------- 2 files changed, 186 insertions(+), 91 deletions(-) diff --git a/docs/IM-前后端对接文档.md b/docs/IM-前后端对接文档.md index c2fdaec..35251cb 100644 --- a/docs/IM-前后端对接文档.md +++ b/docs/IM-前后端对接文档.md @@ -335,6 +335,8 @@ X-MeetingIM-APIKey: <预置的普通 API Key> | 登录失败 | `{"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"}}}` | +**注册 `username` 规则(服务端强校验,2026-10-14 确认)**:3~10 个字符,仅允许小写字母 `a-z` 和数字 `0-9`;不允许大写、下划线、点、汉字及任何符号。不传 `username` 只传 `phone` 时服务端自动生成随机用户名(此时 `secret` 使用 `base64(手机号:密码)`)。违规表现:过短返回 `422 policy violation, params.what=auth`(本次联调实测,用户名 `zz` 即此情形);超长或含非法字符返回 `422, params.what=username, reason=USERNAME_INVALID_FORMAT`。最小长度来自部署配置 `basic` 认证的 `min_login_length`(当前部署模板为 3),服务端代码上限为 10。 + ##### 0.5.4.2 启动订阅、会话列表和增量恢复 | 场景 | 客户端发送 | 服务端返回示例 | diff --git a/docs/对接文档最新/IM-前后端对接文档.md b/docs/对接文档最新/IM-前后端对接文档.md index f5acc83..35251cb 100644 --- a/docs/对接文档最新/IM-前后端对接文档.md +++ b/docs/对接文档最新/IM-前后端对接文档.md @@ -60,9 +60,35 @@ API Key 可以被客户端应用提取,因此不能把它当成用户密码或 | WebSocket(支持自定义 Header 的原生客户端) | `wss://{host}/{api_path}v0/channels` | Upgrade Header `X-MeetingIM-APIKey` | 同上 | | 产品 HTTP | `https://{host}/{api_path}v1/im/*` | `Authorization: Bearer ` | 好友、群、收藏、搜索、工作台和设置 | | 文件 HTTP | `POST /v0/file/u/`、`GET /v0/file/s/{file_id}` | 当前用 `X-MeetingIM-Auth: 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 @@ -220,6 +246,18 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。 订阅成功后才允许 `{pub}`、`note read/recv` 等会话操作。断线恢复必须重新执行 `WebSocket Upgrade -> hi -> token login -> sub me -> 恢复 Topic`,同一连接内不得跳过 `hi` 或切换企业码。 +##### 0.5.1.1 WebSocket 心跳与在线状态 + +当前 IM 长连接心跳使用 WebSocket 协议层 Ping/Pong,不使用业务 JSON 消息做保活。服务端每 15 秒发送一次 WebSocket Ping 帧(`pingPeriod=15s`),并期望客户端在 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 仍然必须由客户端预先持有。 @@ -292,11 +330,13 @@ X-MeetingIM-APIKey: <预置的普通 API Key> | 租户握手 `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"}}}` | +| 邀请码注册 | `{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"138...","registration_type":1,"secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"138...","resp":"AbCdE","params":{"challenge_id":"challenge-id"}}],"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","registration_type":1,"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"}}}` | +**注册 `username` 规则(服务端强校验,2026-10-14 确认)**:3~10 个字符,仅允许小写字母 `a-z` 和数字 `0-9`;不允许大写、下划线、点、汉字及任何符号。不传 `username` 只传 `phone` 时服务端自动生成随机用户名(此时 `secret` 使用 `base64(手机号:密码)`)。违规表现:过短返回 `422 policy violation, params.what=auth`(本次联调实测,用户名 `zz` 即此情形);超长或含非法字符返回 `422, params.what=username, reason=USERNAME_INVALID_FORMAT`。最小长度来自部署配置 `basic` 认证的 `min_login_length`(当前部署模板为 3),服务端代码上限为 10。 + ##### 0.5.4.2 启动订阅、会话列表和增量恢复 | 场景 | 客户端发送 | 服务端返回示例 | @@ -313,6 +353,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key> | 类型 | 客户端发送 | 服务端返回/下行示例 | | --- | --- | --- | | 文本消息 | `{"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-reply-1","topic":"p2p...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"text","x-im-client-mid":"mid_reply_1","x-im-reply":{"seq":128,"type":"text","text":"今天晚上一起吃饭吗?","from":"usr..."}},"content":{"text":"好啊"}}}` | `x-im-reply` 必须保留在 `head` 中;服务端不解析业务语义,入库、实时下行和历史读取均原样返回 | | 图片消息 | `{"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}}}` | @@ -324,7 +365,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key> ##### 0.5.4.3.1 会话内系统提示和可点击动作消息 -截图中的灰色提示、入群邀请说明、好友已添加提示等属于会话内系统消息,必须进入对应 P2P 或群 Topic 的消息流,占用 `seq`,参与历史拉取、重连补偿和多端同步。它们不是 `info` 实时业务事件,也不是系统通知列表记录;`info` 只负责触发刷新,真正需要在聊天窗口长期展示的内容必须以 `{data}` 下发并入库。 +截图中的灰色提示、群内邀请确认说明、好友已添加提示等属于会话内系统消息,必须进入对应 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、脚本或非白名单路由。 @@ -365,6 +406,8 @@ X-MeetingIM-APIKey: <预置的普通 API Key> | 删除单条/多条消息 | `{"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。 + **单聊已读/已收回执协议约定(服务端已验证,2026-09-29):** 1. **在线下行形态**:`info.topic` 为话题名(如 `p2p...`),`info.from` 为报读者,`info.what` 为 `recv`/`read`,`info.seq` 为水位。服务端跳过上报会话自身,推送给对端及本人其他在线设备(多 Pod 部署经 Kafka 同步,各 Pod 均会下发)。 @@ -414,7 +457,9 @@ X-MeetingIM-APIKey: <预置的普通 API Key> "event": "group.member.joined", "payload": { "topic": "grp_xxx", - "user_id": "usr_xxx" + "user_id": "usr_xxx", + "member_sync": "single", + "visibility": "all_members" }, "ts": "2026-08-25T10:00:00Z" } @@ -431,7 +476,10 @@ X-MeetingIM-APIKey: <预置的普通 API Key> | 修改好友备注 | 操作人本人 | `{"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"}}}` | 按事件刷新群详情、成员列表、入群申请、公告、禁言或角色权限;除操作人外当前会写系统通知 | +| 单聊消息变更脏标记 | P2P 双方在线会话 | `{"info":{"topic":"usr_target","from":"usr_actor","what":"im","event":"message.mutation","payload":{"topic":"p2p...","seq":12,"version":2,"event":"message.recalled"}}}` | 当前未订阅该 P2P 也要标记会话脏,并调用 `message-mutations` 做补偿同步 | +| 成员加入 | 群 Topic 在线成员 | `{"info":{"topic":"grp...","from":"usr_actor","what":"group","event":"group.member.joined","payload":{"user_id":"usr_xxx","member_sync":"single","visibility":"all_members"}}}` | 全体成员可见;调用 `GET /v1/im/groups/{topic}/members/{user_id}` 刷新单个成员。加入事件同时写入群消息,离线后可按 Topic seq 补拉 | +| 成员退出/移除、角色或禁言变化 | 群主、管理员和被操作人 | `{"info":{"topic":"grp...","from":"usr_actor","what":"group","event":"group.member.left","payload":{"user_id":"usr_xxx","member_sync":"remove","visibility":"admins_and_affected"}}}` | 普通成员不可见;`single` 拉取单成员,`remove` 本地删除,`full` 重新分页拉取全部成员 | +| 其他群业务变化 | 按具体事件授权范围 | `info.what="group"` | 刷新群详情、入群申请、公告或治理权限 | | 会议变化 | 会议参会人 | `{"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"}}}` | 刷新安全验证页面;当前会写系统通知 | @@ -469,16 +517,20 @@ X-MeetingIM-APIKey: <预置的普通 API Key> 客户端侧要求: - App 集成 JPush SDK,初始化使用极光 `AppKey`,不接触 `MasterSecret`。 -- 登录 IM 后将极光 alias 绑定为当前 `user_id`,例如 `usr...`;同一用户多设备允许绑定同一个 alias。 -- SDK 返回 `registration_id` 后调用 `PUT /v1/im/devices/{device_id}/push-token` 上报,`provider` 固定传 `jpush`。 +- 当前测试环境使用 `registration_id` 模式,服务端配置 `jpush_use_alias=0`,不依赖客户端 alias;alias 绑定可保留但不是投递依据。 +- SDK 返回 `registration_id` 后调用 `PUT /v1/im/devices/{device_id}/push-token` 上报,`provider` 固定传 `jpush`。`device_id` 必须稳定,同一台设备不要每次启动变化。 +- 切换账号、切换租户、清登录态后必须重新上报 Push Token;客户端本地的 `user_id/token/registration_id` 去重缓存必须按 `tenant_code + user_id + device_id + registration_id` 隔离,避免把设备继续绑在旧账号上。 +- 若后续切回 alias 模式,登录 IM 后将极光 alias 绑定为当前 `user_id` 的安全别名:`user_id.replace("-", ".").replace("_", "|")`,例如 `usrd6Ag5opa-ww` 绑定为 `usrd6Ag5opa.ww`;同一用户多设备允许绑定同一个 alias。 - 点击 Push 后按 `extras.type` 跳转,再按服务端数据补偿,不能只凭 Push payload 渲染最终状态。 服务端侧要求: - `jpush_master_secret` 只保存在后台配置 `im_jpush` 分组,IM Server 通过内部配置接口读取并调用极光 REST API。 +- 当前测试环境按 `registration_id` 投递:服务端从 `im_device_push_tokens` 按目标 `user_id + provider=jpush` 查询设备 token,并调用极光 `audience.registration_id`。 - 用户在线时优先 WebSocket 实时投递;用户离线、后台或不可投递时生成 Push 任务。 - 万人大群实时 fanout 不靠 Push;离线只对需要提醒的成员生成极光 Push,客户端重连后按 Topic seq 拉取补偿。 - Push payload 默认不带敏感正文,通知栏文案按用户隐私设置降级。 +- iOS 测试环境固定使用 `options.apns_production=false`;客户端包也必须是开发 APNs 环境。TestFlight/App Store 生产包需要后端与客户端同时切到生产 APNs。 极光 Push extras 统一格式: @@ -497,7 +549,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key> | --- | --- | --- | | `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_invite` | `tenant_code,topic,seq,call_id,call_type,caller_id,caller_display,expires_at` | 打开来电页前先登录 IM 并确认通话仍为有效来电。 | | `call_missed` | `tenant_code,topic,call_id` | 打开通话记录或对应会话。 | | `system_notification` | `tenant_code,notification_id` | 打开系统消息列表并刷新详情。 | | `friend_request` | `tenant_code,request_id` | 打开新朋友列表并刷新未处理数。 | @@ -507,22 +559,11 @@ X-MeetingIM-APIKey: <预置的普通 API Key> ```json { - "platform": "all", + "platform": "ios", "audience": { - "alias": ["usr_target"] + "registration_id": ["161a3797c95ee4a8bd3"] }, "notification": { - "alert": "你收到一条新消息", - "android": { - "title": "MeetingIM", - "alert": "你收到一条新消息", - "extras": { - "type": "im_message", - "tenant_code": "default", - "topic": "p2p...", - "seq": "128" - } - }, "ios": { "alert": { "title": "MeetingIM", @@ -550,25 +591,11 @@ X-MeetingIM-APIKey: <预置的普通 API Key> ```json { - "platform": "all", + "platform": "ios", "audience": { - "alias": ["usr_callee"] + "registration_id": ["161a3797c95ee4a8bd3"] }, "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": "语音通话", @@ -576,6 +603,8 @@ X-MeetingIM-APIKey: <预置的普通 API Key> }, "sound": "default", "badge": "+1", + "category": "im_call", + "content-available": 1, "extras": { "type": "call_invite", "tenant_code": "default", @@ -583,18 +612,22 @@ X-MeetingIM-APIKey: <预置的普通 API Key> "seq": "30", "call_id": "call_01K...", "call_type": "audio", - "caller_id": "usr_caller" + "caller_id": "usr_caller", + "caller_display": "张三", + "expires_at": "2026-09-18T10:00:30Z" } } }, "options": { - "time_to_live": 60, + "time_to_live": 30, "apns_production": false, "classification": 1 } } ``` +来电推送必须走普通 APNs/极光 alert 通知,不使用 PushKit VoIP topic;iOS `notification.ios.category` 固定为 `im_call`,用于客户端通知按钮,同时带 `content-available: 1` 唤醒后台 App 拉取 `topic/seq` 并启动 Live Activity。`time_to_live` 和 `expires_at` 以 30 秒左右短有效期为准,过期后服务端不再投递。 + #### 0.5.5 Long Polling 与文件基础示例 ##### 0.5.5.1 WebSocket 与文件基础接口 @@ -603,10 +636,16 @@ X-MeetingIM-APIKey: <预置的普通 API Key> | --- | --- | --- | | `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"}}}` | +| WebSocket `acc new` | `{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"15500000000","registration_type":1,"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 `;业务表单可带 `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 `,可带 `Range: bytes=0-` | 返回文件字节流;失败返回 `FILE_FORBIDDEN` 或 `FILE_UNAVAILABLE` | +| `POST /v0/file/u/` | Multipart 文件;Header `X-MeetingIM-Auth: token `;聊天附件必须带 `topic=<消息所在会话 topic>`(`p2p.../grp...`),可选 `purpose=chat_file`、`id=<幂等ID>` | `{"ctrl":{"code":200,"params":{"expires":"2026-10-13T06:21:20Z","file_id":"s6yhMyiOfaU","url":"/v0/file/s/s6yhMyiOfaU.txt"}}}` | +| `GET /v0/file/s/{file_id}` | Header `X-MeetingIM-Auth: token `,可带 `Range: bytes=0-`;URL 可带文件扩展名(服务端自动剥离) | 对象存储模式下鉴权通过后返回 302 到短期签名 URL;客户端跟随 Location 获取文件 | + +**文件上传/下载鉴权与回收规则(已实现,客户端必须遵守):** + +1. 上传聊天附件(图片/视频/语音/文件)必须携带 `topic=<消息将要发送到的会话 topic>`。服务端校验上传者是该 `p2p`/`grp` 会话的订阅成员(`modewant`/`modegiven` 含读权限),不是成员返回 `403 permission denied`;校验通过则在服务端建立「文件-会话」引用,该会话全体成员凭有效 Token 即可下载。 +2. 不带 `topic` 的上传只有上传者本人可下载(可用于头像等场景)。任何文件若一直未被任何会话/消息引用,服务端在创建 1 小时后由后台任务物理回收;上传后必须及时发送消息或携带 `topic`,否则对端将拿到 404。 +3. 下载放行范围:上传者本人、文件所属会话成员、消息级引用可见者(未撤回未删除且未被执行删除)、群成员、好友关系、收藏引用。非成员返回 `403 permission denied`(即 `FILE_FORBIDDEN`),客户端不自动重试。 ### 0.6 Topic 与产品消息 @@ -633,6 +672,8 @@ X-MeetingIM-APIKey: <预置的普通 API Key> `x-im-client-mid` 由客户端生成 ULID/UUID,作用域为“租户 + 发送人”,断线重试必须复用;服务端目标实现应返回第一次写入的同一 `seq`。未知 `x-im-type` 显示“暂不支持的消息”,不得崩溃或删除消息。 +引用回复使用 `head.x-im-reply` 承载客户端生成的轻量快照,字段为 `seq:number`、`type:string`、`text:string`,可选 `from:string`。该对象必须放在 `head`,不能放入 `content`;服务端只负责把未知 `head` 字段随消息存储、实时转发、历史拉取和 HTTP 查询一起原样返回,不对被引用消息做二次查询或语义校验。客户端兼容历史服务端将 `x-im-reply` 作为 JSON 字符串返回的情况,但新版本客户端发送时必须使用 JSON 对象。 + 消息类型首版固定为:`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 幂等、重试和安全 @@ -665,7 +706,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key> - 成功数据:`tenant{code,name,tenant_desc,branding}`、`endpoints{websocket,api,file}`、`auth_methods`、`password_policy`、`app_update`。不得返回 `tenant_id` 或 `tenant_ticket`。 - 后续:该接口仅用于服务发现、品牌配置或历史企业入口;当前登录页和注册页不依赖它完成主流程。调用方即使解析成功,仍然必须发送 0.5 节的 `hi(tenant)` 做最终租户绑定。 - 目标失败协议:企业不存在、停用或不允许登录统一返回 `TENANT_UNAVAILABLE`;客户端不得区分不存在和停用。网络失败保留输入并允许重试。 -- 个人登录不通过企业码入口解析 `default`,由个人用户入口提交 `login_type=1` 并内部使用 `tenant_code=default`;企业登录提交 `login_type=0`,如果企业码为 `default`,服务端拒绝并统一提示“企业不存在或不可用”。个人注册必须提交 `registration_type=1`,由该字段进入 `default` 注册链路。详见 [IM-个人无企业码注册前后端对接说明.md](../IM-个人无企业码注册前后端对接说明.md)。 +- 个人登录不通过企业码入口解析 `default`,由个人用户入口提交 `login_type=1` 并内部使用 `tenant_code=default`;企业登录提交 `login_type=0`,如果企业码为 `default`,服务端拒绝并统一提示“企业不存在或不可用”。个人注册必须提交 `registration_type=1`,由该字段进入 `default` 注册链路。详见 [IM-个人无企业码注册前后端对接说明.md](IM-个人无企业码注册前后端对接说明.md)。 成功响应示例: @@ -675,7 +716,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key> "data": { "tenant": {"code": "acme", "name": "示例企业", "tenant_desc": null, "branding": {}}, "endpoints": { - "websocket": "wss://im.example.com/v0/channels", + "websocket": "wss://im.example.com/v0/channels?apikey=AQ...", "api": "https://im.example.com/v1/im", "file": "https://im.example.com/v0/file" }, @@ -840,20 +881,21 @@ X-MeetingIM-APIKey: <预置的普通 API Key> 注册页面 -**状态:注册原型和服务端注册分流均已完成;用户名和手机号为独立字段,服务端已接入 username、邀请码和短信开关逻辑。** +**状态:注册原型和服务端注册分流均已完成;邀请码不再使用独立输入框,与短信验证码共用同一输入框。** - 页面规则:用户名和手机号是独立输入。用户名非空时必须保存为 `im_users.username`;手机号非空时必须保存为规范化后的 `im_users.phone`。两个字段同时为空时拒绝注册。 - 注册类型规则:注册页必须提交 int 字段 `registration_type`,`0` 表示企业注册、`1` 表示个人注册;后端按该字段区分个人注册和企业注册,不能通过企业码是否为空推断。`registration_type=1` 时企业码为空,服务端内部使用 `tenant_code=default`;`registration_type=0` 时企业码必填。前端不做企业码拦截,不单独调用 `/v1/im/tenants/resolve`,获取验证码和提交注册时带上 `registration_type=0` 与用户输入的企业码,由后端在注册链路中校验存在且可用;失败统一显示“企业不存在或不可用”。 - 用户名格式:普通用户名必须匹配 `^[a-z0-9]{1,10}$`,即 1~10 位小写英文字母或数字;允许纯数字,不允许大写、中文、空格、点、下划线和其他符号。前端限制输入,服务端按原始值再次校验,失败返回 `USERNAME_INVALID_FORMAT`。 -- 邀请码格式:非空时必须匹配 `^[A-Za-z]{5}$`,固定 5 位大小写英文字母并区分大小写;客户端不得自动转换大小写。服务端在当前租户内按明文精确查询。 +- 统一验证码输入:输入 5 位大小写英文字母时按邀请码处理;输入 6 位数字时按短信验证码处理。其他格式直接提示输入无效,客户端不得自动转换邀请码大小写。 +- 邀请码格式:必须匹配 `^[A-Za-z]{5}$`,服务端在当前租户内按明文精确查询。邀请码有效时允许注册并建立邀请码与新用户的推荐使用关系。 - 邀请码可复用:同一有效邀请码可以供多个用户注册,使用后仍保持 `active`;服务端为每个注册用户记录邀请来源。只有到期或管理员撤销后才不可继续使用。 - 邀请码主数据位于 HotGo 控制库并按 `tenant_id` 隔离;注册使用明细位于当前租户业务库。客户端契约不受分库影响,仍只提交完整 5 位邀请码。 -- 用户名方式:手机号为空时提交用户名、密码和邀请码,不发送短信。邀请码必须有效,注册后 `im_users.username` 保存用户名,`im_users.phone=NULL`。 +- 旧版独立 `invite_code` 字段仅保留协议兼容,新客户端不得再展示独立邀请码输入框。 - 找回提示:用户名方式提交注册前,客户端明确提示“未绑定手机号时无法自助找回密码,忘记密码需联系企业管理员重置”。 - 手机号方式:前端只接受 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。 +- 短信验证码方式:校验成功后返回一次性 `verification_token`,注册成功时建立已完成的 `tel credential`。 +- 邀请码方式:校验成功返回 `verification_mode=invite_code`;注册时把原始邀请码放在 `cred[].resp`,不提交独立 `invite_code`。该方式保存手机号但不建立已完成的 `tel credential`,同时写入邀请码推荐使用关系。 +- 提交注册:`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`,只有短信验证成功时才建立已完成的电话凭证。 @@ -861,19 +903,19 @@ X-MeetingIM-APIKey: <预置的普通 API Key> 用户名方式: ```json -{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"","secret":"base64(alice:password)","invite_code":"AbCdE","login":true,"cred":[]}} +{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"138...","registration_type":1,"secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"138...","resp":"AbCdE","params":{"challenge_id":"challenge-id"}}]}} ``` 手机号验证方式: ```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"}}]}} +{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"138...","registration_type":1,"secret":"base64(alice:password)","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"}}]}} +{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"","phone":"138...","registration_type":1,"secret":"base64(+86138...:password)","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`。 - 安全:用户名、手机号分别在租户内唯一;验证码票据单次消费,邀请码在有效期内可重复使用;密码按服务端策略校验并使用强哈希保存。 @@ -913,7 +955,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key> - 发码/校验:服务端返回 `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` 携带。 +- 成功返回 `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 @@ -973,18 +1015,19 @@ X-MeetingIM-APIKey: <预置的普通 API Key> | 接口 | 输入示例 | 成功 `data` 示例 | | --- | --- | --- | -| `POST /v1/im/tenants/resolve` | Header `X-Tenant-Code: acme`;Body `{"tenant_code":"acme","platform":"ios","app_version":"1.0.0"}` | `{"tenant":{"code":"acme","name":"示例企业","tenant_desc":null,"branding":{}},"endpoints":{"websocket":"wss://api.118im.com/v0/channels","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/tenants/resolve` | Header `X-Tenant-Code: acme`;Body `{"tenant_code":"acme","platform":"ios","app_version":"1.0.0"}` | `{"tenant":{"code":"acme","name":"示例企业","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/{challenge_id}/verify` 邀请码 | `{"code":"AbCdE"}` | `{"verification_mode":"invite_code","invite_accepted":true}` | | `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` | `{}`(可选 `device_id`;或 Header `X-Device-ID`/`X-IM-Device-ID`) | `{"status":"ok","device_id":"...","push_unbound":true}`;当前 Token 写入 Redis 撤销集合(TTL=剩余有效期),仅当前设备失效,不影响其他设备;带 `device_id` 时同步解绑该设备推送 | -| `GET /v1/im/app/releases/latest?platform=ios\|android\|web¤t_version=1.0.0&channel=stable` | Query only | `{"platform":"web","channel":"stable","latest_version":"1.0.0","min_supported_version":"1.0.0","update_type":"none","package_url":"","store_url":"","release_notes":"当前已是最新版本。","remind_after_seconds":0}` | +| `POST /v1/im/auth/logout` | `{}` | `{"status":"ok"}` | +| `GET /v1/im/app/releases/latest?platform=ios\|android\|web¤t_version=1.0.0&channel=stable` | Query only | `{"current_version":"1.0.0","has_update":true,"force_update":false,"platform":"web","channel":"stable","latest_version":"1.1.0","min_supported_version":"1.0.0","update_type":"recommended","package_url":"","store_url":"","release_notes":"修复已知问题。","remind_after_seconds":86400}` | ## 2. 消息首页、全局导航与系统通知 @@ -1118,11 +1161,12 @@ X-MeetingIM-APIKey: <预置的普通 API Key> 双方沟通状态 -**状态:上传基础已实现;媒体消息 Schema、ACL 和恢复需改造。** +**状态:上传带 `topic` 与下载按会话授权已在服务端实现;客户端按本节接入。** -- 选择媒体后本地生成 `client_mid` 和稳定占位;调用文件上传,得到 `file_id` 后再 `pub x-im-type=image|video|voice`。 +- 选择媒体后本地生成 `client_mid` 和稳定占位;调用文件上传(**必须带 `topic=<该会话 topic>`**,服务端校验成员资格并自动建立文件-会话引用),得到 `file_id` 后再 `pub x-im-type=image|video|voice`。 - `content` 至少含 `file_id,name,size,mime,width,height,duration_ms,thumbnail_file_id` 中适用字段;上传进度是客户端任务状态,不写成聊天消息。 -- 服务端发布成功后关联文件与 Topic/消息;文件下载必须校验消息可见性。 +- 服务端在上传阶段即建立文件与会话的引用(带 `topic`);消息发布成功后按消息附件引用建立消息级 ACL;文件下载校验会话成员身份与消息可见性。 +- 媒体文件(缩略图、原图、视频、语音)一律走同一次上传流程并带同一个 `topic`;缩略图也必须挂到会话,否则对端只能看到占位图。 - 上传失败(网络中断、超时、5xx)按**整文件重传**处理:重新调用 `POST /v0/file/u/` 上传完整文件,不做分片/断点续传;每次重传得到新 `file_id`,旧 `file_id` 直接放弃、由服务端 TTL 回收,客户端不得再引用。 - 上传成功但 `pub` 失败时不重新上传,复用已上传 `file_id` 和原 `client_mid` 重发消息,服务端按 `client_mid` 幂等去重。 @@ -1367,8 +1411,8 @@ X-MeetingIM-APIKey: <预置的普通 API Key> **状态:新增。** - `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`;服务端保持源顺序,重新生成目标消息,不能复用原 `seq`。 -- 附件必须建立新的授权引用;原消息已撤回、过期或不可见时该项失败。留言作为每个目标的一条独立文本消息发送。 +- 返回每个目标和源消息的 `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 @@ -1377,6 +1421,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key> **状态:新增。** - 调用 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 内容但分别生成目标消息与授权记录。 @@ -1572,9 +1617,32 @@ Body: #### 4.21.3 消息变更同步水位 -撤回、编辑属于 `seq` 不变的原地变更。在线且已订阅 Topic 时,客户端会收到 `message.recalled` / `message.edited` 实时事件;离线或未订阅时,需要靠会话级水位补偿。 +撤回、编辑属于 `seq` 不变的原地变更。在线且已订阅 Topic 时,客户端会收到 `message.recalled` / `message.edited` 实时事件;单聊中如果客户端在线但当前没有订阅该 P2P,服务端会额外向双方用户通道下发 `message.mutation` 脏标记事件;离线或错过实时事件时,需要靠会话级水位补偿。 -单聊场景中,客户端在线但未订阅该 P2P 时,服务端还会向双方用户通道下发 `message.mutation` 脏标记事件(`what:"im"`、`event:"message.mutation"`,payload 含 `topic/seq/version/event`),客户端收到后同样按下方水位补偿流程拉取;群聊不下发脏标记,仅靠会话列表水位 + 拉取补偿。 +单聊未订阅脏标记示例: + +```json +{ + "info": { + "topic": "usr_target", + "from": "usr_actor", + "what": "im", + "event": "message.mutation", + "payload": { + "topic": "p2pJaOWxMvALjB4i1K_h_NZgQ", + "seq": 12, + "version": 2, + "event": "message.recalled" + } + } +} +``` + +收到 `message.mutation` 后不要直接改本地消息内容,先把 `payload.topic` 标记为脏,再调用: + +```http +GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}&limit=50 +``` 会话列表同步时,`me.meta.sub[].private` 会带: @@ -1642,7 +1710,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} **状态:新增产品通讯录接口。** -- 好友列表:`GET /v1/im/friends?query=&initial=&cursor=`,返回 `user_id,display_name,remark,avatar_file_id,initial,online_visibility`;显示名优先级为备注名、昵称、账号。 +- 好友列表:`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"`,未读来自系统通知业务表。列表缓存以租户和用户隔离。 @@ -1705,7 +1773,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} **状态:新增。** -- `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`。 +- `GET /v1/im/users/{identifier}/profile` 返回按当前查看人裁剪后的 `username,display_name,remark,avatar_file_id,gender,region,public_account,signature,relationship,capabilities`;`identifier` 可传 `user_id`、`username` 或 `public_account`。`username` 是登录账号,`nickname` 是原始昵称,`display_name` 是当前查看人应展示的名称(好友备注优先);三者不得混用。 - 查看该用户二维码调用 `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` 展示,真正呼叫时再次鉴权。隐藏字段必须从响应中省略,不能只由前端遮挡。 @@ -1803,7 +1871,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} | `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..."}` | +| `GET /v1/im/users/{identifier}/profile` | Path `user_id`/`username`/`public_account` | `{"user_id":"usr...","username":"zhangsan7","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":""}` | @@ -1841,7 +1909,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} **状态:新增邀请卡片和加入接口。** -- 邀请卡展示以群邀请接口返回和系统通知记录为准;相关实时刷新使用 `info.what="group"` 群业务事件。 +- 邀请卡展示以群邀请接口返回和系统通知记录为准;系统通知 `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`。 @@ -1861,7 +1929,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} **状态:新增。** -- `GET /v1/im/groups/{topic}/join-requests?status=pending&cursor=` 返回申请人、邀请人、来源、时间、状态和 `version`。 +- `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` 和最终状态。 @@ -1944,6 +2012,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} - 服务端校验成员资格、长度和敏感词,返回 `nickname,version,updated_at` 并推送成员资料变化。 - 群不存在返回 `404/30004 RESOURCE_NOT_FOUND`;群已解散返回 `410/30004 GROUP_DISSOLVED`;非群成员返回 `403/30005 PERMISSION_DENIED`。通用语义见 0.4。 - 该昵称仅作用当前群,不能修改全局用户昵称或其他群资料。 +- 群成员列表和群成员资料中的 `display_name` 仍由服务端按当前登录人计算,好友备注优先;群内昵称单独返回为 `group_nickname`,前端不要用 `nickname/group_nickname` 覆盖 `display_name`。 ### 6.13.1 群成员列表拉取与缓存策略 @@ -2128,8 +2197,8 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} **状态:需改造。** -- 先用 `purpose=group_file,topic=...` 上传,再 `POST /v1/im/groups/{topic}/files`,Body `{"file_id":"..."}` 建立群文件记录。 -- 服务端校验 `member_upload_files`、单文件上限、租户总存储和群成员身份;成功返回完整群文件项。 +- 先用 `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` 非负且与上传记录一致、单文件上限、租户总存储和群成员身份;成功返回完整群文件项。 - 上传失败按整文件重传重新上传并取得新 `file_id`;登记失败不得在列表显示,孤立文件按 TTL 回收。 ### 6.29 接口示例 @@ -2151,18 +2220,20 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} | `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}` | +| `DELETE /v1/im/groups/{topic}` | Header `X-Action-Token: action_token`;Body `{}` | `{"topic":"grp...","deleted":true,"status":"dissolved","version":2}` | | `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":"张三","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?query=&cursor=&mentionable=true` | Query only | `{"topic":"grp...","items":[{"user_id":"usr...","display_name":"张三","group_nickname":"小张","avatar_file_id":"","role":"member","member_status":"active","version":1,"joined_at":"2026-08-20T09:00:00Z","updated_at":"2026-08-20T09:00:00Z","mentionable":true,"acs":{"want":"JRWP","given":"JRWP","mode":"JRWP"}}],"next_cursor":"","sync_mode":"full","permissions":{"can_mention_all":true}}` | +| `GET /v1/im/groups/{topic}/members/{user_id}` | Path topic/user | `{"topic":"grp...","user_id":"usr...","display_name":"张三","group_nickname":"小张","role":"member","member_status":"active","version":1,"joined_at":"2026-08-20T09:00:00Z","updated_at":"2026-08-20T09:00:00Z","sync_mode":"single","acs":{"want":"JRWP","given":"JRWP","mode":"JRWP"}}` | +| `DELETE /v1/im/groups/{topic}/members/{user_id}` | Path topic/user;管理员可移除普通成员,群主可移除管理员和普通成员;不能移除群主或自己 | `{"topic":"grp...","user_id":"usr...","status":"left"}` | | `GET /v1/im/groups/{topic}/members/{user_id}/profile` | Path topic/user;`topic` 必须是 `grp...`,不能传 `usr...`;`user_id` 可传本人,返回 `relationship:"self"`、`capabilities.can_send=true` | `{"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":{"topic":"grp...","name":"项目群","member_count":2},"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","applicant":{"user_id":"usr...","display_name":"张三"},"status":"pending","source":"qr","created_at":"2026-08-20T09:00:00Z","version":1}],"next_cursor":""}` | +| `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}}` | @@ -2269,10 +2340,11 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} 文件-下载 -**状态:底层下载已实现租户校验;资源 ACL 需改造。** +**状态:下载授权已实现(按上传者/会话成员/消息引用/群成员/好友/收藏放行)。** -- 客户端使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-MeetingIM-Auth: token `;禁止 `?auth=&secret=`。 -- 服务端除 Token 租户外,还必须校验该用户通过消息、群文件或收藏拥有有效引用,并支持 `Range`、`ETag`、内容长度和安全文件名。 +- 客户端使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-MeetingIM-Auth: token `;禁止 `?auth=&secret=`。URL 可带扩展名,服务端自动剥离。 +- 服务端按文件 ACL 放行:上传者本人、文件所属 `p2p`/`grp` 会话成员、消息级引用可见者(未撤回未删除且未被执行删除)、群成员、好友关系、收藏引用;非成员返回 `403 permission denied`(`FILE_FORBIDDEN`)。对象存储模式下鉴权通过返回 302 短期签名 URL。 +- 前提:发送方上传附件时带了 `topic`(见 0.5.5.1)。若对端下载一直 404,说明该文件从未关联会话/消息且已被 1 小时回收任务删除,应让发送方重新上传并携带 `topic`。 - 下载前客户端校验本地空间和网络策略;`FILE_FORBIDDEN` 不自动重试,`FILE_UNAVAILABLE` 显示失效。 ### 7.10 文件-下载中.png @@ -2361,10 +2433,10 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} | `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}],"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"}],"failed":[]}` | -| `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"}],"failed":[]}` | +| `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"}` | @@ -2384,11 +2456,12 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} 对方状态 -**状态:在线邀请已实现;Push 与产品鉴权需改造。** +**状态:在线邀请、短有效期普通 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 鉴权。 +- 后台 Push 只携带 `callId,topic,seq,caller_id,caller_display,callType,expires_at`,不含 SDP、ICE 或 Token;iOS 来电 Push 使用普通 alert,`category=im_call`,`content-available=1`,短 TTL 约 30 秒,不使用 PushKit VoIP。 +- 被叫在未发送 `ringing/accept` 前也可以直接发送 `hang-up`,服务端按 `topic+seq+callId` 校验后立即结束主叫侧振铃,并把原通话消息替换为 `declined`;主叫主动取消则替换为 `missed`。 ### 8.3 语音通话-对方待接受状态.png @@ -2510,7 +2583,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} **状态:新增。** -- `GET /v1/im/workbench` 返回 `modules[{code,name,icon_file_id,route,unread_count,enabled,permission}]`、布局版本和企业配置。 +- `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"` 业务事件下发。切租户必须重新加载,不复用另一企业工作台。 @@ -2558,6 +2631,16 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} - 返回我的最终响应和会议版本,并通过 `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=`、`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 日报 @@ -2634,7 +2717,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} **状态:新增。** - 页面配置:`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":[]}`,带幂等键。 +- 提交:`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 @@ -2723,7 +2806,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} **状态:基础文件读取已实现;会议笔记 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}`。两个接口都必须校验当前用户仍有会议笔记查看权限。 +- 原生可预览文件使用受认证的 `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 秒;离开页面立即停止轮询。 @@ -2733,7 +2816,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} **状态:复用统一下载任务;会议笔记 ACL 新增。** -- 下载使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-MeetingIM-Auth: token `,并支持 `Range` 和 `If-Range` 断点续传。 +- 下载使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-MeetingIM-Auth: token `,并支持 `Range` 和 `If-Range` 断点续传;对象存储模式下客户端跟随 302 `Location`。 - 下载任务保存 `file_id,etag,total_bytes,downloaded_bytes,temp_path,state`。Token 更新后使用新 Token 续传,禁止把 Token 写入任务文件或日志。 - 服务端在每次下载及续传时重新校验会议可见权限。返回 `FILE_FORBIDDEN` 时客户端停止重试;返回 `FILE_UNAVAILABLE` 时标记文件失效并刷新详情。 - 下载进度属于客户端本地状态,不更新会议笔记业务状态、不产生工作台通知,也不写入未读数。 @@ -2766,7 +2849,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} | `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-` | 返回文件字节流及 `Content-Type`、`Content-Length`、`ETag`、`Accept-Ranges`;失败返回 `FILE_FORBIDDEN` 或 `FILE_UNAVAILABLE` | +| `GET /v0/file/s/{file_id}` | Header `X-MeetingIM-Auth`;可带 `Range: bytes=0-` | 对象存储模式下鉴权通过后返回 302 到短期签名 URL;客户端跟随 Location 获取文件内容 | ### 9.14.10 字段与状态约定 @@ -2845,6 +2928,10 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} | `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}}` | @@ -2882,8 +2969,9 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} **状态:MeetingIM `me` 资料更新已有基础;目标产品接口需改造。** -- 查询 `GET /v1/im/me/profile` 返回 `avatar_file_id,nickname,birth_date,signature,gender,region,email,public_account,version,field_rules`;`email` 当前仅为资料字段,不用于登录或找回密码。 +- 查询 `GET /v1/im/me/profile` 返回 `username,avatar_file_id,nickname,birth_date,signature,gender,region,email,public_account,phone_mask,version,field_rules`;`username` 为只读登录账号;`phone_mask` 为只读脱敏手机号(例如 `136****3333`),未绑定手机号时返回空字符串;`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 @@ -2957,8 +3045,13 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} **状态:新增 App 配置接口。** -- `GET /v1/im/app/releases/latest?platform=ios|android|web¤t_version=1.0.0&channel=stable`,未登录可在企业解析后调用。 -- 返回 `latest_version,min_supported_version,update_type=none|optional|required,release_notes,store_url,package_url,checksum,published_at`。 +完整接口与 Android OSS APK 对接流程见:[IM-App版本更新前后端对接文档](./IM-App版本更新前后端对接文档.md)。 + +- `GET /v1/im/app/releases/latest?platform=ios|android|web¤t_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。 +- Android 客户端使用 `package_url` 下载 OSS/CDN 上的 APK,并在安装前使用 `checksum` 完成 SHA-256 校验;强制更新时不得绕过进入主业务。 +- 版本策略由 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 @@ -3082,7 +3175,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} | 接口 | 输入示例 | 成功 `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}}}` | +| `GET /v1/im/me/profile` | Query only | `{"username":"zhangsan7","avatar_file_id":"","nickname":"张三","birth_date":"1990-01-01","signature":"签名","gender":"male","region":"上海","email":"a@example.com","public_account":"usr...","phone_mask":"136****3333","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,"friend_request_verification":true,"group_invite_verification":false,"version":1,"editable":{"add_me_methods":true,"profile_visibility":true,"last_seen_visibility":true,"read_receipts":true,"friend_request_verification":true,"group_invite_verification":true}}` | | `PATCH /v1/im/me/privacy` | `{"add_me_methods":{"phone":false},"read_receipts":false,"friend_request_verification":false,"group_invite_verification":true,"version":1}` | `{"add_me_methods":{"account":true,"phone":false,"qr":true,"group":true},"read_receipts":false,"friend_request_verification":false,"group_invite_verification":true,"version":2,"updated_at":"2026-08-20T09:00:00Z"}` |