视频会议
This commit is contained in:
@@ -127,13 +127,20 @@ func (s *MeetingRecordingService) StartRecording(ctx context.Context, userID int
|
|||||||
return nil, ErrRecordingNoProducers
|
return nil, ErrRecordingNoProducers
|
||||||
}
|
}
|
||||||
|
|
||||||
// 反查 Router;若房间初始化阶段 Router 已被释放(极端场景),转为媒体服务不可用
|
// 反查 Router;缺失时(go-service / media-server 重启且 Redis 也丢了)触发 CreateRouter 兜底重建
|
||||||
|
// 与 JoinRoom 兜底保持一致语义:房间未结束 → mediasoup 侧没了就重建,对 Node 是新房间无冲突
|
||||||
routerID, ok := s.mediaOrchestrator.ResolveRouterID(code)
|
routerID, ok := s.mediaOrchestrator.ResolveRouterID(code)
|
||||||
if !ok || routerID == "" {
|
if !ok || routerID == "" {
|
||||||
logs.Warn(ctx, funcName, "未能解析 Router ID,会议媒体未就绪",
|
logs.Warn(ctx, funcName, "RouterID 缓存缺失,触发 CreateRouter 兜底重建",
|
||||||
zap.String("room_code", code))
|
zap.String("room_code", code))
|
||||||
|
newID, mediaErr := s.mediaOrchestrator.CreateRouter(ctx, code)
|
||||||
|
if mediaErr != nil {
|
||||||
|
logs.Error(ctx, funcName, "兜底 CreateRouter 失败,会议媒体未就绪",
|
||||||
|
zap.String("room_code", code), zap.Error(mediaErr))
|
||||||
return nil, ErrMediaServiceUnavailable
|
return nil, ErrMediaServiceUnavailable
|
||||||
}
|
}
|
||||||
|
routerID = newID
|
||||||
|
}
|
||||||
|
|
||||||
// 调 media-server
|
// 调 media-server
|
||||||
resp, err := s.mediaOrchestrator.StartRecording(ctx, &StartRecordingReq{
|
resp, err := s.mediaOrchestrator.StartRecording(ctx, &StartRecordingReq{
|
||||||
@@ -324,6 +331,48 @@ func (s *MeetingRecordingService) ListRecordings(ctx context.Context, userID int
|
|||||||
return s.recordingDAO.ListByRoom(ctx, room.ID)
|
return s.recordingDAO.ListByRoom(ctx, room.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ForceStopByAdmin 管理后台强制结束指定录制(兜底场景)
|
||||||
|
//
|
||||||
|
// 触发场景:
|
||||||
|
// - host 离线/失联且会议仍在进行,但不希望长时间空录占用磁盘
|
||||||
|
// - ffmpeg 异常退出但失败 webhook 未生效(INTERNAL_WEBHOOK_SECRET 未配/网络不通)
|
||||||
|
// - 任意 admin 主动介入收尾
|
||||||
|
//
|
||||||
|
// 行为:
|
||||||
|
// - 加载录制 → 加载所在房间(拿 HostID 满足 StopRecording 内部 host 校验)
|
||||||
|
// - 终态(ready/failed/uploading)幂等返回当前记录,不重复 stop
|
||||||
|
// - 内部直接复用 StopRecording 走完整 stop + 上传 MinIO + DB 状态机
|
||||||
|
// - media-server 返回 404 时 StopRecording 已经会降级处理(用本地 outputPath 上传残片)
|
||||||
|
func (s *MeetingRecordingService) ForceStopByAdmin(ctx context.Context, recordingID int64) (*model.MeetingRecording, error) {
|
||||||
|
funcName := "service.meeting_recording_service.ForceStopByAdmin"
|
||||||
|
|
||||||
|
rec, err := s.recordingDAO.GetByID(ctx, recordingID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if rec == nil {
|
||||||
|
return nil, ErrRecordingNotFound
|
||||||
|
}
|
||||||
|
// 终态幂等:admin 重复点击不应触发二次 stop
|
||||||
|
if rec.Status != model.MeetingRecordingStatusRecording {
|
||||||
|
logs.Info(ctx, funcName, "录制已非 recording 状态,直接返回",
|
||||||
|
zap.Int64("recording_id", rec.ID), zap.String("status", string(rec.Status)))
|
||||||
|
return rec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
room, err := s.roomDAO.GetByCode(ctx, rec.RoomCode)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if room == nil {
|
||||||
|
return nil, ErrMeetingNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
logs.Info(ctx, funcName, "管理员强制结束录制",
|
||||||
|
zap.Int64("recording_id", rec.ID), zap.String("room_code", rec.RoomCode))
|
||||||
|
return s.StopRecording(ctx, room.HostID, rec.RoomCode, rec.ID)
|
||||||
|
}
|
||||||
|
|
||||||
// StopActiveForEndRoom 在 EndRoom / EmptyTTL 路径上由系统强制停止当前活跃录制
|
// StopActiveForEndRoom 在 EndRoom / EmptyTTL 路径上由系统强制停止当前活跃录制
|
||||||
//
|
//
|
||||||
// 与对外 StopRecording 不同:
|
// 与对外 StopRecording 不同:
|
||||||
|
|||||||
@@ -435,11 +435,25 @@ func (s *MeetingService) JoinRoom(ctx context.Context, userID int64, code, passw
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Task 8:JoinRoom 不再主动调 CreateRouter(Router 在 CreateRoom 时创建、由 HTTPMediaOrchestrator 本地缓存)
|
// Task 8:JoinRoom 不再主动调 CreateRouter(Router 在 CreateRoom 时创建、由 HTTPMediaOrchestrator 本地缓存)
|
||||||
// 从缓存读取 routerID;缺失时(极少见:服务重启后未重建缓存)保持为空,不阻塞加入流程
|
// 从缓存读取 routerID;缺失时(go-service / media-server 重启且 Redis 也丢了 / 历史房间无缓存)走兜底重建:
|
||||||
|
// 房间状态仍为 Active,但 mediasoup 侧已无该 Router → 直接 CreateRouter 重新拉起,对 Node 是新房间不会冲突。
|
||||||
|
// 失败则与 CreateRoom 保持一致:返回 ErrMediaServiceUnavailable,前端提示重试。
|
||||||
routerID, _ := s.mediaOrchestrator.ResolveRouterID(code)
|
routerID, _ := s.mediaOrchestrator.ResolveRouterID(code)
|
||||||
if routerID == "" {
|
if routerID == "" {
|
||||||
logs.Debug(ctx, funcName, "RouterID 缓存缺失(非致命,可能服务重启)",
|
logs.Warn(ctx, funcName, "RouterID 缓存缺失,触发 CreateRouter 兜底重建",
|
||||||
zap.String("room_code", code))
|
zap.String("room_code", code))
|
||||||
|
newID, mediaErr := s.mediaOrchestrator.CreateRouter(ctx, code)
|
||||||
|
if mediaErr != nil {
|
||||||
|
logs.Error(ctx, funcName, "兜底 CreateRouter 失败",
|
||||||
|
zap.String("room_code", code), zap.Int64("user_id", userID), zap.Error(mediaErr))
|
||||||
|
// 已写入 participant 行:补偿离开,避免占用"一人一会议"名额
|
||||||
|
if _, leaveErr := s.participantDAO.LeaveRoom(ctx, room.ID, userID, constants.MeetingLeftReasonSelf); leaveErr != nil {
|
||||||
|
logs.Warn(ctx, funcName, "兜底失败后 LeaveRoom 失败(清理任务会兜底)",
|
||||||
|
zap.Int64("room_id", room.ID), zap.Int64("user_id", userID), zap.Error(leaveErr))
|
||||||
|
}
|
||||||
|
return nil, nil, "", ErrMediaServiceUnavailable
|
||||||
|
}
|
||||||
|
routerID = newID
|
||||||
}
|
}
|
||||||
|
|
||||||
// 广播 payload 附带 user_name / user_avatar,前端 _onMemberJoined 直接落库,无需二次拉取
|
// 广播 payload 附带 user_name / user_avatar,前端 _onMemberJoined 直接落库,无需二次拉取
|
||||||
|
|||||||
@@ -1265,6 +1265,10 @@ export const useMeetingStore = defineStore('meeting', () => {
|
|||||||
try { stream.getTracks().forEach(t => t.stop()) } catch {}
|
try { stream.getTracks().forEach(t => t.stop()) } catch {}
|
||||||
throw new Error('未获取到屏幕共享视频流')
|
throw new Error('未获取到屏幕共享视频流')
|
||||||
}
|
}
|
||||||
|
// contentHint='detail' 提示浏览器编码器按"清晰度优先"策略:
|
||||||
|
// 倾向保留高分辨率与文字锐度,降低不必要的帧率/运动估计开销,对 PPT/代码屏共享体验更好。
|
||||||
|
// 仅设置不报错的浏览器(Chromium 系全部支持),不支持时静默忽略。
|
||||||
|
try { track.contentHint = 'detail' } catch {}
|
||||||
|
|
||||||
let producer
|
let producer
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -120,7 +120,24 @@ export async function resumeConsumer(consumerId: string): Promise<void> {
|
|||||||
throw notFound('consumer', consumerId);
|
throw notFound('consumer', consumerId);
|
||||||
}
|
}
|
||||||
await entry.consumer.resume();
|
await entry.consumer.resume();
|
||||||
log.info({ consumerId }, 'consumer resumed');
|
|
||||||
|
// 屏幕共享/低运动视频的"花屏拖影"根因:consumer 从 paused 切到 active 后,第一时间
|
||||||
|
// 到达的可能是 P 帧(delta),需要等 producer 自然出 I 帧(屏幕场景常 5~10s+)。
|
||||||
|
// 主动调 requestKeyFrame() 让 mediasoup 立刻向上游 producer 发 PLI,I 帧到达后画面恢复正常。
|
||||||
|
// 音频 consumer 调用此 API 是 no-op,但 mediasoup 仍会触发不必要的工作,故按 kind 过滤。
|
||||||
|
if (entry.consumer.kind === 'video') {
|
||||||
|
try {
|
||||||
|
await entry.consumer.requestKeyFrame();
|
||||||
|
} catch (err) {
|
||||||
|
// requestKeyFrame 在 codec 不支持时会抛错(极少见),仅记日志不阻塞 resume 主路径
|
||||||
|
log.warn(
|
||||||
|
{ consumerId, err: err instanceof Error ? err.message : String(err) },
|
||||||
|
'requestKeyFrame after resume failed (ignored)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info({ consumerId, kind: entry.consumer.kind }, 'consumer resumed');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function closeConsumer(consumerId: string): Promise<void> {
|
export async function closeConsumer(consumerId: string): Promise<void> {
|
||||||
|
|||||||
Reference in New Issue
Block a user