hyapp-server/docs/语音房麦位房管开发.md

748 lines
29 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 连接态属于腾讯 RTCRoom 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 Celloutbox 保留待处理记录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 | 首版不能直接 KickUserowner 需先转 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 麦位事实后再做。