将 meeting.* 事件族从 Task 5 的 PublishToUser 循环升级为完整 WS 信令协议:
抽离统一广播层、扩容 MediaOrchestrator 接口、实现 8 个 C→S 事件业务逻辑
+ Redis 资源追踪 + host 权限校验,端到端 18/18 PASS。
核心产出:
- 新建 MeetingBroadcaster(统一广播层,REST/WS 共用)
- 新建 MeetingSignalService 8 C→S 事件 + cleanupUserResources
- 新建 MeetingWSHandler 薄层 controller
- MediaOrchestrator 扩容 9 方法 + NoopMediaOrchestrator 占位(Task 7 替换)
- C→S 白名单机制防恶意伪造广播事件
- Redis Set 资源追踪防 mediasoup 端资源泄漏
文档同步:
- docs/api/frontend/meeting.md 追加 §WebSocket 信令协议(Task 6)200 行
- docs/progress/CURRENT_STATUS.md + project-context.mdc + 实施计划 Task 6 ✅
Made-with: Cursor
23 KiB
会议模块 API (Meeting) — Phase 2e-2 MVP
通用规范(认证、响应包络、通用错误码)见 README.md 会议内实时信令(Transport / Producer / Consumer / 控制事件)通过 WebSocket 完成,见 websocket.md
实施状态:本文档对应 Phase 2e-2 Task 5 / Task 6 已落地的 12 个 REST 接口 + 13 个 WebSocket 信令事件,统一前缀 /api/v1/meeting(REST)与 /ws(WebSocket),全部需要 JWT 认证。Task 5 完成时间:2026-04-21;Task 6 完成时间:2026-04-21。
设计口径:以 docs/plans/2026-04-21-phase2e-2-design.md §6.2 为单一事实来源(SSOT)。
路径总览
| # | 方法 | 路径 | 业务 | 权限 |
|---|---|---|---|---|
| 1 | POST | /api/v1/meeting/rooms |
创建即时会议 | 已登录且当前不在其他活跃会议 |
| 2 | GET | /api/v1/meeting/rooms/mine |
我发起/参与过的最近会议 | 已登录 |
| 3 | GET | /api/v1/meeting/rooms/:code |
会议详情 + 成员列表 | 当前活跃参会者 |
| 4 | POST | /api/v1/meeting/rooms/:code/join |
加入会议 | 已登录 |
| 5 | POST | /api/v1/meeting/rooms/:code/leave |
离开会议 | 当前活跃参会者 |
| 6 | POST | /api/v1/meeting/rooms/:code/end |
结束会议 | host |
| 7 | POST | /api/v1/meeting/rooms/:code/transfer-host |
转让主持人 | host |
| 8 | POST | /api/v1/meeting/rooms/:code/kick |
移除成员 | host |
| 9 | POST | /api/v1/meeting/rooms/:code/invite |
邀请用户(走通知中心) | 当前活跃参会者 |
| 10 | POST | /api/v1/meeting/invite-tokens/:token/redeem |
兑换邀请链接 | 已登录 |
| 11 | POST | /api/v1/meeting/rooms/:code/chats |
发送会议内文字消息 | 当前活跃参会者 |
| 12 | GET | /api/v1/meeting/rooms/:code/chats |
拉取会议历史消息(游标分页) | 当前活跃参会者 |
路径参数约定::code 为用户可见的 9 位会议号 XXX-XXX-XXX;:token 为 32 位十六进制邀请令牌。
通用约定
响应包络
成功:
{ "code": 0, "message": "success", "data": { ... }, "trace_id": "...", "time": "2026-04-21 16:30:00" }
失败:
{ "code": 400, "message": "会议号或密码错误", "trace_id": "...", "time": "2026-04-21 16:30:00" }
其中 HTTP 状态码与 code 一致:200 OK / 201 Created / 400 Bad Request / 403 Forbidden / 404 Not Found / 500 Internal Server Error。
领域错误码映射
所有领域错误以 message 中文字面量为准,由 controller 层 handleError 统一映射:
| HTTP | 领域错误 | 触发场景 |
|---|---|---|
| 404 | 会议不存在 |
:code 无匹配记录 |
| 403 | 仅主持人可操作 |
非 host 调用 end / transfer-host / kick |
| 400 | 会议已结束 |
会议 status=2 时再次操作 |
| 400 | 会议已满员 |
活跃参会人数 ≥ max_members |
| 400 | 会议需要密码 |
房间设密但请求未携带 |
| 400 | 会议密码错误 |
bcrypt 校验失败 |
| 400 | 密码尝试过多,请稍后再试 |
同 (user, code) 5 次内错,Redis 锁 10 分钟 |
| 400 | 你当前未在会议中 |
操作要求活跃参会者但调用方 left_at 非空 |
| 400 | 你已在会议中 |
已活跃时重复 join |
| 400 | 你当前已在其他会议中 |
用户已在其它活跃会议中,违反单点参会 |
| 400 | 邀请链接已失效 |
redis key 过期或被兑换 |
| 400 | 会议号冲突,请重试 |
生成房间号 5 次重试仍冲突(理论无发生) |
| 400 | 不能踢自己 |
kick 目标为自己 |
| 400 | 不能将主持人转让给自己 |
transfer-host 目标为自己 |
| 400 | 目标用户不是当前活跃参会者 |
transfer-host / kick 对象 left_at 非空 |
| 500 | {fallbackMsg} + 原 error |
未识别异常(DB 故障等) |
1. 创建即时会议
POST /api/v1/meeting/rooms
请求体
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| title | string | 是 | 会议标题,1~200 字符 |
| password | string | 否 | 入会密码明文,4~20 字符;服务端 bcrypt 哈希存储 |
| max_members | int | 否 | 容量上限,2~8(MVP 硬上限 8,超过将被截断为 8) |
响应 201 Created
{
"code": 0,
"data": {
"room": {
"id": 12,
"room_code": "835-000-036",
"title": "T5 验证会议",
"host_id": 16,
"type": 1,
"has_password": false,
"max_members": 4,
"status": 1,
"status_label": "进行中",
"started_at": "2026-04-21 16:30:10",
"settings": "{}",
"created_at": "2026-04-21 16:30:10",
"online_count": 1
}
}
}
错误:你当前已在其他会议中 (400) / 会议号冲突,请重试 (400)。
2. 我的会议列表
GET /api/v1/meeting/rooms/mine
查询参数
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| status | int | 否 | 过滤状态:0=未开始 / 1=进行中 / 2=已结束;不传=全部 |
| before_id | int64 | 否 | 游标:仅返回 id < before_id 的记录 |
| limit | int | 否 | 页大小,默认 20,最大 50 |
响应 200 OK
{
"code": 0,
"data": {
"list": [
{ "id": 12, "room_code": "835-000-036", "title": "T5 验证会议", "status": 1, ... }
],
"has_more": false
}
}
说明:返回 host + 参会者两类记录合并后的最近会议,按 id DESC 排序。
3. 会议详情
GET /api/v1/meeting/rooms/:code
权限:调用方必须是该会议的当前活跃参会者(host 或 participant,left_at IS NULL)。
响应 200 OK
{
"code": 0,
"data": {
"room": { "id": 12, "room_code": "835-000-036", ... },
"participants": [
{ "id": 30, "room_id": 12, "user_id": 16, "role": 1, "role_label": "主持人", "is_active": true, "joined_at": "...", "duration": 0 },
{ "id": 31, "room_id": 12, "user_id": 17, "role": 0, "role_label": "参会者", "is_active": true, "joined_at": "...", "duration": 0 }
],
"online_count": 2
}
}
错误:会议不存在 (404) / 你当前未在会议中 (400)。
4. 加入会议
POST /api/v1/meeting/rooms/:code/join
请求体
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| password | string | 条件 | 房间 has_password=true 时必传 |
校验顺序:房间存在 → 未结束 → 单点参会(若已在其他活跃会议则 400)→ 密码锁定(5 次错误封禁 10 分钟)→ 密码校验 → 容量 → 写 participant → 广播 meeting.member.joined。
首次加入:创建 meeting_participants 新行。
复入(之前 left):复用原行重置 left_at=NULL、joined_at=now、duration=0,避免审计表膨胀。
响应 200 OK
{
"code": 0,
"data": {
"room": { ... },
"participant": { ... },
"router_id": "stub-router-835-000-036"
}
}
router_id 当前为 Noop 占位,Task 7 接入 Node media-server 后改为真实 mediasoup Router ID,前端据此建立 WebSocket 订阅。
5. 离开会议
POST /api/v1/meeting/rooms/:code/leave
行为:
participant.left_at = now、duration = EXTRACT(EPOCH FROM now - joined_at);- 若离开者是 host 且仍有其他活跃成员 → 自动将 host 转让给最早加入的活跃成员,广播
meeting.host.changed; - 若房间无剩余活跃成员 → 标记
status=2 ended_reason=empty_ttl并触发 mediaOrchestrator.CloseRouter; - 广播
meeting.member.left。
响应 200 OK
{ "code": 0, "data": { "duration": 185 } }
6. 结束会议
POST /api/v1/meeting/rooms/:code/end — host 专用。
行为:所有活跃成员强制离会(left_reason=host_end),房间 status=2 ended_reason=host_ended,广播 meeting.room.ended,调用 mediaOrchestrator.CloseRouter。
错误:仅主持人可操作 (403)。
7. 转让主持人
POST /api/v1/meeting/rooms/:code/transfer-host
请求体
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| target_user_id | int64 | 是 | 新 host 的用户 ID,必须是当前活跃参会者且不是自己 |
行为:meeting_rooms.host_id 更新 + meeting_participants.role 对调(事务),广播 meeting.host.changed。
错误:仅主持人可操作 (403) / 不能将主持人转让给自己 (400) / 目标用户不是当前活跃参会者 (400)。
8. 踢出成员
POST /api/v1/meeting/rooms/:code/kick
请求体
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| user_id | int64 | 是 | 被踢用户 ID |
行为:标记 left_at=now、left_reason=kicked;对被踢者定向推送 meeting.member.kicked(前端收到后跳首页),同时房间广播 meeting.member.left。
错误:仅主持人可操作 (403) / 不能踢自己 (400) / 目标用户不是当前活跃参会者 (400)。
9. 邀请用户
POST /api/v1/meeting/rooms/:code/invite
请求体
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| invitee_ids | int64[] | 是 | 被邀请用户 ID 数组,1~50 个,自动去重 |
行为:对每个 invitee:
- 若该用户已在本会议活跃 → 跳过;
- 生成 32 位十六进制 Token 写 Redis key
echo:meeting:invite:{token},TTL 600 秒(MeetingInviteTokenTTL),value ={"room_code","inviter_id","invitee_id","has_password"}; - 通过 Phase 2e-1
notify.Pusher.PushBatch推送type=meeting_invite通知,Extra含room_code / room_title / has_password / invite_token; - 离线被邀请者走通知入库,上线后由 WS 或未读轮询获得。
响应 200 OK
{ "code": 0, "data": { "pushed": 1, "skipped": 0 } }
出于安全考虑,响应体不包含 token(token 仅通过通知 Extra 定向下发给被邀请者)。
10. 兑换邀请链接
POST /api/v1/meeting/invite-tokens/:token/redeem
行为:查询 Redis key,若存在则返回 room_code + inviter_id + has_password,前端据此决定弹密码框再调 POST /rooms/:code/join。Token 兑换后保留 60 秒冗余(不立即删除,允许用户刷新页面二次兑换),随后由 Redis TTL 自然过期。
响应 200 OK
{
"code": 0,
"data": {
"room_code": "835-000-036",
"inviter_id": 16,
"has_password": false
}
}
错误:邀请链接已失效 (400)。
11. 发送会议内聊天
POST /api/v1/meeting/rooms/:code/chats
请求体
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| content | string | 是 | 消息文本,1~500 字符 |
行为:写 meeting_chats 后向房间内活跃成员广播 WS 事件 meeting.chat,载荷为响应中的 message 对象。
响应 201 Created
{
"code": 0,
"data": {
"message": {
"id": 18,
"room_id": 12,
"user_id": 17,
"content": "hello",
"created_at": "2026-04-21 16:32:05"
}
}
}
12. 拉取会议历史聊天
GET /api/v1/meeting/rooms/:code/chats
查询参数
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| before_id | int64 | 否 | 游标:仅返回 id < before_id 的消息 |
| limit | int | 否 | 页大小,默认 30,最大 100 |
响应 200 OK
{
"code": 0,
"data": {
"list": [
{ "id": 18, "room_id": 12, "user_id": 17, "content": "hello", "created_at": "..." }
],
"has_more": false
}
}
保留策略:定时任务每日清理 status=2 AND ended_at < NOW() - 24h 的房间聊天记录(不进入 IM 消息流)。
WebSocket 信令协议(Task 6)
全部会议相关实时信令走 /ws?token=<access_token> 统一通道,共 13 个 meeting.* 事件:8 个客户端→服务端(C→S)操作事件 + 5 个服务端→客户端(S→C)广播事件 + 2 个补充业务事件(聊天 + 被踢定向推送,与 REST 广播复用)。
帧格式
所有消息使用 ws.Message 统一信封(JSON):
| 字段 | 类型 | 说明 |
|---|---|---|
| event | string | 事件名,如 meeting.room.join |
| seq | int64 | 客户端自增序号;服务端 ACK 原样回传,用于客户端匹配请求-响应 |
| data | object | 业务载荷 |
| code | int | 仅 ACK / 广播带:0=成功,-1=业务失败 |
| message | string | 仅 ACK:失败时的人类可读原因(与 REST 领域错误口径一致) |
| time | string | ISO8601 时间戳 |
ACK 约定:每一个 C→S 事件服务端都会回发 <event>.ack,seq 与请求一致;成功时 code=0,失败时 code=-1 且 message 含中文原因(如 仅主持人可执行此操作、会议不存在、你当前未在会议中)。
连接级鉴权:Token 校验见 websocket.md 连接管理;会议内事件额外在每次调用时校验 (user_id, room_code) 是否为当前活跃参会者,非法请求全部以 ACK 形式返回 -1,不会触发 401/403 HTTP 错误。
事件总览
| # | 方向 | 事件 | 用途 | 服务端动作 |
|---|---|---|---|---|
| 1 | C→S | meeting.room.join |
前端建立 WS 后声明加入某会议房间频道 | 验证活跃参会,记录 WS 会话 |
| 2 | C→S | meeting.room.leave |
前端主动离开 WS 会议频道 | 清理媒体资源(transport / producer / consumer) |
| 3 | C→S | meeting.member.state.changed |
成员更新自己的麦克风/摄像头;host 可指定 target_user_id 强制静音他人 |
广播 S→C 同名事件,权限不符返回 ACK code=-1 |
| 4 | C→S | meeting.transport.create |
请求创建 mediasoup WebRtcTransport(send/recv) | 调用 mediaOrchestrator.CreateTransport,返回 id/iceParameters/iceCandidates/dtlsParameters |
| 5 | C→S | meeting.transport.connect |
提交 DTLS Parameters 完成 transport 连接 | mediaOrchestrator.ConnectTransport |
| 6 | C→S | meeting.produce.start |
创建 Producer(上行流) | mediaOrchestrator.CreateProducer,成功后广播 meeting.member.producer.new |
| 7 | C→S | meeting.consume.start |
创建 Consumer(订阅对端 Producer) | mediaOrchestrator.CreateConsumer |
| 8 | C→S | meeting.producer.close |
关闭自己的 Producer | mediaOrchestrator.CloseProducer,广播 meeting.member.producer.new (closed=true) |
| 9 | S→C | meeting.member.joined |
新成员加入广播(复用 REST /join) | 房间广播 |
| 10 | S→C | meeting.member.left |
成员离开广播(REST /leave /kick 或 WS leave) | 房间广播 |
| 11 | S→C | meeting.member.kicked |
定向通知被踢者(REST /kick) | PublishToUser |
| 12 | S→C | meeting.host.changed |
主持人变更 | 房间广播 |
| 13 | S→C | meeting.room.ended |
会议被结束(REST /end 或空房 TTL) | 房间广播 |
| 14 | S→C | meeting.member.state.changed |
成员状态变化(静音/关摄像头/举手) | 房间广播 |
| 15 | S→C | meeting.member.producer.new |
成员开启/关闭媒体流 | 房间广播,closed=true 表示关闭 |
| 16 | S→C | meeting.chat |
会议内聊天(REST /chats) | 房间广播 |
说明:客户端仅注册 C→S 白名单中的 8 个事件(见
app/constants/meeting.go:MeetingWSClientEvents),其余meeting.*事件若由客户端发送均被静默丢弃,防止恶意客户端伪造广播。
客户端白名单(C→S)详细契约
1. meeting.room.join
请求载荷
{ "room_code": "835-000-036" }
ACK code=0:data 为空对象;失败原因:会议不存在 / 你当前未在会议中。
说明:WS 加入仅用于开启该房间的事件推送通道,REST /join 已将 participant 写入 DB,此事件不会再次修改 participant 表。
2. meeting.room.leave
请求载荷
{ "room_code": "835-000-036" }
ACK code=0:data 为空对象;服务端同时发起媒体资源清理:按 Redis key echo:meeting:resources:{room_id}:{user_id} 中记录的 transport / producer / consumer 依次调用 mediaOrchestrator.CloseTransport/CloseProducer/CloseConsumer,随后删除该 key。
说明:此事件不会将 participant.left_at 置空,若用户希望真正离会仍需调用 REST POST /rooms/:code/leave。此设计允许客户端在 WS 重连时调用 leave + join 刷新资源而不退出会议。
3. meeting.member.state.changed
请求载荷
{
"room_code": "835-000-036",
"target_user_id": 17,
"audio_enabled": false,
"video_enabled": true,
"hand_raised": false
}
target_user_id可选:省略或等于自己时为自我操作,任何人均可;指定为他人时需调用方为 host,否则 ACKcode=-1 message=仅主持人可执行此操作。audio_enabled / video_enabled / hand_raised均为可选布尔字段,至少传一个。
ACK code=0:data 为空。广播 meeting.member.state.changed:
{
"room_code": "835-000-036",
"user_id": 17,
"audio_enabled": false,
"video_enabled": true,
"hand_raised": false,
"actor_id": 16
}
actor_id 为触发变更的 user_id(host 强制静音场景下与 user_id 不同)。
4. meeting.transport.create
请求载荷
{ "room_code": "835-000-036", "direction": "send" }
direction:"send" 或 "recv"。
ACK code=0 data:
{
"id": "transport-abc",
"iceParameters": { ... },
"iceCandidates": [ ... ],
"dtlsParameters": { ... }
}
资源追踪:服务端将 transport-id 按 echo:meeting:resources:{room_id}:{user_id} 的 Redis Set 记录,TTL 1 小时。
5. meeting.transport.connect
请求载荷
{
"room_code": "835-000-036",
"transport_id": "transport-abc",
"dtls_parameters": { ... }
}
ACK code=0:data 为空。
6. meeting.produce.start
请求载荷
{
"room_code": "835-000-036",
"transport_id": "transport-abc",
"kind": "audio",
"rtp_parameters": { ... }
}
kind: "audio" | "video"。
ACK code=0 data:
{ "producer_id": "producer-xyz" }
同时广播 meeting.member.producer.new:
{
"room_code": "835-000-036",
"user_id": 16,
"producer_id": "producer-xyz",
"kind": "audio",
"closed": false
}
7. meeting.consume.start
请求载荷
{
"room_code": "835-000-036",
"transport_id": "transport-recv",
"producer_id": "producer-xyz",
"rtp_capabilities": { ... }
}
ACK code=0 data:
{
"id": "consumer-def",
"producerId": "producer-xyz",
"kind": "audio",
"rtpParameters": { ... }
}
资源追踪:consumer-id 也会进入 Redis resources Set。
8. meeting.producer.close
请求载荷
{ "room_code": "835-000-036", "producer_id": "producer-xyz" }
ACK code=0:data 为空。广播 meeting.member.producer.new closed=true:
{
"room_code": "835-000-036",
"user_id": 16,
"producer_id": "producer-xyz",
"closed": true
}
服务端广播(S→C)详细契约
| 事件 | 载荷字段 | 说明 |
|---|---|---|
meeting.member.joined |
room_code / participant 对象 | REST /join 触发 |
meeting.member.left |
room_code / user_id / reason | REST /leave /kick 或 WS 资源清理 |
meeting.member.kicked |
room_code / reason | 仅发给被踢者本人,对应 REST /kick |
meeting.host.changed |
room_code / old_host_id / new_host_id | REST /transfer-host 或 host 离会自动转让 |
meeting.room.ended |
room_code / reason(host_ended / empty_ttl) |
REST /end 或空房 TTL |
meeting.member.state.changed |
见白名单 #3 | WS meeting.member.state.changed 触发 |
meeting.member.producer.new |
见白名单 #6 / #8 | WS meeting.produce.start / meeting.producer.close 触发 |
meeting.chat |
message 对象 | REST /chats 触发 |
架构
- MeetingWSHandler(controller 层):thin adapter,仅负责 ws.Hub 事件注册 + JSON 反序列化 + ACK 回写;位于
app/meeting/controller/meeting_ws_handler.go。 - MeetingSignalService(service 层):承载 8 个 C→S 事件的业务逻辑(活跃参会校验、host 权限校验、mediaOrchestrator 调用、Redis 资源追踪、广播),位于
app/meeting/service/meeting_signal_service.go。 - MeetingBroadcaster(service 层):封装
BroadcastToMeeting(查询活跃 participant 列表 → 逐个PubSub.PublishToUser)与PublishToUser,供 REST / WS 两个入口统一使用,位于app/meeting/service/meeting_broadcaster.go。 - MediaOrchestrator(interface):定义 9 个 mediasoup 操作方法;Task 6 使用
NoopMediaOrchestrator占位(返回 stub IDs),Task 7 替换为HTTPMediaOrchestrator对接 Node media-server。
错误处理
- 鉴权失败(token 无效 / 未传):WS 握手时直接 4001 关闭,不进入业务层。
- 业务失败(非参会者 / 非 host / 会议已结束):ACK
code=-1+ 中文message,与 REST 领域错误口径完全一致。 - mediasoup 调用失败:
message形如mediaOrchestrator error: <原始错误>,前端可据此降级。 - WS 连接断开:服务端
OnDisconnect钩子会对该用户所有活跃会议调用cleanupUserResources(遍历 Redisecho:meeting:resources:*:{user_id}完成 transport / producer / consumer 关闭),防止服务端资源泄漏。
验证记录
- Task 5 端到端验证脚本(
/tmp/meeting_t5_test.sh)结果:19/19 PASS,覆盖 12 接口的 happy path 与 5 类错误路径(密码错误 / 房间不存在 / 单点参会冲突 / 非 host 越权 / 邀请链接失效)。 - Task 6 端到端 WS 测试脚本(
/tmp/meeting_ws_t6_test.mjs)结果:18/18 PASS,覆盖:meeting.room.join双端 ACKmeeting.member.state.changed自我静音(ACK + 对端广播)- host 强制静音他人(ACK)
- 非 host 强制静音他人 → ACK
code=-1 message=仅主持人可执行此操作 meeting.transport.create双方向(send / recv)meeting.transport.connectmeeting.produce.start+meeting.member.producer.new广播meeting.consume.startmeeting.producer.close+meeting.member.producer.new closed=true广播meeting.room.leave+meeting.member.left广播- 不存在会议号
meeting.room.join→ ACKcode=-1
后续任务关联
- Task 7:Go → Node HTTP 客户端,将
NoopMediaOrchestrator替换为HTTPMediaOrchestrator,接入真实 mediasoup Router,此时 WS 白名单事件契约与本文档完全不变,仅iceParameters / producer_id等字段由 stub 变为真实值。 - Task 8:Vue 前端 mediasoup-client 接入,按本文 WS 契约实现
mediasoup.Transport的connect / produce回调。 - Task 13:通知卡片 UI 补齐
meeting_invite内联按钮。