diff --git a/src/main/java/com/example/solidconnection/admin/controller/AdminUserController.java b/src/main/java/com/example/solidconnection/admin/controller/AdminUserController.java new file mode 100644 index 000000000..841ef901e --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/controller/AdminUserController.java @@ -0,0 +1,62 @@ +package com.example.solidconnection.admin.controller; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import com.example.solidconnection.admin.dto.RestrictedUserInfoDetailResponse; +import com.example.solidconnection.admin.dto.RestrictedUserSearchCondition; +import com.example.solidconnection.admin.dto.RestrictedUserSearchResponse; +import com.example.solidconnection.admin.dto.UserInfoDetailResponse; +import com.example.solidconnection.admin.dto.UserSearchCondition; +import com.example.solidconnection.admin.dto.UserSearchResponse; +import com.example.solidconnection.admin.service.AdminUserService; +import com.example.solidconnection.common.response.PageResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@RequestMapping("/admin/users") +@RestController +public class AdminUserController { + + private final AdminUserService adminUserService; + + @GetMapping + public ResponseEntity> searchAllUsers( + @Valid @ModelAttribute UserSearchCondition searchCondition, + Pageable pageable + ) { + Page page = adminUserService.searchAllUsers(searchCondition, pageable); + return ResponseEntity.ok(PageResponse.of(page)); + } + + @GetMapping("/{user-id}") + public ResponseEntity getUserInfoDetail( + @PathVariable(name = "user-id") long userId + ) { + UserInfoDetailResponse response = adminUserService.getUserInfoDetail(userId); + return ResponseEntity.ok(response); + } + + @GetMapping("/restricted") + public ResponseEntity> searchRestrictedUsers( + @Valid @ModelAttribute RestrictedUserSearchCondition searchCondition, + Pageable pageable + ) { + Page page = adminUserService.searchRestrictedUsers(searchCondition, pageable); + return ResponseEntity.ok(PageResponse.of(page)); + } + + @GetMapping("/restricted/{user-id}") + public ResponseEntity getRestrictedUserInfoDetail( + @PathVariable(name = "user-id") long userId + ) { + RestrictedUserInfoDetailResponse response = adminUserService.getRestrictedUserInfoDetail(userId); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/example/solidconnection/admin/dto/BannedHistoryResponse.java b/src/main/java/com/example/solidconnection/admin/dto/BannedHistoryResponse.java new file mode 100644 index 000000000..de3ab7a53 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/dto/BannedHistoryResponse.java @@ -0,0 +1,9 @@ +package com.example.solidconnection.admin.dto; + +import java.time.ZonedDateTime; + +public record BannedHistoryResponse( + ZonedDateTime createdAt +) { + +} diff --git a/src/main/java/com/example/solidconnection/admin/dto/BannedInfoResponse.java b/src/main/java/com/example/solidconnection/admin/dto/BannedInfoResponse.java new file mode 100644 index 000000000..1228fff73 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/dto/BannedInfoResponse.java @@ -0,0 +1,10 @@ +package com.example.solidconnection.admin.dto; + +import com.example.solidconnection.siteuser.domain.UserBanDuration; + +public record BannedInfoResponse( + boolean isBanned, + UserBanDuration duration +) { + +} diff --git a/src/main/java/com/example/solidconnection/admin/dto/MatchedInfoResponse.java b/src/main/java/com/example/solidconnection/admin/dto/MatchedInfoResponse.java new file mode 100644 index 000000000..d3b5c6f68 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/dto/MatchedInfoResponse.java @@ -0,0 +1,10 @@ +package com.example.solidconnection.admin.dto; + +import java.time.ZonedDateTime; + +public record MatchedInfoResponse( + String nickname, + ZonedDateTime matchedDate +) { + +} diff --git a/src/main/java/com/example/solidconnection/admin/dto/MenteeInfoResponse.java b/src/main/java/com/example/solidconnection/admin/dto/MenteeInfoResponse.java new file mode 100644 index 000000000..6db2b61ae --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/dto/MenteeInfoResponse.java @@ -0,0 +1,10 @@ +package com.example.solidconnection.admin.dto; + +import java.util.List; + +public record MenteeInfoResponse( + UnivApplyInfoResponse univApplyInfos, + List mentorInfos +) { + +} diff --git a/src/main/java/com/example/solidconnection/admin/dto/MentorApplicationHistoryInfoResponse.java b/src/main/java/com/example/solidconnection/admin/dto/MentorApplicationHistoryInfoResponse.java new file mode 100644 index 000000000..f026299f0 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/dto/MentorApplicationHistoryInfoResponse.java @@ -0,0 +1,12 @@ +package com.example.solidconnection.admin.dto; + +import com.example.solidconnection.mentor.domain.MentorApplicationStatus; +import java.time.ZonedDateTime; + +public record MentorApplicationHistoryInfoResponse( + MentorApplicationStatus mentorApplicationStatus, + String rejectedReason, + ZonedDateTime createdAt +) { + +} diff --git a/src/main/java/com/example/solidconnection/admin/dto/MentorInfoResponse.java b/src/main/java/com/example/solidconnection/admin/dto/MentorInfoResponse.java new file mode 100644 index 000000000..bd3dad8d5 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/dto/MentorInfoResponse.java @@ -0,0 +1,10 @@ +package com.example.solidconnection.admin.dto; + +import java.util.List; + +public record MentorInfoResponse( + List menteeInfos, + List mentorApplicationHistory +) { + +} diff --git a/src/main/java/com/example/solidconnection/admin/dto/ReportedHistoryResponse.java b/src/main/java/com/example/solidconnection/admin/dto/ReportedHistoryResponse.java new file mode 100644 index 000000000..d7fbaa94c --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/dto/ReportedHistoryResponse.java @@ -0,0 +1,12 @@ +package com.example.solidconnection.admin.dto; + +import java.time.ZonedDateTime; + +import com.example.solidconnection.report.domain.ReportType; + +public record ReportedHistoryResponse( + ZonedDateTime reportedDate, + ReportType reportType +) { + +} diff --git a/src/main/java/com/example/solidconnection/admin/dto/ReportedInfoResponse.java b/src/main/java/com/example/solidconnection/admin/dto/ReportedInfoResponse.java new file mode 100644 index 000000000..95d7f20a3 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/dto/ReportedInfoResponse.java @@ -0,0 +1,14 @@ +package com.example.solidconnection.admin.dto; + +import java.time.ZonedDateTime; + +import com.example.solidconnection.report.domain.ReportType; +import com.example.solidconnection.report.domain.TargetType; + +public record ReportedInfoResponse( + ZonedDateTime reportedDate, + TargetType targetType, + ReportType reportType +) { + +} diff --git a/src/main/java/com/example/solidconnection/admin/dto/RestrictedUserInfoDetailResponse.java b/src/main/java/com/example/solidconnection/admin/dto/RestrictedUserInfoDetailResponse.java new file mode 100644 index 000000000..1c9cc40bb --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/dto/RestrictedUserInfoDetailResponse.java @@ -0,0 +1,10 @@ +package com.example.solidconnection.admin.dto; + +import java.util.List; + +public record RestrictedUserInfoDetailResponse( + List reportedHistoryResponses, + List bannedHistoryResponses +) { + +} diff --git a/src/main/java/com/example/solidconnection/admin/dto/RestrictedUserSearchCondition.java b/src/main/java/com/example/solidconnection/admin/dto/RestrictedUserSearchCondition.java new file mode 100644 index 000000000..2f9db3309 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/dto/RestrictedUserSearchCondition.java @@ -0,0 +1,12 @@ +package com.example.solidconnection.admin.dto; + +import com.example.solidconnection.siteuser.domain.Role; +import com.example.solidconnection.siteuser.domain.UserStatus; + +public record RestrictedUserSearchCondition( + Role role, + UserStatus userStatus, + String keyword +) { + +} diff --git a/src/main/java/com/example/solidconnection/admin/dto/RestrictedUserSearchResponse.java b/src/main/java/com/example/solidconnection/admin/dto/RestrictedUserSearchResponse.java new file mode 100644 index 000000000..ee0cfd773 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/dto/RestrictedUserSearchResponse.java @@ -0,0 +1,14 @@ +package com.example.solidconnection.admin.dto; + +import com.example.solidconnection.siteuser.domain.Role; +import com.example.solidconnection.siteuser.domain.UserStatus; + +public record RestrictedUserSearchResponse( + String nickname, + Role role, + UserStatus userStatus, + ReportedInfoResponse reportedInfoResponse, + BannedInfoResponse bannedInfoResponse +) { + +} diff --git a/src/main/java/com/example/solidconnection/admin/dto/UnivApplyInfoResponse.java b/src/main/java/com/example/solidconnection/admin/dto/UnivApplyInfoResponse.java new file mode 100644 index 000000000..225991d64 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/dto/UnivApplyInfoResponse.java @@ -0,0 +1,9 @@ +package com.example.solidconnection.admin.dto; + +public record UnivApplyInfoResponse( + String firstChoiceUnivName, + String secondChoiceUnivName, + String thirdChoiceUnivName +) { + +} diff --git a/src/main/java/com/example/solidconnection/admin/dto/UserInfoDetailResponse.java b/src/main/java/com/example/solidconnection/admin/dto/UserInfoDetailResponse.java new file mode 100644 index 000000000..9c2dd637d --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/dto/UserInfoDetailResponse.java @@ -0,0 +1,11 @@ +package com.example.solidconnection.admin.dto; + +import java.util.List; + +public record UserInfoDetailResponse( + MentorInfoResponse mentorInfoResponse, // 멘티일 경우 null + MenteeInfoResponse menteeInfoResponse, // 멘토일 경우 null + List reportedHistoryResponses +) { + +} diff --git a/src/main/java/com/example/solidconnection/admin/dto/UserSearchCondition.java b/src/main/java/com/example/solidconnection/admin/dto/UserSearchCondition.java new file mode 100644 index 000000000..9d6bc1bd8 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/dto/UserSearchCondition.java @@ -0,0 +1,10 @@ +package com.example.solidconnection.admin.dto; + +import com.example.solidconnection.siteuser.domain.Role; + +public record UserSearchCondition( + Role role, + String keyword +) { + +} diff --git a/src/main/java/com/example/solidconnection/admin/dto/UserSearchResponse.java b/src/main/java/com/example/solidconnection/admin/dto/UserSearchResponse.java new file mode 100644 index 000000000..f2352d186 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/dto/UserSearchResponse.java @@ -0,0 +1,13 @@ +package com.example.solidconnection.admin.dto; + +import com.example.solidconnection.siteuser.domain.Role; +import com.example.solidconnection.siteuser.domain.UserStatus; + +public record UserSearchResponse( + String nickname, + String email, + Role role, + UserStatus userStatus +) { + +} diff --git a/src/main/java/com/example/solidconnection/admin/service/AdminUserService.java b/src/main/java/com/example/solidconnection/admin/service/AdminUserService.java new file mode 100644 index 000000000..e7ef7b538 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/service/AdminUserService.java @@ -0,0 +1,51 @@ +package com.example.solidconnection.admin.service; + +import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.example.solidconnection.admin.dto.RestrictedUserInfoDetailResponse; +import com.example.solidconnection.admin.dto.RestrictedUserSearchCondition; +import com.example.solidconnection.admin.dto.RestrictedUserSearchResponse; +import com.example.solidconnection.admin.dto.UserInfoDetailResponse; +import com.example.solidconnection.admin.dto.UserSearchCondition; +import com.example.solidconnection.admin.dto.UserSearchResponse; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.siteuser.repository.custom.SiteUserFilterRepository; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Service +public class AdminUserService { + + private final SiteUserRepository siteUserRepository; + private final SiteUserFilterRepository siteUserFilterRepository; + + @Transactional(readOnly = true) + public Page searchAllUsers(UserSearchCondition searchCondition, Pageable pageable) { + return siteUserFilterRepository.searchAllUsers(searchCondition, pageable); + } + + @Transactional(readOnly = true) + public Page searchRestrictedUsers(RestrictedUserSearchCondition searchCondition, Pageable pageable) { + return siteUserFilterRepository.searchRestrictedUsers(searchCondition, pageable); + } + + @Transactional(readOnly = true) + public UserInfoDetailResponse getUserInfoDetail(long userId) { + SiteUser siteUser = siteUserRepository.findById(userId) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + return siteUserFilterRepository.getUserInfoDetailByUserId(siteUser.getId()); + } + + @Transactional(readOnly = true) + public RestrictedUserInfoDetailResponse getRestrictedUserInfoDetail(long userId) { + SiteUser siteUser = siteUserRepository.findById(userId) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + return siteUserFilterRepository.getRestrictedUserInfoDetail(siteUser.getId()); + } +} diff --git a/src/main/java/com/example/solidconnection/siteuser/repository/custom/SiteUserFilterRepository.java b/src/main/java/com/example/solidconnection/siteuser/repository/custom/SiteUserFilterRepository.java new file mode 100644 index 000000000..1edc2539f --- /dev/null +++ b/src/main/java/com/example/solidconnection/siteuser/repository/custom/SiteUserFilterRepository.java @@ -0,0 +1,21 @@ +package com.example.solidconnection.siteuser.repository.custom; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import com.example.solidconnection.admin.dto.RestrictedUserInfoDetailResponse; +import com.example.solidconnection.admin.dto.RestrictedUserSearchCondition; +import com.example.solidconnection.admin.dto.RestrictedUserSearchResponse; +import com.example.solidconnection.admin.dto.UserInfoDetailResponse; +import com.example.solidconnection.admin.dto.UserSearchCondition; +import com.example.solidconnection.admin.dto.UserSearchResponse; + +public interface SiteUserFilterRepository { + + Page searchAllUsers(UserSearchCondition searchCondition, Pageable pageable); + + Page searchRestrictedUsers(RestrictedUserSearchCondition searchCondition, Pageable pageable); + + UserInfoDetailResponse getUserInfoDetailByUserId(long userId); + + RestrictedUserInfoDetailResponse getRestrictedUserInfoDetail(long userId); +} diff --git a/src/main/java/com/example/solidconnection/siteuser/repository/custom/SiteUserFilterRepositoryImpl.java b/src/main/java/com/example/solidconnection/siteuser/repository/custom/SiteUserFilterRepositoryImpl.java new file mode 100644 index 000000000..8b9db7c1e --- /dev/null +++ b/src/main/java/com/example/solidconnection/siteuser/repository/custom/SiteUserFilterRepositoryImpl.java @@ -0,0 +1,351 @@ +package com.example.solidconnection.siteuser.repository.custom; + +import static com.example.solidconnection.application.domain.QApplication.application; +import static com.example.solidconnection.mentor.domain.QMentor.mentor; +import static com.example.solidconnection.mentor.domain.QMentorApplication.mentorApplication; +import static com.example.solidconnection.mentor.domain.QMentoring.mentoring; +import static com.example.solidconnection.report.domain.QReport.report; +import static com.example.solidconnection.siteuser.domain.QSiteUser.siteUser; +import static com.example.solidconnection.siteuser.domain.QUserBan.userBan; +import static java.time.ZoneOffset.UTC; +import static org.springframework.util.StringUtils.hasText; + +import com.example.solidconnection.admin.dto.MentorApplicationHistoryInfoResponse; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import com.example.solidconnection.admin.dto.RestrictedUserInfoDetailResponse; +import com.example.solidconnection.university.domain.QUnivApplyInfo; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; +import com.example.solidconnection.admin.dto.BannedHistoryResponse; +import com.example.solidconnection.admin.dto.BannedInfoResponse; +import com.example.solidconnection.admin.dto.MatchedInfoResponse; +import com.example.solidconnection.admin.dto.MenteeInfoResponse; +import com.example.solidconnection.admin.dto.MentorInfoResponse; +import com.example.solidconnection.admin.dto.ReportedHistoryResponse; +import com.example.solidconnection.admin.dto.ReportedInfoResponse; +import com.example.solidconnection.admin.dto.RestrictedUserSearchCondition; +import com.example.solidconnection.admin.dto.RestrictedUserSearchResponse; +import com.example.solidconnection.admin.dto.UnivApplyInfoResponse; +import com.example.solidconnection.admin.dto.UserInfoDetailResponse; +import com.example.solidconnection.admin.dto.UserSearchCondition; +import com.example.solidconnection.admin.dto.UserSearchResponse; +import com.example.solidconnection.siteuser.domain.Role; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.domain.UserStatus; +import com.querydsl.core.types.ConstructorExpression; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; + +@Repository +public class SiteUserFilterRepositoryImpl implements SiteUserFilterRepository { + + private static final ConstructorExpression USER_SEARCH_RESPONSE_PROJECTION = + Projections.constructor( + UserSearchResponse.class, + siteUser.nickname, + siteUser.email, + siteUser.role, + siteUser.userStatus + ); + + private static final ConstructorExpression REPORTED_INFO_RESPONSE_PROJECTION = + Projections.constructor( + ReportedInfoResponse.class, + report.createdAt, + report.targetType, + report.reportType + ); + + private static final ConstructorExpression BANNED_INFO_RESPONSE_PROJECTION = + Projections.constructor( + BannedInfoResponse.class, + siteUser.userStatus.eq(UserStatus.BANNED), + userBan.duration + ); + + private static final ConstructorExpression RESTRICTED_USER_SEARCH_RESPONSE_PROJECTION = + Projections.constructor( + RestrictedUserSearchResponse.class, + siteUser.nickname, + siteUser.role, + siteUser.userStatus, + REPORTED_INFO_RESPONSE_PROJECTION, + BANNED_INFO_RESPONSE_PROJECTION + ); + + private static final ConstructorExpression REPORTED_HISTORY_RESPONSE_PROJECTION = + Projections.constructor( + ReportedHistoryResponse.class, + report.createdAt, + report.reportType + ); + + private static final ConstructorExpression MATCHED_INFO_RESPONSE_PROJECTION = + Projections.constructor( + MatchedInfoResponse.class, + siteUser.nickname, + mentoring.confirmedAt + ); + + private static final ConstructorExpression MENTOR_APPLICATION_HISTORY_RESPONSE_PROJECTION = + Projections.constructor( + MentorApplicationHistoryInfoResponse.class, + mentorApplication.mentorApplicationStatus, + mentorApplication.rejectedReason, + mentorApplication.createdAt + ); + + private static final ConstructorExpression BANNED_HISTORY_RESPONSE_PROJECTION = + Projections.constructor( + BannedHistoryResponse.class, + userBan.createdAt + ); + + private static final QUnivApplyInfo firstChoiceUnivApplyInfo = new QUnivApplyInfo("firstChoiceUnivApplyInfo"); + private static final QUnivApplyInfo secondChoiceUnivApplyInfo = new QUnivApplyInfo("secondChoiceUnivApplyInfo"); + private static final QUnivApplyInfo thirdChoiceUnivApplyInfo = new QUnivApplyInfo("thirdChoiceUnivApplyInfo"); + + private static final ConstructorExpression UNIV_APPLY_INFO_RESPONSE_PROJECTION = + Projections.constructor( + UnivApplyInfoResponse.class, + firstChoiceUnivApplyInfo.koreanName, + secondChoiceUnivApplyInfo.koreanName, + thirdChoiceUnivApplyInfo.koreanName + ); + + private final JPAQueryFactory queryFactory; + + @Autowired + public SiteUserFilterRepositoryImpl(EntityManager em) { + this.queryFactory = new JPAQueryFactory(em); + } + + @Override + public Page searchAllUsers(UserSearchCondition condition, Pageable pageable) { + List content = queryFactory + .select(USER_SEARCH_RESPONSE_PROJECTION) + .from(siteUser) + .where( + roleEq(condition.role()), + keywordContains(condition.keyword()) + ) + .orderBy(siteUser.createdAt.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long totalCount = createUserCountQuery(condition).fetchOne(); + return new PageImpl<>(content, pageable, totalCount != null ? totalCount : 0L); + } + + private JPAQuery createUserCountQuery(UserSearchCondition condition) { + return queryFactory + .select(siteUser.count()) + .from(siteUser) + .where( + roleEq(condition.role()), + keywordContains(condition.keyword()) + ); + } + + @Override + public Page searchRestrictedUsers( + RestrictedUserSearchCondition condition, + Pageable pageable + ) { + List content = queryFactory + .select(RESTRICTED_USER_SEARCH_RESPONSE_PROJECTION) + .from(siteUser) + + // 최신 신고 내역 조회 + .leftJoin(report).on( + report.reportedId.eq(siteUser.id) + .and( + report.id.eq( + JPAExpressions + .select(report.id.max()) + .from(report) + .where(report.reportedId.eq(siteUser.id)) + ) + ) + ) + + // 최신 차단 내역 조회 + .leftJoin(userBan).on( + userBan.bannedUserId.eq(siteUser.id) + .and(userBan.isExpired.eq(false)) + .and(userBan.expiredAt.after(ZonedDateTime.now(UTC))) + ) + + .where( + roleEq(condition.role()), + isRestrictedUser(), + userStatusEq(condition.userStatus()), + keywordContains(condition.keyword()) + ) + .orderBy(siteUser.createdAt.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long totalCount = createRestrictedUserCountQuery(condition).fetchOne(); + + return new PageImpl<>(content, pageable, totalCount != null ? totalCount : 0L); + } + + + private JPAQuery createRestrictedUserCountQuery(RestrictedUserSearchCondition condition) { + return queryFactory + .select(siteUser.count()) + .from(siteUser) + .where( + roleEq(condition.role()), + isRestrictedUser(), + userStatusEq(condition.userStatus()), + keywordContains(condition.keyword()) + ); + } + + private BooleanExpression isRestrictedUser() { + return siteUser.userStatus.in( + UserStatus.REPORTED, + UserStatus.BANNED + ); + } + + private BooleanExpression roleEq(Role role) { + return role != null ? siteUser.role.eq(role) : null; + } + + private BooleanExpression userStatusEq(UserStatus userStatus) { + return userStatus != null ? siteUser.userStatus.eq(userStatus) : null; + } + + private BooleanExpression keywordContains(String keyword) { + if (!hasText(keyword)) { + return null; + } + return siteUser.nickname.containsIgnoreCase(keyword); + } + + @Override + public UserInfoDetailResponse getUserInfoDetailByUserId(long userId) { + SiteUser user = queryFactory + .selectFrom(siteUser) + .where(siteUser.id.eq(userId)) + .fetchOne(); + + // 신고 내역 + List reportedHistoryResponses = new ArrayList<>(); + if (user.getUserStatus() != UserStatus.ACTIVE) { + reportedHistoryResponses = fetchReportedHistories(userId); + } + + if (user.getRole() == Role.MENTOR) { + // 멘토 상세 내역 + MentorInfoResponse mentorInfoResponse = fetchMentorInfo(userId); + return new UserInfoDetailResponse(mentorInfoResponse, null, reportedHistoryResponses); + } else { + // 멘티 상세 내역 + MenteeInfoResponse menteeInfoResponse = fetchMenteeInfo(userId); + return new UserInfoDetailResponse(null, menteeInfoResponse, reportedHistoryResponses); + } + } + + @Override + public RestrictedUserInfoDetailResponse getRestrictedUserInfoDetail(long userId) { + List reportedHistoryResponses = fetchReportedHistories(userId); + List bannedHistoryResponses = fetchBannedHistories(userId); + + return new RestrictedUserInfoDetailResponse(reportedHistoryResponses, bannedHistoryResponses); + } + + private List fetchReportedHistories(long userId) { + return queryFactory + .select(REPORTED_HISTORY_RESPONSE_PROJECTION) + .from(report) + .where(report.reportedId.eq(userId)) + .orderBy(report.createdAt.desc()) + .fetch(); + } + + private List fetchBannedHistories(long userId) { + return queryFactory + .select(BANNED_HISTORY_RESPONSE_PROJECTION) + .from(userBan) + .where(userBan.bannedUserId.eq(userId)) + .orderBy(userBan.createdAt.desc()) + .fetch(); + } + + private MentorInfoResponse fetchMentorInfo(long userId) { + Long mentorId = queryFactory + .select(mentor.id) + .from(mentor) + .where(mentor.siteUserId.eq(userId)) + .fetchOne(); + + List menteeInfos = new ArrayList<>(); + if (mentorId != null) { + menteeInfos = queryFactory + .select(MATCHED_INFO_RESPONSE_PROJECTION) + .from(mentoring) + .join(siteUser).on(siteUser.id.eq(mentoring.menteeId)) + .where(mentoring.mentorId.eq(mentorId)) + .orderBy(mentoring.confirmedAt.desc()) + .fetch(); + } + + List mentorApplicationHistory = queryFactory + .select(MENTOR_APPLICATION_HISTORY_RESPONSE_PROJECTION) + .from(mentorApplication) + .where(mentorApplication.siteUserId.eq(userId)) + .orderBy(mentorApplication.createdAt.desc()) + .fetch(); + + return new MentorInfoResponse(menteeInfos, mentorApplicationHistory); + } + + private MenteeInfoResponse fetchMenteeInfo(long userId) { + UnivApplyInfoResponse univApplyInfoResponse = fetchUnivApplyInfo(userId); + List mentorInfos = queryFactory + .select(MATCHED_INFO_RESPONSE_PROJECTION) + .from(mentoring) + .join(mentor).on(mentor.id.eq(mentoring.mentorId)) + .join(siteUser).on(siteUser.id.eq(mentor.siteUserId)) + .where(mentoring.menteeId.eq(userId)) + .orderBy(mentoring.confirmedAt.desc()) + .fetch(); + + return new MenteeInfoResponse(univApplyInfoResponse, mentorInfos); + } + + private UnivApplyInfoResponse fetchUnivApplyInfo(long userId) { + UnivApplyInfoResponse result = queryFactory + .select(UNIV_APPLY_INFO_RESPONSE_PROJECTION) + .from(application) + .leftJoin(firstChoiceUnivApplyInfo).on(firstChoiceUnivApplyInfo.id.eq(application.firstChoiceUnivApplyInfoId)) + .leftJoin(secondChoiceUnivApplyInfo).on(secondChoiceUnivApplyInfo.id.eq(application.secondChoiceUnivApplyInfoId)) + .leftJoin(thirdChoiceUnivApplyInfo).on(thirdChoiceUnivApplyInfo.id.eq(application.thirdChoiceUnivApplyInfoId)) + .where(application.siteUserId.eq(userId)) + .orderBy(application.createdAt.desc()) + .fetchFirst(); + + if (result == null) { + return new UnivApplyInfoResponse(null, null, null); + } + + return result; + } + + +} diff --git a/src/test/java/com/example/solidconnection/admin/service/AdminUserServiceTest.java b/src/test/java/com/example/solidconnection/admin/service/AdminUserServiceTest.java new file mode 100644 index 000000000..ec73c3468 --- /dev/null +++ b/src/test/java/com/example/solidconnection/admin/service/AdminUserServiceTest.java @@ -0,0 +1,445 @@ +package com.example.solidconnection.admin.service; + +import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.example.solidconnection.admin.dto.RestrictedUserInfoDetailResponse; +import com.example.solidconnection.admin.dto.RestrictedUserSearchCondition; +import com.example.solidconnection.admin.dto.RestrictedUserSearchResponse; +import com.example.solidconnection.admin.dto.UserInfoDetailResponse; +import com.example.solidconnection.admin.dto.UserSearchCondition; +import com.example.solidconnection.admin.dto.UserSearchResponse; +import com.example.solidconnection.application.domain.Gpa; +import com.example.solidconnection.application.domain.LanguageTest; +import com.example.solidconnection.application.fixture.ApplicationFixture; +import com.example.solidconnection.university.domain.LanguageTestType; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.community.board.fixture.BoardFixture; +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.domain.PostCategory; +import com.example.solidconnection.community.post.fixture.PostFixture; +import com.example.solidconnection.mentor.domain.Mentor; +import com.example.solidconnection.mentor.domain.UniversitySelectType; +import com.example.solidconnection.mentor.fixture.MentorApplicationFixture; +import com.example.solidconnection.mentor.fixture.MentorFixture; +import com.example.solidconnection.mentor.fixture.MentoringFixture; +import com.example.solidconnection.report.domain.TargetType; +import com.example.solidconnection.report.fixture.ReportFixture; +import com.example.solidconnection.siteuser.domain.Role; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.domain.UserStatus; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.siteuser.fixture.UserBanFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.term.fixture.TermFixture; +import com.example.solidconnection.university.domain.UnivApplyInfo; +import com.example.solidconnection.university.domain.HostUniversity; +import com.example.solidconnection.university.fixture.UnivApplyInfoFixture; +import com.example.solidconnection.university.fixture.UniversityFixture; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +@TestContainerSpringBootTest +@DisplayName("어드민 유저 관리 서비스 테스트") +public class AdminUserServiceTest { + + @Autowired + private AdminUserService adminUserService; + + @Autowired + private SiteUserFixture siteUserFixture; + + @Autowired + private ReportFixture reportFixture; + + @Autowired + private UserBanFixture userBanFixture; + + @Autowired + private PostFixture postFixture; + + @Autowired + private BoardFixture boardFixture; + + @Autowired + private MentorFixture mentorFixture; + + @Autowired + private MentorApplicationFixture mentorApplicationFixture; + + @Autowired + private MentoringFixture mentoringFixture; + + @Autowired + private UniversityFixture universityFixture; + + @Autowired + private UnivApplyInfoFixture univApplyInfoFixture; + + @Autowired + private ApplicationFixture applicationFixture; + + @Autowired + private TermFixture termFixture; + + @Nested + @DisplayName("전체 유저 검색") + class 전체_유저_검색 { + + @Test + void 전체_유저를_조회한다() { + // given + siteUserFixture.사용자(1, "유저1"); + siteUserFixture.사용자(2, "유저2"); + siteUserFixture.사용자(3, "유저3"); + + UserSearchCondition condition = new UserSearchCondition(null, null); + Pageable pageable = PageRequest.of(0, 10); + + // when + Page result = adminUserService.searchAllUsers(condition, pageable); + + // then + assertThat(result.getContent()).hasSize(3); + } + + @Test + void role로_필터링하여_조회한다() { + // given + siteUserFixture.사용자(1, "멘티1"); + siteUserFixture.사용자(2, "멘티2"); + siteUserFixture.멘토(1, "멘토1"); + + UserSearchCondition condition = new UserSearchCondition(Role.MENTEE, null); + Pageable pageable = PageRequest.of(0, 10); + + // when + Page result = adminUserService.searchAllUsers(condition, pageable); + + // then + assertAll( + () -> assertThat(result.getContent()).hasSize(2), + () -> assertThat(result.getContent()) + .allMatch(user -> user.role() == Role.MENTEE) + ); + } + + @Test + void 닉네임으로_검색한다() { + // given + siteUserFixture.사용자(1, "피카츄1"); + siteUserFixture.사용자(2, "꼬부기"); + siteUserFixture.사용자(3, "피카츄2"); + + UserSearchCondition condition = new UserSearchCondition(null, "피카"); + Pageable pageable = PageRequest.of(0, 10); + + // when + Page result = adminUserService.searchAllUsers(condition, pageable); + + // then + assertAll( + () -> assertThat(result.getContent()).hasSize(2), + () -> assertThat(result.getContent()) + .allMatch(user -> user.nickname().contains("피카")) + ); + } + + @Test + void 페이징이_정상_작동한다() { + // given + for (int i = 1; i <= 15; i++) { + siteUserFixture.사용자(i, "유저" + i); + } + + UserSearchCondition condition = new UserSearchCondition(null, null); + Pageable pageable = PageRequest.of(0, 10); + + // when + Page result = adminUserService.searchAllUsers(condition, pageable); + + // then + assertAll( + () -> assertThat(result.getContent()).hasSize(10), + () -> assertThat(result.getTotalElements()).isEqualTo(15), + () -> assertThat(result.getTotalPages()).isEqualTo(2) + ); + } + } + + @Nested + @DisplayName("유저 상세 정보 조회") + class 유저_상세_정보_조회 { + + @Test + void 멘티_유저_상세_정보를_조회한다() { + // given + SiteUser mentee = siteUserFixture.사용자(1, "멘티유저"); + long termId = termFixture.현재_학기("2025-1").getId(); + + UnivApplyInfo firstChoice = univApplyInfoFixture.괌대학_A_지원_정보(termId); + UnivApplyInfo secondChoice = univApplyInfoFixture.네바다주립대학_라스베이거스_지원_정보(termId); + + applicationFixture.지원서( + mentee, + "지원닉네임", + termId, + new Gpa(4.0, 4.5, "http://gpa-report.com/test.pdf"), + new LanguageTest(LanguageTestType.TOEIC, "900", "http://language-test.com/test.pdf"), + firstChoice.getId(), + secondChoice.getId(), + null + ); + + // when + UserInfoDetailResponse result = adminUserService.getUserInfoDetail(mentee.getId()); + + // then + assertAll( + () -> assertThat(result.mentorInfoResponse()).isNull(), + () -> assertThat(result.menteeInfoResponse()).isNotNull(), + () -> assertThat(result.menteeInfoResponse().univApplyInfos()).isNotNull() + ); + } + + @Test + void 멘토_유저_상세_정보를_조회한다() { + // given + SiteUser mentorUser = siteUserFixture.멘토(1, "멘토유저"); + HostUniversity university = universityFixture.괌_대학(); + + Mentor mentor = mentorFixture.멘토(mentorUser.getId(), university.getId()); + mentorApplicationFixture.승인된_멘토신청( + mentorUser.getId(), + UniversitySelectType.CATALOG, + university.getId() + ); + + SiteUser mentee = siteUserFixture.사용자(1, "멘티유저"); + mentoringFixture.승인된_멘토링(mentor.getId(), mentee.getId()); + + // when + UserInfoDetailResponse result = adminUserService.getUserInfoDetail(mentorUser.getId()); + + // then + assertAll( + () -> assertThat(result.mentorInfoResponse()).isNotNull(), + () -> assertThat(result.menteeInfoResponse()).isNull(), + () -> assertThat(result.mentorInfoResponse().menteeInfos()).hasSize(1), + () -> assertThat(result.mentorInfoResponse().mentorApplicationHistory()).hasSize(1) + ); + } + + @Test + void 신고된_유저는_신고_내역이_포함된다() { + // given + SiteUser reportedUser = siteUserFixture.신고된_사용자("신고된유저"); + SiteUser reporter = siteUserFixture.사용자(1, "신고자"); + + Post post = postFixture.게시글( + "신고된 게시글", + "내용", + false, + PostCategory.자유, + boardFixture.자유게시판(), + reportedUser + ); + + reportFixture.신고(reporter.getId(), reportedUser.getId(), TargetType.POST, post.getId()); + + // when + UserInfoDetailResponse result = adminUserService.getUserInfoDetail(reportedUser.getId()); + + // then + assertThat(result.reportedHistoryResponses()).hasSize(1); + } + + @Test + void 존재하지_않는_유저_조회_시_예외가_발생한다() { + // given + long notExistUserId = 999999L; + + // when & then + assertThatCode(() -> adminUserService.getUserInfoDetail(notExistUserId)) + .isInstanceOf(CustomException.class) + .hasMessage(USER_NOT_FOUND.getMessage()); + } + } + + @Nested + class 신고_차단된_유저_검색 { + + @Test + void 신고_차단된_유저만_조회한다() { + // given + siteUserFixture.사용자(1, "일반유저"); + siteUserFixture.신고된_사용자("신고된유저"); + siteUserFixture.차단된_사용자("차단된유저"); + + RestrictedUserSearchCondition condition = new RestrictedUserSearchCondition(null, null, null); + Pageable pageable = PageRequest.of(0, 10); + + // when + Page result = adminUserService.searchRestrictedUsers(condition, pageable); + + // then + assertAll( + () -> assertThat(result.getContent()).hasSize(2), + () -> assertThat(result.getContent()) + .allMatch(user -> + user.userStatus() == UserStatus.REPORTED || + user.userStatus() == UserStatus.BANNED + ) + ); + } + + @Test + void role로_필터링하여_조회한다() { + // given + siteUserFixture.신고된_사용자("신고된멘티"); + siteUserFixture.신고된_사용자_멘토(1, "신고된멘토"); + + RestrictedUserSearchCondition condition = new RestrictedUserSearchCondition(Role.MENTOR, null, null); + Pageable pageable = PageRequest.of(0, 10); + + // when + Page result = adminUserService.searchRestrictedUsers(condition, pageable); + + // then + assertAll( + () -> assertThat(result.getContent()).hasSize(1), + () -> assertThat(result.getContent().get(0).role()).isEqualTo(Role.MENTOR) + ); + } + + @Test + void userStatus로_필터링하여_조회한다() { + // given + siteUserFixture.신고된_사용자("신고된유저"); + siteUserFixture.차단된_사용자("차단된유저"); + + RestrictedUserSearchCondition condition = new RestrictedUserSearchCondition(null, UserStatus.BANNED, null); + Pageable pageable = PageRequest.of(0, 10); + + // when + Page result = adminUserService.searchRestrictedUsers(condition, pageable); + + // then + assertAll( + () -> assertThat(result.getContent()).hasSize(1), + () -> assertThat(result.getContent().get(0).userStatus()).isEqualTo(UserStatus.BANNED) + ); + } + + @Test + void 닉네임으로_검색한다() { + // given + siteUserFixture.신고된_사용자("피카츄"); + siteUserFixture.차단된_사용자("꼬부기"); + + RestrictedUserSearchCondition condition = new RestrictedUserSearchCondition(null, null, "피카"); + Pageable pageable = PageRequest.of(0, 10); + + // when + Page result = adminUserService.searchRestrictedUsers(condition, pageable); + + // then + assertAll( + () -> assertThat(result.getContent()).hasSize(1), + () -> assertThat(result.getContent().get(0).nickname()).contains("피카") + ); + } + } + + @Nested + class 신고_차단된_유저_상세_정보_조회 { + + @Test + void 신고_내역을_조회한다() { + // given + SiteUser reportedUser = siteUserFixture.신고된_사용자("신고된유저"); + SiteUser reporter1 = siteUserFixture.사용자(1, "신고자1"); + SiteUser reporter2 = siteUserFixture.사용자(2, "신고자2"); + + Post post = postFixture.게시글( + "게시글", + "내용", + false, + PostCategory.자유, + boardFixture.자유게시판(), + reportedUser + ); + + reportFixture.신고(reporter1.getId(), reportedUser.getId(), TargetType.POST, post.getId()); + reportFixture.신고(reporter2.getId(), reportedUser.getId(), TargetType.POST, post.getId()); + + // when + RestrictedUserInfoDetailResponse result = adminUserService.getRestrictedUserInfoDetail(reportedUser.getId()); + + // then + assertThat(result.reportedHistoryResponses()).hasSize(2); + } + + @Test + void 차단_내역을_조회한다() { + // given + SiteUser bannedUser = siteUserFixture.차단된_사용자("차단된유저"); + SiteUser admin = siteUserFixture.관리자(); + + userBanFixture.수동_차단_해제(bannedUser.getId(), admin.getId()); + + // when + RestrictedUserInfoDetailResponse result = adminUserService.getRestrictedUserInfoDetail(bannedUser.getId()); + + // then + assertThat(result.bannedHistoryResponses()).hasSize(1); + } + + @Test + void 신고_차단_내역을_함께_조회한다() { + // given + SiteUser user = siteUserFixture.차단된_사용자("차단된유저"); + SiteUser reporter = siteUserFixture.사용자(1, "신고자"); + SiteUser admin = siteUserFixture.관리자(); + + Post post = postFixture.게시글( + "게시글", + "내용", + false, + PostCategory.자유, + boardFixture.자유게시판(), + user + ); + + reportFixture.신고(reporter.getId(), user.getId(), TargetType.POST, post.getId()); + userBanFixture.수동_차단_해제(user.getId(), admin.getId()); + + // when + RestrictedUserInfoDetailResponse result = adminUserService.getRestrictedUserInfoDetail(user.getId()); + + // then + assertAll( + () -> assertThat(result.reportedHistoryResponses()).hasSize(1), + () -> assertThat(result.bannedHistoryResponses()).hasSize(1) + ); + } + + @Test + void 존재하지_않는_유저_조회_시_예외가_발생한다() { + // given + long notExistUserId = 999999L; + + // when & then + assertThatCode(() -> adminUserService.getRestrictedUserInfoDetail(notExistUserId)) + .isInstanceOf(CustomException.class) + .hasMessage(USER_NOT_FOUND.getMessage()); + } + } +} diff --git a/src/test/java/com/example/solidconnection/siteuser/fixture/SiteUserFixture.java b/src/test/java/com/example/solidconnection/siteuser/fixture/SiteUserFixture.java index cdf48a024..05262b6a2 100644 --- a/src/test/java/com/example/solidconnection/siteuser/fixture/SiteUserFixture.java +++ b/src/test/java/com/example/solidconnection/siteuser/fixture/SiteUserFixture.java @@ -97,6 +97,18 @@ public class SiteUserFixture { .create(); } + public SiteUser 신고된_사용자_멘토(int index, String nickname) { + return siteUserFixtureBuilder.siteUser() + .email("reported" + index + "@example.com") + .authType(AuthType.EMAIL) + .nickname(nickname) + .profileImageUrl("profileImageUrl") + .role(Role.MENTOR) + .password("reported123") + .userStatus(UserStatus.REPORTED) + .create(); + } + public SiteUser 차단된_사용자(String nickname) { return siteUserFixtureBuilder.siteUser() .email("banned@example.com")