- 新增 MediaServerConfig + config.{dev,docker}.yaml 的 media_server 段
(base_url / internal_token / timeout_ms / close_timeout_ms / close_retry)
- 新建 HTTPMediaOrchestrator(8 方法 + sync.Map 缓存 roomCode→routerID
+ 关闭类指数退避重试 200/500ms + ErrMediaResourceNotFound/ErrMediaServerError
两类错误)实现 MediaOrchestrator 接口
- wire 绑定由 NoopMediaOrchestrator 切换到 HTTPMediaOrchestrator;
MeetingService / MeetingSignalService 调用侧零改动
- E2E 脚本 docs/verify/meeting_t7_verify.mjs 证明 16/16 PASS:
健康检查/错token 401/REST 创房加入/WS room.join/真实 mediasoup
transport.create(ICE/DTLS 指纹均由 Node 返回非占位)/404 幂等关 producer
/host 结束会议触发 CloseRouter
- 同步更新 13 份文档:CURRENT_STATUS / implementation.plan / design
变更记录 / project-context / api 导览 / api websocket / api frontend meeting
/ system-architecture / media-server README / 顶层 README 等
- go build ./... 全绿
Made-with: Cursor
650 lines
24 KiB
Markdown
650 lines
24 KiB
Markdown
# 会议模块 API (Meeting) — Phase 2e-2 MVP
|
||
|
||
> 通用规范(认证、响应包络、通用错误码)见 [README.md](../README.md)
|
||
> 会议内实时信令(Transport / Producer / Consumer / 控制事件)通过 WebSocket 完成,见 [websocket.md](../websocket.md)
|
||
|
||
**实施状态**:本文档对应 Phase 2e-2 Task 5 / Task 6 / Task 7 已落地的 12 个 REST 接口 + 13 个 WebSocket 信令事件,统一前缀 `/api/v1/meeting`(REST)与 `/ws`(WebSocket),全部需要 JWT 认证。Task 5/6/7 完成时间:2026-04-21。自 Task 7 起 **Go 后端直连 Node media-server**,`transport.id` / `iceCandidates` / `dtlsParameters.fingerprints` 等字段均由真实 mediasoup 返回(不再是 stub 占位)。
|
||
|
||
**设计口径**:以 [`docs/plans/2026-04-21-phase2e-2-design.md`](../../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 位十六进制邀请令牌。
|
||
|
||
---
|
||
|
||
## 通用约定
|
||
|
||
### 响应包络
|
||
|
||
成功:
|
||
|
||
```json
|
||
{ "code": 0, "message": "success", "data": { ... }, "trace_id": "...", "time": "2026-04-21 16:30:00" }
|
||
```
|
||
|
||
失败:
|
||
|
||
```json
|
||
{ "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`**
|
||
|
||
```json
|
||
{
|
||
"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`**
|
||
|
||
```json
|
||
{
|
||
"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`**
|
||
|
||
```json
|
||
{
|
||
"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`**
|
||
|
||
```json
|
||
{
|
||
"code": 0,
|
||
"data": {
|
||
"room": { ... },
|
||
"participant": { ... },
|
||
"router_id": "stub-router-835-000-036"
|
||
}
|
||
}
|
||
```
|
||
|
||
`router_id` 自 Task 7 起为 Node media-server 返回的真实 mediasoup Router ID(Phase 2e-2 Task 7 完成,2026-04-21)。前端可据此建立 WebSocket 订阅,也可忽略,仅依赖 WS `meeting.transport.create` 的返回值创建 mediasoup-client Transport。
|
||
|
||
---
|
||
|
||
## 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`**
|
||
|
||
```json
|
||
{ "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:
|
||
1. 若该用户已在本会议活跃 → 跳过;
|
||
2. 生成 32 位十六进制 Token 写 Redis key `echo:meeting:invite:{token}`,TTL 600 秒(`MeetingInviteTokenTTL`),value = `{"room_code","inviter_id","invitee_id","has_password"}`;
|
||
3. 通过 Phase 2e-1 `notify.Pusher.PushBatch` 推送 `type=meeting_invite` 通知,`Extra` 含 `room_code / room_title / has_password / invite_token`;
|
||
4. 离线被邀请者走通知入库,上线后由 WS 或未读轮询获得。
|
||
|
||
**响应 `200 OK`**
|
||
|
||
```json
|
||
{ "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`**
|
||
|
||
```json
|
||
{
|
||
"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`**
|
||
|
||
```json
|
||
{
|
||
"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`**
|
||
|
||
```json
|
||
{
|
||
"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](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`
|
||
|
||
**请求载荷**
|
||
|
||
```json
|
||
{ "room_code": "835-000-036" }
|
||
```
|
||
|
||
**ACK** `code=0`:`data` 为空对象;失败原因:`会议不存在` / `你当前未在会议中`。
|
||
|
||
**说明**:WS 加入仅用于开启该房间的事件推送通道,REST `/join` 已将 participant 写入 DB,此事件**不会再次修改 participant 表**。
|
||
|
||
#### 2. `meeting.room.leave`
|
||
|
||
**请求载荷**
|
||
|
||
```json
|
||
{ "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`
|
||
|
||
**请求载荷**
|
||
|
||
```json
|
||
{
|
||
"room_code": "835-000-036",
|
||
"target_user_id": 17,
|
||
"audio_enabled": false,
|
||
"video_enabled": true,
|
||
"hand_raised": false
|
||
}
|
||
```
|
||
|
||
- `target_user_id` 可选:省略或等于自己时为自我操作,任何人均可;指定为他人时需调用方为 host,否则 ACK `code=-1 message=仅主持人可执行此操作`。
|
||
- `audio_enabled / video_enabled / hand_raised` 均为可选布尔字段,至少传一个。
|
||
|
||
**ACK** `code=0`:`data` 为空。**广播** `meeting.member.state.changed`:
|
||
|
||
```json
|
||
{
|
||
"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`
|
||
|
||
**请求载荷**
|
||
|
||
```json
|
||
{ "room_code": "835-000-036", "direction": "send" }
|
||
```
|
||
|
||
`direction`:`"send"` 或 `"recv"`。
|
||
|
||
**ACK** `code=0` `data`:
|
||
|
||
```json
|
||
{
|
||
"id": "transport-abc",
|
||
"iceParameters": { ... },
|
||
"iceCandidates": [ ... ],
|
||
"dtlsParameters": { ... }
|
||
}
|
||
```
|
||
|
||
**资源追踪**:服务端将 `transport-id` 按 `echo:meeting:resources:{room_id}:{user_id}` 的 Redis Set 记录,TTL 1 小时。
|
||
|
||
#### 5. `meeting.transport.connect`
|
||
|
||
**请求载荷**
|
||
|
||
```json
|
||
{
|
||
"room_code": "835-000-036",
|
||
"transport_id": "transport-abc",
|
||
"dtls_parameters": { ... }
|
||
}
|
||
```
|
||
|
||
**ACK** `code=0`:`data` 为空。
|
||
|
||
#### 6. `meeting.produce.start`
|
||
|
||
**请求载荷**
|
||
|
||
```json
|
||
{
|
||
"room_code": "835-000-036",
|
||
"transport_id": "transport-abc",
|
||
"kind": "audio",
|
||
"rtp_parameters": { ... }
|
||
}
|
||
```
|
||
|
||
`kind`: `"audio"` | `"video"`。
|
||
|
||
**ACK** `code=0` `data`:
|
||
|
||
```json
|
||
{ "producer_id": "producer-xyz" }
|
||
```
|
||
|
||
**同时广播** `meeting.member.producer.new`:
|
||
|
||
```json
|
||
{
|
||
"room_code": "835-000-036",
|
||
"user_id": 16,
|
||
"producer_id": "producer-xyz",
|
||
"kind": "audio",
|
||
"closed": false
|
||
}
|
||
```
|
||
|
||
#### 7. `meeting.consume.start`
|
||
|
||
**请求载荷**
|
||
|
||
```json
|
||
{
|
||
"room_code": "835-000-036",
|
||
"transport_id": "transport-recv",
|
||
"producer_id": "producer-xyz",
|
||
"rtp_capabilities": { ... }
|
||
}
|
||
```
|
||
|
||
**ACK** `code=0` `data`:
|
||
|
||
```json
|
||
{
|
||
"id": "consumer-def",
|
||
"producerId": "producer-xyz",
|
||
"kind": "audio",
|
||
"rtpParameters": { ... }
|
||
}
|
||
```
|
||
|
||
**资源追踪**:consumer-id 也会进入 Redis resources Set。
|
||
|
||
#### 8. `meeting.producer.close`
|
||
|
||
**请求载荷**
|
||
|
||
```json
|
||
{ "room_code": "835-000-036", "producer_id": "producer-xyz" }
|
||
```
|
||
|
||
**ACK** `code=0`:`data` 为空。**广播** `meeting.member.producer.new` `closed=true`:
|
||
|
||
```json
|
||
{
|
||
"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):定义 8 个 mediasoup 操作方法(第 9 个 `ResumeConsumer` 推迟到 Task 9);Task 6 曾用 `NoopMediaOrchestrator` 占位,**Task 7 (2026-04-21) 已替换为 `HTTPMediaOrchestrator`**,通过 `X-Internal-Token` 鉴权直连 Node media-server 的 `/internal/v1/*` 接口;错误类型 `ErrMediaResourceNotFound`(Node 404 → 关闭类幂等转 nil)与 `ErrMediaServerError`(5xx / 超时 / 网络错)可供上层 `errors.Is` 区分;关闭类操作指数退避 200ms→500ms 最多 `CloseRetry+1` 次。
|
||
|
||
### 错误处理
|
||
|
||
- 鉴权失败(token 无效 / 未传):WS 握手时直接 4001 关闭,不进入业务层。
|
||
- 业务失败(非参会者 / 非 host / 会议已结束):ACK `code=-1` + 中文 `message`,与 REST 领域错误口径完全一致。
|
||
- mediasoup 调用失败:`message` 形如 `mediaOrchestrator error: <原始错误>`,前端可据此降级。
|
||
- WS 连接断开:服务端 `OnDisconnect` 钩子会对该用户所有活跃会议调用 `cleanupUserResources`(遍历 Redis `echo: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` 双端 ACK
|
||
- `meeting.member.state.changed` 自我静音(ACK + 对端广播)
|
||
- host 强制静音他人(ACK)
|
||
- 非 host 强制静音他人 → ACK `code=-1 message=仅主持人可执行此操作`
|
||
- `meeting.transport.create` 双方向(send / recv)
|
||
- `meeting.transport.connect`
|
||
- `meeting.produce.start` + `meeting.member.producer.new` 广播
|
||
- `meeting.consume.start`
|
||
- `meeting.producer.close` + `meeting.member.producer.new closed=true` 广播
|
||
- `meeting.room.leave` + `meeting.member.left` 广播
|
||
- 不存在会议号 `meeting.room.join` → ACK `code=-1`
|
||
- **Task 7** 端到端验证脚本(`docs/verify/meeting_t7_verify.mjs`)结果:**16/16 PASS**,覆盖:
|
||
- media-server `/healthz` + `/internal/info`(正确 token 200、错误 token 401)
|
||
- 注册登录 2 用户 → host 创建会议(HTTP 201 + 内部调 `CreateRouter`)→ 第二用户 REST 加入(再次 `CreateRouter`)
|
||
- 双端 WS `meeting.room.join`
|
||
- `meeting.transport.create`(direction=send)返回**真实** mediasoup `id`(非 `noop-` 前缀)+ `iceParameters` 对象 + `iceCandidates[]` 非空 + `dtlsParameters.fingerprints[]` 非空
|
||
- 虚构 producer_id 调 `meeting.producer.close` → Node 返回 404 → Go 幂等转 `code=0`
|
||
- host `POST /rooms/:code/end` 触发 `CloseRouter`,media-server 日志确认 `router closed explicitly`
|
||
|
||
---
|
||
|
||
## 后续任务关联
|
||
|
||
- **Task 8**:会议生命周期状态机(host 宽限期 + 自动转让 + 空房 TTL),同时修复"CreateRoom + JoinRoom 重复调 `CreateRouter`"遗留行为。
|
||
- **Task 9**:Vue 前端 mediasoup-client 接入,按本文 WS 契约实现 `mediasoup.Transport` 的 `connect / produce` 回调;补齐 `ResumeConsumer`(Node REST 已就绪)或在 Go 侧改为创建 Consumer 后自动 resume。
|
||
- **Task 13**:通知卡片 UI 补齐 `meeting_invite` 内联按钮。
|