# Voice Room Mic And Room Management Development Guide 本文档定义语音房“麦位与房间管理”下一阶段开发设计。目标是在现有 `MicUp/MicDown/ChangeMicSeat/MuteUser/KickUser` 基础上补齐权限矩阵、锁麦、聊天开关、管理员管理、解封、时长型管控和 RTC 推流联动。 ## Scope | Capability | Owner | Required Result | | --- | --- | --- | | 麦位状态 | `room-service` | Room Cell 串行维护麦位占用、锁定和换位 | | 房间角色 | `room-service` | owner、host、admin、audience 权限可判定、可恢复 | | 管理命令入口 | `gateway-service` | HTTP 只做鉴权和协议转换,actor 只能来自 access token | | IM 发言守卫 | `gateway-service` + `room-service` | 腾讯 IM 发言前回查禁言、封禁、聊天开关和 presence | | RTC 推流策略 | Client + Tencent RTC | 服务端给出麦位事实,客户端按麦位事实切换 TRTC role 或推流状态 | | 房间系统事件 | `room-service` + Tencent IM | 管理动作进入 outbox,并同步推送房间系统消息 | ## Non-Goals | Non-goal | Reason | | --- | --- | | 不在 gateway 判断房间权限 | gateway 不持有 Room Cell,不能复制权限逻辑 | | 不把 RTC 在线态写进 Room Cell | RTC 连接态属于腾讯 RTC,Room Cell 只保存业务麦位事实 | | 不做客户端 socket 管理 | 长连接和群消息继续由腾讯 IM 承担 | | 不把全局封禁放进 room-service | 全局用户状态属于 user-service 或后台风控服务 | | 不在本阶段做复杂主持排班 | 当前只补房间内即时管理能力 | ## Current State 现有能力: | Feature | Status | Current Behavior | | --- | --- | --- | | 上麦 | `DONE` | `MicUp` 校验用户在 presence、目标麦位存在、未锁、未占用 | | 下麦 | `DONE` | `MicDown` 可把指定 target 从麦位移下 | | 换麦 | `DONE` | `ChangeMicSeat` 原子清空源麦位并占用目标麦位 | | 禁言 | `DONE` | `MuteUser` 写 `MuteUsers` 集合,`CheckSpeakPermission` 拒绝发言 | | 踢人 | `DONE` | `KickUser` 移除 presence、释放麦位、写 `BanUsers` 集合 | | 麦位锁定字段 | `PARTIAL` | `SeatState.locked` 和 `RoomState.MicSeat.Locked` 已存在,但没有命令 | | 管理员集合 | `PARTIAL` | `RoomState.AdminUsers` 和 snapshot `admin_user_ids` 已存在,但没有管理命令 | | 聊天开关字段 | `PARTIAL` | `chat_enabled` 已影响 `CheckSpeakPermission`,但没有命令 | | RTC 发流确认 | `DONE` | `MicUp` 进入 `pending_publish`,客户端或 RTC webhook 确认后进入 `publishing`,超时自动下麦 | 现有主要缺口: - 管理命令没有统一权限矩阵。 - 管理员增删、主持转移、owner 转让未实现。 - 锁麦、聊天开关、解封没有命令。 - 禁言/封禁没有过期时间、原因、操作者审计字段。 - RTC token 仍未按麦位事实动态返回 `anchor/audience`;当前由客户端在 `MicUp` 成功后切换角色并确认发流。 ## Target State 本阶段完成后,房间管理应该满足: - 普通用户只能自助上麦、主动下麦、主动离房和送礼。 - owner/host/admin 可以管理麦位、禁言、踢人和聊天开关。 - 只有 owner 可以增删 admin、转 host;转 owner 推迟到后续房间资产和后台审计阶段。 - 锁麦位后任何角色都不能上麦或换入,必须先解锁。 - 被禁言用户不能发公屏,但仍可收消息、上麦规则由权限策略决定。 - 被踢/ban 用户不能 JoinRoom、不能 IM 进群、不能发言、不能拿 RTC token;已在腾讯 IM 群内的用户必须通过 IM bridge 触发踢出群副作用。 - 所有管理动作写 command log、snapshot、outbox,并可恢复。 - 客户端通过房间系统消息和最新 snapshot 修正本地 UI。 ## Role Model ### Roles | Role | Meaning | Source | | --- | --- | --- | | `owner` | 房间所有者,最高权限 | `RoomState.OwnerUserID` | | `host` | 主持人,负责房间现场管理 | `RoomState.HostUserID` | | `admin` | 房间管理员 | `RoomState.AdminUsers` 中除 owner/host 外的用户 | | `audience` | 普通观众 | `OnlineUsers[user].Role` 或默认值 | 角色判定不要只信 `RoomUser.role`。权威顺序: ```text if actor == owner_user_id -> owner else if actor == host_user_id -> host else if admin_users[actor] -> admin else -> audience ``` `JoinRoom.role` 只能作为展示和默认入房角色,不能让客户端自报 `admin` 获得权限。 ### Permission Matrix | Action | Owner | Host | Admin | Audience | | --- | --- | --- | --- | --- | | 自己上麦 `MicUp` | allow | allow | allow | allow | | 自己下麦 `MicDown(self)` | allow | allow | allow | allow | | 下别人麦 `MicDown(target)` | allow | allow | allow | deny | | 换别人麦 `ChangeMicSeat(target)` | allow | allow | allow | deny | | 锁/解锁麦位 | allow | allow | allow | deny | | 禁言/解禁 | allow | allow | allow | deny | | 踢人/ban | allow | allow | allow | deny | | 解封/unban | allow | allow | deny | deny | | 开关公屏 | allow | allow | allow | deny | | 增删 admin | allow | deny | deny | deny | | 转 host | allow | deny | deny | deny | | 转 owner(后续批次) | allow | deny | deny | deny | | 关房(后续批次) | allow | deny by default | deny | deny | 补充约束: - owner 不能被 host/admin 踢、禁言、下麦或移出管理员集合。 - host 不能被普通 admin 踢、禁言或降级。 - actor 对自己执行 `MuteUser` 和 `KickUser` 默认拒绝,避免错误操作造成不可恢复状态。 - 非 presence 用户不能被设为 host/admin,除非后续明确支持预设管理员。 - 锁麦是硬锁,owner/host/admin 也不能直接上锁定麦位;管理员需要先解锁,或先下麦再锁麦。 - 管理命令 actor 必须在当前房间 presence 中;离房后的 host/admin 不能继续远程管理,重新 JoinRoom 后恢复其持久角色。 ## Command Design 现有命令保留: - `MicUp` - `MicDown` - `ChangeMicSeat` - `MuteUser` - `KickUser` 本阶段新增命令: | Command | Purpose | Notes | | --- | --- | --- | | `SetMicSeatLock` | 锁定或解锁指定麦位 | 首版只允许锁空麦位 | | `SetChatEnabled` | 开启或关闭公屏 | 影响 `CheckSpeakPermission` | | `SetRoomAdmin` | 添加或移除管理员 | 只有 owner 可执行 | | `TransferRoomHost` | 转移主持人 | 只有 owner 可执行 | | `UnbanUser` | 解除房间 ban | 用户可重新 JoinRoom | 后续批次命令: | Command | Reason To Defer | | --- | --- | | `TransferRoomOwner` | 影响房间资产、收益、后台审计和纠纷处理,本阶段只做现场主持转移 | | `CloseRoom` | 属于房间生命周期管理,需要和房间列表、搜索、推荐和恢复策略一起设计 | ### `SetMicSeatLock` 语义: - `locked=true` 锁麦。 - `locked=false` 解锁。 - 首版锁麦只允许目标麦位为空;如果麦位有人,返回 `CONFLICT`。 - 解锁空麦位或已解锁麦位幂等返回当前快照,不重复写事件。 拒绝锁 occupied seat 的原因: - 避免一个命令同时产生“下麦”和“锁麦”两个 UI 事件。 - 如果管理端要清空并锁定,应先 `MicDown(target)`,再 `SetMicSeatLock(seat_no,true)`。 ### `SetChatEnabled` 语义: - `enabled=false` 后 `CheckSpeakPermission` 返回 `chat_disabled`。 - 关闭公屏对所有用户自发公屏消息生效,包括 owner/host/admin。 - 系统消息和管理消息不受影响,因为它们由 room-service 通过腾讯 IM REST 发布。 - 重复设置相同值幂等,不重复写事件。 ### `SetRoomAdmin` 语义: - `enabled=true` 添加管理员。 - `enabled=false` 移除管理员。 - owner/host 永远属于管理集合,不能通过该命令移除。 - target 必须在当前房间 presence 中,避免后台误把不存在用户加入房间管理。 - admin 离房后保留 admin 身份,重新 JoinRoom 后继续具备 admin 权限。 - admin 被 KickUser/ban 后必须从 `AdminUsers` 删除;Unban 后不自动恢复 admin,必须 owner 重新添加。 ### `TransferRoomHost` 语义: - 新 host 必须在 room presence 中。 - 新 host 自动进入 `AdminUsers`。 - 旧 host 是否仍为 admin:首版保留为 admin,避免主持交接后失去管理权限造成现场中断。 - host 主动离房后 `HostUserID` 不自动变化,但离房期间不能执行管理命令。 - 当前 host 不能被 KickUser 直接踢出;owner 必须先 `TransferRoomHost`,再按普通 admin/audience 规则管理旧 host。 ### Deferred `TransferRoomOwner` 语义: - 本阶段不实现 HTTP/RPC,只保留后续语义边界。 - 新 owner 必须在 room presence 中。 - 新 owner 自动成为 owner、host 和 admin。 - 旧 owner 是否降级:首版降级为 admin,不继续是 host,避免双 owner。 - 转 owner 必须写强审计事件。 ### `UnbanUser` 语义: - 从 `BanUsers` 删除 target。 - 不自动 JoinRoom;用户必须重新调用 `JoinRoom`。 - 重复 unban 已不在 ban 集合的用户幂等返回当前快照。 - 不恢复被 KickUser 时移除的 admin 身份、麦位或 presence。 ### `KickUser` / Room Ban Extension `KickUser` 表达“踢出房间并写入房间 ban 集合”。如果本阶段要支持限时 ban,直接把请求和快照契约调整为: ```proto message KickUserRequest { RequestMeta meta = 1; int64 target_user_id = 2; int64 duration_ms = 3; string reason = 4; } ``` 语义: - `duration_ms=0` 表示永久 ban,直到 `UnbanUser`。 - `duration_ms>0` 表示 ban 到 `now + duration_ms`;过期后 JoinRoom、IM 进群和 RTC token 可以重新通过。 - KickUser 成功后必须原子移除 target 的 presence、麦位和 admin 身份,并写 `BanUsers[target]`。 - KickUser 成功提交后,outbox/IM bridge 必须触发腾讯 IM 群成员踢出副作用;副作用失败不回滚 Room Cell,但必须重试。 - 在 IM 踢出副作用成功前,`CheckSpeakPermission` 和 `VerifyRoomPresence` 仍必须拒绝 target,避免其继续发言或重新进群。 ### `MuteUser` Extension 如果需要限时禁言,`MuteUser` 请求和快照契约直接调整为: ```proto message MuteUserRequest { RequestMeta meta = 1; int64 target_user_id = 2; bool muted = 3; int64 duration_ms = 4; string reason = 5; } ``` 语义: - `muted=true,duration_ms=0` 表示永久禁言,直到手动解除。 - `muted=true,duration_ms>0` 表示禁言到 `now + duration_ms`。 - `muted=false` 表示解除禁言,忽略 `duration_ms`。 - 当前 snapshot 仍保留 `mute_user_ids`,后续可追加 `mute_states` 暴露过期时间。 - MuteUser 只阻断公屏发言,不移除 presence、不影响收消息、不改变 RTC 麦位;如果需要离房必须使用 KickUser。 ## Proto Changes 只允许追加字段、消息和 RPC,不能改已有字段含义或编号。 本阶段固定追加: ```proto message SetMicSeatLockRequest { RequestMeta meta = 1; int32 seat_no = 2; bool locked = 3; } message SetMicSeatLockResponse { CommandResult result = 1; RoomSnapshot room = 2; } message SetChatEnabledRequest { RequestMeta meta = 1; bool enabled = 2; } message SetChatEnabledResponse { CommandResult result = 1; RoomSnapshot room = 2; } message SetRoomAdminRequest { RequestMeta meta = 1; int64 target_user_id = 2; bool enabled = 3; } message SetRoomAdminResponse { CommandResult result = 1; RoomSnapshot room = 2; } message TransferRoomHostRequest { RequestMeta meta = 1; int64 target_user_id = 2; } message TransferRoomHostResponse { CommandResult result = 1; RoomSnapshot room = 2; } message UnbanUserRequest { RequestMeta meta = 1; int64 target_user_id = 2; } message UnbanUserResponse { CommandResult result = 1; RoomSnapshot room = 2; } ``` 如果本阶段启用限时 mute/ban,再追加 moderation state 和 `RoomSnapshot` 字段: ```proto message ModerationState { int64 user_id = 1; int64 expires_at_ms = 2; int64 operator_user_id = 3; string reason = 4; } message RoomSnapshot { ... repeated ModerationState mute_states = 16; repeated ModerationState ban_states = 17; } ``` 字段规则: - `mute_user_ids` / `ban_user_ids` 输出当前仍有效的 user_id,供只关心布尔状态的客户端使用。 - `mute_states` / `ban_states` 输出过期时间、原因和操作者,用于完整管理面展示。 - 快照和恢复链路必须使用同一套 state 结构,不能同时维护两套来源。 `RoomCommandService` 追加: ```proto rpc SetMicSeatLock(SetMicSeatLockRequest) returns (SetMicSeatLockResponse); rpc SetChatEnabled(SetChatEnabledRequest) returns (SetChatEnabledResponse); rpc SetRoomAdmin(SetRoomAdminRequest) returns (SetRoomAdminResponse); rpc TransferRoomHost(TransferRoomHostRequest) returns (TransferRoomHostResponse); rpc UnbanUser(UnbanUserRequest) returns (UnbanUserResponse); ``` `TransferRoomOwner` 和 `CloseRoom` 可以放到下一批,如果本阶段只做现场管理。 ## HTTP API Design gateway 追加 HTTP 路由: ```text POST /api/v1/rooms/mic/lock POST /api/v1/rooms/chat/enabled POST /api/v1/rooms/admin/set POST /api/v1/rooms/host/transfer POST /api/v1/rooms/user/unban ``` 所有请求都必须支持 `command_id`: ```json { "room_id": "room_1001", "command_id": "cmd_room_1001_lock_seat_1_10001_1" } ``` ### Command Idempotency `command_id` 是房间命令幂等键,不是链路追踪 ID。 规则: - 幂等作用域为 `(app_code, room_id, command_id)`。 - room-service 持久化 command log 时必须保存 `command_type` 和 payload hash。 - 重复提交相同 `(app_code, room_id, command_id, command_type, payload_hash)` 返回幂等成功,不重复执行 mutate、不重复写 outbox。 - 重复提交相同 `(app_code, room_id, command_id)` 但 `command_type` 或 payload hash 不一致,返回 `CONFLICT`。 - 幂等返回可以携带当前最新 snapshot,但 `CommandResult.applied=false`,客户端按 snapshot `version` 修正 UI。 - 业务 no-op 也必须写入幂等记录或 command log;例如重复 `SetChatEnabled(false)` 不能因为未递增版本而丢失幂等保护。 - `request_id` 只用于追踪,可以每次重试不同;不能参与幂等判定。 ### Lock Mic ```json { "room_id": "room_1001", "command_id": "cmd_lock_1", "seat_no": 3, "locked": true } ``` ### Chat Enabled ```json { "room_id": "room_1001", "command_id": "cmd_chat_off_1", "enabled": false } ``` ### Set Admin ```json { "room_id": "room_1001", "command_id": "cmd_admin_add_10002", "target_user_id": 10002, "enabled": true } ``` ### Transfer Host ```json { "room_id": "room_1001", "command_id": "cmd_host_to_10002", "target_user_id": 10002 } ``` ### Unban ```json { "room_id": "room_1001", "command_id": "cmd_unban_10002", "target_user_id": 10002 } ``` ## RoomState Changes 短期最小变更: ```go type RoomState struct { ... HostUserID int64 ChatEnabled bool AdminUsers map[int64]bool BanUsers map[int64]bool MuteUsers map[int64]bool } ``` 这些字段已存在。 如果本阶段启用时长型禁言和封禁,内部状态替换为: ```go type UserModerationState struct { UserID int64 ExpiresAtMS int64 OperatorUserID int64 Reason string } MuteUsers map[int64]UserModerationState BanUsers map[int64]UserModerationState ``` 开发策略: - snapshot 输出 `mute_user_ids`、`ban_user_ids`、`mute_states` 和 `ban_states` 时必须来自同一份状态。 - 请求里没有 `duration_ms` 时按永久处理,不能依赖旧命令语义分支。 - `CheckSpeakPermission`、`VerifyRoomPresence`、`JoinRoom` 和 `/api/v1/rtc/token` 都只看未过期的 ban/mute。 - 如果本阶段不做限时 ban/mute,就不要追加 `duration_ms`、`mute_states`、`ban_states`;保留 bool set,避免半套协议。 ## Authorization Design 在 room-service 内新增统一权限 helper: ```go type roomRole string const ( roleOwner roomRole = "owner" roleHost roomRole = "host" roleAdmin roomRole = "admin" roleAudience roomRole = "audience" ) func actorRole(state *RoomState, actorUserID int64) roomRole func canManageMic(state *RoomState, actorUserID int64, targetUserID int64) bool func canManageModeration(state *RoomState, actorUserID int64, targetUserID int64) bool func canManageAdmins(state *RoomState, actorUserID int64) bool func canTransferHost(state *RoomState, actorUserID int64) bool ``` 权限失败统一返回: ```text PERMISSION_DENIED ``` 不要在错误信息里暴露过多角色细节,避免客户端据此枚举房间管理结构。服务端日志可以记录: ```text request_id, command_id, room_id, actor_user_id, target_user_id, action, actor_role, denied_reason ``` ## RTC Push Linkage 服务端不直接控制 TRTC 推流,但必须区分“业务占麦成功”和“RTC 已确认发布音频”: | Room State | Client RTC Behavior | | --- | --- | | 用户不在麦位 | TRTC role `audience`,不能发布音频 | | `MicUp` 成功且 `SeatState.user_id == self`、`publish_state=pending_publish` | 客户端切 `anchor` 并 `startLocalAudio`,UI 展示为等待发流确认 | | SDK 本地发流成功后调用 `ConfirmMicPublishing(mic_session_id, room_version, event_time_ms)` | 服务端把当前麦位切到 `publish_state=publishing` | | `pending_publish` 超过 `publish_deadline_ms` 未确认 | 服务端自动 `MicDown(reason=publish_timeout)`,客户端停止上行并切回 `audience` | | `MicDown`/`KickUser`/`LeaveRoom` 后麦位释放 | 客户端停止本地音频上行并切回 `audience` | | `ChangeMicSeat` | 客户端只更新 UI,不需要重进 RTC | | `SetMicSeatLock` | 客户端更新麦位 UI,不影响当前 RTC 连接 | 后续接 RTC webhook 时,payload 必须带 `mic_session_id`、`room_version`、`event_time_ms`。room-service 只接受当前 session 且事件时间不旧于已接受事件的数据,旧 audience/leave 事件不能清理新的上麦会话。 `/api/v1/rtc/token` 首版仍返回 `audience`。后续如果要服务端返回 `anchor`,必须在签 token 时读取 Room Cell 麦位事实: ```text if user is on mic -> role=anchor else -> role=audience ``` 但不要把 RTC 在线态写入 Room Cell。 ## Event Design 新增或复用 room outbox 事件: | Event Type | Trigger | IM Event Type | | --- | --- | --- | | `RoomMicChanged` | MicUp/MicDown/ChangeMicSeat | `room_mic_up/down/change` | | `RoomMicSeatLocked` | SetMicSeatLock | `room_mic_seat_locked` | | `RoomChatEnabledChanged` | SetChatEnabled | `room_chat_enabled_changed` | | `RoomAdminChanged` | SetRoomAdmin | `room_admin_changed` | | `RoomHostTransferred` | TransferRoomHost | `room_host_transferred` | | `RoomUserMuted` | MuteUser | `room_user_muted` | | `RoomUserKicked` | KickUser | `room_user_kicked` | | `RoomUserUnbanned` | UnbanUser | `room_user_unbanned` | ### Event Payload Contract 所有 room outbox 事件和腾讯 IM 系统消息必须使用同一套字段语义,避免客户端按不同来源写两套解析。 | Event Type | Required Payload | | --- | --- | | `RoomMicChanged` | `event_id`、`room_id`、`room_version`、`actor_user_id`、`target_user_id`、`from_seat`、`to_seat`、`action`、`mic_session_id`、`publish_state`、`reason`、`publish_deadline_ms`、`publish_event_time_ms` | | `RoomMicSeatLocked` | `event_id`、`room_id`、`room_version`、`actor_user_id`、`seat_no`、`locked` | | `RoomChatEnabledChanged` | `event_id`、`room_id`、`room_version`、`actor_user_id`、`enabled` | | `RoomAdminChanged` | `event_id`、`room_id`、`room_version`、`actor_user_id`、`target_user_id`、`enabled` | | `RoomHostTransferred` | `event_id`、`room_id`、`room_version`、`actor_user_id`、`old_host_user_id`、`new_host_user_id` | | `RoomUserMuted` | `event_id`、`room_id`、`room_version`、`actor_user_id`、`target_user_id`、`muted`、`expires_at_ms`、`reason` | | `RoomUserKicked` | `event_id`、`room_id`、`room_version`、`actor_user_id`、`target_user_id`、`expires_at_ms`、`reason` | | `RoomUserUnbanned` | `event_id`、`room_id`、`room_version`、`actor_user_id`、`target_user_id` | 事件要求: - `event_id` 由 outbox 生成。 - `room_version` 必须等于命令提交后的版本。 - 同步 IM publish 失败不回滚命令。 - outbox worker 后续补投。 - 客户端按 `event_id` 去重,按 `room_version` 判断是否需要拉最新 snapshot。 - 客户端不能把 `room_mic_up` 当成音频已发布,只能在 `publish_state=publishing` 后展示为真正发流中。 - IM 系统消息只是 UI 通知;KickUser 的群成员踢出是独立 side effect,必须由 outbox/IM bridge 可重试执行。 ### IM Side Effects `KickUser`/ban 成功提交后,需要两个层面的外部效果: | Side Effect | Required Behavior | | --- | --- | | 房间系统消息 | 发布 `room_user_kicked`,让客户端立即关闭麦位、弹出提示、停止 RTC 上行 | | 腾讯 IM 群成员移除 | 调用腾讯 IM REST 把 target 从房间群移出,避免继续收公屏消息 | | 腾讯 IM 失败补偿 | 不回滚 Room Cell;outbox 保留待处理记录,worker 重试直到成功或进入人工告警 | | 失败窗口守卫 | IM 移除成功前,`CheckSpeakPermission` 继续拒绝发言,`VerifyRoomPresence` 继续拒绝进群 | 首版不在 room-service 管理客户端 socket;如果腾讯 IM REST 持续失败,服务端安全边界是“不能发言、不能重新进群、不能拿 RTC token”,但可能短时间继续收到群消息,需要告警和补偿。 ## Recovery Replay 每个新增命令都必须加入: - `command` 模型。 - `command.Serialize/Deserialize`。 - `replay` 分支。 - command log 测试。 恢复语义: | Command | Replay Behavior | | --- | --- | | `SetMicSeatLock` | 设置 `MicSeats[index].Locked` | | `SetChatEnabled` | 设置 `ChatEnabled` | | `SetRoomAdmin` | 增删 `AdminUsers[target]`,但 owner/host 保持 admin | | `TransferRoomHost` | 更新 `HostUserID`,新 host 加入 `AdminUsers` | | `UnbanUser` | 删除 `BanUsers[target]` | | `MuteUser` with duration | 如果恢复时已过期,清理;否则保留 | | `KickUser` with duration | 恢复 presence/mic/admin 移除结果;ban 未过期时保留,已过期时不恢复 ban | 如果支持时长型禁言/封禁,需要有清理 worker 或在 guard 查询时懒清理过期状态。首版建议 guard 查询时懒判断: ```text if expires_at_ms > 0 && now >= expires_at_ms -> treat as not muted/banned ``` 是否立即写回清理结果可以后续由 moderation sweep worker 完成。 恢复时不要重新执行腾讯 IM 踢人副作用;IM side effect 只从 outbox 待投递记录补偿,不从 command replay 直接调用外部服务。 ## Validation Rules | Input | Rule | Error | | --- | --- | --- | | `seat_no` | 必须存在于当前房间麦位 | `INVALID_ARGUMENT` | | `target_user_id` | 必须大于 0 | `INVALID_ARGUMENT` | | actor | 必须来自 `RequestMeta.actor_user_id` | `UNAUTHENTICATED` or `INVALID_ARGUMENT` | | room | 必须 active | `FAILED_PRECONDITION` | | locked occupied seat | 首版拒绝 | `CONFLICT` | | permission denied | actor 权限不足 | `PERMISSION_DENIED` | | target is owner | host/admin 不能管理 owner | `PERMISSION_DENIED` | | self kick/mute | 默认拒绝 | `INVALID_ARGUMENT` or `PERMISSION_DENIED` | | `command_id` duplicate with different payload | 同房间同 command_id 不能复用不同 payload | `CONFLICT` | | `duration_ms` | `>=0`,上限按配置限制,首版建议不超过 30 天 | `INVALID_ARGUMENT` | | `reason` | trim 后最多 128 字符,不写入客户端不可见敏感信息 | `INVALID_ARGUMENT` | | target is current host | 首版不能直接 KickUser,owner 需先转 host | `PERMISSION_DENIED` | ## Implementation Order 1. 新增 room-service 权限 helper 和单测,先接入现有 `MicDown/ChangeMicSeat/MuteUser/KickUser`。 2. 补齐 command_id payload hash 校验,确保重复 command_id 不会静默吞掉不同请求。 3. 追加 proto:`SetMicSeatLock`、`SetChatEnabled`、`SetRoomAdmin`、`TransferRoomHost`、`UnbanUser`;如果本阶段启用限时管控,再追加 moderation state 和 duration 字段。 4. 运行 `make proto`,提交生成的 `.pb.go` 和 `_grpc.pb.go`。 5. 扩展 gateway room client、HTTP handler 和 OpenAPI。 6. 新增 command 模型和 `Deserialize` 分支。 7. 在 room-service 实现新增命令,全部走 `mutateRoom`。 8. 新增 room events,接入 outbox 和腾讯 IM system event。 9. 为 KickUser 接入腾讯 IM 群成员移除 side effect,失败不回滚命令但必须由 outbox worker 补偿。 10. 扩展 recovery replay,确保 replay 不直接调用外部 IM。 11. 补全单元测试、gateway transport 测试和 outbox/IM bridge 补偿测试。 12. 客户端联调麦位 UI、系统消息和 RTC 推流切换。 ## Test Plan ### Permission Tests | Case | Expected | | --- | --- | | audience 下别人麦 | `PERMISSION_DENIED` | | audience 自己下麦 | success | | admin 下 audience 麦 | success | | admin 下 owner 麦 | `PERMISSION_DENIED` | | host 禁言 audience | success | | admin 禁言 host | `PERMISSION_DENIED` | | owner 添加 admin | success | | host 添加 admin | `PERMISSION_DENIED` | | admin 解封用户 | `PERMISSION_DENIED` | | host 解封用户 | success | ### Mic Tests | Case | Expected | | --- | --- | | 锁空麦 | `SeatState.locked=true`,写事件 | | 重复锁同一空麦 | 幂等返回,不重复事件 | | 锁 occupied 麦 | `CONFLICT` | | 解锁已锁麦 | `SeatState.locked=false` | | locked seat MicUp | `PERMISSION_DENIED` | | locked seat ChangeMicSeat | `PERMISSION_DENIED` | | owner 上 locked seat | `PERMISSION_DENIED`,必须先解锁 | ### Chat Tests | Case | Expected | | --- | --- | | SetChatEnabled false | snapshot `chat_enabled=false` | | chat disabled 后发言守卫 | `allowed=false, reason=chat_disabled` | | SetChatEnabled true | 发言守卫恢复正常 | ### Admin And Host Tests | Case | Expected | | --- | --- | | owner 添加 admin | `admin_user_ids` 包含 target | | owner 移除 admin | `admin_user_ids` 不包含 target | | 移除 owner admin | 拒绝 | | TransferRoomHost | `host_user_id` 更新,新 host 在 admin 集合 | | 新 host 管理麦位 | success | | admin 离房后重进 | admin 权限保留 | | Kick admin | admin 身份被移除,Unban 后不自动恢复 | | Kick current host | `PERMISSION_DENIED`,必须先转 host | ### Ban And Unban Tests | Case | Expected | | --- | --- | | KickUser 后 JoinRoom | `PERMISSION_DENIED` | | KickUser 后 VerifyRoomPresence | `present=false, reason=user_banned` | | KickUser 后 CheckSpeakPermission | `allowed=false, reason=user_banned` | | KickUser 后 IM side effect | outbox/IM bridge 触发群成员移除 | | IM side effect 失败 | Room Cell 不回滚,outbox 保留重试 | | 限时 KickUser 过期后 JoinRoom | success | | UnbanUser 后 JoinRoom | success | | 重复 UnbanUser | 幂等 | ### Idempotency Tests | Case | Expected | | --- | --- | | 相同 command_id 相同 payload 重试 | 不重复 mutate,不重复 outbox,返回幂等结果 | | 相同 command_id 不同 payload | `CONFLICT` | | no-op 命令重试 | 不因未递增版本而丢失幂等保护 | ### Event Payload Tests | Case | Expected | | --- | --- | | 每类 room outbox event | 包含文档 payload 矩阵要求字段 | | IM system event | 与 outbox payload 字段语义一致 | | 客户端重复收到同一 event_id | 去重后 UI 不重复变更 | ### Recovery Tests | Case | Expected | | --- | --- | | snapshot 后回放 SetMicSeatLock | locked 状态恢复 | | snapshot 后回放 SetChatEnabled | chat_enabled 恢复 | | snapshot 后回放 SetRoomAdmin | admin 集合恢复 | | snapshot 后回放 TransferRoomHost | host 恢复 | | snapshot 后回放 UnbanUser | ban 集合恢复 | ## Acceptance Criteria - 权限矩阵覆盖所有麦位和房间管理命令。 - 普通用户不能管理他人麦位、禁言、踢人、锁麦或开关公屏。 - owner 可以添加管理员、转 host、管理所有麦位。 - 锁麦命令可用,locked 麦位对所有角色都不能上麦或换入。 - 关闭公屏后腾讯 IM 发言回调被拒绝,打开后恢复。 - 踢人后用户不能 JoinRoom、不能 IM 进群、不能发言、不能拿 RTC token。 - 踢人后 outbox/IM bridge 会补偿执行腾讯 IM 群成员移除;副作用失败不回滚 Room Cell。 - 解封后用户可以重新 JoinRoom,但不会自动恢复 presence。 - 解封后不会自动恢复 admin、host、麦位或 RTC 推流状态。 - 相同 command_id 不同 payload 会被拒绝,no-op 管理命令也具备幂等保护。 - 新增管理动作全部写 command log、snapshot、outbox,并能恢复。 - 同步 IM 失败不回滚房间状态,outbox worker 后续补投。 - 客户端可通过系统消息和 snapshot 正确更新麦位、禁言、踢人、聊天开关和管理员 UI。 ## Remaining Decisions - 是否本阶段启用限时 mute/ban;如果不开启,就不要追加 `duration_ms` 和 `mute_states/ban_states`。 - 上麦后 RTC role 首版由客户端自行切换;gateway RTC token 返回 `anchor` 需要后续读取 Room Cell 麦位事实后再做。