diff --git a/docs/IM-前后端对接文档.md b/docs/IM-前后端对接文档.md index 36fccaf..8674c67 100644 --- a/docs/IM-前后端对接文档.md +++ b/docs/IM-前后端对接文档.md @@ -451,7 +451,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key> | 好友申请通过 | 申请人、通过人本人 | `{"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":"relationship.blocked","payload":{"user_id":"usr_actor"}}}`;取消拉黑:`{"info":{"topic":"usr_actor","from":"usr_actor","what":"im","event":"relationship.unblocked","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` 重新拉取消息详情或修订记录 | @@ -494,16 +494,20 @@ X-MeetingIM-APIKey: <预置的普通 API Key> 客户端侧要求: - 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`。 +- 当前测试环境使用 `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 统一格式: @@ -532,22 +536,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", @@ -575,25 +568,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": "语音通话", @@ -1779,7 +1758,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} **状态:新增。** - 开启拉黑:`PUT /v1/im/blocks/{user_id}`;解除:`DELETE /v1/im/blocks/{user_id}`,均带幂等键。 -- 成功返回 `blocked,relationship,updated_at` 并向当前用户其他设备推送 `privacy.block.updated`。拉黑后服务端拦截对方消息和呼叫。 +- 成功返回 `blocked,relationship,updated_at` 并向当前用户其他设备推送 `relationship.blocked`;解除拉黑推送 `relationship.unblocked`。事件 `topic` 为操作人自己的 `usr_actor`,`payload.user_id` 为操作人 ID;客户端收到后刷新黑名单、好友列表和会话 context。拉黑后服务端拦截对方消息和呼叫。 - 解除拉黑只恢复黑名单 ACL;若双方已非好友,`relationship` 仍为 `none`,不能自动恢复好友。 ### 5.11 删除好友提示.png @@ -1985,6 +1964,45 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} - 该昵称仅作用当前群,不能修改全局用户昵称或其他群资料。 - 群成员列表和群成员资料中的 `display_name` 仍由服务端按当前登录人计算,好友备注优先;群内昵称单独返回为 `group_nickname`,前端不要用 `nickname/group_nickname` 覆盖 `display_name`。 +### 6.13.1 群成员列表拉取与缓存策略 + +**状态:已按分页 + 服务端页缓存实现。** + +客户端进入普通群聊会话时不要主动全量拉取群成员列表;群详情中的 `member_count`、当前用户权限和会话上下文足够渲染聊天页。只有进入群成员页、管理员候选页、禁言候选页、转让群主候选页或输入 `@` 搜索成员时,才调用 `GET /v1/im/groups/{topic}/members`。 + +请求参数: + +| 参数 | 类型 | 用法 | +| --- | --- | --- | +| `topic` | Path string | 群 Topic,例如 `grpORCbFk4mAzk`。 | +| `query` | Query string,可选 | 成员搜索关键字。空或省略表示拉当前筛选条件下的成员页;有值时服务端按群昵称、用户昵称、备注展示名、用户名等可搜索字段匹配。`@` 人、候选人选择器必须优先传 `query`,不要先拉全量再本地筛。 | +| `cursor` | Query string,可选 | 分页游标。第一页省略或传空;下一页必须使用上一次响应的 `next_cursor`,客户端不得自行拼游标。 | +| `limit` | Query int,可选 | 每页数量,默认 `30`,最大 `100`。万人群场景建议普通列表 `30`,搜索/候选页 `30`,不要循环预拉完整群成员。 | +| `mentionable` | Query bool,可选 | `@` 成员选择器传 `true`,服务端只返回当前可被提及的成员,并在响应里返回 `permissions.can_mention_all`;普通成员列表省略或传 `false`。 | +| `role` | Query enum,可选 | 按现有角色筛选:`owner`、`admin`、`member`。例如管理员列表传 `role=admin`。 | +| `eligible_role` | Query enum,可选 | 候选角色视图:`admin` 表示添加管理员候选,`owner` 表示转让群主候选;响应项会带 `eligible`、`ineligible_reason`。 | +| `mute_eligible` | Query bool,可选 | 禁言候选选择器传 `true`;响应项会带 `eligible`、`ineligible_reason`。该参数用于响应阶段计算候选状态,不改变公共成员页缓存维度。 | + +响应重点字段:`items[]` 返回 `user_id,display_name,group_nickname,avatar_file_id,role,mentionable,acs,eligible,ineligible_reason`;`next_cursor` 为空表示没有下一页;`permissions.can_mention_all` 只表示当前登录用户是否可以使用 `@所有人`。 + +典型调用: + +| 场景 | 调用方式 | 说明 | +| --- | --- | --- | +| 进入普通群聊 | 不调用成员列表 | 只用群详情、会话上下文和 `member_count`。 | +| 群成员第一页 | `GET /v1/im/groups/{topic}/members?limit=30` | 打开成员列表时拉第一页。 | +| 群成员下一页 | `GET /v1/im/groups/{topic}/members?cursor={next_cursor}&limit=30` | 只使用服务端返回的 `next_cursor`。 | +| 成员搜索 | `GET /v1/im/groups/{topic}/members?query=张&limit=30` | `query` 变化时清空本地结果,从第一页重新拉。 | +| `@` 选择器 | `GET /v1/im/groups/{topic}/members?query=张&mentionable=true&limit=30` | 只展示 `mentionable=true` 的成员;是否展示 `@所有人` 看 `permissions.can_mention_all`。 | +| 管理员列表 | `GET /v1/im/groups/{topic}/members?role=admin&limit=30` | 群设置页展示管理员。 | +| 添加管理员候选 | `GET /v1/im/groups/{topic}/members?eligible_role=admin&query=张&limit=30` | 用 `eligible=false` 和 `ineligible_reason` 禁用不可选项。 | +| 转让群主候选 | `GET /v1/im/groups/{topic}/members?eligible_role=owner&query=张&limit=30` | 只允许选择符合转让条件的成员。 | +| 禁言候选 | `GET /v1/im/groups/{topic}/members?mute_eligible=true&query=张&limit=30` | 管理员选择禁言对象时使用。 | + +客户端本地缓存建议按 `tenant_code + my_user_id + topic + query + cursor + limit + role + eligible_role + mentionable + mute_eligible` 建 key,只缓存已加载页,并且只在当前页面生命周期内复用。收到 `group.member.joined`、`group.member.left`、`group.member.updated`、`group.settings.updated` 等群业务事件后,应清理该 `topic` 的本地成员列表、候选人和 `@` 搜索缓存;只发生用户资料/备注变化时,可刷新当前可见页或对应用户资料。 + +服务端以 `topic + role + eligible_role + query + cursor + limit + mentionable` 作为 Redis 成员页缓存维度,同一页短时间重复请求会命中页缓存。`mute_eligible`、好友备注、当前查看人关系等 viewer-specific 字段不进入公共页缓存,而是在 HTTP 响应阶段按当前登录人实时补齐。成员加入、退出、角色/昵称/状态变化时按 `topic` 清理相关页缓存。该接口不承载在线状态,在线/离线应走 presence 或会话状态逻辑。 + ### 6.14 群简介.png 群简介 @@ -2010,7 +2028,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} **状态:新增。** -- `POST /v1/im/groups/{topic}/qrcodes`,Body `{"expires_in_seconds":86400}`,返回签名 `qr_token,qr_payload,expires_at,group_snapshot`。 +- `POST /v1/im/groups/{topic}/qrcodes`,Body `{"expires_in_seconds":86400}`,返回签名 `qr_token,qr_payload,expires_at,group,group_snapshot`;`group` 与 `group_snapshot` 内容一致,前端优先使用 `group`。 - 扫码调用 `POST /v1/im/qrcodes/resolve`,`purpose:"join_group"`;随后 `POST /v1/im/groups/{topic}/join-requests` 并携带 `qr_token`。 - 群解散、二维码过期或管理员撤销后必须失效;二维码不得直接承载内部群权限。 @@ -2157,7 +2175,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} | `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}/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}` | @@ -2994,7 +3012,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} **状态:新增。** -- `GET /v1/im/me/privacy` 返回 `add_me_methods,profile_visibility,last_seen_visibility,read_receipts,group_invite_verification,version` 和各字段 `editable`;`group_invite_verification=true` 表示别人邀请我入群时需要走入群审批。 +- `GET /v1/im/me/privacy` 返回 `add_me_methods,profile_visibility,last_seen_visibility,read_receipts,friend_request_verification,group_invite_verification,version` 和各字段 `editable`;`friend_request_verification=true` 表示别人加我为好友时需要我确认,`false` 时符合来源规则的好友申请自动通过;`group_invite_verification=true` 表示别人邀请我入群时需要走入群审批。 - `PATCH /v1/im/me/privacy` 只提交变更字段和 `version`;成功返回完整最新配置并向其他设备推送 `privacy.updated`。 - 黑名单入口使用 5.12。服务端在搜索、主页、回执等实际读写路径执行隐私规则,不能只保存开关。 @@ -3102,8 +3120,8 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} | `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 | `{"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,"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/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"}` | | `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"}` | diff --git a/docs/IM-数据库变更汇总.sql b/docs/IM-数据库变更汇总.sql index 2c4b0ba..a970ef7 100644 --- a/docs/IM-数据库变更汇总.sql +++ b/docs/IM-数据库变更汇总.sql @@ -712,6 +712,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_user_privacy_settings ( profile_visibility VARCHAR(32) NOT NULL DEFAULT 'friends', last_seen_visibility VARCHAR(32) NOT NULL DEFAULT 'friends', read_receipts TINYINT NOT NULL DEFAULT 1, + friend_request_verification TINYINT NOT NULL DEFAULT 1, group_invite_verification TINYINT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 1, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), diff --git a/docs/IM-系统设计文档.md b/docs/IM-系统设计文档.md index cfba600..1b778ca 100644 --- a/docs/IM-系统设计文档.md +++ b/docs/IM-系统设计文档.md @@ -423,6 +423,16 @@ stateDiagram-v2 | 转让群主 | 二次确认;同一事务更新 owner、双方角色、Topic Owner/ACL、审计和 Outbox | | 解散群聊 | 标记群状态、踢出成员、停用二维码和审批入口 | +### 10.3 群成员列表读取与缓存 + +群成员列表按需分页读取,不作为进入会话的必拉数据。普通聊天页只依赖群详情、当前用户权限和 `member_count`;成员页、候选人选择器和 `@` 搜索才调用成员列表接口。 + +服务端成员列表查询接口为 `GET /v1/im/groups/{topic}/members`。参数含义:`query` 用于搜索成员,空值表示读取当前筛选条件下的成员页;`cursor` 必须来自上一页 `next_cursor`;`limit` 默认 `30`,最大 `100`;`mentionable=true` 用于 `@` 选择器,只返回可被提及成员;`role=owner|admin|member` 用于按现有角色筛选;`eligible_role=admin|owner` 用于添加管理员和转让群主候选视图;`mute_eligible=true` 用于禁言候选视图。候选视图的 `eligible/ineligible_reason` 在 HTTP 响应阶段按当前登录人的权限和目标成员状态计算。 + +成员页按 `im_group_members.id` 游标分页,常规列表走 `(topic, member_status, id)` 索引,角色筛选走 `(topic, role, member_status, id)` 索引,搜索和候选列表必须复用分页查询,不得提供一次返回全群成员的接口。万人群场景中,客户端打开群聊不拉成员列表;打开成员页只拉第一页;搜索、`@`、管理员候选、禁言候选和转让群主候选都带 `query` 缩小结果集。 + +成员页缓存使用 Redis,缓存维度为 `topic + role + eligible_role + query + cursor + limit + mentionable`,缓存内容为成员页结果和 `next_cursor`。`mute_eligible`、好友备注、当前查看人关系等 viewer-specific 字段不进入公共页缓存,而是在 HTTP 响应阶段按当前登录人补齐。成员加入、退出、角色变更、群昵称变更、成员状态变化、资料投影刷新等写操作必须调用 `invalidateGroupMemberPages(topic)` 清理该群所有成员页缓存。 + ## 11. 收藏、转发与搜索设计 ### 11.1 收藏 diff --git a/docs/sql/IM-隐私设置加好友验证SQL.sql b/docs/sql/IM-隐私设置加好友验证SQL.sql new file mode 100644 index 0000000..4f7c122 --- /dev/null +++ b/docs/sql/IM-隐私设置加好友验证SQL.sql @@ -0,0 +1,37 @@ +-- Add personal privacy switch for requiring approval when others add the user as a friend. +-- Default is on to preserve the current "friend request needs approval" behavior. +-- Run this for every tenant business database. The current test environment uses: +-- default -> tinode +-- system -> meeting_im + +SET @im_schema = 'tinode'; +SET @im_sql = ( + SELECT IF( + COUNT(*) = 0, + CONCAT('ALTER TABLE ', @im_schema, '.im_user_privacy_settings ADD COLUMN friend_request_verification TINYINT NOT NULL DEFAULT 1 AFTER read_receipts'), + 'SELECT 1' + ) + FROM information_schema.columns + WHERE table_schema = @im_schema + AND table_name = 'im_user_privacy_settings' + AND column_name = 'friend_request_verification' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +SET @im_schema = 'meeting_im'; +SET @im_sql = ( + SELECT IF( + COUNT(*) = 0, + CONCAT('ALTER TABLE ', @im_schema, '.im_user_privacy_settings ADD COLUMN friend_request_verification TINYINT NOT NULL DEFAULT 1 AFTER read_receipts'), + 'SELECT 1' + ) + FROM information_schema.columns + WHERE table_schema = @im_schema + AND table_name = 'im_user_privacy_settings' + AND column_name = 'friend_request_verification' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; diff --git a/docs/对接文档最新/IM-前后端对接文档.md b/docs/对接文档最新/IM-前后端对接文档.md index 30886d3..1b0a148 100644 --- a/docs/对接文档最新/IM-前后端对接文档.md +++ b/docs/对接文档最新/IM-前后端对接文档.md @@ -411,7 +411,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key> | 好友申请通过 | 申请人、通过人本人 | `{"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":"relationship.blocked","payload":{"user_id":"usr_actor"}}}`;取消拉黑:`{"info":{"topic":"usr_actor","from":"usr_actor","what":"im","event":"relationship.unblocked","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` 重新拉取消息详情或修订记录 | @@ -1710,7 +1710,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} **状态:新增。** - 开启拉黑:`PUT /v1/im/blocks/{user_id}`;解除:`DELETE /v1/im/blocks/{user_id}`,均带幂等键。 -- 成功返回 `blocked,relationship,updated_at` 并向当前用户其他设备推送 `privacy.block.updated`。拉黑后服务端拦截对方消息和呼叫。 +- 成功返回 `blocked,relationship,updated_at` 并向当前用户其他设备推送 `relationship.blocked`;解除拉黑推送 `relationship.unblocked`。事件 `topic` 为操作人自己的 `usr_actor`,`payload.user_id` 为操作人 ID;客户端收到后刷新黑名单、好友列表和会话 context。拉黑后服务端拦截对方消息和呼叫。 - 解除拉黑只恢复黑名单 ACL;若双方已非好友,`relationship` 仍为 `none`,不能自动恢复好友。 ### 5.11 删除好友提示.png @@ -1915,6 +1915,45 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} - 服务端校验成员资格、长度和敏感词,返回 `nickname,version,updated_at` 并推送成员资料变化。 - 该昵称仅作用当前群,不能修改全局用户昵称或其他群资料。 +### 6.13.1 群成员列表拉取与缓存策略 + +**状态:已按分页 + 服务端页缓存实现。** + +客户端进入普通群聊会话时不要主动全量拉取群成员列表;群详情中的 `member_count`、当前用户权限和会话上下文足够渲染聊天页。只有进入群成员页、管理员候选页、禁言候选页、转让群主候选页或输入 `@` 搜索成员时,才调用 `GET /v1/im/groups/{topic}/members`。 + +请求参数: + +| 参数 | 类型 | 用法 | +| --- | --- | --- | +| `topic` | Path string | 群 Topic,例如 `grpORCbFk4mAzk`。 | +| `query` | Query string,可选 | 成员搜索关键字。空或省略表示拉当前筛选条件下的成员页;有值时服务端按群昵称、用户昵称、备注展示名、用户名等可搜索字段匹配。`@` 人、候选人选择器必须优先传 `query`,不要先拉全量再本地筛。 | +| `cursor` | Query string,可选 | 分页游标。第一页省略或传空;下一页必须使用上一次响应的 `next_cursor`,客户端不得自行拼游标。 | +| `limit` | Query int,可选 | 每页数量,默认 `30`,最大 `100`。万人群场景建议普通列表 `30`,搜索/候选页 `30`,不要循环预拉完整群成员。 | +| `mentionable` | Query bool,可选 | `@` 成员选择器传 `true`,服务端只返回当前可被提及的成员,并在响应里返回 `permissions.can_mention_all`;普通成员列表省略或传 `false`。 | +| `role` | Query enum,可选 | 按现有角色筛选:`owner`、`admin`、`member`。例如管理员列表传 `role=admin`。 | +| `eligible_role` | Query enum,可选 | 候选角色视图:`admin` 表示添加管理员候选,`owner` 表示转让群主候选;响应项会带 `eligible`、`ineligible_reason`。 | +| `mute_eligible` | Query bool,可选 | 禁言候选选择器传 `true`;响应项会带 `eligible`、`ineligible_reason`。该参数用于响应阶段计算候选状态,不改变公共成员页缓存维度。 | + +响应重点字段:`items[]` 返回 `user_id,display_name,group_nickname,avatar_file_id,role,mentionable,acs,eligible,ineligible_reason`;`next_cursor` 为空表示没有下一页;`permissions.can_mention_all` 只表示当前登录用户是否可以使用 `@所有人`。 + +典型调用: + +| 场景 | 调用方式 | 说明 | +| --- | --- | --- | +| 进入普通群聊 | 不调用成员列表 | 只用群详情、会话上下文和 `member_count`。 | +| 群成员第一页 | `GET /v1/im/groups/{topic}/members?limit=30` | 打开成员列表时拉第一页。 | +| 群成员下一页 | `GET /v1/im/groups/{topic}/members?cursor={next_cursor}&limit=30` | 只使用服务端返回的 `next_cursor`。 | +| 成员搜索 | `GET /v1/im/groups/{topic}/members?query=张&limit=30` | `query` 变化时清空本地结果,从第一页重新拉。 | +| `@` 选择器 | `GET /v1/im/groups/{topic}/members?query=张&mentionable=true&limit=30` | 只展示 `mentionable=true` 的成员;是否展示 `@所有人` 看 `permissions.can_mention_all`。 | +| 管理员列表 | `GET /v1/im/groups/{topic}/members?role=admin&limit=30` | 群设置页展示管理员。 | +| 添加管理员候选 | `GET /v1/im/groups/{topic}/members?eligible_role=admin&query=张&limit=30` | 用 `eligible=false` 和 `ineligible_reason` 禁用不可选项。 | +| 转让群主候选 | `GET /v1/im/groups/{topic}/members?eligible_role=owner&query=张&limit=30` | 只允许选择符合转让条件的成员。 | +| 禁言候选 | `GET /v1/im/groups/{topic}/members?mute_eligible=true&query=张&limit=30` | 管理员选择禁言对象时使用。 | + +客户端本地缓存建议按 `tenant_code + my_user_id + topic + query + cursor + limit + role + eligible_role + mentionable + mute_eligible` 建 key,只缓存已加载页,并且只在当前页面生命周期内复用。收到 `group.member.joined`、`group.member.left`、`group.member.updated`、`group.settings.updated` 等群业务事件后,应清理该 `topic` 的本地成员列表、候选人和 `@` 搜索缓存;只发生用户资料/备注变化时,可刷新当前可见页或对应用户资料。 + +服务端以 `topic + role + eligible_role + query + cursor + limit + mentionable` 作为 Redis 成员页缓存维度,同一页短时间重复请求会命中页缓存。`mute_eligible`、好友备注、当前查看人关系等 viewer-specific 字段不进入公共页缓存,而是在 HTTP 响应阶段按当前登录人实时补齐。成员加入、退出、角色/昵称/状态变化时按 `topic` 清理相关页缓存。该接口不承载在线状态,在线/离线应走 presence 或会话状态逻辑。 + ### 6.14 群简介.png 群简介 @@ -1940,7 +1979,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} **状态:新增。** -- `POST /v1/im/groups/{topic}/qrcodes`,Body `{"expires_in_seconds":86400}`,返回签名 `qr_token,qr_payload,expires_at,group_snapshot`。 +- `POST /v1/im/groups/{topic}/qrcodes`,Body `{"expires_in_seconds":86400}`,返回签名 `qr_token,qr_payload,expires_at,group,group_snapshot`;`group` 与 `group_snapshot` 内容一致,前端优先使用 `group`。 - 扫码调用 `POST /v1/im/qrcodes/resolve`,`purpose:"join_group"`;随后 `POST /v1/im/groups/{topic}/join-requests` 并携带 `qr_token`。 - 群解散、二维码过期或管理员撤销后必须失效;二维码不得直接承载内部群权限。 @@ -2087,7 +2126,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} | `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/{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}/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}` | @@ -2904,7 +2943,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} **状态:新增。** -- `GET /v1/im/me/privacy` 返回 `add_me_methods,profile_visibility,last_seen_visibility,read_receipts,group_invite_verification,version` 和各字段 `editable`;`group_invite_verification=true` 表示别人邀请我入群时需要走入群审批。 +- `GET /v1/im/me/privacy` 返回 `add_me_methods,profile_visibility,last_seen_visibility,read_receipts,friend_request_verification,group_invite_verification,version` 和各字段 `editable`;`friend_request_verification=true` 表示别人加我为好友时需要我确认,`false` 时符合来源规则的好友申请自动通过;`group_invite_verification=true` 表示别人邀请我入群时需要走入群审批。 - `PATCH /v1/im/me/privacy` 只提交变更字段和 `version`;成功返回完整最新配置并向其他设备推送 `privacy.updated`。 - 黑名单入口使用 5.12。服务端在搜索、主页、回执等实际读写路径执行隐私规则,不能只保存开关。 @@ -3012,8 +3051,8 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} | `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/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"}` | | `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"}` |