204 lines
6.1 KiB
Go
204 lines
6.1 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"
|
|
)
|
|
|
|
// 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"
|
|
}
|
|
}
|