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