Rocket.Chat 房间封禁(Ban)与解封(Unban)机制全解
被封禁(Banned)用户无法以任何途径重新进入房间,除非管理员显式解封——这是 Rocket.Chat 房间治理(Room Moderation)中最严格的准入控制手段。本文将围绕 docs/features/ban-user.md 中定义的封禁/解封流程,结合 apps/meteor/server/lib、server/lib/rooms 下的真实实现与 server/api/v1/rooms.ts 中的 REST 路由,逐层拆解从权限校验、底层数据变更到联邦(Matrix Federation)回调的完整链路,并给出可直接复用的 REST API 与斜杠命令示例。读完你将能精确回答"封禁到底删了什么、解封后为什么还需要重新邀请"以及"被封禁用户为何所有入口都被拦截"等关键问题。
概述:封禁(Ban)与踢出(Kick)的本质区别
在 Rocket.Chat 中,房间踢人(kick)会直接删除订阅记录,用户可随时重新加入;而封禁(ban)保留订阅记录,但把记录状态置为 BANNED。这两种操作在 ISubscription.ts 的类型定义中可见:
export type SubscriptionStatus = 'INVITED' | 'BANNED';
status: 'BANNED' 形成一道持久化的访问屏障——它既不是一个"已删除"的订阅(可用于被管理员查询到谁被封禁过),又是一条"无效"订阅(无法通过任何准入校验)。此外封禁还能作为联邦网络中的传播信号,这是踢人所不具备的。
权限上,只有持有 ban-user 权限的角色(默认涵盖 admin、房间 owner、moderator)才能发起封禁。封禁的入口共有三个:
- UI:房间内用户信息面板中的 Ban 操作;
- REST API:
POST /v1/rooms.banUser; - 斜杠命令:
/ban @username。
封禁流程(Ban Flow):从权限校验到数据变更
封禁动作在服务端由两条核心函数配合完成:
banUserFromRoomMethod(校验层):server/lib/banUserFromRoom.tsbanUserFromRoom/performUserBan(执行层):server/lib/rooms/banUserFromRoom.ts
1. 校验层 banUserFromRoomMethod 的完整检查链
校验函数接收执行者 fromId 与目标 { rid, username },依次执行:
- 权限校验:
hasPermissionAsync(fromId, 'ban-user', data.rid),权限须作用域于目标房间; - 房间类型是否允许:通过
roomCoordinator.getRoomDirectives(room.t).allowMemberAction(room, RoomMemberActions.BAN, fromId)判断该类型房间是否允许 Ban 动作(不同类型的房间可通过房间协调器配置差异化规则); - 执行者房间访问权:
canAccessRoomAsync(room, fromUser),防止越权操作不在自己房间内的用户; - 目标用户存在性:
Users.findOneByUsernameIgnoringCase(data.username); - 目标是否在房间中:查询
Subscriptions.findOneByRoomIdAndUserId,若不存在订阅则抛出'User is not in this room'; - 是否已处于封禁态:通过
isBannedSubscription(subscription)判断,若已封禁则拒绝(避免重复封禁); - 最后一个 owner 保护:若目标是 owner,使用
Roles.countUsersInRole('owner', room._id)统计房间 owner 数量,若仅剩 1 人则拒绝,抛出'You are the last owner. Please set new owner before banning the user.'。
校验层
server/lib/banUserFromRoom.ts顶部导出的方法实际调用了执行层./rooms/banUserFromRoom,而后者会向联邦发送回调;tests/unit/server/lib/banUserFromRoom.spec.ts 与 tests/unit/server/lib/rooms/banUserFromRoom.spec.ts 中提供了对这两层函数的关键路径覆盖。
2. 执行层 performUserBan 的六步数据库操作
校验通过后,performUserBan(在 server/lib/rooms/banUserFromRoom.ts)负责真正的数据变更:
| 步骤 | 操作 | 底层调用 | 说明 |
|---|---|---|---|
| 1 | 订阅状态置为 BANNED | Subscriptions.banByRoomIdAndUserId |
只改 status,保留记录(与 kick 删除记录形成对比) |
| 2 | 从成员列表剔除 | Users.removeRoomByUserId |
把该房间从用户的 __rooms 数组移除,成员列表不再显示 |
| 3 | 递减房间人数 | Rooms.incUsersCountById(room._id, -1) |
保持 usersCount 与成员一致 |
| 4 | 移除房间级角色 | removeUserFromRolesAsync(user._id, ['moderator', 'owner', 'leader'], room._id) |
仅对频道/私聊群(room.t 为 'c'/'p')执行 |
| 5 | 团队主房间特殊处理 | Team.removeMember(room.teamId, user._id) |
若房间是团队主房间,同步移除团队成员,保证 roster 一致 |
| 6 | 系统消息 + 客户端通知 | Message.saveSystemMessage('user-banned', ...) 与 notifyOnSubscriptionChanged(subscription, 'removed') |
服务端落一条 user-banned 消息;向客户端推送 removed 事件,让客户端断开该房间的数据流/订阅 |
封禁发起人(byUser)的信息会被写入系统消息的 u 字段,保证审计可追溯。
3. 本地动作与联邦传播的回调拆分
执行层区分了两个函数:
performUserBan:仅执行必要的数据库操作,不触发回调。它被注释为 "Executes only the necessary database operations, with no callbacks, to prevent propagation loops",专门用于联邦或其他外部事件驱动的封禁,避免外部事件处理时的回调传播死循环。banUserFromRoom:在调用performUserBan之后执行afterBanFromRoomCallback.run({ bannedUser, userWhoBanned }, room),用于本地动作(UI/API),让封禁正常地向联邦与订阅方传播。
这一设计在 Matrix 联邦场景下意义重大:本地封禁通过 afterBanFromRoom 回调向联邦网络广播,而对端回传的事件则走 performUserBan 的"无回调"路径,从源码结构上规避了双向传播的乒乓效应。
解封流程(Unban Flow):删除记录而非恢复成员身份
解封入口同样有三个:UI 的 "Banned Users" 上下文栏、POST /v1/rooms.unbanUser、斜杠命令 /unban @username。
核心实现位于 server/lib/rooms/executeUnbanUserFromRoom.ts,其流程为:
- 通过
Subscriptions.findOneByRoomIdAndUserId(rid, user._id)定位订阅; - 分支一(invite 态):若订阅处于
INVITED态(即"先解封又被邀请、邀请被接受后又收到 leave 事件"这一特殊时序),只补发user-unbanned系统消息后直接返回; - 分支二(普通封禁态):若订阅不存在则报
error-invalid-subscription;若存在但不是 BANNED 态则报error-user-not-banned; - 真正执行:
Subscriptions.removeById(subscription._id)—— 将封禁订阅整个删除; - 保存
user-unbanned系统消息;发送removed通知并触发房间变更通知; - 回调:
afterUnbanFromRoomCallback.run(...)(供联邦传播解封)。
需要特别强调的是:解封 ≠ 恢复成员身份。由于封禁时已移除 __rooms 与递减 usersCount,解封只做一件事——删除那条 BANNED 订阅,房间人数与成员列表并不会因此自动恢复。因此解封后用户依然不是房间成员,必须由管理员重新邀请(或用户自行加入公开房间)。
禁止再入机制:被封禁用户的所有入口都被拦截
Rocket.Chat 对被封禁用户实行"全方位拦截",无论普通房间还是联邦房间都无法绕过。各入口的拦截点如下:
邀请(API/UI:groups.invite、channels.invite、Add Users)
底层方法 addUsersToRoom 在调用 addUserToRoom 之前先检查是否存在 BANNED 订阅:存在则直接拒绝并返回 error-user-is-banned。该检查位于方法层、处于房间类型分支之前,因此对普通房间与联邦房间一视同仁。UI 侧会弹出警告模态框,提示管理员先解封。
邀请链接(useInviteToken)
useInviteToken 在保存 invite token 或调用 addUserToRoom 之前同样执行 BANNED 检查:命中则返回 error-user-is-banned 且邀请 token 不会被消耗。由于检查发生在 Users.updateInviteToken 之前,经由邀请链接注册后 setUsername 的二次路径同样被封死,没有绕过缝隙。
直接加入(channels.join、joinRoom)
Room.join 在调用 addUserToRoom 之前会先走 canAccessRoom:
- 公开房间 / 团队内公开房间:
canAccessRoom校验器显式检查findOneBannedSubscription,命中即拒绝访问; - 私密房间:
countByRoomIdAndUserId的查询条件排除了 BANNED 订阅(例如仅匹配status: { $exists: false }或特定状态的记录),因此"已是成员"的校验返回 false,访问被拒。
无论公开还是私密,被封禁者都没有"有效订阅",因此准入逻辑全部归于失败。
联邦邀请事件
当 Matrix 联邦 homeserver 向一个本地已被封禁的用户发送邀请时,事件处理位于 apps/ee/packages/federation-matrix/src/events/member.ts 的 handleInvite:它会发现已存在(被封禁的)订阅并直接提前返回,不会新建订阅。用户因此永远不会得到 INVITED 订阅,后续的 handleJoin 永远不会被触达。
推荐的"解封后再入"标准流程
- 解封:通过
POST /v1/rooms.unbanUser、/unban @username或 "Banned Users" 上下文栏删除 BANNED 订阅; - 邀请或加入:解封后即可通过 API/UI/邀请链接重新邀请,公开房间也可正常自行加入。
REST 端点与斜杠命令
REST API
文档定义并可由 server/api/v1/rooms.ts 实现佐证的相关端点为:
| 方法 | 端点 | 描述 | 请求体 / 查询参数 |
|---|---|---|---|
| POST | /v1/rooms.banUser |
封禁用户 | body:roomId + userId 或 username;需认证 |
| POST | /v1/rooms.unbanUser |
解封用户 | body:roomId + userId 或 username;需认证 |
| GET | /v1/rooms.bannedUsers |
列出被封禁用户(分页) | query:roomId、offset、count;需认证 |
请求与响应均通过 ajv 编译的 schema 做运行时校验(如 isRoomsBanUserProps、isRoomsUnbanUserProps、roomsBannedUsersResponseSchema),约束 success/bannedUsers/count/offset/total 等字段。rooms.banUser 与 rooms.unbanUser 均要求调用者已认证(authRequired: true),目标用户会通过 getUserFromParams 从 body 中解析。
以封禁为例的调用形态:
POST /api/v1/rooms.banUser
Content-Type: application/json
X-Auth-Token: <token>
X-User-Id: <userId>
{
"roomId": "GENERAL_ROOM_ID",
"username": "offender"
}
成功返回 { "success": true };失败可能返回 error-invalid-room、error-user-not-banned、error-invalid-subscription 或 401 等。
rooms.bannedUsers 的实现展示了其数据来源:它通过 Subscriptions.findPaginated({ rid: roomId, status: 'BANNED' }, { sort: { ts: 1 }, ... }) 分页查询 BANNED 订阅,再按 u._id 批量反查用户 username 与 name 后返回,且列表按封禁时间正序排列。UI 侧正是借助该端点实现 "Banned Users" 列表的无限滚动分页。
斜杠命令
对应实现位于 server/slashcommands/ban/ban.ts 与 server/slashcommands/ban/unban.ts,客户端注册在 client/startup/slashCommands/ban.ts:
/ban @username # 在房间内封禁指定用户
/unban @username # 在房间内解封指定用户
UI 交互与客户端实现
UI 侧的封禁/解封与文档中列出的 "Banned Users" 功能一一对应:
- Ban 入口:房间内用户信息面板,由
ban-user权限 +roomCanBan+ 联邦规则共同门控;客户端动作封装在 client/views/room/hooks/useUserInfoActions/actions/useBanUserAction.tsx; - 客户端 Hook:封禁/解封分别封装于 client/views/room/hooks/useBanUser.tsx 与 client/views/room/hooks/useUnbanUser.tsx,后者附有单元测试 client/views/room/hooks/useUnbanUser.spec.ts;
- 被封禁用户列表:"Banned Users" 标签位于房间工具箱(toolbox)中,图标 key 为
ban、顺序位为 13,需要ban-user权限,使用虚拟滚动并通过GET /v1/rooms.bannedUsers无限分页加载,整体位于 client/views/room/contextualBar/BannedUsers/; - 解封入口:被封禁列表每一项的右键/上下文菜单;
- 确认弹窗:封禁与解封均使用
danger变体的GenericModal二次确认,解封模态框可参考 BannedUsersUnbanModal.tsx。
系统消息与消息流
整个流程会产生两类系统消息,作为审计与前端展示的依据:
| key | 触发时机 |
|---|---|
user-banned |
用户被从房间封禁时 |
user-unbanned |
用户被解封时(包括"解封后重新加入/被添加"触发的同步场景) |
关键文件速查
| 层次 | 文件 |
|---|---|
| API 路由 | server/api/v1/rooms.ts |
| 校验与权限 | server/lib/banUserFromRoom.ts |
| 核心封禁逻辑 | server/lib/rooms/banUserFromRoom.ts |
| 核心解封逻辑 | server/lib/rooms/executeUnbanUserFromRoom.ts |
| 解封服务端入口 | server/lib/unbanUserFromRoom.ts |
| 斜杠命令 | server/slashcommands/ban/ban.ts、server/slashcommands/ban/unban.ts |
| 联邦解封/封禁回调 | server/lib/callbacks/afterUnbanFromRoomCallback.ts |
| 客户端封禁 Hook | client/views/room/hooks/useBanUser.tsx |
| 客户端解封 Hook | client/views/room/hooks/useUnbanUser.tsx |
| Ban 动作(用户信息) | client/views/room/hooks/useUserInfoActions/actions/useBanUserAction.tsx |
| Banned Users UI | client/views/room/contextualBar/BannedUsers/ |
| 订阅类型定义 | packages/core-typings/src/ISubscription.ts |
| REST 类型定义 | packages/rest-typings/src/v1/rooms.ts |
| 订阅模型类型 | packages/model-typings/src/models/ISubscriptionsModel.ts |
| 单元测试 | tests/unit/server/lib/banUserFromRoom.spec.ts、tests/unit/server/lib/rooms/banUserFromRoom.spec.ts |
小结:记住五个关键点
- 封禁保留订阅并将
status置为BANNED;踢人是删除订阅——二者本质不同; - 封禁会同步移除
__rooms记录、递减usersCount、清理房间角色,并在团队主房间场景同步移出团队; - 解封只是删除 BANNED 订阅,不会恢复成员身份,必须重新邀请/加入;
- 被封禁用户的每一条入室路径(邀请、邀请链接、直接加入、联邦邀请)都在方法层或
canAccessRoom校验层被拦截,不存在绕过; - 本地动作会触发
afterBanFromRoom/afterUnbanFromRoom回调向 Matrix 联邦传播,而外部事件驱动的操作走无回调的performUserBan路径,从结构上避免传播循环。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00