301 lines
11 KiB
Go
301 lines
11 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"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"
|
||
)
|
||
|
||
// MuteUser 修改用户禁言状态。
|
||
func (s *Service) MuteUser(ctx context.Context, req *roomv1.MuteUserRequest) (*roomv1.MuteUserResponse, error) {
|
||
ctx = contextFromMeta(ctx, req.GetMeta())
|
||
// 禁言状态由 room-service 持有:腾讯云 IM 发言前回调查 CheckSpeakPermission,RTC 麦位则同步为服务端静音态。
|
||
cmd := command.MuteUser{
|
||
Base: baseFromMeta(req.GetMeta()),
|
||
TargetUserID: req.GetTargetUserId(),
|
||
Muted: req.GetMuted(),
|
||
}
|
||
|
||
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 cmd.TargetUserID == cmd.ActorUserID() {
|
||
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "cannot mute self")
|
||
}
|
||
if err := requireActorPresent(current, cmd.ActorUserID()); err != nil {
|
||
return mutationResult{}, nil, err
|
||
}
|
||
if err := canManageTarget(current, cmd.ActorUserID(), cmd.TargetUserID); err != nil {
|
||
return mutationResult{}, nil, err
|
||
}
|
||
if cmd.Muted {
|
||
if err := s.requireTargetWithoutVIPBenefit(ctx, req.GetMeta().GetRequestId(), cmd.TargetUserID, vipBenefitAntiMute); err != nil {
|
||
// anti_mute 只阻止普通房主/管理员新增禁言;解除既有禁言必须始终允许。
|
||
return mutationResult{}, nil, err
|
||
}
|
||
}
|
||
if current.MuteUsers[cmd.TargetUserID] == cmd.Muted {
|
||
// 重复设置同一禁言状态是业务 no-op,仍由 pipeline 写 command log 做幂等保护。
|
||
return mutationResult{snapshot: current.ToProto()}, nil, nil
|
||
}
|
||
|
||
var mutedSeat *state.MicSeat
|
||
if cmd.Muted {
|
||
// map set 是公屏、上麦和开麦权限的统一事实;麦位静音只是这个事实的 RTC 投影。
|
||
current.MuteUsers[cmd.TargetUserID] = true
|
||
if seat, exists := current.SeatByUser(cmd.TargetUserID); exists && !seat.MicMuted {
|
||
// 房间禁言不能只依赖目标客户端执行 muteLocalAudio;先把当前麦位标成 muted,
|
||
// 让快照和 room_mic_mute_changed 都保持静音,目标用户解除房间禁言前也不能自行开麦。
|
||
index := current.SeatIndex(seat.SeatNo)
|
||
current.MicSeats[index].MicMuted = true
|
||
updated := current.MicSeats[index]
|
||
mutedSeat = &updated
|
||
}
|
||
} else {
|
||
// 解除房间禁言只恢复“允许开麦”;不替用户自动打开本地麦克风,当前麦位仍需显式 SetMicMute(false)。
|
||
delete(current.MuteUsers, cmd.TargetUserID)
|
||
}
|
||
|
||
current.Version++
|
||
|
||
// 禁言变更进入 outbox,外部系统可以做审核和操作审计。
|
||
muteEvent, err := outbox.Build(current.RoomID, "RoomUserMuted", current.Version, now, &roomeventsv1.RoomUserMuted{
|
||
ActorUserId: cmd.ActorUserID(),
|
||
TargetUserId: cmd.TargetUserID,
|
||
Muted: cmd.Muted,
|
||
})
|
||
if err != nil {
|
||
return mutationResult{}, nil, err
|
||
}
|
||
records := make([]outbox.Record, 0, 2)
|
||
if mutedSeat != nil {
|
||
micChanged := roomMicChangedEvent(&roomeventsv1.RoomMicChanged{
|
||
ActorUserId: cmd.ActorUserID(),
|
||
TargetUserId: cmd.TargetUserID,
|
||
FromSeat: mutedSeat.SeatNo,
|
||
ToSeat: mutedSeat.SeatNo,
|
||
Action: "mic_mute",
|
||
MicSessionId: mutedSeat.MicSessionID,
|
||
PublishState: mutedSeat.PublishState,
|
||
PublishDeadlineMs: mutedSeat.PublishDeadlineMS,
|
||
MicMuted: true,
|
||
SeatStatus: mutedSeat.SeatStatus(),
|
||
TargetGiftValue: roomUserGiftValue(current, cmd.TargetUserID),
|
||
}, command.UserDisplayProfile{})
|
||
micEvent, err := outbox.Build(current.RoomID, "RoomMicChanged", current.Version, now, micChanged)
|
||
if err != nil {
|
||
return mutationResult{}, nil, err
|
||
}
|
||
records = append(records, micEvent)
|
||
}
|
||
records = append(records, muteEvent)
|
||
|
||
return mutationResult{
|
||
snapshot: current.ToProto(),
|
||
syncEvent: &tencentim.RoomEvent{
|
||
EventID: muteEvent.EventID,
|
||
RoomID: current.RoomID,
|
||
EventType: "room_user_muted",
|
||
ActorUserID: cmd.ActorUserID(),
|
||
TargetUserID: cmd.TargetUserID,
|
||
RoomVersion: current.Version,
|
||
},
|
||
}, records, nil
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return &roomv1.MuteUserResponse{
|
||
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
|
||
Room: result.snapshot,
|
||
}, nil
|
||
}
|
||
|
||
// KickUser 把目标用户踢出房间并移除其麦位与 presence。
|
||
func (s *Service) KickUser(ctx context.Context, req *roomv1.KickUserRequest) (*roomv1.KickUserResponse, error) {
|
||
ctx = contextFromMeta(ctx, req.GetMeta())
|
||
// KickUser 同时修改 presence、麦位和 ban 集合,是典型 Room Cell 强关联命令。
|
||
cmd := command.KickUser{
|
||
Base: baseFromMeta(req.GetMeta()),
|
||
TargetUserID: req.GetTargetUserId(),
|
||
DurationMS: req.GetDurationMs(),
|
||
}
|
||
|
||
shouldKickRTC := false
|
||
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 cmd.TargetUserID == cmd.ActorUserID() {
|
||
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "cannot kick self")
|
||
}
|
||
if cmd.DurationMS < 0 {
|
||
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "duration_ms must be non-negative")
|
||
}
|
||
if err := requireActorPresent(current, cmd.ActorUserID()); err != nil {
|
||
return mutationResult{}, nil, err
|
||
}
|
||
if err := canManageTarget(current, cmd.ActorUserID(), cmd.TargetUserID); err != nil {
|
||
return mutationResult{}, nil, err
|
||
}
|
||
if err := s.requireTargetWithoutVIPBenefit(ctx, req.GetMeta().GetRequestId(), cmd.TargetUserID, vipBenefitAntiKick); err != nil {
|
||
// 平台封禁、风控和后台治理使用独立 SystemEvictUser;只有普通房间治理受 anti_kick 保护。
|
||
return mutationResult{}, nil, err
|
||
}
|
||
|
||
_, inRoom := current.OnlineUsers[cmd.TargetUserID]
|
||
_, onSeat := current.SeatByUser(cmd.TargetUserID)
|
||
// 只有目标确实还在业务房间或麦位上,才需要请求 TRTC 服务端移除实时音频连接。
|
||
shouldKickRTC = inRoom || onSeat
|
||
|
||
var releasedSeatNo int32
|
||
var releasedMicSessionID string
|
||
if seat, exists := current.SeatByUser(cmd.TargetUserID); exists {
|
||
// 被踢用户如果在麦上,必须先释放麦位,避免恢复后出现幽灵占位。
|
||
index := current.SeatIndex(seat.SeatNo)
|
||
releasedSeatNo = seat.SeatNo
|
||
releasedMicSessionID = current.MicSeats[index].MicSessionID
|
||
current.ClearMicSession(index)
|
||
}
|
||
|
||
// ban 集合会让后续 JoinRoom 和 VerifyRoomPresence 都失败。
|
||
delete(current.OnlineUsers, cmd.TargetUserID)
|
||
delete(current.AdminUsers, cmd.TargetUserID)
|
||
expiresAtMS := int64(0)
|
||
nowMS := now.UnixMilli()
|
||
if cmd.DurationMS > 0 {
|
||
expiresAtMS = nowMS + cmd.DurationMS
|
||
if expiresAtMS <= nowMS {
|
||
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "duration_ms is too large")
|
||
}
|
||
}
|
||
current.BanUsers[cmd.TargetUserID] = state.UserModerationState{
|
||
UserID: cmd.TargetUserID,
|
||
ActorUserID: cmd.ActorUserID(),
|
||
CreatedAtMS: nowMS,
|
||
ExpiresAtMS: expiresAtMS,
|
||
}
|
||
current.Version++
|
||
|
||
kickEvent, err := outbox.Build(current.RoomID, "RoomUserKicked", current.Version, now, &roomeventsv1.RoomUserKicked{
|
||
ActorUserId: cmd.ActorUserID(),
|
||
TargetUserId: cmd.TargetUserID,
|
||
})
|
||
if err != nil {
|
||
return mutationResult{}, nil, err
|
||
}
|
||
records := make([]outbox.Record, 0, 2)
|
||
if releasedMicSessionID != "" {
|
||
// 踢人释放麦位必须也生成 down 事实,时长聚合不能从 RoomUserKicked 反推麦位结束。
|
||
micEvent, err := buildMicDownEventForSeat(current.RoomID, current.Version, now, cmd.ActorUserID(), cmd.TargetUserID, releasedSeatNo, releasedMicSessionID, "kick")
|
||
if err != nil {
|
||
return mutationResult{}, nil, err
|
||
}
|
||
records = append(records, micEvent)
|
||
}
|
||
records = append(records, kickEvent)
|
||
|
||
return mutationResult{
|
||
snapshot: current.ToProto(),
|
||
syncEvent: &tencentim.RoomEvent{
|
||
EventID: kickEvent.EventID,
|
||
RoomID: current.RoomID,
|
||
EventType: "room_user_kicked",
|
||
ActorUserID: cmd.ActorUserID(),
|
||
TargetUserID: cmd.TargetUserID,
|
||
RoomVersion: current.Version,
|
||
},
|
||
}, records, nil
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
resp := &roomv1.KickUserResponse{
|
||
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
|
||
Room: result.snapshot,
|
||
}
|
||
if result.applied && shouldKickRTC && s.rtcUserRemover != nil {
|
||
// RTC 是房间外连接态:踢人事实已提交后再调用 TRTC,失败不回滚 Room Cell,但要回传给调用方做告警。
|
||
if err := s.rtcUserRemover.RemoveUserByStrRoomID(ctx, cmd.RoomID(), cmd.TargetUserID); err != nil {
|
||
resp.RtcKickError = err.Error()
|
||
} else {
|
||
resp.RtcKicked = true
|
||
}
|
||
}
|
||
|
||
return resp, nil
|
||
}
|
||
|
||
// UnbanUser 解除房间 ban;不会恢复 presence、管理员、麦位或 RTC 推流事实。
|
||
func (s *Service) UnbanUser(ctx context.Context, req *roomv1.UnbanUserRequest) (*roomv1.UnbanUserResponse, error) {
|
||
ctx = contextFromMeta(ctx, req.GetMeta())
|
||
cmd := command.UnbanUser{
|
||
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) {
|
||
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 := requireOwnerPresent(current, cmd.ActorUserID()); err != nil {
|
||
return mutationResult{}, nil, err
|
||
}
|
||
if _, exists := current.BanUsers[cmd.TargetUserID]; !exists {
|
||
// 重复 unban 是 no-op,仍写 command log 确保 command_id 幂等可判定。
|
||
return mutationResult{snapshot: current.ToProto()}, nil, nil
|
||
}
|
||
|
||
delete(current.BanUsers, cmd.TargetUserID)
|
||
current.Version++
|
||
|
||
unbanEvent, err := outbox.Build(current.RoomID, "RoomUserUnbanned", current.Version, now, &roomeventsv1.RoomUserUnbanned{
|
||
ActorUserId: cmd.ActorUserID(),
|
||
TargetUserId: cmd.TargetUserID,
|
||
})
|
||
if err != nil {
|
||
return mutationResult{}, nil, err
|
||
}
|
||
|
||
return mutationResult{
|
||
snapshot: current.ToProto(),
|
||
syncEvent: &tencentim.RoomEvent{
|
||
EventID: unbanEvent.EventID,
|
||
RoomID: current.RoomID,
|
||
EventType: "room_user_unbanned",
|
||
ActorUserID: cmd.ActorUserID(),
|
||
TargetUserID: cmd.TargetUserID,
|
||
RoomVersion: current.Version,
|
||
},
|
||
}, []outbox.Record{unbanEvent}, nil
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return &roomv1.UnbanUserResponse{
|
||
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
|
||
Room: result.snapshot,
|
||
}, nil
|
||
}
|