package service import ( "context" "strings" "time" roomeventsv1 "hyapp.local/api/proto/events/room/v1" roomv1 "hyapp.local/api/proto/room/v1" "hyapp/pkg/tencentim" "hyapp/pkg/xerr" "hyapp/services/room-service/internal/room/command" "hyapp/services/room-service/internal/room/outbox" "hyapp/services/room-service/internal/room/rank" "hyapp/services/room-service/internal/room/state" ) const ( rtcEventAudioStarted = "audio_started" rtcEventAudioStopped = "audio_stopped" rtcEventRoomExited = "room_exited" ) // ApplyRTCEvent 把已验签的腾讯 RTC 服务端事件落到 Room Cell。 func (s *Service) ApplyRTCEvent(ctx context.Context, req *roomv1.ApplyRTCEventRequest) (*roomv1.ApplyRTCEventResponse, error) { ctx = contextFromMeta(ctx, req.GetMeta()) cmd := command.ApplyRTCEvent{ Base: baseFromMeta(req.GetMeta()), TargetUserID: req.GetTargetUserId(), EventType: strings.TrimSpace(req.GetEventType()), EventTimeMS: req.GetEventTimeMs(), Reason: strings.TrimSpace(req.GetReason()), Source: strings.TrimSpace(req.GetSource()), ExternalEventID: strings.TrimSpace(req.GetExternalEventId()), } if cmd.TargetUserID == 0 { // gateway 正常会显式传 target_user_id;兜底使用 actor 保持内部调用更易测试。 cmd.TargetUserID = cmd.ActorUserID() } if cmd.Source == "" { cmd.Source = "rtc_webhook" } result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) { if cmd.TargetUserID <= 0 || cmd.EventType == "" || cmd.EventTimeMS <= 0 { return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "rtc event is incomplete") } switch cmd.EventType { case rtcEventAudioStarted: return s.applyRTCAudioStarted(now, current, cmd) case rtcEventAudioStopped: return s.applyRTCAudioStopped(now, current, cmd) case rtcEventRoomExited: return s.applyRTCRoomExited(now, current, cmd) default: return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "rtc event type is unsupported") } }) if err != nil { return nil, err } return &roomv1.ApplyRTCEventResponse{ Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()), SeatNo: result.seatNo, Room: result.snapshot, }, nil } func (s *Service) applyRTCAudioStarted(now time.Time, current *state.RoomState, cmd command.ApplyRTCEvent) (mutationResult, []outbox.Record, error) { if err := requireActiveRoom(current); err != nil { return mutationResult{}, nil, err } if _, exists := current.OnlineUsers[cmd.TargetUserID]; !exists { // RTC 事件不拥有业务进房事实;用户不在业务 presence 时只忽略旧事件。 return mutationResult{snapshot: current.ToProto()}, nil, nil } seat, exists := current.SeatByUser(cmd.TargetUserID) if !exists { return mutationResult{snapshot: current.ToProto()}, nil, nil } if !rtcEventMatchesCurrentMicSession(seat, cmd.EventTimeMS, s.micPublishTimeout) { return mutationResult{snapshot: current.ToProto(), seatNo: seat.SeatNo}, nil, nil } if seat.PublishState == state.MicPublishPublishing { return mutationResult{snapshot: current.ToProto(), seatNo: seat.SeatNo}, nil, nil } if seat.PublishState != state.MicPublishPending { return mutationResult{snapshot: current.ToProto(), seatNo: seat.SeatNo}, nil, nil } index := current.SeatIndex(seat.SeatNo) current.MicSeats[index].PublishState = state.MicPublishPublishing current.MicSeats[index].LastPublishEventTimeMS = cmd.EventTimeMS current.Version++ micEvent, err := outbox.Build(current.RoomID, "RoomMicChanged", current.Version, now, &roomeventsv1.RoomMicChanged{ ActorUserId: cmd.ActorUserID(), TargetUserId: cmd.TargetUserID, FromSeat: seat.SeatNo, ToSeat: seat.SeatNo, Action: "publish_confirmed", MicSessionId: seat.MicSessionID, PublishState: state.MicPublishPublishing, PublishDeadlineMs: seat.PublishDeadlineMS, PublishEventTimeMs: cmd.EventTimeMS, }) if err != nil { return mutationResult{}, nil, err } return mutationResult{ snapshot: current.ToProto(), seatNo: seat.SeatNo, syncEvent: &tencentim.RoomEvent{ EventID: micEvent.EventID, RoomID: current.RoomID, EventType: "room_mic_publish_confirmed", ActorUserID: cmd.ActorUserID(), TargetUserID: cmd.TargetUserID, SeatNo: seat.SeatNo, RoomVersion: current.Version, Attributes: map[string]string{ "action": "publish_confirmed", "publish_state": state.MicPublishPublishing, "mic_session_id": seat.MicSessionID, "publish_event_time_ms": int64String(cmd.EventTimeMS), "source": cmd.Source, "external_event_id": cmd.ExternalEventID, }, }, }, []outbox.Record{micEvent}, nil } func (s *Service) applyRTCAudioStopped(now time.Time, current *state.RoomState, cmd command.ApplyRTCEvent) (mutationResult, []outbox.Record, error) { seat, exists := current.SeatByUser(cmd.TargetUserID) if !exists { return mutationResult{snapshot: current.ToProto()}, nil, nil } if !rtcStopEventMatchesCurrentMicSession(seat, cmd.EventTimeMS, s.micPublishTimeout) { return mutationResult{snapshot: current.ToProto(), seatNo: seat.SeatNo}, nil, nil } index := current.SeatIndex(seat.SeatNo) micSessionID := current.MicSeats[index].MicSessionID current.ClearMicSession(index) current.Version++ micEvent, err := outbox.Build(current.RoomID, "RoomMicChanged", current.Version, now, &roomeventsv1.RoomMicChanged{ ActorUserId: cmd.ActorUserID(), TargetUserId: cmd.TargetUserID, FromSeat: seat.SeatNo, ToSeat: 0, Action: "down", MicSessionId: micSessionID, PublishState: state.MicPublishIdle, Reason: cmd.Reason, PublishEventTimeMs: cmd.EventTimeMS, }) if err != nil { return mutationResult{}, nil, err } return mutationResult{ snapshot: current.ToProto(), seatNo: seat.SeatNo, syncEvent: &tencentim.RoomEvent{ EventID: micEvent.EventID, RoomID: current.RoomID, EventType: "room_mic_down", ActorUserID: cmd.ActorUserID(), TargetUserID: cmd.TargetUserID, SeatNo: seat.SeatNo, RoomVersion: current.Version, Attributes: map[string]string{ "action": "down", "publish_state": state.MicPublishIdle, "mic_session_id": micSessionID, "reason": cmd.Reason, "publish_event_time_ms": int64String(cmd.EventTimeMS), "source": cmd.Source, "external_event_id": cmd.ExternalEventID, }, }, }, []outbox.Record{micEvent}, nil } func (s *Service) applyRTCRoomExited(now time.Time, current *state.RoomState, cmd command.ApplyRTCEvent) (mutationResult, []outbox.Record, error) { if user, exists := current.OnlineUsers[cmd.TargetUserID]; exists && user.LastSeenAtMS > cmd.EventTimeMS { // 腾讯可能重试或延迟投递旧退房事件;如果业务 presence 已被更新到事件之后,保留当前重连和麦位状态。 return mutationResult{snapshot: current.ToProto()}, nil, nil } _, onSeat := current.SeatByUser(cmd.TargetUserID) if !onSeat { // RTC 退房只代表媒体频道状态,不拥有 room-service 业务 presence。 return mutationResult{snapshot: current.ToProto()}, nil, nil } // 用户仍可能停留在房间页继续重连;这里只释放当前 mic_session,业务离房只能由 LeaveRoom、kick、close 或 stale worker 触发。 return s.applyRTCAudioStopped(now, current, cmd) } func rtcEventMatchesCurrentMicSession(seat state.MicSeat, eventTimeMS int64, timeout time.Duration) bool { if !rtcStopEventMatchesCurrentMicSession(seat, eventTimeMS, timeout) { return false } if seat.PublishDeadlineMS > 0 && eventTimeMS > seat.PublishDeadlineMS { return false } return true } func rtcStopEventMatchesCurrentMicSession(seat state.MicSeat, eventTimeMS int64, timeout time.Duration) bool { if seat.MicSessionID == "" || eventTimeMS <= seat.LastPublishEventTimeMS { return false } if createdAtMS := rtcMicSessionCreatedAtMS(seat, timeout); createdAtMS > 0 && eventTimeMS < createdAtMS { return false } return true } func rtcMicSessionCreatedAtMS(seat state.MicSeat, timeout time.Duration) int64 { timeoutMS := timeout.Milliseconds() if seat.PublishDeadlineMS <= 0 || timeoutMS <= 0 { return 0 } return seat.PublishDeadlineMS - timeoutMS }