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:
@@ -158,10 +158,13 @@ func (ctl *MeetingController) CreateRoom(c *gin.Context) {
|
||||
ctl.handleError(c, err, "创建会议失败")
|
||||
return
|
||||
}
|
||||
// Task 9:CreateRoom 响应携带 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 返回占位 RouterID,Task 7 后可拼入响应供前端订阅使用
|
||||
utils.ResponseCreated(c, resp)
|
||||
}
|
||||
|
||||
@@ -214,10 +217,13 @@ func (ctl *MeetingController) JoinRoom(c *gin.Context) {
|
||||
ctl.handleError(c, err, "加入会议失败")
|
||||
return
|
||||
}
|
||||
// Task 9:JoinRoom 响应携带 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)
|
||||
}
|
||||
|
||||
@@ -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.resume(Task 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
|
||||
|
||||
@@ -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 需要 rtpCapabilities,JoinRoom 响应会回填此字段
|
||||
// 返回 (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 创建,前端收到后需额外调 /resume(MediaOrchestrator 暂未暴露 ResumeConsumer,
|
||||
// 将在 Task 8 前端接入 mediasoup-client 时按需补充)
|
||||
// Node 侧 Consumer 强制 paused=true 创建,前端在 recv Transport 与 track 挂载完成后
|
||||
// 需发送 meeting.consume.resume WS 事件触发 ResumeConsumer 才会真正收到 RTP(Task 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/resume(Task 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 统一请求参数
|
||||
|
||||
@@ -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 必须 rtpCapabilities,JoinRoom 响应由此填充
|
||||
// 返回 (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 开始接收 RTP(Task 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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user