Files
EchoChat/docs/api/frontend/meeting.md
bujinyuan 46b37b8b4c docs(meeting): 补齐 frontend/meeting.md 后续任务关联至 Task 14
- Task 9 补完完整描述(2026-04-21 前端 mediasoup-client 接入 + resume 走 WS 路径)
- Task 10-12 补入(2026-04-22 前端主链路 5 页 + 6 组件落地)
- Task 14 补入(2026-04-24 docker-compose 双态部署,API 契约无变)

Made-with: Cursor
2026-04-22 18:06:50 +08:00

30 KiB
Raw Blame History

会议模块 API (Meeting) — Phase 2e-2 MVP

通用规范(认证、响应包络、通用错误码)见 README.md 会议内实时信令Transport / Producer / Consumer / 控制事件)通过 WebSocket 完成,见 websocket.md

实施状态:本文档对应 Phase 2e-2 Task 5 / Task 6 / Task 7 / Task 9 已落地的 12 个 REST 接口 + 14 个 WebSocket 信令事件Task 9 新增 meeting.consume.resume),统一前缀 /api/v1/meetingREST/wsWebSocket全部需要 JWT 认证。Task 5/6/7/9 完成时间2026-04-21。自 Task 7 起 Go 后端直连 Node media-servertransport.id / iceCandidates / dtlsParameters.fingerprints 等字段均由真实 mediasoup 返回(不再是 stub 占位);自 Task 9 起 CreateRoom / JoinRoom 响应同时返回 router_id + rtp_capabilities,供前端 mediasoupClient.Device.load() 直接初始化。

设计口径:以 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~8MVP 硬上限 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 或 participantleft_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=NULLjoined_at=nowduration=0,避免审计表膨胀。

响应 200 OK

{
  "code": 0,
  "data": {
    "room": { ... },
    "participant": { ... },
    "router_id": "abc-router-835-000-036",
    "rtp_capabilities": {
      "codecs": [ { "mimeType": "audio/opus", "clockRate": 48000, ... } ],
      "headerExtensions": [ ... ]
    }
  }
}

router_id 自 Task 7 起为 Node media-server 返回的真实 mediasoup Router IDPhase 2e-2 Task 7 完成2026-04-21rtp_capabilities 自 Task 9 起随响应一并返回Phase 2e-2 Task 92026-04-21供前端 mediasoupClient.Device.load({ routerRtpCapabilities }) 初始化媒体协商上下文。前端 MVP 推荐流程:joinRoom → Device.load(rtp_capabilities) → Transport 双建 → produce/consume,整个链路不再需要额外 REST 往返。

同形响应体在 POST /rooms(创建即时会议)中也会返回 router_id + rtp_capabilities,创建者无需额外请求即可 Device.load


5. 离开会议

POST /api/v1/meeting/rooms/:code/leave

