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

160 lines
5.8 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"
"strings"
"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"
)
const defaultSystemEvictReason = "platform_moderation"
// SystemEvictUser 把平台封禁、风控命中或后台治理用户从当前 Room Cell 驱逐。
// 该命令绕过房间管理员权限矩阵,但仍必须经过 Room Cell、command log、outbox 和快照链路。
func (s *Service) SystemEvictUser(ctx context.Context, req *roomv1.SystemEvictUserRequest) (*roomv1.SystemEvictUserResponse, error) {
ctx = contextFromMeta(ctx, req.GetMeta())
targetUserID := req.GetTargetUserId()
if targetUserID <= 0 {
// 系统驱逐一定要有明确目标用户,不能依赖 actor 兜底误踢触发人。
return nil, xerr.New(xerr.InvalidArgument, "target_user_id is required")
}
roomID := strings.TrimSpace(req.GetMeta().GetRoomId())
if roomID == "" {
// 上游不知道房间时从当前房间读模型定位;该读模型来自 RoomSnapshot 投影,不扫描所有 Cell。
presence, exists, err := s.repository.GetCurrentRoomPresence(ctx, targetUserID)
if err != nil {
return nil, err
}
if !exists {
return &roomv1.SystemEvictUserResponse{
HadCurrentRoom: false,
Result: commandResult(false, 0, s.clock.Now()),
}, nil
}
// presence 只提供候选房间,后续 mutateRoom 仍会恢复并校验 Room Cell 权威状态。
roomID = presence.RoomID
}
base := baseFromMeta(req.GetMeta())
base.Room = roomID
if base.ActorID <= 0 {
// mutateRoom 需要非零 actor 作为命令元信息;系统驱逐不使用它做房间权限判断。
base.ActorID = targetUserID
}
cmd := command.SystemEvictUser{
Base: base,
TargetUserID: targetUserID,
OperatorUserID: req.GetOperatorUserId(),
Reason: normalizeSystemEvictReason(req.GetReason()),
BanFromRoom: req.GetBanFromRoom(),
}
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
}
_, inRoom := current.OnlineUsers[cmd.TargetUserID]
_, onSeat := current.SeatByUser(cmd.TargetUserID)
_, alreadyBanned := current.BanUsers[cmd.TargetUserID]
if !inRoom && !onSeat && !current.AdminUsers[cmd.TargetUserID] && (!cmd.BanFromRoom || alreadyBanned) {
// 重复系统驱逐保持幂等;没有残留状态时只返回当前快照。
return mutationResult{snapshot: current.ToProto()}, nil, nil
}
// 只有用户确实还在业务房间或麦位上,才需要额外调用 RTC 服务端踢出媒体连接。
shouldKickRTC = inRoom || onSeat
var releasedSeatNo int32
var releasedMicSessionID string
if seat, exists := current.SeatByUser(cmd.TargetUserID); exists {
// 系统驱逐释放麦位时同样写 mic down 事件,麦上时长统计不能只看 kick 事件。
index := current.SeatIndex(seat.SeatNo)
releasedSeatNo = seat.SeatNo
releasedMicSessionID = current.MicSeats[index].MicSessionID
current.ClearMicSession(index)
}
delete(current.OnlineUsers, cmd.TargetUserID)
delete(current.AdminUsers, cmd.TargetUserID)
if cmd.BanFromRoom {
// BanFromRoom 由治理入口决定;只驱逐不封房间时,用户后续可按产品策略重新进房。
current.BanUsers[cmd.TargetUserID] = state.UserModerationState{
UserID: cmd.TargetUserID,
ActorUserID: 0,
CreatedAtMS: now.UnixMilli(),
}
}
current.Version++
// 复用 RoomUserKicked 事实类型,让客户端和活动消费者不用区分管理踢人与系统驱逐的清场语义。
evictEvent, err := outbox.Build(current.RoomID, "RoomUserKicked", current.Version, now, &roomeventsv1.RoomUserKicked{
ActorUserId: 0,
TargetUserId: cmd.TargetUserID,
})
if err != nil {
return mutationResult{}, nil, err
}
records := make([]outbox.Record, 0, 2)
if releasedMicSessionID != "" {
micEvent, err := buildMicDownEventForSeat(current.RoomID, current.Version, now, 0, cmd.TargetUserID, releasedSeatNo, releasedMicSessionID, cmd.Reason)
if err != nil {
return mutationResult{}, nil, err
}
records = append(records, micEvent)
}
records = append(records, evictEvent)
return mutationResult{
snapshot: current.ToProto(),
syncEvent: &tencentim.RoomEvent{
EventID: evictEvent.EventID,
RoomID: current.RoomID,
EventType: "room_user_kicked",
TargetUserID: cmd.TargetUserID,
RoomVersion: current.Version,
Attributes: map[string]string{"reason": cmd.Reason},
},
}, records, nil
})
if err != nil {
return nil, err
}
// Room Cell 提交后再做 RTC 踢人RTC 是房间外副作用,失败不能回滚已经落盘的治理事实。
response := &roomv1.SystemEvictUserResponse{
HadCurrentRoom: true,
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
Room: result.snapshot,
RoomId: roomID,
}
if result.applied && shouldKickRTC && s.rtcUserRemover != nil {
if err := s.rtcUserRemover.RemoveUserByStrRoomID(ctx, roomID, targetUserID); err != nil {
response.RtcKickError = err.Error()
} else {
response.RtcKicked = true
}
}
return response, nil
}
// normalizeSystemEvictReason 固定治理事件默认原因,避免空 reason 进入 outbox 和 IM 属性。
func normalizeSystemEvictReason(reason string) string {
reason = strings.TrimSpace(reason)
if reason == "" {
return defaultSystemEvictReason
}
return reason
}