package service import ( "context" "crypto/sha256" "encoding/hex" "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" ) // MicUp 把目标用户放到指定麦位。 func (s *Service) MicUp(ctx context.Context, req *roomv1.MicUpRequest) (*roomv1.MicUpResponse, error) { ctx = contextFromMeta(ctx, req.GetMeta()) // MicUp 以 actor 作为上麦目标,管理员代操作走 MicDown/ChangeMicSeat 等显式 target 命令。 cmd := command.MicUp{ Base: baseFromMeta(req.GetMeta()), SeatNo: req.GetSeatNo(), } cmd.MicSessionID = micSessionIDForCommand(cmd.RoomID(), cmd.ID()) cmd.PublishDeadlineMS = s.clock.Now().Add(s.micPublishTimeout).UnixMilli() result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) { if err := requireActiveRoom(current); err != nil { return mutationResult{}, nil, err } if _, exists := current.OnlineUsers[cmd.ActorUserID()]; !exists { // 不在 room-service presence 的用户不能上麦。 return mutationResult{}, nil, xerr.New(xerr.NotFound, "user not in room") } if _, exists := current.SeatByUser(cmd.ActorUserID()); exists { // 一个用户同一时间只能占一个麦位。 return mutationResult{}, nil, xerr.New(xerr.Conflict, "user already on seat") } index := current.SeatIndex(cmd.SeatNo) if index < 0 { // 麦位编号必须来自房间初始化的 MicSeats。 return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "seat does not exist") } if current.MicSeats[index].Locked { // 锁定麦位不能被普通上麦命令占用。 return mutationResult{}, nil, xerr.New(xerr.PermissionDenied, "seat is locked") } if current.MicSeats[index].UserID != 0 { // 非空麦位不能被覆盖,避免两个用户同时占同一麦位。 return mutationResult{}, nil, xerr.New(xerr.Conflict, "seat is occupied") } // MicUp 只代表业务占麦成功;RTC 发流必须再通过 ConfirmMicPublishing 确认。 current.Version++ current.MicSeats[index].UserID = cmd.ActorUserID() current.MicSeats[index].PublishState = state.MicPublishPending current.MicSeats[index].MicSessionID = cmd.MicSessionID current.MicSeats[index].PublishDeadlineMS = cmd.PublishDeadlineMS current.MicSeats[index].MicSessionRoomVersion = current.Version // 新 session 尚未接受任何 RTC/客户端事件;不能用服务端 MicUp 时间初始化, // 否则客户端设备时间略慢时,合法的首次确认会被误判为旧事件。 current.MicSeats[index].LastPublishEventTimeMS = 0 // RoomMicChanged 是房间外消费者和腾讯云 IM 共同使用的麦位事件源。 micEvent, err := outbox.Build(current.RoomID, "RoomMicChanged", current.Version, now, &roomeventsv1.RoomMicChanged{ ActorUserId: cmd.ActorUserID(), TargetUserId: cmd.ActorUserID(), FromSeat: 0, ToSeat: cmd.SeatNo, Action: "up", MicSessionId: cmd.MicSessionID, PublishState: state.MicPublishPending, PublishDeadlineMs: cmd.PublishDeadlineMS, }) if err != nil { return mutationResult{}, nil, err } return mutationResult{ snapshot: current.ToProto(), seatNo: cmd.SeatNo, micSessionID: cmd.MicSessionID, publishDeadlineMS: cmd.PublishDeadlineMS, syncEvent: &tencentim.RoomEvent{ EventID: micEvent.EventID, RoomID: current.RoomID, EventType: "room_mic_up", ActorUserID: cmd.ActorUserID(), TargetUserID: cmd.ActorUserID(), SeatNo: cmd.SeatNo, RoomVersion: current.Version, Attributes: map[string]string{ "action": "up", "publish_state": state.MicPublishPending, "mic_session_id": cmd.MicSessionID, "publish_deadline_ms": int64String(cmd.PublishDeadlineMS), }, }, }, []outbox.Record{micEvent}, nil }) if err != nil { return nil, err } if result.micSessionID == "" { // 幂等重试不会重新执行 mutate,需要从当前快照补回首次 MicUp 生成的会话信息。 if seat := protoSeatByUser(result.snapshot, cmd.ActorUserID()); seat != nil { result.seatNo = seat.GetSeatNo() result.micSessionID = seat.GetMicSessionId() result.publishDeadlineMS = seat.GetPublishDeadlineMs() } } return &roomv1.MicUpResponse{ Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()), SeatNo: result.seatNo, Room: result.snapshot, MicSessionId: result.micSessionID, PublishDeadlineMs: result.publishDeadlineMS, }, nil } // MicDown 把目标用户从当前麦位移下。 func (s *Service) MicDown(ctx context.Context, req *roomv1.MicDownRequest) (*roomv1.MicDownResponse, error) { ctx = contextFromMeta(ctx, req.GetMeta()) // MicDown 支持操作者和目标用户不同,权限策略后续可在命令入口扩展。 cmd := command.MicDown{ Base: baseFromMeta(req.GetMeta()), TargetUserID: req.GetTargetUserId(), Reason: req.GetReason(), } if cmd.TargetUserID == 0 { // target 缺省时按主动下麦处理;管理别人下麦必须显式传 target_user_id。 cmd.TargetUserID = cmd.ActorUserID() } result, err := s.micDown(ctx, cmd) if err != nil { return nil, err } return &roomv1.MicDownResponse{ Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()), SeatNo: result.seatNo, Room: result.snapshot, }, nil } func (s *Service) micDown(ctx context.Context, cmd command.MicDown) (mutationResult, error) { return s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) { if err := requireActiveRoom(current); err != nil { return mutationResult{}, nil, err } if cmd.TargetUserID <= 0 { return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "target_user_id is required") } if err := requireActorPresent(current, cmd.ActorUserID()); err != nil { return mutationResult{}, nil, err } if err := canSelfOrManageTarget(current, cmd.ActorUserID(), cmd.TargetUserID); err != nil { return mutationResult{}, nil, err } seat, exists := current.SeatByUser(cmd.TargetUserID) if !exists { // 目标用户不在麦上时不能生成下麦事件。 return mutationResult{}, nil, xerr.New(xerr.NotFound, "target not on seat") } if cmd.MicSessionID != "" && seat.MicSessionID != cmd.MicSessionID { // 超时任务携带旧 mic_session_id 时只能成为 no-op,不能清掉用户新的上麦会话。 return mutationResult{snapshot: current.ToProto(), seatNo: seat.SeatNo}, nil, nil } // SeatByUser 返回的是值拷贝,真正修改需要再定位切片下标。 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, }) 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, }, }, }, []outbox.Record{micEvent}, nil }) } // ChangeMicSeat 调整指定用户所在麦位。 func (s *Service) ChangeMicSeat(ctx context.Context, req *roomv1.ChangeMicSeatRequest) (*roomv1.ChangeMicSeatResponse, error) { ctx = contextFromMeta(ctx, req.GetMeta()) // ChangeMicSeat 是原子换位:从原麦位清空和写入新麦位必须同版本完成。 cmd := command.ChangeMicSeat{ Base: baseFromMeta(req.GetMeta()), TargetUserID: req.GetTargetUserId(), SeatNo: req.GetSeatNo(), } result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) { if err := requireActiveRoom(current); err != nil { return mutationResult{}, nil, err } if cmd.TargetUserID <= 0 { return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "target_user_id is required") } if err := requireActorPresent(current, cmd.ActorUserID()); err != nil { return mutationResult{}, nil, err } if err := canSelfOrManageTarget(current, cmd.ActorUserID(), cmd.TargetUserID); err != nil { return mutationResult{}, nil, err } fromSeat, exists := current.SeatByUser(cmd.TargetUserID) if !exists { // 未上麦用户没有可换出的源麦位。 return mutationResult{}, nil, xerr.New(xerr.NotFound, "target not on seat") } toIndex := current.SeatIndex(cmd.SeatNo) if toIndex < 0 { // 目标麦位必须存在。 return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "seat does not exist") } if current.MicSeats[toIndex].Locked { // 锁定麦位不能换入。 return mutationResult{}, nil, xerr.New(xerr.PermissionDenied, "seat is locked") } if current.MicSeats[toIndex].UserID != 0 { // 目标麦位被占用时拒绝换位,避免覆盖另一个用户。 return mutationResult{}, nil, xerr.New(xerr.Conflict, "seat is occupied") } // 清空源麦位和占用目标麦位在同一 Cell 任务内完成,客户端不会看到中间态。 fromIndex := current.SeatIndex(fromSeat.SeatNo) movedSeat := current.MicSeats[fromIndex] current.MicSeats[fromIndex].UserID = 0 current.MicSeats[fromIndex].PublishState = "" current.MicSeats[fromIndex].MicSessionID = "" current.MicSeats[fromIndex].PublishDeadlineMS = 0 current.MicSeats[fromIndex].MicSessionRoomVersion = 0 current.MicSeats[fromIndex].LastPublishEventTimeMS = 0 current.MicSeats[toIndex].UserID = cmd.TargetUserID current.MicSeats[toIndex].PublishState = movedSeat.PublishState current.MicSeats[toIndex].MicSessionID = movedSeat.MicSessionID current.MicSeats[toIndex].PublishDeadlineMS = movedSeat.PublishDeadlineMS current.MicSeats[toIndex].MicSessionRoomVersion = movedSeat.MicSessionRoomVersion current.MicSeats[toIndex].LastPublishEventTimeMS = movedSeat.LastPublishEventTimeMS current.MicSeats[toIndex].MicMuted = movedSeat.MicMuted current.Version++ micEvent, err := outbox.Build(current.RoomID, "RoomMicChanged", current.Version, now, &roomeventsv1.RoomMicChanged{ ActorUserId: cmd.ActorUserID(), TargetUserId: cmd.TargetUserID, FromSeat: fromSeat.SeatNo, ToSeat: cmd.SeatNo, Action: "change", MicSessionId: movedSeat.MicSessionID, PublishState: movedSeat.PublishState, PublishDeadlineMs: movedSeat.PublishDeadlineMS, }) if err != nil { return mutationResult{}, nil, err } return mutationResult{ snapshot: current.ToProto(), syncEvent: &tencentim.RoomEvent{ EventID: micEvent.EventID, RoomID: current.RoomID, EventType: "room_mic_change", ActorUserID: cmd.ActorUserID(), TargetUserID: cmd.TargetUserID, SeatNo: cmd.SeatNo, RoomVersion: current.Version, Attributes: map[string]string{ "action": "change", "publish_state": movedSeat.PublishState, "mic_session_id": movedSeat.MicSessionID, "publish_deadline_ms": int64String(movedSeat.PublishDeadlineMS), }, }, }, []outbox.Record{micEvent}, nil }) if err != nil { return nil, err } return &roomv1.ChangeMicSeatResponse{ Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()), Room: result.snapshot, }, nil } // ConfirmMicPublishing 把 pending_publish 麦位推进到 publishing。 func (s *Service) ConfirmMicPublishing(ctx context.Context, req *roomv1.ConfirmMicPublishingRequest) (*roomv1.ConfirmMicPublishingResponse, error) { ctx = contextFromMeta(ctx, req.GetMeta()) cmd := command.ConfirmMicPublishing{ Base: baseFromMeta(req.GetMeta()), TargetUserID: req.GetTargetUserId(), MicSessionID: req.GetMicSessionId(), RoomVersion: req.GetRoomVersion(), EventTimeMS: req.GetEventTimeMs(), Source: req.GetSource(), } if cmd.TargetUserID == 0 { cmd.TargetUserID = cmd.ActorUserID() } result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) { if err := requireActiveRoom(current); err != nil { return mutationResult{}, nil, err } if cmd.TargetUserID <= 0 || cmd.MicSessionID == "" || cmd.RoomVersion <= 0 || cmd.EventTimeMS <= 0 { return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "mic publish confirmation is incomplete") } if err := requireActorPresent(current, cmd.ActorUserID()); err != nil { return mutationResult{}, nil, err } if cmd.ActorUserID() != cmd.TargetUserID { if err := canManageTarget(current, cmd.ActorUserID(), cmd.TargetUserID); err != nil { return mutationResult{}, nil, err } } seat, exists := current.SeatByUser(cmd.TargetUserID) if !exists { // RTC webhook 可能晚于用户下麦到达;旧事件只忽略,不返回错误清掉新状态。 return mutationResult{snapshot: current.ToProto()}, nil, nil } if seat.MicSessionID != cmd.MicSessionID || cmd.RoomVersion < seat.MicSessionRoomVersion || cmd.EventTimeMS <= seat.LastPublishEventTimeMS || cmd.EventTimeMS > seat.PublishDeadlineMS { 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{}, nil, xerr.New(xerr.Conflict, "mic is not waiting for publish confirmation") } 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: cmd.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, micSessionID: cmd.MicSessionID, publishDeadlineMS: seat.PublishDeadlineMS, 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": cmd.MicSessionID, "publish_event_time_ms": int64String(cmd.EventTimeMS), "source": cmd.Source, }, }, }, []outbox.Record{micEvent}, nil }) if err != nil { return nil, err } return &roomv1.ConfirmMicPublishingResponse{ Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()), SeatNo: result.seatNo, Room: result.snapshot, }, nil } // SetMicMute 修改麦位上的服务端可见静音态。 func (s *Service) SetMicMute(ctx context.Context, req *roomv1.SetMicMuteRequest) (*roomv1.SetMicMuteResponse, error) { ctx = contextFromMeta(ctx, req.GetMeta()) cmd := command.SetMicMute{ Base: baseFromMeta(req.GetMeta()), TargetUserID: req.GetTargetUserId(), Muted: req.GetMuted(), } if cmd.TargetUserID == 0 { cmd.TargetUserID = cmd.ActorUserID() } result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) { if err := requireActiveRoom(current); err != nil { return mutationResult{}, nil, err } if cmd.TargetUserID <= 0 { return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "target_user_id is required") } if err := requireActorPresent(current, cmd.ActorUserID()); err != nil { return mutationResult{}, nil, err } if err := canSelfOrManageTarget(current, cmd.ActorUserID(), cmd.TargetUserID); err != nil { return mutationResult{}, nil, err } seat, exists := current.SeatByUser(cmd.TargetUserID) if !exists { return mutationResult{}, nil, xerr.New(xerr.NotFound, "target not on seat") } if seat.MicMuted == cmd.Muted { return mutationResult{snapshot: current.ToProto(), seatNo: seat.SeatNo}, nil, nil } index := current.SeatIndex(seat.SeatNo) current.MicSeats[index].MicMuted = cmd.Muted current.Version++ action := "mic_unmute" if cmd.Muted { action = "mic_mute" } 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: action, MicSessionId: seat.MicSessionID, PublishState: seat.PublishState, PublishDeadlineMs: seat.PublishDeadlineMS, MicMuted: cmd.Muted, }) 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_mute_changed", ActorUserID: cmd.ActorUserID(), TargetUserID: cmd.TargetUserID, SeatNo: seat.SeatNo, RoomVersion: current.Version, Attributes: map[string]string{ "action": action, "mic_session_id": seat.MicSessionID, "publish_state": seat.PublishState, "mic_muted": boolString(cmd.Muted), }, }, }, []outbox.Record{micEvent}, nil }) if err != nil { return nil, err } return &roomv1.SetMicMuteResponse{ Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()), SeatNo: result.seatNo, Room: result.snapshot, }, nil } func micSessionIDForCommand(roomID string, commandID string) string { sum := sha256.Sum256([]byte(roomID + "\x00" + commandID)) return "mic_" + hex.EncodeToString(sum[:16]) } func protoSeatByUser(snapshot *roomv1.RoomSnapshot, userID int64) *roomv1.SeatState { if snapshot == nil { return nil } for _, seat := range snapshot.GetMicSeats() { if seat.GetUserId() == userID { return seat } } return nil }