package service import ( "context" "time" roomeventsv1 "hyapp/api/proto/events/room/v1" roomv1 "hyapp/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) { // MicUp 以 actor 作为上麦目标,管理员代操作走 MicDown/ChangeMicSeat 等显式 target 命令。 cmd := command.MicUp{ Base: baseFromMeta(req.GetMeta()), SeatNo: req.GetSeatNo(), } result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) { 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") } // 麦位占用和房间版本必须在同一个 Room Cell 任务里更新。 current.MicSeats[index].UserID = cmd.ActorUserID() current.Version++ // 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", }) if err != nil { return mutationResult{}, nil, err } return mutationResult{ snapshot: current.ToProto(), seatNo: cmd.SeatNo, 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, }, }, []outbox.Record{micEvent}, nil }) if err != nil { return nil, err } return &roomv1.MicUpResponse{ Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()), SeatNo: result.seatNo, Room: result.snapshot, }, nil } // MicDown 把目标用户从当前麦位移下。 func (s *Service) MicDown(ctx context.Context, req *roomv1.MicDownRequest) (*roomv1.MicDownResponse, error) { // MicDown 支持操作者和目标用户不同,权限策略后续可在命令入口扩展。 cmd := command.MicDown{ Base: baseFromMeta(req.GetMeta()), TargetUserID: req.GetTargetUserId(), } result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) { seat, exists := current.SeatByUser(cmd.TargetUserID) if !exists { // 目标用户不在麦上时不能生成下麦事件。 return mutationResult{}, nil, xerr.New(xerr.NotFound, "target not on seat") } // SeatByUser 返回的是值拷贝,真正修改需要再定位切片下标。 index := current.SeatIndex(seat.SeatNo) current.MicSeats[index].UserID = 0 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", }) 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, }, }, []outbox.Record{micEvent}, nil }) 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 } // ChangeMicSeat 调整指定用户所在麦位。 func (s *Service) ChangeMicSeat(ctx context.Context, req *roomv1.ChangeMicSeatRequest) (*roomv1.ChangeMicSeatResponse, error) { // 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) { 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) current.MicSeats[fromIndex].UserID = 0 current.MicSeats[toIndex].UserID = cmd.TargetUserID 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", }) 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, }, }, []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 }