2026-05-29 11:59:57 +08:00

260 lines
9.1 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"
"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。
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 current.MuteUsers[cmd.TargetUserID] == cmd.Muted {
// 重复设置同一禁言状态是业务 no-op仍由 pipeline 写 command log 做幂等保护。
return mutationResult{snapshot: current.ToProto()}, nil, nil
}
if cmd.Muted {
// map set 表达禁言集合true 才会被快照导出。
current.MuteUsers[cmd.TargetUserID] = true
} else {
// 解除禁言直接删除集合成员。
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
}
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,
},
}, []outbox.Record{muteEvent}, 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
}
_, 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
}