视频会议保存

This commit is contained in:
duoaohui
2026-05-18 19:42:55 +08:00
parent 315884685f
commit ac8ec4c903
41 changed files with 5069 additions and 39 deletions

View File

@@ -69,6 +69,39 @@ func memberStateKey(roomCode string, userID int64) string {
return fmt.Sprintf("echo:meeting:member_state:%s:%d", roomCode, userID)
}
// screenOwnerKey Redis String记录当前会议正在共享屏幕的人 + Producer ID
// 值格式:"{userId}:{producerId}";为空 / Nil 表示当前无人共享
// 单字符串而非 Hash 是因为"会议同时只允许 1 份屏幕共享",无需多字段
//
// 生命周期:
// - OnProduceStart 收到 appData.screen=true 时 SET同时广播 meeting.screen.started
// - OnProducerClose / cleanupUserResources 命中该 Producer 时 DEL同时广播 meeting.screen.stopped
// - 房间销毁路径cleanupRoomRedisResidual一并 Del
func screenOwnerKey(roomCode string) string {
return fmt.Sprintf("echo:meeting:screen_owner:%s", roomCode)
}
// formatScreenOwnerValue / parseScreenOwnerValue 统一 screenOwnerKey 的取值格式
// 复用于 set / parse 双向,避免格式漂移
func formatScreenOwnerValue(userID int64, producerID string) string {
return fmt.Sprintf("%d:%s", userID, producerID)
}
func parseScreenOwnerValue(raw string) (int64, string, bool) {
if raw == "" {
return 0, "", false
}
parts := strings.SplitN(raw, ":", 2)
if len(parts) != 2 || parts[1] == "" {
return 0, "", false
}
var uid int64
if _, err := fmt.Sscanf(parts[0], "%d", &uid); err != nil || uid <= 0 {
return 0, "", false
}
return uid, parts[1], true
}
// resourceTTL 单个用户资源追踪集合 TTL
// 设计:会议期间维持可达即可;若用户长期不活跃由断线清理接管
// Task 16 Nit常量已迁出至 constants.MeetingResourceTrackTTLSeconds此处保留计算式 wrapper 方便调用侧零改动
@@ -237,6 +270,43 @@ func (s *MeetingSignalService) OnRoomJoin(ctx context.Context, userID int64, roo
func (s *MeetingSignalService) pushExistingRoomState(ctx context.Context, roomID int64, roomCode string, userID int64) {
s.pushExistingProducers(ctx, roomID, roomCode, userID)
s.pushExistingMemberStates(ctx, roomID, roomCode, userID)
s.pushExistingScreenShare(ctx, roomCode, userID)
}
// pushExistingScreenShare 后入者定向补推当前屏幕共享状态Phase 3
// 复用 meeting.screen.started 事件语义,前端共用同一 handler无需新事件类型
// 没有人在共享 / Redis 异常 / 解析失败 都静默 no-op
func (s *MeetingSignalService) pushExistingScreenShare(ctx context.Context, roomCode string, userID int64) {
funcName := "service.meeting_signal_service.pushExistingScreenShare"
raw, err := s.redis.Get(ctx, screenOwnerKey(roomCode)).Result()
if err != nil {
if err != redis.Nil {
logs.Warn(ctx, funcName, "读取屏幕共享拥有者失败(忽略)",
zap.String("room_code", roomCode), zap.Error(err))
}
return
}
ownerUID, producerID, ok := parseScreenOwnerValue(raw)
if !ok {
return
}
if ownerUID == userID {
// 自己就是共享者(理论上不会发生:刚 join 不应已是 owner跳过避免回响
return
}
if err := s.broadcaster.PublishToUser(ctx, userID, constants.MeetingWSEventScreenStarted, map[string]interface{}{
"room_code": roomCode,
"user_id": ownerUID,
"producer_id": producerID,
"existing": true,
}); err != nil {
logs.Warn(ctx, funcName, "定向推送 existing screen.started 失败",
zap.Int64("to_user", userID),
zap.Int64("owner_user", ownerUID),
zap.String("producer_id", producerID),
zap.Error(err))
}
}
// pushExistingProducers 向刚加入者定向推送房间里其他用户已产生的 producer 列表
@@ -384,7 +454,7 @@ func (s *MeetingSignalService) OnWSDisconnect(ctx context.Context, userID int64)
return
}
s.cleanupUserResources(ctx, room.RoomCode, userID)
s.cleanupUserResources(ctx, room.ID, room.RoomCode, userID)
if s.lifecycleSvc != nil && room.HostID == userID {
s.lifecycleSvc.OnHostDisconnect(ctx, room.RoomCode, userID)
@@ -405,7 +475,7 @@ func (s *MeetingSignalService) OnRoomLeave(ctx context.Context, userID int64, ro
if err != nil {
return err
}
s.cleanupUserResources(ctx, roomCode, userID)
s.cleanupUserResources(ctx, room.ID, roomCode, userID)
// P2-8 修复:使用常量 MeetingLeftReasonDisconnect避免 "ws_disconnect" 等硬编码
// 与前端 MEETING_LEFT_REASON_LABEL 字面值不一致
@@ -524,6 +594,36 @@ type ProduceStartPayload struct {
TransportID string `json:"transport_id"`
Kind string `json:"kind"` // "audio" | "video"
RtpParameters json.RawMessage `json:"rtp_parameters"`
// AppData 客户端透传给 mediasoup Producer 的自定义元信息
// Phase 3 屏幕共享引入:约定 {"screen": true} 表示该 video Producer 是屏幕分享流;
// 服务端识别后会写入 screenOwnerKey + 广播 meeting.screen.started前端将该流提升大画面。
// 服务端会强制把 userId / roomCode 注入回 appData覆盖客户端伪造尝试见 HTTPMediaOrchestrator.CreateProducer
AppData json.RawMessage `json:"app_data,omitempty"`
}
// isScreenAppData 判断客户端 app_data 是否声明了屏幕共享标识
// 容错raw 为空 / 非对象 / 字段缺失均返回 false
func isScreenAppData(raw json.RawMessage) bool {
if len(raw) == 0 {
return false
}
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
return false
}
v, ok := m["screen"]
if !ok {
return false
}
switch x := v.(type) {
case bool:
return x
case string:
return x == "true" || x == "1"
case float64:
return x != 0
}
return false
}
// ProduceStartResult 返回给客户端的 producerID
@@ -544,25 +644,61 @@ func (s *MeetingSignalService) OnProduceStart(ctx context.Context, userID int64,
if err := s.assertOwnsResource(ctx, payload.RoomCode, userID, "transport", payload.TransportID); err != nil {
return nil, err
}
// Phase 3识别屏幕共享意图强制 video kind 才允许(音频流不参与屏幕共享语义)
isScreen := payload.Kind == "video" && isScreenAppData(payload.AppData)
// Phase 3单会议同时仅允许 1 份屏幕共享。若已有他人在共享,直接拒绝;
// 同一用户重复请求(例如客户端重发)则容忍,由后续 SET 覆盖
if isScreen {
if existingRaw, err := s.redis.Get(ctx, screenOwnerKey(payload.RoomCode)).Result(); err == nil {
if ownerUID, _, ok := parseScreenOwnerValue(existingRaw); ok && ownerUID != userID {
return nil, fmt.Errorf("当前会议已有成员正在共享屏幕")
}
}
}
producerID, err := s.mediaOrchestrator.CreateProducer(ctx, &CreateProducerReq{
RoomCode: payload.RoomCode,
UserID: userID,
TransportID: payload.TransportID,
Kind: payload.Kind,
RtpParameters: payload.RtpParameters,
AppData: payload.AppData,
})
if err != nil {
return nil, err
}
s.trackResource(ctx, payload.RoomCode, userID, "producer", producerID)
// Phase 3屏幕共享额外维护 owner key + 触发 meeting.screen.started 广播
// 注意:先广播 producer.new保证消费者侧 Consumer 创建),再广播 screen.started标记大画面提升
if isScreen {
if err := s.redis.Set(ctx, screenOwnerKey(payload.RoomCode), formatScreenOwnerValue(userID, producerID), resourceTTL).Err(); err != nil {
logs.Warn(ctx, "service.meeting_signal_service.OnProduceStart", "写入屏幕共享拥有者失败(不阻断业务)",
zap.String("room_code", payload.RoomCode),
zap.Int64("user_id", userID),
zap.String("producer_id", producerID),
zap.Error(err))
}
}
go s.broadcaster.BroadcastToMeeting(logs.DetachContext(ctx), room.ID, constants.MeetingWSEventMemberProducerNew, map[string]interface{}{
"room_code": payload.RoomCode,
"user_id": userID,
"producer_id": producerID,
"kind": payload.Kind,
"screen": isScreen,
}, userID)
if isScreen {
go s.broadcaster.BroadcastToMeeting(logs.DetachContext(ctx), room.ID, constants.MeetingWSEventScreenStarted, map[string]interface{}{
"room_code": payload.RoomCode,
"user_id": userID,
"producer_id": producerID,
}) // 不排除任何人:共享者本人也接收,便于前端统一更新大画面 UI
}
return &ProduceStartResult{ProducerID: producerID}, nil
}
@@ -653,6 +789,9 @@ func (s *MeetingSignalService) OnProducerClose(ctx context.Context, userID int64
}
s.untrackResource(ctx, payload.RoomCode, userID, "producer", payload.ProducerID)
// Phase 3若关闭的恰好是当前屏幕共享 Producer则一并清 screenOwnerKey + 广播 screen.stopped
s.maybeReleaseScreenOwner(ctx, room.ID, payload.RoomCode, userID, payload.ProducerID)
go s.broadcaster.BroadcastToMeeting(logs.DetachContext(ctx), room.ID, constants.MeetingWSEventMemberProducerNew, map[string]interface{}{
"room_code": payload.RoomCode,
"user_id": userID,
@@ -662,11 +801,57 @@ func (s *MeetingSignalService) OnProducerClose(ctx context.Context, userID int64
return nil
}
// maybeReleaseScreenOwner 检查并释放屏幕共享拥有者状态
// 当 producerID 恰好等于 screenOwnerKey 当前值的 producer 段时,认为这次关闭是屏幕分享停止:
// - DEL screenOwnerKey
// - 广播 meeting.screen.stopped 给所有活跃成员(含共享者本人)
//
// 非屏幕 Producer 或 key 已被他人覆盖时静默 no-op保证幂等
func (s *MeetingSignalService) maybeReleaseScreenOwner(ctx context.Context, roomID int64, roomCode string, userID int64, producerID string) {
funcName := "service.meeting_signal_service.maybeReleaseScreenOwner"
key := screenOwnerKey(roomCode)
raw, err := s.redis.Get(ctx, key).Result()
if err != nil {
// redis.Nil无人共享属正常路径其他错误仅 Warn 不阻塞
if err != redis.Nil {
logs.Warn(ctx, funcName, "读取屏幕共享拥有者失败(忽略)",
zap.String("room_code", roomCode), zap.Error(err))
}
return
}
ownerUID, ownerProducerID, ok := parseScreenOwnerValue(raw)
if !ok || ownerProducerID != producerID {
return
}
// 防御性日志:理论上 ownerUID 必然等于 userIDtrackResource 已校验归属)
if ownerUID != userID {
logs.Warn(ctx, funcName, "屏幕共享拥有者与关闭者不一致(仍按停止处理)",
zap.Int64("owner_uid", ownerUID),
zap.Int64("closer_uid", userID),
zap.String("producer_id", producerID))
}
if err := s.redis.Del(ctx, key).Err(); err != nil {
logs.Warn(ctx, funcName, "删除屏幕共享拥有者失败(忽略)",
zap.String("room_code", roomCode), zap.Error(err))
}
go s.broadcaster.BroadcastToMeeting(logs.DetachContext(ctx), roomID, constants.MeetingWSEventScreenStopped, map[string]interface{}{
"room_code": roomCode,
"user_id": ownerUID,
"producer_id": producerID,
})
}
// ========== 资源清理 ==========
// cleanupUserResources 批量关闭指定用户在某会议的所有媒体资源
// WS 断开、主动离会、被踢时使用;依赖 Redis 集合中追踪的资源 ID
func (s *MeetingSignalService) cleanupUserResources(ctx context.Context, roomCode string, userID int64) {
//
// Phase 3 屏幕共享:调用方需提供 roomID便于在该用户恰好是屏幕共享者时触发
// meeting.screen.stopped 广播。roomID==0 时跳过屏幕广播(仅清 Redis保持向后兼容。
func (s *MeetingSignalService) cleanupUserResources(ctx context.Context, roomID int64, roomCode string, userID int64) {
funcName := "service.meeting_signal_service.cleanupUserResources"
key := resourceTrackKey(roomCode, userID)
@@ -687,6 +872,11 @@ func (s *MeetingSignalService) cleanupUserResources(ctx context.Context, roomCod
switch kind {
case "producer":
_ = s.mediaOrchestrator.CloseProducer(ctx, id)
// Phase 3若该 Producer 是当前屏幕共享流,释放 owner key 并广播 screen.stopped
// 复用 OnProducerClose 同款幂等逻辑roomID==0 跳过避免无效广播
if roomID > 0 {
s.maybeReleaseScreenOwner(ctx, roomID, roomCode, userID, id)
}
case "consumer":
_ = s.mediaOrchestrator.CloseConsumer(ctx, id)
case "transport":
@@ -714,7 +904,14 @@ func (s *MeetingSignalService) cleanupUserResources(ctx context.Context, roomCod
// - package 私有:允许 meeting_service / meeting_lifecycle_service 等同包文件直接调用
func cleanupRoomRedisResidual(ctx context.Context, rdb *redis.Client, roomCode string, userIDs []int64) {
funcName := "service.meeting_signal_service.cleanupRoomRedisResidual"
if rdb == nil || len(userIDs) == 0 {
if rdb == nil {
return
}
// Phase 3会议销毁路径必删 screenOwnerKey即使无活跃用户也清理
// 单独 Del 不依赖 pipeline因为会议销毁是低频路径多一次 RTT 可接受
_ = rdb.Del(ctx, screenOwnerKey(roomCode)).Err()
if len(userIDs) == 0 {
return
}
pipe := rdb.Pipeline()