feat(phase2e-2): WebSocket 信令协议 13 事件全量落地(Task 6)

将 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
This commit is contained in:
bujinyuan
2026-04-21 17:10:32 +08:00
parent e235e001b3
commit 096563d3ea
13 changed files with 1212 additions and 107 deletions

View File

@@ -16,7 +16,6 @@ import (
notifyService "github.com/echochat/backend/app/notify/service"
"github.com/echochat/backend/pkg/logs"
"github.com/echochat/backend/pkg/utils"
"github.com/echochat/backend/pkg/ws"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
@@ -51,16 +50,17 @@ const (
// MeetingService 会议业务服务
// Task 5 完成:会议生命周期、主持人管理、邀请、会议内聊天 12 个 REST API 全部落地
// Task 6 会在此基础上追加 WS 信令事件处理器Task 7 会把 mediaOrchestrator 的 Noop 实现替换为真实 Node HTTP Client
// Task 6 新增:广播能力抽离到 MeetingBroadcaster后续 WS 信令事件由 MeetingSignalService 处理
// Task 7 会把 mediaOrchestrator 的 Noop 实现替换为真实 Node HTTP Client
type MeetingService struct {
roomDAO *dao.MeetingRoomDAO
participantDAO *dao.MeetingParticipantDAO
chatDAO *dao.MeetingChatDAO
db *gorm.DB
redis *redis.Client
pubsub *ws.PubSub
db *gorm.DB
redis *redis.Client
broadcaster *MeetingBroadcaster
notifyPusher NotifyPusher
userResolver UserInfoResolver
onlineChecker OnlineChecker
@@ -68,14 +68,15 @@ type MeetingService struct {
}
// NewMeetingService 创建 MeetingService 实例
// 依赖通过构造函数注入;接口依赖由上游 Wire 绑定到具体实现Task 7 之前 mediaOrchestrator 使用 NoopMediaOrchestrator
// 依赖通过构造函数注入;接口依赖由上游 Wire 绑定到具体实现
// Task 7 之前 mediaOrchestrator 使用 NoopMediaOrchestrator
func NewMeetingService(
roomDAO *dao.MeetingRoomDAO,
participantDAO *dao.MeetingParticipantDAO,
chatDAO *dao.MeetingChatDAO,
db *gorm.DB,
redis *redis.Client,
pubsub *ws.PubSub,
broadcaster *MeetingBroadcaster,
notifyPusher NotifyPusher,
userResolver UserInfoResolver,
onlineChecker OnlineChecker,
@@ -87,7 +88,7 @@ func NewMeetingService(
chatDAO: chatDAO,
db: db,
redis: redis,
pubsub: pubsub,
broadcaster: broadcaster,
notifyPusher: notifyPusher,
userResolver: userResolver,
onlineChecker: onlineChecker,
@@ -139,28 +140,10 @@ func (s *MeetingService) generateUniqueRoomCode(ctx context.Context) (string, er
}
// broadcastToActiveParticipants 向房间内所有活跃参会者广播 WS 事件
// 调用 PubSub.Publish 支持多实例可选排除发送者自身excludeUserIDs
// 该辅助作为 Task 6 BroadcastToMeeting 的临时等效实现,签名保持兼容便于后续替换
// Task 6 起实现已迁移至 MeetingBroadcaster.BroadcastToMeeting本方法作为兼容壳保留
// 以减少调用侧改动;未来可逐步替换为直接调用 s.broadcaster.BroadcastToMeeting
func (s *MeetingService) broadcastToActiveParticipants(ctx context.Context, roomID int64, event string, data interface{}, excludeUserIDs ...int64) {
funcName := "service.meeting_service.broadcastToActiveParticipants"
participants, err := s.participantDAO.ListActiveByRoom(ctx, roomID)
if err != nil {
logs.Warn(ctx, funcName, "拉取活跃参会者失败", zap.Int64("room_id", roomID), zap.Error(err))
return
}
exclude := make(map[int64]struct{}, len(excludeUserIDs))
for _, id := range excludeUserIDs {
exclude[id] = struct{}{}
}
msg := ws.NewPushMessage(event, data)
for _, p := range participants {
if _, skip := exclude[p.UserID]; skip {
continue
}
if err := s.pubsub.PublishToUser(ctx, p.UserID, msg); err != nil {
logs.Warn(ctx, funcName, "WS 广播失败", zap.Int64("user_id", p.UserID), zap.String("event", event), zap.Error(err))
}
}
s.broadcaster.BroadcastToMeeting(ctx, roomID, event, data, excludeUserIDs...)
}
// ====== 会议生命周期 ======
@@ -386,7 +369,7 @@ func (s *MeetingService) LeaveRoom(ctx context.Context, userID int64, code strin
if uErr := s.roomDAO.UpdateHost(ctx, room.ID, newHost.UserID); uErr != nil {
logs.Warn(ctx, funcName, "UpdateHost 失败", zap.Error(uErr))
}
go s.broadcastToActiveParticipants(context.Background(), room.ID, constants.MeetingWSEventRoomHostChange, map[string]interface{}{
go s.broadcastToActiveParticipants(context.Background(), room.ID, constants.MeetingWSEventHostChanged, map[string]interface{}{
"room_code": code,
"old_host_id": userID,
"new_host_id": newHost.UserID,
@@ -441,15 +424,14 @@ func (s *MeetingService) EndRoom(ctx context.Context, userID int64, code string)
}
payload := map[string]interface{}{
"room_code": code,
"room_code": code,
"ended_reason": constants.MeetingEndedReasonHostEnded,
"ended_at": now.Format("2006-01-02 15:04:05"),
"ended_at": now.Format("2006-01-02 15:04:05"),
}
msg := ws.NewPushMessage(constants.MeetingWSEventRoomEnded, payload)
// activesBefore 已经是结束前的活跃成员快照,此时 participantDAO.ListActiveByRoom 查会返回空集合
// 因此使用 broadcaster.PublishToUser 逐人定向推送(而非 BroadcastToMeeting 基于当前状态查库)
for _, p := range activesBefore {
if err := s.pubsub.PublishToUser(ctx, p.UserID, msg); err != nil {
logs.Warn(ctx, funcName, "meeting.room.ended 广播失败", zap.Int64("user_id", p.UserID), zap.Error(err))
}
_ = s.broadcaster.PublishToUser(ctx, p.UserID, constants.MeetingWSEventRoomEnded, payload)
}
if err := s.mediaOrchestrator.CloseRouter(ctx, code); err != nil {
@@ -498,7 +480,7 @@ func (s *MeetingService) TransferHost(ctx context.Context, operatorID int64, cod
return err
}
go s.broadcastToActiveParticipants(context.Background(), room.ID, constants.MeetingWSEventRoomHostChange, map[string]interface{}{
go s.broadcastToActiveParticipants(context.Background(), room.ID, constants.MeetingWSEventHostChanged, map[string]interface{}{
"room_code": code,
"old_host_id": operatorID,
"new_host_id": target.UserID,
@@ -542,12 +524,11 @@ func (s *MeetingService) KickMember(ctx context.Context, operatorID int64, code
return ErrNotInMeeting
}
kickMsg := ws.NewPushMessage(constants.MeetingWSEventMemberKicked, map[string]interface{}{
_ = s.broadcaster.PublishToUser(ctx, targetUserID, constants.MeetingWSEventMemberKicked, map[string]interface{}{
"room_code": code,
"user_id": targetUserID,
"by": operatorID,
})
_ = s.pubsub.PublishToUser(ctx, targetUserID, kickMsg)
go s.broadcastToActiveParticipants(context.Background(), room.ID, constants.MeetingWSEventMemberLeft, map[string]interface{}{
"room_code": code,
"user_id": targetUserID,