diff --git a/.gitignore b/.gitignore index c2065bc..a391a0f 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,9 @@ out/ !**/src/main/**/out/ !**/src/test/**/out/ +### QueryDSL ### +/src/main/generated + ### NetBeans ### /nbproject/private/ /nbbuild/ diff --git a/src/main/java/com/catcher/common/exception/BaseResponseStatus.java b/src/main/java/com/catcher/common/exception/BaseResponseStatus.java index 8e0fbad..54f3223 100644 --- a/src/main/java/com/catcher/common/exception/BaseResponseStatus.java +++ b/src/main/java/com/catcher/common/exception/BaseResponseStatus.java @@ -22,6 +22,10 @@ public enum BaseResponseStatus { FULL_PARTICIPATE_LIMIT(2006, "참여 제한 인원을 초과하였습니다."), REJECTED_PARTICIPATE(2007, "참여가 거절된 일정입니다."), INVALID_SCHEDULE_PARTICIPANT_TIME(2008, "참가 일정이 유효하지 않은 신청입니다."), + INVALID_DATE_INPUT(2030, "입력하신 일자를 확인해 주세요."), + THREE_MONTHS_DATE_RANGE_EXCEPTION(2031, "최대 조회 가능 기간은 3개월 입니다."), + TAG_NOT_FOUND(2032, "태그가 존재하지 않습니다."), + ALREAD_BLACKLISTED(2033, "이미 블랙리스트에 등록된 유저입니다."), /** * 3000 : Response 오류 diff --git a/src/main/java/com/catcher/core/db/UserRepository.java b/src/main/java/com/catcher/core/db/UserRepository.java index e78ec7b..e0d51bd 100644 --- a/src/main/java/com/catcher/core/db/UserRepository.java +++ b/src/main/java/com/catcher/core/db/UserRepository.java @@ -1,8 +1,13 @@ package com.catcher.core.db; +import com.catcher.core.domain.UserSearchFilterType; import com.catcher.core.domain.entity.User; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import java.time.LocalDate; import java.util.List; +import java.util.Map; import java.util.Optional; public interface UserRepository { @@ -13,4 +18,17 @@ public interface UserRepository { User save(User user); void saveAll(List userList); + + Long countByDeletedAtIsNotNull(); + + Long count(); + + Map countNewUsersPerDay(LocalDate startDate, LocalDate endDate); + + Map countDeletedUsersPerDay(LocalDate startDate, LocalDate endDate); + + Map countReportedUsersPerDay(LocalDate startDate, LocalDate endDate); + + Page searchUsersWithFilter(UserSearchFilterType filterType, LocalDate startDate, LocalDate endDate, String query, Pageable pageable); + } diff --git a/src/main/java/com/catcher/core/domain/GeneralSearchFilterType.java b/src/main/java/com/catcher/core/domain/GeneralSearchFilterType.java new file mode 100644 index 0000000..a10d479 --- /dev/null +++ b/src/main/java/com/catcher/core/domain/GeneralSearchFilterType.java @@ -0,0 +1,8 @@ +package com.catcher.core.domain; + +public interface GeneralSearchFilterType { + + String getMatchedField(); + + GeneralSearchFilterType getDefaultField(); +} diff --git a/src/main/java/com/catcher/core/domain/UserSearchFilterType.java b/src/main/java/com/catcher/core/domain/UserSearchFilterType.java new file mode 100644 index 0000000..459eac8 --- /dev/null +++ b/src/main/java/com/catcher/core/domain/UserSearchFilterType.java @@ -0,0 +1,16 @@ +package com.catcher.core.domain; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum UserSearchFilterType implements GeneralSearchFilterType { + ID("username"), NICKNAME("nickname"), EMAIL("email"), PHONE_NUMBER("phone"), NONE(""); + + private final String matchedField; + + public GeneralSearchFilterType getDefaultField() { + return NONE; + } +} diff --git a/src/main/java/com/catcher/core/domain/UserStatus.java b/src/main/java/com/catcher/core/domain/UserStatus.java new file mode 100644 index 0000000..23ac192 --- /dev/null +++ b/src/main/java/com/catcher/core/domain/UserStatus.java @@ -0,0 +1,6 @@ +package com.catcher.core.domain; + +public enum UserStatus { + NORMAL, DELETED, REPORTED, BLACKLISTED + +} diff --git a/src/main/java/com/catcher/core/domain/entity/User.java b/src/main/java/com/catcher/core/domain/entity/User.java index 320f91c..e2724ed 100644 --- a/src/main/java/com/catcher/core/domain/entity/User.java +++ b/src/main/java/com/catcher/core/domain/entity/User.java @@ -1,5 +1,8 @@ package com.catcher.core.domain.entity; +import com.catcher.common.exception.BaseException; +import com.catcher.common.exception.BaseResponseStatus; +import com.catcher.core.domain.UserStatus; import com.catcher.core.domain.entity.enums.UserGender; import com.catcher.core.domain.entity.enums.UserProvider; import com.catcher.core.domain.entity.enums.UserRole; @@ -7,7 +10,9 @@ import lombok.*; import java.time.ZonedDateTime; +import java.util.ArrayList; import java.util.Date; +import java.util.List; @Entity @Getter @@ -50,6 +55,10 @@ public class User extends BaseTimeEntity { @Enumerated(value = EnumType.STRING) private UserRole userRole; + @Enumerated(value = EnumType.STRING) + @Builder.Default + private UserStatus status = UserStatus.NORMAL; + private ZonedDateTime phoneAuthentication; @Column(nullable = false) @@ -66,4 +75,25 @@ public class User extends BaseTimeEntity { private ZonedDateTime phoneMarketingTerm; // 핸드폰 선택약관 private ZonedDateTime deletedAt; + + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private List userStatusChangeHistories = new ArrayList<>(); + + public boolean changeUserStatus(UserStatus userStatus) { + if (this.status == userStatus && this.status == UserStatus.BLACKLISTED) { + throw new BaseException(BaseResponseStatus.ALREAD_BLACKLISTED); + } + + if (this.status == userStatus) { + return false; // 같은 상태로 변경하려고 하면 변경되지 않음 + } + + if (this.status == UserStatus.BLACKLISTED && userStatus == UserStatus.REPORTED) { + return false; // 블랙리스트 상태에서 신고해도 신고 상태로 변경되지 않음 + } + + this.status = userStatus; + return true; + } + } \ No newline at end of file diff --git a/src/main/java/com/catcher/core/domain/entity/UserStatusChangeHistory.java b/src/main/java/com/catcher/core/domain/entity/UserStatusChangeHistory.java new file mode 100644 index 0000000..386ccfe --- /dev/null +++ b/src/main/java/com/catcher/core/domain/entity/UserStatusChangeHistory.java @@ -0,0 +1,55 @@ +package com.catcher.core.domain.entity; + +import com.catcher.core.domain.UserStatus; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +@Table(name = "user_status_change_history") +public class UserStatusChangeHistory extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "child_id") + private UserStatusChangeHistory child; + + private UserStatus action; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "changed_user_id", nullable = false) + private User changedUser; + + private String reason; + + private boolean affected; + + public static UserStatusChangeHistory create(User user, + User changedUser, + UserStatus action, + String reason, + boolean affected) { + return UserStatusChangeHistory.builder() + .user(user) + .changedUser(changedUser) + .action(action) + .reason(reason) + .affected(affected) + .build(); + } + + public void setChild(final UserStatusChangeHistory history) { + this.child = history; + } + +} diff --git a/src/main/java/com/catcher/core/port/UserStatusChangeHistoryRepository.java b/src/main/java/com/catcher/core/port/UserStatusChangeHistoryRepository.java new file mode 100644 index 0000000..e3f4c9b --- /dev/null +++ b/src/main/java/com/catcher/core/port/UserStatusChangeHistoryRepository.java @@ -0,0 +1,18 @@ +package com.catcher.core.port; + +import com.catcher.core.domain.UserStatus; +import com.catcher.core.domain.entity.User; +import com.catcher.core.domain.entity.UserStatusChangeHistory; + +import java.util.List; +import java.util.Optional; + +public interface UserStatusChangeHistoryRepository { + + UserStatusChangeHistory save(UserStatusChangeHistory userStatusChangeHistory); + + Optional findFirstByUserAndActionAndAffectedOrderByIdDesc(User user, UserStatus userStatus, boolean affected); + + List findAllByUserId(Long id); + +} diff --git a/src/main/java/com/catcher/core/service/AdminUserService.java b/src/main/java/com/catcher/core/service/AdminUserService.java new file mode 100644 index 0000000..e290e07 --- /dev/null +++ b/src/main/java/com/catcher/core/service/AdminUserService.java @@ -0,0 +1,101 @@ +package com.catcher.core.service; + +import com.catcher.common.exception.BaseException; +import com.catcher.common.exception.BaseResponseStatus; +import com.catcher.core.db.UserRepository; +import com.catcher.core.domain.UserSearchFilterType; +import com.catcher.core.domain.UserStatus; +import com.catcher.core.domain.entity.User; +import com.catcher.core.domain.entity.UserStatusChangeHistory; +import com.catcher.core.port.UserStatusChangeHistoryRepository; +import com.catcher.resource.response.AdminUserCountPerDayResponse; +import com.catcher.resource.response.AdminUserDetailResponse; +import com.catcher.resource.response.AdminUserSearchResponse; +import com.catcher.resource.response.AdminUserTotalCountResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +@Service +@RequiredArgsConstructor +public class AdminUserService { + + private final UserRepository userRepository; + + private final UserStatusChangeHistoryRepository userStatusChangeHistoryRepository; + + @Transactional(readOnly = true) + public AdminUserTotalCountResponse getTotalUserCountInfo() { + + Long totalUserCount = userRepository.count(); + Long deletedUserCount = userRepository.countByDeletedAtIsNotNull(); + + return AdminUserTotalCountResponse.create(totalUserCount, deletedUserCount); + } + + @Transactional(readOnly = true) + public AdminUserCountPerDayResponse getUserCountPerDay(final LocalDate startDate, final LocalDate endDate) { + validateTimeInput(startDate, endDate); + + final var newUsersDateCountMap = userRepository.countNewUsersPerDay(startDate, endDate); + final var deletedUsersDateCountMap = userRepository.countDeletedUsersPerDay(startDate, endDate); + final var reportedUsersDateCountMap = userRepository.countReportedUsersPerDay(startDate, endDate); + + return AdminUserCountPerDayResponse.create(newUsersDateCountMap, deletedUsersDateCountMap, reportedUsersDateCountMap); + } + + @Transactional(readOnly = true) + public Page searchUser(UserSearchFilterType filterType, + LocalDate startDate, + LocalDate endDate, + String query, + Pageable pageable) { + final var userPage = userRepository.searchUsersWithFilter(filterType, startDate, endDate, query, pageable); + + return userPage.map(AdminUserSearchResponse::create); + } + + private void validateTimeInput(final LocalDate startDate, final LocalDate endDate) { + if (startDate.isAfter(endDate) || startDate.isAfter(LocalDate.now()) || endDate.isBefore(LocalDate.now())) { + throw new BaseException(BaseResponseStatus.INVALID_DATE_INPUT); + } + } + + @Transactional + public void changeUserStatus(final Long userId, final Long adminUserId, final UserStatus afterStatus, final String reason) { + final User user = userRepository.findById(userId).orElseThrow(() -> new BaseException(BaseResponseStatus.DATA_NOT_FOUND)); + final User adminUser = userRepository.findById(adminUserId).orElseThrow(() -> new BaseException(BaseResponseStatus.DATA_NOT_FOUND)); + + final UserStatus beforeStatus = user.getStatus(); + final boolean affected = user.changeUserStatus(afterStatus); + + var parent = userStatusChangeHistoryRepository.findFirstByUserAndActionAndAffectedOrderByIdDesc(user, beforeStatus, true).orElse(null); + + UserStatusChangeHistory history = UserStatusChangeHistory.create(user, adminUser, afterStatus, reason, affected); + history = userStatusChangeHistoryRepository.save(history); + if (parent != null) { + parent.setChild(history); + } + } + + public void validateUserCountDateInput(final LocalDate startDate, final LocalDate endDate) { + if (startDate.isAfter(endDate)) { + throw new BaseException(BaseResponseStatus.INVALID_DATE_INPUT); + } + + if (startDate.plusMonths(3).isAfter(endDate)) { + throw new BaseException(BaseResponseStatus.THREE_MONTHS_DATE_RANGE_EXCEPTION); + } + } + + public AdminUserDetailResponse searchUserDetail(final Long userId) { + final var user = userRepository.findById(userId).orElseThrow(() -> new BaseException(BaseResponseStatus.DATA_NOT_FOUND)); + + return AdminUserDetailResponse.create(user); + } + +} diff --git a/src/main/java/com/catcher/datasource/repository/UserStatusChangeHistoryJpaRepository.java b/src/main/java/com/catcher/datasource/repository/UserStatusChangeHistoryJpaRepository.java new file mode 100644 index 0000000..2883b55 --- /dev/null +++ b/src/main/java/com/catcher/datasource/repository/UserStatusChangeHistoryJpaRepository.java @@ -0,0 +1,17 @@ +package com.catcher.datasource.repository; + +import com.catcher.core.domain.UserStatus; +import com.catcher.core.domain.entity.User; +import com.catcher.core.domain.entity.UserStatusChangeHistory; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface UserStatusChangeHistoryJpaRepository extends JpaRepository { + + Optional findFirstByUserAndActionAndAffectedOrderByIdDesc(User user, UserStatus userStatus, boolean affected); + + List findByUserId(Long userId); + +} diff --git a/src/main/java/com/catcher/datasource/user/UserJpaRepository.java b/src/main/java/com/catcher/datasource/user/UserJpaRepository.java index 527a1f0..9a136f5 100644 --- a/src/main/java/com/catcher/datasource/user/UserJpaRepository.java +++ b/src/main/java/com/catcher/datasource/user/UserJpaRepository.java @@ -1,11 +1,50 @@ package com.catcher.datasource.user; import com.catcher.core.domain.entity.User; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Map; import java.util.Optional; public interface UserJpaRepository extends JpaRepository { Optional findByUsername(String username); + + Long countByDeletedAtIsNotNull(); + + Page findAll(Specification specification, Pageable pageable); + + @Query("SELECT new map(date(u.createdAt) as date, COUNT(u) as count) " + + "FROM User u " + + "WHERE u.createdAt BETWEEN :startDate AND :endDate " + + "GROUP BY date(u.createdAt)") + List> countNewUsersPerDay(@Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate); + + @Query("SELECT new map(date(u.createdAt) as date, COUNT(u) as count) " + + "FROM User u " + + "WHERE u.deletedAt BETWEEN :startDate AND :endDate " + + "GROUP BY date(u.createdAt)") + List> countDeletedUsersPerDay(@Param("startDate") ZonedDateTime startDate, @Param("endDate") ZonedDateTime endDate); + + default List> countDeletedUsersPerDay(@Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate) { + return countDeletedUsersPerDay(startDate.atZone(ZoneId.systemDefault()), endDate.atZone(ZoneId.systemDefault())); + } + + @Query("SELECT new map(date(ush.createdAt) as date, COUNT(DISTINCT u) as count) " + + "FROM UserStatusChangeHistory ush " + + "JOIN ush.user u " + + "WHERE ush.createdAt BETWEEN :startDate AND :endDate " + + "AND ush.action = com.catcher.core.domain.UserStatus.REPORTED " + + "GROUP BY date(ush.createdAt)") + List> countReportedUsersPerDay(@Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate); + } diff --git a/src/main/java/com/catcher/datasource/user/UserRepositoryImpl.java b/src/main/java/com/catcher/datasource/user/UserRepositoryImpl.java index 11bd063..e835a95 100644 --- a/src/main/java/com/catcher/datasource/user/UserRepositoryImpl.java +++ b/src/main/java/com/catcher/datasource/user/UserRepositoryImpl.java @@ -1,11 +1,19 @@ package com.catcher.datasource.user; import com.catcher.core.db.UserRepository; +import com.catcher.core.domain.UserSearchFilterType; import com.catcher.core.domain.entity.User; +import com.catcher.infrastructure.utils.CriteriaUtil; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Repository; +import java.time.*; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Optional; @Repository @@ -32,4 +40,69 @@ public User save(User user) { public void saveAll(List userList) { userJpaRepository.saveAll(userList); } + + @Override + public Long countByDeletedAtIsNotNull() { + return userJpaRepository.countByDeletedAtIsNotNull(); + } + + @Override + public Long count() { + return userJpaRepository.count(); + } + + /** + * startDate 부터 endDate 까지의 날짜를 key로 하고 0L을 value로 갖는 Map을 생성한다. + */ + private Map initMap(final LocalDate startDate, final LocalDate endDate) { + Map dateMap = new LinkedHashMap<>(); + + for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { + dateMap.put(date.toString(), 0L); + } + + return dateMap; + } + + /** + * startDate 부터 endDate 까지의 날짜를 key로 하고 해당 날짜의 count를 쿼리 결과로 부터 가져와서 value에 넣는 Map을 생성한다. + */ + private Map returnMap(final LocalDate startDate, final LocalDate endDate, GroupedByDateQueryFunction groupedByDateQueryFunction) { + final var resultMap = initMap(startDate, endDate); + final List> queryResults = groupedByDateQueryFunction.apply(startDate.atStartOfDay(), endDate.atTime(LocalTime.MAX)); + + queryResults.forEach(result -> { + resultMap.put(result.get("date").toString(), (Long) result.get("count")); + }); + + return resultMap; + } + + @FunctionalInterface + public interface GroupedByDateQueryFunction { + List> apply(LocalDateTime start, LocalDateTime end); + } + + @Override + public Map countNewUsersPerDay(final LocalDate startDate, final LocalDate endDate) { + return returnMap(startDate, endDate, userJpaRepository::countNewUsersPerDay); + } + + @Override + public Map countDeletedUsersPerDay(final LocalDate startDate, final LocalDate endDate) { + return returnMap(startDate, endDate, userJpaRepository::countDeletedUsersPerDay); + } + + @Override + public Map countReportedUsersPerDay(final LocalDate startDate, final LocalDate endDate) { + return returnMap(startDate, endDate, userJpaRepository::countReportedUsersPerDay); + } + + @Override + public Page searchUsersWithFilter(final UserSearchFilterType filterType, final LocalDate startDate, final LocalDate endDate, final String query, final Pageable pageable) { + Specification specification = CriteriaUtil.generateQueryBuilder(filterType, startDate, endDate, query); + + return userJpaRepository.findAll(specification, pageable); + } + } diff --git a/src/main/java/com/catcher/infrastructure/adapter/UserStatusChangeHistoryRepositoryImpl.java b/src/main/java/com/catcher/infrastructure/adapter/UserStatusChangeHistoryRepositoryImpl.java new file mode 100644 index 0000000..9f2caa9 --- /dev/null +++ b/src/main/java/com/catcher/infrastructure/adapter/UserStatusChangeHistoryRepositoryImpl.java @@ -0,0 +1,36 @@ +package com.catcher.infrastructure.adapter; + +import com.catcher.core.domain.UserStatus; +import com.catcher.core.domain.entity.User; +import com.catcher.core.domain.entity.UserStatusChangeHistory; +import com.catcher.core.port.UserStatusChangeHistoryRepository; +import com.catcher.datasource.repository.UserStatusChangeHistoryJpaRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class UserStatusChangeHistoryRepositoryImpl implements UserStatusChangeHistoryRepository { + + private final UserStatusChangeHistoryJpaRepository userStatusChangeHistoryJpaRepository; + + @Override + public UserStatusChangeHistory save(final UserStatusChangeHistory userStatusChangeHistory) { + return userStatusChangeHistoryJpaRepository.save(userStatusChangeHistory); + } + + @Override + public Optional findFirstByUserAndActionAndAffectedOrderByIdDesc(final User user, final UserStatus userStatus, final boolean affected) { + return userStatusChangeHistoryJpaRepository.findFirstByUserAndActionAndAffectedOrderByIdDesc(user, userStatus, affected); + } + + @Override + public List findAllByUserId(final Long userId) { + + return userStatusChangeHistoryJpaRepository.findByUserId(userId); + } + +} diff --git a/src/main/java/com/catcher/infrastructure/utils/CriteriaUtil.java b/src/main/java/com/catcher/infrastructure/utils/CriteriaUtil.java new file mode 100644 index 0000000..9c7c488 --- /dev/null +++ b/src/main/java/com/catcher/infrastructure/utils/CriteriaUtil.java @@ -0,0 +1,39 @@ +package com.catcher.infrastructure.utils; + +import com.catcher.core.domain.GeneralSearchFilterType; +import jakarta.persistence.criteria.Predicate; +import org.springframework.data.jpa.domain.Specification; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.List; + +public class CriteriaUtil { + + private static final String DEFAULT_SEARCH_DATE_COLUMN = "createdAt"; + + public static Specification generateQueryBuilder(final GeneralSearchFilterType filterType, final LocalDate startDate, final LocalDate endDate, final String query) { + return (root, criteriaQuery, criteriaBuilder) -> { + List predicates = new ArrayList<>(); + + if (filterType != null && filterType != filterType.getDefaultField() && query != null) { + predicates.add(criteriaBuilder.like(root.get(filterType.getMatchedField()), "%" + query + "%")); // 검색 쿼리 적용 + } + + if (startDate != null && endDate != null) { + predicates.add(criteriaBuilder.between( + root.get(DEFAULT_SEARCH_DATE_COLUMN), + startDate.atStartOfDay(), + endDate.atTime(LocalTime.MAX)) + ); + } + + return criteriaBuilder.and(predicates.toArray(new Predicate[0])); + }; + } + + public static Specification generateQueryBuilderWithoutDate(final GeneralSearchFilterType filterType, final String query) { + return generateQueryBuilder(filterType, null, null, query); + } +} diff --git a/src/main/java/com/catcher/resource/AdminUserController.java b/src/main/java/com/catcher/resource/AdminUserController.java new file mode 100644 index 0000000..6de600b --- /dev/null +++ b/src/main/java/com/catcher/resource/AdminUserController.java @@ -0,0 +1,97 @@ +package com.catcher.resource; + +import com.catcher.common.response.CommonResponse; +import com.catcher.core.domain.UserSearchFilterType; +import com.catcher.core.domain.UserStatus; +import com.catcher.core.domain.entity.User; +import com.catcher.core.domain.entity.enums.UserRole; +import com.catcher.core.service.AdminUserService; +import com.catcher.resource.request.AdminBlackListRequest; +import com.catcher.resource.response.AdminUserCountPerDayResponse; +import com.catcher.resource.response.AdminUserDetailResponse; +import com.catcher.resource.response.AdminUserSearchResponse; +import com.catcher.resource.response.AdminUserTotalCountResponse; +import com.catcher.security.annotation.AuthorizationRequired; +import com.catcher.security.annotation.CurrentUser; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; + +@RestController +@RequestMapping("/admin/user") +@RequiredArgsConstructor +public class AdminUserController { + + private final AdminUserService adminUserService; + + @GetMapping("/count") + @AuthorizationRequired(UserRole.ADMIN) + public CommonResponse getUserCountInfo() { + + return CommonResponse.success(adminUserService.getTotalUserCountInfo()); + } + + @GetMapping("/count/day") + @AuthorizationRequired(UserRole.ADMIN) + public CommonResponse getUserCountInfoPerDay(@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) { + + if (startDate == null) { + startDate = LocalDate.now().minusDays(10); + } + + if (endDate == null) { + endDate = LocalDate.now(); + } + + adminUserService.validateUserCountDateInput(startDate, endDate); + + return CommonResponse.success(adminUserService.getUserCountPerDay(startDate, endDate)); + } + + @GetMapping("/search") + @AuthorizationRequired(UserRole.ADMIN) + public CommonResponse> searchUser(@PageableDefault(size = 20, sort = "updatedAt", direction = Sort.Direction.DESC) Pageable pageable, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, + @RequestParam(required = false) String query, + @RequestParam(required = false) UserSearchFilterType filterType) { + + return CommonResponse.success(adminUserService.searchUser(filterType, startDate, endDate, query, pageable)); + } + + @GetMapping("/search/detail/{userId}") + @AuthorizationRequired(UserRole.ADMIN) + public CommonResponse searchUserDetail(@PathVariable Long userId) { + + return CommonResponse.success(adminUserService.searchUserDetail(userId)); + } + + @PostMapping("/blacklist") + @AuthorizationRequired(UserRole.ADMIN) + public CommonResponse addBlackList(@CurrentUser User user, + @RequestBody AdminBlackListRequest request) { + + final var adminUserId = user.getId(); + + adminUserService.changeUserStatus(request.getUserId(), adminUserId, UserStatus.BLACKLISTED, request.getReason()); + return CommonResponse.success(); + } + + @PostMapping("/blacklist/cancel/{userId}") + @AuthorizationRequired(UserRole.ADMIN) + public CommonResponse cancelBlackList(@CurrentUser User user, + @RequestBody AdminBlackListRequest request) { + + final var adminUserId = user.getId(); + + adminUserService.changeUserStatus(request.getUserId(), adminUserId, UserStatus.NORMAL, request.getReason()); + return CommonResponse.success(); + } +} diff --git a/src/main/java/com/catcher/resource/request/AdminBlackListRequest.java b/src/main/java/com/catcher/resource/request/AdminBlackListRequest.java new file mode 100644 index 0000000..b335525 --- /dev/null +++ b/src/main/java/com/catcher/resource/request/AdminBlackListRequest.java @@ -0,0 +1,11 @@ +package com.catcher.resource.request; + +import lombok.Getter; + +@Getter +public class AdminBlackListRequest { + + private Long userId; + + private String reason; +} diff --git a/src/main/java/com/catcher/resource/request/AdminCreateTagRequest.java b/src/main/java/com/catcher/resource/request/AdminCreateTagRequest.java new file mode 100644 index 0000000..02fc78d --- /dev/null +++ b/src/main/java/com/catcher/resource/request/AdminCreateTagRequest.java @@ -0,0 +1,14 @@ +package com.catcher.resource.request; + +import lombok.Getter; + +@Getter +public class AdminCreateTagRequest { + + private String tagName; + + private boolean isAvailable; + + private boolean isRecommended; + +} diff --git a/src/main/java/com/catcher/resource/response/AdminUserCountPerDayResponse.java b/src/main/java/com/catcher/resource/response/AdminUserCountPerDayResponse.java new file mode 100644 index 0000000..d970ddf --- /dev/null +++ b/src/main/java/com/catcher/resource/response/AdminUserCountPerDayResponse.java @@ -0,0 +1,59 @@ +package com.catcher.resource.response; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +@Getter +@Builder(access = AccessLevel.PRIVATE) +public class AdminUserCountPerDayResponse { + + private Long numberOfAllNewUsers; + + private Long numberOfAllWithDrawalUsers; + + private Long numberOfAllReports; + + private List userListData; + + public static AdminUserCountPerDayResponse create(final Map newUsersDateCountMap, + final Map deletedUsersDateCountMap, + final Map reportedUsersDateCountMap) { + return AdminUserCountPerDayResponse.builder() + .userListData(newUsersDateCountMap.keySet() + .stream() + .map(targetDate -> InnerUserCountResponse.create(LocalDate.parse(targetDate), newUsersDateCountMap, deletedUsersDateCountMap, reportedUsersDateCountMap)) + .toList()) + .numberOfAllNewUsers(newUsersDateCountMap.values().stream().mapToLong(Long::longValue).sum()) + .numberOfAllWithDrawalUsers(deletedUsersDateCountMap.values().stream().mapToLong(Long::longValue).sum()) + .numberOfAllReports(reportedUsersDateCountMap.values().stream().mapToLong(Long::longValue).sum()) + .build(); + } + + @Getter + @Builder(access = AccessLevel.PRIVATE) + public static class InnerUserCountResponse { + + private LocalDate date; + + private Long numberOfNewUsers; + + private Long numberOfWithdrawalUsers; + + private Long numberOfReport; + + public static InnerUserCountResponse create(LocalDate targetDate, Map newUsersCount, Map deletedUserCount, Map reportedUserCount) { + return InnerUserCountResponse.builder() + .date(targetDate) + .numberOfNewUsers(newUsersCount.getOrDefault(targetDate.toString(), 0L)) + .numberOfWithdrawalUsers(deletedUserCount.getOrDefault(targetDate.toString(), 0L)) + .numberOfReport(reportedUserCount.getOrDefault(targetDate.toString(), 0L)) + .build(); + } + } + +} diff --git a/src/main/java/com/catcher/resource/response/AdminUserDetailResponse.java b/src/main/java/com/catcher/resource/response/AdminUserDetailResponse.java new file mode 100644 index 0000000..3a6799f --- /dev/null +++ b/src/main/java/com/catcher/resource/response/AdminUserDetailResponse.java @@ -0,0 +1,75 @@ +package com.catcher.resource.response; + +import com.catcher.core.domain.UserStatus; +import com.catcher.core.domain.entity.User; +import com.catcher.core.domain.entity.UserStatusChangeHistory; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; +import java.util.List; + +@Getter +@Builder(access = AccessLevel.PRIVATE) +public class AdminUserDetailResponse { + + private String id; + + private String nickname; + + private String email; + + private String phoneNumber; + + private LocalDate joinDate; + + private String status; + + private List blackListHistoryList; + + public static AdminUserDetailResponse create(User user) { + final var blackListHistoryList = user.getUserStatusChangeHistories() + .stream() + .filter(history -> history.getAction() == UserStatus.BLACKLISTED) + .toList(); + + return AdminUserDetailResponse.builder() + .id(user.getUsername()) + .nickname(user.getNickname()) + .email(user.getEmail()) + .phoneNumber(user.getPhone()) + .joinDate(user.getCreatedAt().toLocalDate()) + .status(user.getStatus().name()) + .blackListHistoryList(blackListHistoryList.stream().map(BlackListHistory::create).toList()) + .build(); + } + + @Getter + @Builder(access = AccessLevel.PRIVATE) + public static class BlackListHistory { + + private LocalDate setDate; + + private String reason; + + private LocalDate cancelDate; + + private String setter; + + private String canceler; + + public static BlackListHistory create(UserStatusChangeHistory blackListHistory) { + final var child = blackListHistory.getChild(); + + return BlackListHistory.builder() + .setDate(blackListHistory.getCreatedAt().toLocalDate()) + .reason(blackListHistory.getReason()) + .setter(blackListHistory.getUser().getUsername()) + .cancelDate(child != null ? child.getCreatedAt().toLocalDate() : null) + .canceler(child != null ? child.getUser().getUsername() : null) + .build(); + } + } + +} diff --git a/src/main/java/com/catcher/resource/response/AdminUserSearchResponse.java b/src/main/java/com/catcher/resource/response/AdminUserSearchResponse.java new file mode 100644 index 0000000..02a8158 --- /dev/null +++ b/src/main/java/com/catcher/resource/response/AdminUserSearchResponse.java @@ -0,0 +1,37 @@ +package com.catcher.resource.response; + +import com.catcher.core.domain.entity.User; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; + +@Getter +@Builder(access = AccessLevel.PRIVATE) +public class AdminUserSearchResponse { + + private String id; + + private String nickname; + + private String email; + + private String phoneNumber; + + private LocalDate joinDate; + + private String status; + + public static AdminUserSearchResponse create(User user) { + return AdminUserSearchResponse.builder() + .id(user.getUsername()) + .nickname(user.getNickname()) + .email(user.getEmail()) + .phoneNumber(user.getPhone()) + .joinDate(user.getCreatedAt().toLocalDate()) + .status(user.getStatus().name()) + .build(); + } + +} diff --git a/src/main/java/com/catcher/resource/response/AdminUserTotalCountResponse.java b/src/main/java/com/catcher/resource/response/AdminUserTotalCountResponse.java new file mode 100644 index 0000000..fdb5681 --- /dev/null +++ b/src/main/java/com/catcher/resource/response/AdminUserTotalCountResponse.java @@ -0,0 +1,22 @@ +package com.catcher.resource.response; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder(access = AccessLevel.PRIVATE) +public class AdminUserTotalCountResponse { + + private Long numberOfUsers; + + private Long numberOfWithDrawalUsers; + + public static AdminUserTotalCountResponse create(Long totalUserCount, Long deletedUserCount) { + return AdminUserTotalCountResponse.builder() + .numberOfUsers(totalUserCount) + .numberOfWithDrawalUsers(deletedUserCount) + .build(); + } + +} diff --git a/src/test/java/com/catcher/core/MountainInsertTest.java b/src/test/java/com/catcher/core/MountainInsertTest.java index ba77ea0..98f89a2 100644 --- a/src/test/java/com/catcher/core/MountainInsertTest.java +++ b/src/test/java/com/catcher/core/MountainInsertTest.java @@ -7,6 +7,7 @@ import com.catcher.core.port.CatcherItemPort; import com.catcher.core.port.CategoryPort; import com.catcher.core.port.LocationPort; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvFileSource; import org.springframework.beans.factory.annotation.Autowired; @@ -36,6 +37,7 @@ public class MountainInsertTest { public static final String CATEGORY_NAME = "mountain"; + @Disabled @ParameterizedTest @CsvFileSource(resources = "/MNT_CODE.csv", numLinesToSkip = 1, encoding = "EUC-KR") public void 산_정보_최초_insert(Long order, String mountainName, String locationDescription, String mountainCode) { diff --git a/src/test/java/com/catcher/core/service/AdminUserServiceTest.java b/src/test/java/com/catcher/core/service/AdminUserServiceTest.java new file mode 100644 index 0000000..c881d42 --- /dev/null +++ b/src/test/java/com/catcher/core/service/AdminUserServiceTest.java @@ -0,0 +1,191 @@ +package com.catcher.core.service; + +import com.catcher.core.db.UserRepository; +import com.catcher.core.domain.UserStatus; +import com.catcher.core.domain.entity.User; +import com.catcher.core.domain.entity.UserStatusChangeHistory; +import com.catcher.core.domain.entity.enums.UserProvider; +import com.catcher.core.domain.entity.enums.UserRole; +import com.catcher.core.port.UserStatusChangeHistoryRepository; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest(properties = "spring.profiles.active=local") +class AdminUserServiceTest { + + @Autowired + private UserRepository userRepository; + + @Autowired + private AdminUserService adminUserService; + + @Autowired + private UserStatusChangeHistoryRepository userStatusChangeHistoryRepository; + + @PersistenceContext + private EntityManager em; + + @Test + @DisplayName("startDate 부터 endDate 까지의 날짜를 key로 하고 각 날짜의 count을 value로 갖는 Map을 생성한다.") + void countNewUsersPerDay() { + // given + LocalDate startDate = LocalDate.of(2023, 12, 1); + LocalDate endDate = LocalDate.of(2023, 12, 31); + + // when + final var resultMap = userRepository.countNewUsersPerDay(startDate, endDate); + + // then + for (LocalDate date = startDate; date.isBefore(endDate); date = date.plusDays(1)) { + assertTrue(resultMap.containsKey(date.toString())); + } + } + + @Test + @Transactional + @DisplayName("블랙리스트 설정 후 다시 신고 시 블랙리스트 상태에 머무른다.") + void changeUserStatusToBlackListAndReport() { + // given + User user = getUserFixture(UserRole.USER); + User adminUser = getUserFixture(UserRole.ADMIN); + + // when + adminUserService.changeUserStatus(user.getId(), adminUser.getId(), UserStatus.BLACKLISTED, "테스트 블랙리스트 설정"); + adminUserService.changeUserStatus(user.getId(), adminUser.getId(), UserStatus.REPORTED, "테스트 신고"); + + // then + assertSame(user.getStatus(), UserStatus.BLACKLISTED); + } + + @Test + @Transactional + @DisplayName("유저를 여러 번 신고하고 이에 대한 신고 횟수를 확인한다.") + void countReportedUsersPerDay() { + // given + + User user1 = getUserFixture(UserRole.USER); + User user2 = getUserFixture(UserRole.USER); + + // when + adminUserService.changeUserStatus(user1.getId(), user1.getId(), UserStatus.REPORTED, "신고 테스트1"); + adminUserService.changeUserStatus(user1.getId(), user1.getId(), UserStatus.REPORTED, "신고 테스트2"); + adminUserService.changeUserStatus(user1.getId(), user1.getId(), UserStatus.BLACKLISTED, "테스트 블랙리스트 설정"); + adminUserService.changeUserStatus(user1.getId(), user1.getId(), UserStatus.REPORTED, "신고 테스트3"); + adminUserService.changeUserStatus(user1.getId(), user1.getId(), UserStatus.NORMAL, "테스트 블랙리스트 해제"); + adminUserService.changeUserStatus(user1.getId(), user1.getId(), UserStatus.REPORTED, "신고 테스트4"); + adminUserService.changeUserStatus(user2.getId(), user2.getId(), UserStatus.REPORTED, "유저2 신고테스트"); + + final var response = adminUserService.getUserCountPerDay(LocalDate.now(), LocalDate.now()); + final var userResponse = response.getUserListData().stream() + .filter(p -> p.getDate().equals(LocalDate.now())) + .findFirst().get(); + // then + assertEquals(userResponse.getNumberOfReport(), 2); + } + + @Test + @DisplayName("블랙리스트 설정을 수행한다") + @Transactional + void changeUserStatusToBlackList() { + // given + User normalUser = getUserFixture(UserRole.USER); + User adminUser = getUserFixture(UserRole.ADMIN); + + // when + adminUserService.changeUserStatus(normalUser.getId(), adminUser.getId(), UserStatus.BLACKLISTED, "테스트 블랙리스트 설정"); + List userStatusChangeHistory = userStatusChangeHistoryRepository.findAllByUserId(normalUser.getId()); + + // then + assertSame(normalUser.getStatus(), UserStatus.BLACKLISTED); + assertFalse(userStatusChangeHistory.isEmpty()); + } + + /** + * 다음과 같은 식으로 수행된다. + * 1. 블랙리스트 설정 + * 2. 블랙리스트 해제 + *

