# 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 麦位事实后再做。
