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"
}
}