+ * userStatusChangeHistory는 다음과 같이 생성되어야 한다. + * 1. (1, before: NORMAL, after: BLACKLISTED, parent: null, child: 2) + * 2. (2, before: BLACKLISTED, after: NORMAL, parent: 1, child: null) + */ + @Test + @DisplayName("블랙리스트 해제를 수행한다") + @Transactional + void rollBackUserStatusToNormal() { + // given + User user = getUserFixture(UserRole.USER); + User adminUser = getUserFixture(UserRole.ADMIN); + + // when + adminUserService.changeUserStatus(user.getId(), adminUser.getId(), UserStatus.BLACKLISTED, "테스트 블랙리스트 설정"); + adminUserService.changeUserStatus(user.getId(), adminUser.getId(), UserStatus.NORMAL, "테스트 블랙리스트 해제"); + Optional childUserStatusChangeHistoryOptional = userStatusChangeHistoryRepository.findFirstByUserAndActionAndAffectedOrderByIdDesc(user, UserStatus.NORMAL, true); + Optional parentUserStatusChangeHistoryOptional = userStatusChangeHistoryRepository.findFirstByUserAndActionAndAffectedOrderByIdDesc(user, UserStatus.BLACKLISTED, true); + + // then + assertSame(user.getStatus(), UserStatus.NORMAL); + + assertEquals(parentUserStatusChangeHistoryOptional.get().getChild(), childUserStatusChangeHistoryOptional.get()); + assertNotEquals(parentUserStatusChangeHistoryOptional.get().getChild(), parentUserStatusChangeHistoryOptional.get()); + } + + /** + * 블랙리스트 이력은 블랙리스트를 설정한 리스트만 나와야 함 + */ + @Test + @DisplayName("admin userDetail API 테스트") + @Transactional + void getUserDetail() { + // given + User user = getUserFixture(UserRole.USER); + User adminUser = getUserFixture(UserRole.ADMIN); + adminUserService.changeUserStatus(user.getId(), adminUser.getId(), UserStatus.BLACKLISTED, "테스트 블랙리스트 설정"); + adminUserService.changeUserStatus(user.getId(), adminUser.getId(), UserStatus.NORMAL, "테스트 블랙리스트 해제"); + + em.flush(); + em.clear(); + user = userRepository.findById(user.getId()).get(); + + // when + final var userDetail = adminUserService.searchUserDetail(user.getId()); + + // then + assertEquals(userDetail.getEmail(), user.getEmail()); + assertEquals(userDetail.getNickname(), user.getNickname()); + assertEquals(userDetail.getStatus(), user.getStatus().name()); + assertEquals(userDetail.getBlackListHistoryList().size(), 1); + assertEquals(userDetail.getBlackListHistoryList().get(0).getReason(), "테스트 블랙리스트 설정"); + assertEquals(userDetail.getBlackListHistoryList().get(0).getSetter(), user.getUsername()); + } + + private User getUserFixture(UserRole userRole) { + return userRepository.save(User.builder() + .username(UUID.randomUUID().toString()) + .phone(UUID.randomUUID().toString()) + .email(UUID.randomUUID().toString()) + .nickname(UUID.randomUUID().toString()) + .userAgeTerm(ZonedDateTime.now()) + .userServiceTerm(ZonedDateTime.now()) + .userPrivacyTerm(ZonedDateTime.now()) + .userProvider(UserProvider.CATCHER) + .userRole(userRole) + .status(UserStatus.NORMAL) + .password("test") + .build()); + } + +} \ No newline at end of file