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" ) const ( ManagerUserBlockStatusActive = "active" ManagerUserBlockStatusReleased = "released" ManagerUserBlockStatusExpired = "expired" managerUserBlockReasonPrefix = "manager_center_block:" ) // 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 } // ManagerUserBlockCommand 是经理中心封禁用户的服务层命令。 // command_id 是 H5 写操作幂等边界,blocked_until_ms 决定 cron 何时自动解封。 type ManagerUserBlockCommand struct { CommandID string ManagerUserID int64 TargetUserID int64 BlockedUntilMS int64 Reason string RequestID string NowMS int64 } // ReleaseManagerUserBlockCommand 是手动或系统到期释放经理封禁的命令。 type ReleaseManagerUserBlockCommand struct { ManagerUserID int64 BlockID string Status string Reason string RequestID string NowMS int64 } // ManagerUserBlock 是经理封禁列表的稳定读模型。 type ManagerUserBlock struct { BlockID string ManagerUserID int64 TargetUserID int64 TargetDisplayUserID string TargetUsername string TargetAvatar string Country string TargetOldStatus userdomain.Status BlockedUntilMS int64 Status string Reason string CreatedAtMS int64 UpdatedAtMS int64 } // ManagerUserBlockRelease 带上是否需要恢复 users.status,避免释放记录时误放仍被其他经理封禁的用户。 type ManagerUserBlockRelease struct { Block ManagerUserBlock ShouldRestore bool } // 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 } // CreateManagerUserBlock 创建经理封禁记录并把目标用户状态置为 banned。 // 国家和 active 状态在 service 层校验,保证任何 gateway/H5 入口都不能跨国家或重复封禁非 active 用户。 func (s *Service) CreateManagerUserBlock(ctx context.Context, command ManagerUserBlockCommand) (ManagerUserBlock, UserStatusResult, error) { if s.userRepository == nil || s.moderationRepository == nil { return ManagerUserBlock{}, UserStatusResult{}, xerr.New(xerr.Unavailable, "user repository is not configured") } command.CommandID = strings.TrimSpace(command.CommandID) command.Reason = strings.TrimSpace(command.Reason) if command.CommandID == "" || command.ManagerUserID <= 0 || command.TargetUserID <= 0 || command.BlockedUntilMS <= 0 { return ManagerUserBlock{}, UserStatusResult{}, xerr.New(xerr.InvalidArgument, "manager block command is incomplete") } if command.NowMS <= 0 { command.NowMS = s.now().UTC().UnixMilli() } if command.BlockedUntilMS <= command.NowMS { return ManagerUserBlock{}, UserStatusResult{}, xerr.New(xerr.InvalidArgument, "blocked_until_ms must be in the future") } if command.Reason == "" { command.Reason = "manager_center" } manager, err := s.userRepository.GetUser(ctx, command.ManagerUserID) if err != nil { return ManagerUserBlock{}, UserStatusResult{}, err } target, err := s.userRepository.GetUser(ctx, command.TargetUserID) if err != nil { return ManagerUserBlock{}, UserStatusResult{}, err } if manager.Country == "" || target.Country == "" || !strings.EqualFold(manager.Country, target.Country) { return ManagerUserBlock{}, UserStatusResult{}, xerr.New(xerr.PermissionDenied, "target user is outside manager country") } if target.Status != userdomain.StatusActive { return ManagerUserBlock{}, UserStatusResult{}, xerr.New(xerr.InvalidArgument, "target user is not active") } block, err := s.moderationRepository.CreateManagerUserBlock(ctx, command, target) if err != nil { return ManagerUserBlock{}, UserStatusResult{}, err } status, err := s.SetUserStatus(ctx, UserStatusCommand{ AppCode: appcode.FromContext(ctx), TargetUserID: command.TargetUserID, Status: userdomain.StatusBanned, OperatorType: OperatorTypeAppUser, OperatorUserID: command.ManagerUserID, Reason: managerUserBlockReasonPrefix + block.BlockID, RequestID: command.RequestID, NowMs: command.NowMS, }) if err != nil { return ManagerUserBlock{}, UserStatusResult{}, err } return block, status, nil } func (s *Service) ListManagerUserBlocks(ctx context.Context, managerUserID int64, status string, pageSize int32) ([]ManagerUserBlock, error) { if s.moderationRepository == nil { return nil, xerr.New(xerr.Unavailable, "moderation repository is not configured") } if managerUserID <= 0 { return nil, xerr.New(xerr.InvalidArgument, "manager_user_id is required") } status = normalizeManagerBlockStatus(status) if status == "" { status = ManagerUserBlockStatusActive } if pageSize <= 0 || pageSize > 100 { pageSize = 50 } return s.moderationRepository.ListManagerUserBlocks(ctx, managerUserID, status, pageSize) } func (s *Service) UnblockManagerUser(ctx context.Context, command ReleaseManagerUserBlockCommand) (ManagerUserBlock, UserStatusResult, error) { command.Status = ManagerUserBlockStatusReleased return s.releaseManagerUserBlock(ctx, command, OperatorTypeAppUser) } func (s *Service) ExpireManagerUserBlocks(ctx context.Context, limit int32) (int, error) { if s.moderationRepository == nil { return 0, xerr.New(xerr.Unavailable, "moderation repository is not configured") } if limit <= 0 || limit > 500 { limit = 100 } nowMS := s.now().UTC().UnixMilli() releases, err := s.moderationRepository.ExpireManagerUserBlocks(ctx, nowMS, limit) if err != nil { return 0, err } for _, release := range releases { if !release.ShouldRestore { // 释放封禁记录不等于一定恢复用户;仍有其他经理封禁或最后一次 ban 不是经理封禁时必须保持 banned。 continue } if _, err := s.SetUserStatus(ctx, UserStatusCommand{ AppCode: appcode.FromContext(ctx), TargetUserID: release.Block.TargetUserID, Status: userdomain.StatusActive, OperatorType: OperatorTypeSystem, OperatorUserID: 0, Reason: "manager_center_block_expired:" + release.Block.BlockID, RequestID: "manager_block_expire:" + release.Block.BlockID, NowMs: nowMS, }); err != nil { return len(releases), err } } return len(releases), nil } func (s *Service) releaseManagerUserBlock(ctx context.Context, command ReleaseManagerUserBlockCommand, operatorType string) (ManagerUserBlock, UserStatusResult, error) { if s.moderationRepository == nil { return ManagerUserBlock{}, UserStatusResult{}, xerr.New(xerr.Unavailable, "moderation repository is not configured") } command.BlockID = strings.TrimSpace(command.BlockID) command.Reason = strings.TrimSpace(command.Reason) if command.ManagerUserID <= 0 || command.BlockID == "" { return ManagerUserBlock{}, UserStatusResult{}, xerr.New(xerr.InvalidArgument, "manager unblock command is incomplete") } if command.NowMS <= 0 { command.NowMS = s.now().UTC().UnixMilli() } if command.Reason == "" { command.Reason = "manager_center_unblock" } release, err := s.moderationRepository.ReleaseManagerUserBlock(ctx, command) if err != nil { return ManagerUserBlock{}, UserStatusResult{}, err } if !release.ShouldRestore { // repository 已判断能否安全恢复;这里不能盲目 SetUserStatus(active),否则会覆盖后台封禁或并发经理封禁。 return release.Block, UserStatusResult{}, nil } status, err := s.SetUserStatus(ctx, UserStatusCommand{ AppCode: appcode.FromContext(ctx), TargetUserID: release.Block.TargetUserID, Status: userdomain.StatusActive, OperatorType: operatorType, OperatorUserID: command.ManagerUserID, Reason: "manager_center_unblock:" + release.Block.BlockID, RequestID: command.RequestID, NowMs: command.NowMS, }) if err != nil { return ManagerUserBlock{}, UserStatusResult{}, err } return release.Block, status, 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 normalizeManagerBlockStatus(status string) string { status = strings.ToLower(strings.TrimSpace(status)) switch status { case ManagerUserBlockStatusActive, ManagerUserBlockStatusReleased, ManagerUserBlockStatusExpired: return status default: return "" } } 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" } }