2026-04-27 02:29:42 +08:00

220 lines
7.3 KiB
Go

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
}