feat(phase2e-2): 前端 mediasoup-client 集成 + Pinia meeting Store(Task 9)

- frontend/src/api/meeting.js:12 个 REST 接口封装,统一 unwrap envelope.data
- frontend/src/services/websocket.js:新增 sendWithAck(Promise 化 + 超时 + 序列号)
- frontend/src/utils/mediasoup-client.js:MediaEngine 包装 Device/Transport/Producer/Consumer
- frontend/src/store/meeting.js:Pinia 会议状态机,桥接 14 个 WS 事件 + cleanupStaleMeetings
- frontend/src/constants/meeting.js:状态枚举 + 事件名集中管理
- frontend/src/pages/meeting/debug.vue:临时调试页(H5 原生 video/audio DOM 绕过 uni 组件限制)
- backend:meeting.consume.resume WS 事件 + create/join 响应透传 router_id + rtp_capabilities
- 文档:frontend/meeting.md、websocket.md、CURRENT_STATUS、plan 全部同步 Task 9 落地

Made-with: Cursor
This commit is contained in:
bujinyuan
2026-04-22 11:22:49 +08:00
parent e7f6a32dfe
commit f97fec24a8
22 changed files with 2314 additions and 60 deletions

View File

@@ -158,10 +158,13 @@ func (ctl *MeetingController) CreateRoom(c *gin.Context) {
ctl.handleError(c, err, "创建会议失败")
return
}
// Task 9CreateRoom 响应携带 router_id + rtpCapabilities供前端 Device.load 使用
_, rtpCaps, _ := ctl.meetingService.ResolveRouterInfo(room.RoomCode)
resp := dto.CreateMeetingRoomResponse{
Room: *roomToDTO(room, 1),
Room: *roomToDTO(room, 1),
RouterID: routerID,
RtpCapabilities: rtpCaps,
}
_ = routerID // 当前 Noop 返回占位 RouterIDTask 7 后可拼入响应供前端订阅使用
utils.ResponseCreated(c, resp)
}
@@ -214,10 +217,13 @@ func (ctl *MeetingController) JoinRoom(c *gin.Context) {
ctl.handleError(c, err, "加入会议失败")
return
}
// Task 9JoinRoom 响应携带 rtpCapabilities供前端 mediasoup-client Device.load 直接使用
_, rtpCaps, _ := ctl.meetingService.ResolveRouterInfo(code)
resp := dto.JoinMeetingRoomResponse{
Room: *roomToDTO(room, 0),
Participant: *participantToDTO(participant),
RouterID: routerID,
Room: *roomToDTO(room, 0),
Participant: *participantToDTO(participant),
RouterID: routerID,
RtpCapabilities: rtpCaps,
}
utils.ResponseOK(c, resp)
}

View File

