package user import ( "context" "strings" "time" roomv1 "hyapp.local/api/proto/room/v1" "hyapp/pkg/appcode" "hyapp/pkg/idgen" "hyapp/pkg/xerr" userdomain "hyapp/services/user-service/internal/domain/user" ) const ( userStatusRevokeReason = "USER_STATUS_CHANGED" countryChangeRevokeReason = "USER_COUNTRY_CHANGED" ) const ( OperatorTypeAdmin = "admin" OperatorTypeAppUser = "app_user" OperatorTypeSystem = "system" ) // UserStatusCommand 是用户治理入口的服务层命令。 type UserStatusCommand struct { AppCode string TargetUserID int64 Status userdomain.Status OperatorType string OperatorUserID int64 Reason string RequestID string NowMs int64 } // UserStatusPersistenceResult 是状态事务提交后的数据库事实。 type UserStatusPersistenceResult struct { User userdomain.User RevokedSessionIDs []string } // UserStatusResult 汇总状态事实和封禁后需要立即执行的外部副作用结果。 type UserStatusResult struct { User userdomain.User RevokedSessionCount int64 AccessTokenRevoked bool AccessTokenError string IMKicked bool IMKickError string RoomEvicted bool RoomID string RTCKicked bool RTCKickError string RoomEvictError string } // SetUserStatus 是封禁、停用和恢复用户的通用用例入口。 // 非 active 状态会先提交用户状态和 session 吊销事实,再执行 Redis/IM/Room 的可重试副作用。 func (s *Service) SetUserStatus(ctx context.Context, command UserStatusCommand) (UserStatusResult, error) { if command.TargetUserID <= 0 { return UserStatusResult{}, xerr.New(xerr.InvalidArgument, "target_user_id is required") } command.Status = normalizeUserStatus(command.Status) if !validUserStatus(command.Status) { return UserStatusResult{}, xerr.New(xerr.InvalidArgument, "status is invalid") } if s.moderationRepository == nil { return UserStatusResult{}, xerr.New(xerr.Unavailable, "moderation repository is not configured") } if command.NowMs <= 0 { command.NowMs = s.now().UTC().UnixMilli() } command.AppCode = appcode.Normalize(command.AppCode) if command.AppCode == "" { command.AppCode = appcode.FromContext(ctx) } command.Reason = normalizeModerationReason(command.Reason, command.Status) command.OperatorType = normalizeOperatorType(command.OperatorType) persisted, err := s.moderationRepository.SetUserStatus(ctx, command) if err != nil { return UserStatusResult{}, err } result := UserStatusResult{ User: persisted.User, RevokedSessionCount: int64(len(persisted.RevokedSessionIDs)), AccessTokenRevoked: true, } if command.Status == userdomain.StatusActive { // 恢复 active 只改变准入状态,不重建旧 session、IM 登录或房间 presence。 return result, nil } result.AccessTokenRevoked, result.AccessTokenError = s.writeSessionDenylist(ctx, command, persisted.RevokedSessionIDs, userStatusRevokeReason) result.IMKicked, result.IMKickError = s.kickIMLogin(ctx, command.TargetUserID) result.RoomEvicted, result.RoomID, result.RTCKicked, result.RTCKickError, result.RoomEvictError = s.evictCurrentRoom(ctx, command) return result, nil } func (s *Service) writeSessionDenylist(ctx context.Context, command UserStatusCommand, sessionIDs []string, reason string) (bool, string) { if len(sessionIDs) == 0 { return true, "" } if s.sessionDenylist == nil { return false, "session denylist is not configured" } ttl := s.sessionDenylistTTL if ttl <= 0 { ttl = time.Hour } reason = strings.TrimSpace(reason) if reason == "" { reason = userStatusRevokeReason } failed := 0 var firstError string for _, sessionID := range sessionIDs { sessionID = strings.TrimSpace(sessionID) if sessionID == "" { continue } // 单个 Redis 写入失败不能回滚已经提交的封禁事实;调用方可通过重复封禁或重新修改国家补偿剩余 session。 if err := s.sessionDenylist.SetRevokedSession(ctx, command.AppCode, sessionID, reason, ttl); err != nil { failed++ if firstError == "" { firstError = err.Error() } } } if failed > 0 { return false, firstError } return true, "" } func (s *Service) kickIMLogin(ctx context.Context, userID int64) (bool, string) { if s.imLoginKicker == nil { return false, "" } if err := s.imLoginKicker.KickUser(ctx, userID); err != nil { return false, err.Error() } return true, "" } func (s *Service) evictCurrentRoom(ctx context.Context, command UserStatusCommand) (bool, string, bool, string, string) { if s.roomEvictor == nil { return false, "", false, "", "" } resp, err := s.roomEvictor.SystemEvictUser(ctx, &roomv1.SystemEvictUserRequest{ Meta: &roomv1.RequestMeta{ RequestId: command.RequestID, CommandId: idgen.New("cmd_user_status_evict"), ActorUserId: command.TargetUserID, SentAtMs: command.NowMs, AppCode: command.AppCode, }, TargetUserId: command.TargetUserID, OperatorUserId: command.OperatorUserID, Reason: command.Reason, BanFromRoom: true, }) if err != nil { return false, "", false, "", err.Error() } return resp.GetHadCurrentRoom() && resp.GetResult().GetApplied(), resp.GetRoomId(), resp.GetRtcKicked(), resp.GetRtcKickError(), "" } func normalizeUserStatus(status userdomain.Status) userdomain.Status { return userdomain.Status(strings.ToLower(strings.TrimSpace(string(status)))) } func validUserStatus(status userdomain.Status) bool { return status == userdomain.StatusActive || status == userdomain.StatusDisabled || status == userdomain.StatusBanned } func normalizeOperatorType(operatorType string) string { switch strings.ToLower(strings.TrimSpace(operatorType)) { case OperatorTypeAdmin: return OperatorTypeAdmin case OperatorTypeAppUser: return OperatorTypeAppUser default: return OperatorTypeSystem } } func normalizeModerationReason(reason string, status userdomain.Status) string { reason = strings.TrimSpace(reason) if reason != "" { return reason } switch status { case userdomain.StatusBanned: return "user_banned" case userdomain.StatusDisabled: return "user_disabled" default: return "user_status_active" } }