行为

  • participant.left_at = nowduration = EXTRACT(EPOCH FROM now - joined_at)
  • 若离开者是 host 且仍有其他活跃成员 → 自动将 host 转让给最早加入的活跃成员,广播 meeting.host.changedauto_reason=host_left_with_members
  • 若房间无剩余活跃成员Task 8 变更)→ 不再立即销毁,改为调 MeetingLifecycleService.OnAllMembersLeft 设置 echo:meeting:empty_ttl:{code}(默认 TTL 300s+ 启动本地 time.AfterFuncTTL 内若有新成员 POST /joinMeetingLifecycleService.CancelEmptyTTL 会 DEL key 让房间复活TTL 过期触发 HandleEmptyRoomExpiredMarkEnded(reason=empty_ttl) + mediaOrchestrator.CloseRouter + 广播 meeting.room.ended{reason=empty_ttl}
  • 广播 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=nowleft_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 秒(MeetingInviteTokenTTLvalue = {"room_code","inviter_id","invitee_id","has_password"}
  3. 通过 Phase 2e-1 notify.Pusher.PushBatch 推送 type=meeting_invite 通知,ExtraPhase 2e-2 Task 13 完整版)包含以下字段:
    • room_code9 位会议号 XXX-XXX-XXX,前端跳 preview 页使用
    • room_title:房间标题,卡片展示
    • has_password:是否有密码,前端据此决定是否在 preview 页弹密码输入
    • invite_token32 位 hex token可用于兑换接口快速拉取 room_code
    • inviter_id / inviter_name / inviter_avatar:邀请人展示字段,卡片左上角头像 + "XX 邀请你加入..." 文案来源
    • expired_atUnix 秒,与 Redis TTL 同步;前端用 expired_at * 1000 < Date.now() 判断过期并灰显按钮
  4. 离线被邀请者走通知入库,上线后由 WS 或未读轮询获得。

响应 200 OK

{ "code": 0, "data": { "pushed": 1, "skipped": 0 } }

出于安全考虑,响应体不包含 tokentoken 仅通过通知 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> 统一通道,共 14 个 meeting.* 事件9 个 客户端→服务端C→S操作事件Task 9 新增 meeting.consume.resume+ 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>.ackseq 与请求一致;成功时 code=0,失败时 code=-1message 含中文原因(如 仅主持人可执行此操作会议不存在你当前未在会议中)。

连接级鉴权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 WebRtcTransportsend/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 C→S meeting.consume.resume 客户端完成 track 挂载后请求 resume ConsumerTask 9 新增) mediaOrchestrator.ResumeConsumer → Node POST /internal/v1/consumers/:id/resume,不广播
10 S→C meeting.member.joined 新成员加入广播(复用 REST /join 房间广播
11 S→C meeting.member.left 成员离开广播REST /leave /kick 或 WS leave 房间广播
12 S→C meeting.member.kicked 定向通知被踢者REST /kick PublishToUser
13 S→C meeting.host.changed 主持人变更 房间广播
14 S→C meeting.room.ended 会议被结束REST /end 或空房 TTL 房间广播
15 S→C meeting.member.state.changed 成员状态变化(静音/关摄像头/举手) 房间广播
16 S→C meeting.member.producer.new 成员开启/关闭媒体流 房间广播,closed=true 表示关闭
17 S→C meeting.chat 会议内聊天REST /chats 房间广播

说明:客户端仅注册 C→S 白名单中的 9 个事件(见 app/constants/meeting.go:MeetingWSClientEvents),其余 meeting.* 事件若由客户端发送均被静默丢弃,防止恶意客户端伪造广播。

客户端白名单C→S详细契约

1. meeting.room.join

请求载荷

{ "room_code": "835-000-036" }

ACK code=0data 为空对象;失败原因:会议不存在 / 你当前未在会议中

说明WS 加入仅用于开启该房间的事件推送通道REST /join 已将 participant 写入 DB此事件不会再次修改 participant 表

2. meeting.room.leave

请求载荷

{ "room_code": "835-000-036" }

ACK code=0data 为空对象;服务端同时发起媒体资源清理:按 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否则 ACK code=-1 message=仅主持人可执行此操作
  • audio_enabled / video_enabled / hand_raised 均为可选布尔字段,至少传一个。

ACK code=0data 为空。广播 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_idhost 强制静音场景下与 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-idecho: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=0data 为空。

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=0data 为空。广播 meeting.member.producer.new closed=true

{
  "room_code": "835-000-036",
  "user_id": 16,
  "producer_id": "producer-xyz",
  "closed": true
}

9. meeting.consume.resumeTask 9 新增2026-04-21

请求载荷

{ "consumer_id": "consumer-def" }

ACK code=0data 为空。失败原因:会议资源不存在Node 404 → Go 转译HTTP 层归因 ErrMediaResourceNotFound)或 会议媒体服务暂不可用5xx / 网络错)。