@@ -47,6 +47,7 @@ func (h *MeetingWSHandler) registerEvents() {
h.hub.RegisterEvent(constants.MeetingWSEventTransportConnect, h.handleTransportConnect)
h.hub.RegisterEvent(constants.MeetingWSEventProduceStart, h.handleProduceStart)
h.hub.RegisterEvent(constants.MeetingWSEventConsumeStart, h.handleConsumeStart)
h.hub.RegisterEvent(constants.MeetingWSEventConsumeResume, h.handleConsumeResume)
h.hub.RegisterEvent(constants.MeetingWSEventProducerClose, h.handleProducerClose)
}
@@ -182,6 +183,19 @@ func (h *MeetingWSHandler) handleConsumeStart(client *ws.Client, msg *ws.Message
h.sendACK(client, msg, 0, "ok", info)
}
// handleConsumeResume 处理 meeting.consume.resumeTask 9
func (h *MeetingWSHandler) handleConsumeResume(client *ws.Client, msg *ws.Message) {
var payload service.ConsumeResumePayload
if !h.unmarshal(client, msg, &payload) {
return
}
if err := h.signalSvc.OnConsumeResume(context.Background(), client.UserID, &payload); err != nil {
h.sendACK(client, msg, -1, err.Error(), nil)
return
}
h.sendACK(client, msg, 0, "ok", nil)
}
// handleProducerClose 处理 meeting.producer.close
func (h *MeetingWSHandler) handleProducerClose(client *ws.Client, msg *ws.Message) {
var payload service.ProducerClosePayload

View File

@@ -43,12 +43,20 @@ var ErrMediaServerError = errors.New("media server error")
// - X-Internal-Token header 与 media-server/.env 的 MEDIA_INTERNAL_TOKEN 配对,两端不匹配将被 Node 的 401 拒绝
//
// 并发:所有方法可安全并发调用;内部 http.Client 复用连接池
// routerInfoCache Router 本地缓存条目:除 ID 外保存 Node 返回的 rtpCapabilities
// 供前端 mediasoup-client Device.load 直接使用Task 9 引入)。
type routerInfoCache struct {
ID string
RtpCapabilities json.RawMessage
}
type HTTPMediaOrchestrator struct {
cfg config.MediaServerConfig
client *http.Client
// roomCode → routerID 本地缓存,服务重启后会丢失(此时 Node 侧的 Router 也会随 Node 重启而释放,状态一致)
roomRouterIDs sync.Map
// roomCode → *routerInfoCache 本地缓存,服务重启后会丢失(此时 Node 侧的 Router 也会随 Node 重启而释放,状态一致)
// Task 9 起由 sync.Map<string> 升级为 sync.Map<*routerInfoCache>
roomRouterInfos sync.Map
}
// NewHTTPMediaOrchestrator 构造真实 HTTP 客户端
@@ -91,12 +99,12 @@ func NewHTTPMediaOrchestrator(cfg *config.Config) *HTTPMediaOrchestrator {
func (h *HTTPMediaOrchestrator) CreateRouter(ctx context.Context, roomCode string) (string, error) {
funcName := "service.http_media_orchestrator.CreateRouter"
if cached, ok := h.roomRouterIDs.Load(roomCode); ok {
if rid, _ := cached.(string); rid != "" {
if cached, ok := h.roomRouterInfos.Load(roomCode); ok {
if info, _ := cached.(*routerInfoCache); info != nil && info.ID != "" {
logs.Debug(ctx, funcName, "命中本地 Router 缓存,跳过 Node 调用",
zap.String("room_code", roomCode),
zap.String("router_id", rid))
return rid, nil
zap.String("router_id", info.ID))
return info.ID, nil
}
}
@@ -116,15 +124,19 @@ func (h *HTTPMediaOrchestrator) CreateRouter(ctx context.Context, roomCode strin
return "", err
}
info := &routerInfoCache{
ID: resp.RouterID,
RtpCapabilities: resp.RtpCapabilities,
}
// LoadOrStore 防止并发首次创建时重复覆盖:若他人已写入先到的值就用已存在的
actual, loaded := h.roomRouterIDs.LoadOrStore(roomCode, resp.RouterID)
actual, loaded := h.roomRouterInfos.LoadOrStore(roomCode, info)
if loaded {
if existing, _ := actual.(string); existing != "" && existing != resp.RouterID {
if existing, _ := actual.(*routerInfoCache); existing != nil && existing.ID != "" && existing.ID != resp.RouterID {
logs.Warn(ctx, funcName, "并发创建 Router 出现不一致,保留先到值",
zap.String("room_code", roomCode),
zap.String("existing", existing),
zap.String("existing", existing.ID),
zap.String("new", resp.RouterID))
return existing, nil
return existing.ID, nil
}
}
@@ -138,15 +150,30 @@ func (h *HTTPMediaOrchestrator) CreateRouter(ctx context.Context, roomCode strin
// 返回 (id, true) 表示命中;返回 (_, false) 表示缓存缺失(通常意味着该房间尚未创建 Router
// 供 MeetingService.JoinRoom 等需复用已有 Router 的场景使用,避免重复调 Node
func (h *HTTPMediaOrchestrator) ResolveRouterID(roomCode string) (string, bool) {
v, ok := h.roomRouterIDs.Load(roomCode)
v, ok := h.roomRouterInfos.Load(roomCode)
if !ok {
return "", false
}
rid, _ := v.(string)
if rid == "" {
info, _ := v.(*routerInfoCache)
if info == nil || info.ID == "" {
return "", false
}
return rid, true
return info.ID, true
}
// ResolveRouterInfo 读取 roomCode 对应的 Router 完整信息routerID + rtpCapabilities
// Task 9 引入:前端 mediasoup-client Device.load 需要 rtpCapabilitiesJoinRoom 响应会回填此字段
// 返回 (id, caps, true) 命中;(_, _, false) 缺失
func (h *HTTPMediaOrchestrator) ResolveRouterInfo(roomCode string) (string, json.RawMessage, bool) {
v, ok := h.roomRouterInfos.Load(roomCode)
if !ok {
return "", nil, false
}
info, _ := v.(*routerInfoCache)
if info == nil || info.ID == "" {
return "", nil, false
}
return info.ID, info.RtpCapabilities, true
}
// CloseRouter 调用 DELETE /internal/v1/routers/:routerId
@@ -157,16 +184,17 @@ func (h *HTTPMediaOrchestrator) ResolveRouterID(roomCode string) (string, bool)
func (h *HTTPMediaOrchestrator) CloseRouter(ctx context.Context, roomCode string) error {
funcName := "service.http_media_orchestrator.CloseRouter"
v, ok := h.roomRouterIDs.Load(roomCode)
v, ok := h.roomRouterInfos.Load(roomCode)
if !ok {
logs.Debug(ctx, funcName, "无本地映射,跳过 Close", zap.String("room_code", roomCode))
return nil
}
routerID, _ := v.(string)
if routerID == "" {
h.roomRouterIDs.Delete(roomCode)
info, _ := v.(*routerInfoCache)
if info == nil || info.ID == "" {
h.roomRouterInfos.Delete(roomCode)
return nil
}
routerID := info.ID
err := h.doCloseRequest(ctx, fmt.Sprintf("/internal/v1/routers/%s", routerID), funcName, []zap.Field{
zap.String("room_code", roomCode),
@@ -174,7 +202,7 @@ func (h *HTTPMediaOrchestrator) CloseRouter(ctx context.Context, roomCode string
})
// 成功或 ResourceNotFound 都从本地缓存删除(幂等)
if err == nil || errors.Is(err, ErrMediaResourceNotFound) {
h.roomRouterIDs.Delete(roomCode)
h.roomRouterInfos.Delete(roomCode)
return nil
}
return err
@@ -278,8 +306,8 @@ func (h *HTTPMediaOrchestrator) CloseProducer(ctx context.Context, producerID st
}
// CreateConsumer 调用 POST /internal/v1/consumers
// Node 侧 Consumer 强制 paused=true 创建,前端收到后需额外调 /resumeMediaOrchestrator 暂未暴露 ResumeConsumer
// 将在 Task 8 前端接入 mediasoup-client 时按需补充
// Node 侧 Consumer 强制 paused=true 创建,前端在 recv Transport 与 track 挂载完成后
// 需发送 meeting.consume.resume WS 事件触发 ResumeConsumer 才会真正收到 RTPTask 9
func (h *HTTPMediaOrchestrator) CreateConsumer(ctx context.Context, req *CreateConsumerReq) (*ConsumerInfo, error) {
funcName := "service.http_media_orchestrator.CreateConsumer"
@@ -313,6 +341,22 @@ func (h *HTTPMediaOrchestrator) CreateConsumer(ctx context.Context, req *CreateC
return &resp, nil
}
// ResumeConsumer 调用 POST /internal/v1/consumers/:id/resumeTask 9 引入)
// Node 侧会把 mediasoup Consumer 从 paused 切到 active开始向 Transport 推 RTP
// 语义幂等Node 端对已 active 的 Consumer 再次 resume 不报错;未找到 Consumer 返回 404 → ErrMediaResourceNotFound
func (h *HTTPMediaOrchestrator) ResumeConsumer(ctx context.Context, consumerID string) error {
funcName := "service.http_media_orchestrator.ResumeConsumer"
return h.doRequest(ctx, requestOptions{
method: http.MethodPost,
path: fmt.Sprintf("/internal/v1/consumers/%s/resume", consumerID),
body: nil,
timeoutMS: h.cfg.TimeoutMS,
funcName: funcName,
logFields: []zap.Field{zap.String("consumer_id", consumerID)},
}, nil)
}
// CloseConsumer 调用 DELETE /internal/v1/consumers/:id
func (h *HTTPMediaOrchestrator) CloseConsumer(ctx context.Context, consumerID string) error {
funcName := "service.http_media_orchestrator.CloseConsumer"
@@ -331,15 +375,15 @@ func (h *HTTPMediaOrchestrator) CloseConsumer(ctx context.Context, consumerID st
// routerIDByRoomCode 从本地缓存反查 routerID缺失时返回 ErrMediaResourceNotFound
// 此错误语义表达的是"meeting 业务侧尚未(或已清理了)为该房间创建 Router",调用方通常应转为"会议未开始/已结束"
func (h *HTTPMediaOrchestrator) routerIDByRoomCode(roomCode string) (string, error) {
v, ok := h.roomRouterIDs.Load(roomCode)
v, ok := h.roomRouterInfos.Load(roomCode)
if !ok {
return "", fmt.Errorf("%w: no router mapped for room_code=%s", ErrMediaResourceNotFound, roomCode)
}
routerID, _ := v.(string)
if routerID == "" {
info, _ := v.(*routerInfoCache)
if info == nil || info.ID == "" {
return "", fmt.Errorf("%w: empty router_id for room_code=%s", ErrMediaResourceNotFound, roomCode)
}
return routerID, nil
return info.ID, nil
}
// requestOptions 统一请求参数

View File

@@ -94,6 +94,10 @@ type MediaOrchestrator interface {
// Task 8 引入:用于 JoinRoom 等"仅需复用"的场景,避免重复调 CreateRouter
// 返回 (id, true) 命中;(_, false) 缺失(通常说明房间未创建 Router 或服务重启后未 reschedule
ResolveRouterID(roomCode string) (string, bool)
// ResolveRouterInfo 读取 roomCode 对应的 Router 完整信息routerID + rtpCapabilities
// Task 9 引入:前端 mediasoup-client Device.load 必须 rtpCapabilitiesJoinRoom 响应由此填充
// 返回 (id, caps, true) 命中;(_, _, false) 缺失
ResolveRouterInfo(roomCode string) (string, json.RawMessage, bool)
// CreateTransport 为用户创建 send/recv WebRTC Transport
CreateTransport(ctx context.Context, req *CreateTransportReq) (*TransportInfo, error)
@@ -106,7 +110,12 @@ type MediaOrchestrator interface {
CloseProducer(ctx context.Context, producerID string) error
// CreateConsumer 在指定 recv Transport 上创建 Consumer订阅远端 Producer
// Node 端创建的 Consumer 默认 paused=true需在前端 track 就绪后调用 ResumeConsumer 才会开始推流
CreateConsumer(ctx context.Context, req *CreateConsumerReq) (*ConsumerInfo, error)
// ResumeConsumer 恢复 Consumer 开始接收 RTPTask 9 引入)
// 前端 recv Transport 与 track 挂载完成后由 meeting.consume.resume WS 事件触发
// Node 未找到对应 Consumer 返回 ErrMediaResourceNotFound调用方可按"已失效"处理
ResumeConsumer(ctx context.Context, consumerID string) error
// CloseConsumer 关闭指定 Consumer幂等
CloseConsumer(ctx context.Context, consumerID string) error
}
@@ -137,6 +146,11 @@ func (n *NoopMediaOrchestrator) ResolveRouterID(roomCode string) (string, bool)
return "noop-router-" + roomCode, true
}
// ResolveRouterInfo 占位:返回空 rtpCapabilities 对象(保证前端 mediasoup-client 能通过 JSON 解析,但 Device.load 会失败;适合本地无 Node 调试)
func (n *NoopMediaOrchestrator) ResolveRouterInfo(roomCode string) (string, json.RawMessage, bool) {
return "noop-router-" + roomCode, json.RawMessage(`{}`), true
}
// CreateTransport 占位:返回以 "noop-transport-" 为前缀的伪造 ID带最小合法 JSON 结构
func (n *NoopMediaOrchestrator) CreateTransport(_ context.Context, req *CreateTransportReq) (*TransportInfo, error) {
return &TransportInfo{
@@ -173,6 +187,11 @@ func (n *NoopMediaOrchestrator) CreateConsumer(_ context.Context, req *CreateCon
}, nil
}
// ResumeConsumer 占位:直接返回 nil
func (n *NoopMediaOrchestrator) ResumeConsumer(_ context.Context, _ string) error {
return nil
}
// CloseConsumer 占位:直接返回 nil
func (n *NoopMediaOrchestrator) CloseConsumer(_ context.Context, _ string) error {
return nil

View File

@@ -823,3 +823,13 @@ func (s *MeetingService) ListChatMessages(ctx context.Context, userID int64, cod
}
return chats, hasMore, nil
}
// ResolveRouterInfo 代理 mediaOrchestrator.ResolveRouterInfo
// Task 9 引入JoinRoom 响应需把 rtpCapabilities 一并回给前端,避免前端再拉一次
// 返回 (routerID, rtpCapabilities, ok);未命中缓存返回 ("", nil, false)
func (s *MeetingService) ResolveRouterInfo(roomCode string) (string, json.RawMessage, bool) {
if s.mediaOrchestrator == nil {
return "", nil, false
}
return s.mediaOrchestrator.ResolveRouterInfo(roomCode)
}

View File

@@ -368,6 +368,35 @@ func (s *MeetingSignalService) OnConsumeStart(ctx context.Context, userID int64,
return info, nil
}
// ConsumeResumePayload meeting.consume.resume 请求载荷Task 9
// 前端 recv Transport 与 track 就绪后发送,用于把 Node 侧 paused Consumer 切到 active
type ConsumeResumePayload struct {
RoomCode string `json:"room_code"`
ConsumerID string `json:"consumer_id"`
}
// OnConsumeResume 处理 meeting.consume.resume 事件Task 9
// 语义:告知 media-server 把指定 Consumer 从 paused 切到 active
// 权限:仅当 userID 是会议活跃成员且 consumerID 归属该用户时允许
// 幂等Node 对已 active Consumer 再次 resume 不报错Consumer 不存在则 ACK 返回友好错误
func (s *MeetingSignalService) OnConsumeResume(ctx context.Context, userID int64, payload *ConsumeResumePayload) error {
if payload.ConsumerID == "" {
return fmt.Errorf("consumer_id 不能为空")
}
if _, err := s.loadRoomAndParticipant(ctx, payload.RoomCode, userID); err != nil {
return err
}
if err := s.mediaOrchestrator.ResumeConsumer(ctx, payload.ConsumerID); err != nil {
logs.Warn(ctx, "service.meeting_signal_service.OnConsumeResume", "恢复 Consumer 失败",
zap.String("room_code", payload.RoomCode),
zap.Int64("user_id", userID),
zap.String("consumer_id", payload.ConsumerID),
zap.Error(err))
return err
}
return nil
}
// ProducerClosePayload meeting.producer.close 请求载荷
type ProducerClosePayload struct {
RoomCode string `json:"room_code"`