2026-05-06 21:22:39 +08:00

236 lines
8.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}