调用时机(前端推荐流程):

  1. 收到广播 meeting.member.producer.new { closed:false }
  2. 前端创建 recvTransport + consumer = device.consume(),此时 Consumer 为 paused(后端创建时就强制 paused:true
  3. 前端把 consumer.track 挂载到 DOM 元素(<video>.srcObject = new MediaStream([track])
  4. 监听 <video>.onloadedmetadata(或图像解码首帧回调);
  5. 回调中发送 meeting.consume.resume { consumer_id }
  6. 服务端 ResumeConsumer 调 Node POST /internal/v1/consumers/:id/resumeNode mediasoup 开始 forward RTP。

为什么要显式 resumemediasoup 官方规范要求"DOM 挂完 track 再 resume",避免 RTP forward 时浏览器解码空帧造成首帧黑屏/抖动50-200ms同时保留未来 simulcast 层切换、订阅清单变更、后台节流等扩展点。

不广播:本事件是纯 C→S 调用,服务端不广播任何事件。


服务端广播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 / auto_reason? REST /transfer-hostauto_reason/ host 离会自动转让(auto_reason=host_left_with_members/ host 宽限期过期自动转让auto_reason=host_grace_expiredTask 8 新增)
meeting.room.ended room_code / reasonhost_ended / empty_ttl / system_error REST /endhost_ended/ 空房 TTL 过期(empty_ttl/ 兜底清理 4 小时陈旧房间(system_errorTask 8 新增)
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 触发

架构

  • MeetingWSHandlercontroller 层thin adapter仅负责 ws.Hub 事件注册 + JSON 反序列化 + ACK 回写;位于 app/meeting/controller/meeting_ws_handler.go
  • MeetingSignalServiceservice 层):承载 8 个 C→S 事件的业务逻辑活跃参会校验、host 权限校验、mediaOrchestrator 调用、Redis 资源追踪、广播),位于 app/meeting/service/meeting_signal_service.go
  • MeetingBroadcasterservice 层):封装 BroadcastToMeeting(查询活跃 participant 列表 → 逐个 PubSub.PublishToUser)与 PublishToUser,供 REST / WS 两个入口统一使用,位于 app/meeting/service/meeting_broadcaster.go
  • MediaOrchestratorinterface定义 10 个 mediasoup 操作方法Task 9 新增 ResumeConsumer + ResolveRouterInfo(roomCode) → (routerID, rtpCapabilities, ok)Task 6 曾用 NoopMediaOrchestrator 占位,Task 7 (2026-04-21) 已替换为 HTTPMediaOrchestrator,通过 X-Internal-Token 鉴权直连 Node media-server 的 /internal/v1/* 接口;错误类型 ErrMediaResourceNotFoundNode 404 → 关闭类幂等转 nilErrMediaServerError5xx / 超时 / 网络错)可供上层 errors.Is 区分;关闭类操作指数退避 200ms→500ms 最多 CloseRetry+1 次。Router 信息缓存升级:sync.Map[roomCode]*routerInfoCache{ID, RtpCapabilities} 同时缓存 Router ID 与 RTP CapabilitiesTask 9 变更,供 REST rtp_capabilities 字段透传)。

错误处理

  • 鉴权失败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.createdirection=send返回真实 mediasoup id(非 noop- 前缀)+ iceParameters 对象 + iceCandidates[] 非空 + dtlsParameters.fingerprints[] 非空
    • 虚构 producer_id 调 meeting.producer.close → Node 返回 404 → Go 幂等转 code=0
    • host POST /rooms/:code/end 触发 CloseRoutermedia-server 日志确认 router closed explicitly
  • Task 8 端到端验证脚本(docs/verify/meeting_t8_verify.mjs)结果:20/20 PASS,覆盖 5 个会议生命周期场景:
    • S1 host 宽限期过期自动转让host WS 断线 → Redis 写入 echo:meeting:host_grace:{code} → 3s 后过期 → meeting.host.changed{auto_reason=host_grace_expired} 广播 + DB host_id 更新为最早加入者
    • S2 宽限期内重连保留身份host 断线 1s 内重连 meeting.room.joinhost_grace key DEL + host 身份保留
    • S3 空房 TTL 复活:全员 leave → Redis 写入 echo:meeting:empty_ttl:{code} → TTL 内新用户 join → key DEL + 房间保持 Active
    • S4 空房 TTL 过期销毁:全员 leave → 3s 后 TTL 过期 → 房间 Ended + 新 join 被拒
    • S5 Router 幂等POST /meeting/roomsstats.routers +1POST /meeting/rooms/:code/join 不再触发新 Router业务层 JoinRoom 不调 CreateRouter + HTTP 层 sync.Map 幂等防御)

后续任务关联

  • Task 9 2026-04-21Vue 前端 mediasoup-client 已接入,按本文 WS 契约实现 Transport.on('connect'|'produce') 回调;创建 Consumer 后前端通过 meeting.consume.resume WS 事件驱动 Go → Node POST /internal/v1/consumers/:id/resume 完成 resumeUI 已处理 meeting.host.changedauto_reason 字段标注"自动转让"。
  • Task 10-12 2026-04-22前端会议主链路全量打通Hub / Create / Join / Preview / Room 5 页 + MeetingToolbar / VideoGrid / VideoTile / MemberPanel / InviteDialog / ChatPanel 6 个核心组件);会议内聊天显示真实昵称(后端 ResolveUsersDisplay 批量补齐 user_name / user_avatar)。
  • Task 13 2026-04-23:通知卡片 UI 已完成内联按钮("立即加入 / 稍后"),点击"立即加入"跳 /pages/meeting/preview?mode=join&code=xxx 由 preview 页走 /rooms/:code/join;过期态由 extra.expired_at * 1000 < Date.now() 判定并合并为单个 disabled 的"邀请已过期"按钮,不再发起任何请求。
  • Task 14 2026-04-24:后端服务已纳入 deploy/docker-compose.dev.yml 双态编排(本机 Demo / 公网 --profile public + coturn部署指南见 docs/deployment/meeting-mvp.mdAPI 接口契约本身无变化。