316 lines
12 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"
"log/slog"
"time"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/logx"
"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 发言前回调查 CheckSpeakPermissionRTC 麦位则同步为服务端静音态。
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: tencentim.FormatOptionalUserID(cmd.ActorUserID()),
TargetUserID: tencentim.FormatOptionalUserID(cmd.TargetUserID),
RoomVersion: current.Version,
},
}, records, nil
})
if err != nil {
return nil, err
}
if s.rtcUserAudioBlocker != nil {
// 低延迟路径在 Room Cell 提交后立即收敛 RTC必须重读当前状态
// 不能使用 result.snapshot因为旧 command_id 重放会返回历史结果,
// 在 newer unmute 后照旧投影就会把云端状态永久回退。
if err := s.reconcileRTCUserAudio(ctx, cmd.RoomID(), cmd.TargetUserID, false); err != nil {
logx.Warn(ctx, "rtc_user_audio_block_sync_failed",
slog.String("room_id", cmd.RoomID()),
slog.Int64("target_user_id", cmd.TargetUserID),
slog.String("error", err.Error()),
)
}
}
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: tencentim.FormatOptionalUserID(cmd.ActorUserID()),
TargetUserID: tencentim.FormatOptionalUserID(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: tencentim.FormatOptionalUserID(cmd.ActorUserID()),
TargetUserID: tencentim.FormatOptionalUserID(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
}