2026-06-08 19:00:23 +08:00

415 lines
14 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 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"
}
}