748 lines
29 KiB
Markdown
748 lines
29 KiB
Markdown
# 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 麦位事实后再做。
|