160 lines
5.8 KiB
Go
160 lines
5.8 KiB
Go
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
|
||
}
|