From 093a7658e11f87a4a1cad983913df54d37973ec0 Mon Sep 17 00:00:00 2001 From: JAEHEE25 Date: Sun, 28 Dec 2025 22:34:20 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EC=B0=A8=EB=8B=A8=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/AdminUserBanController.java | 38 +++++++++ .../admin/dto/UserBanRequest.java | 12 +++ .../admin/service/AdminUserBanService.java | 80 +++++++++++++++++++ .../admin/service/UserBanScheduler.java | 40 ++++++++++ .../common/config/web/WebMvcConfig.java | 9 +++ .../common/exception/ErrorCode.java | 6 ++ .../interceptor/BannedUserInterceptor.java | 37 +++++++++ .../report/repository/ReportRepository.java | 4 + .../report/service/ReportService.java | 40 ++++++++++ .../siteuser/domain/SiteUser.java | 8 ++ .../siteuser/domain/UserBan.java | 48 +++++++++++ .../siteuser/domain/UserBanDuration.java | 14 ++++ .../siteuser/domain/UserStatus.java | 7 ++ .../repository/UserBanRepository.java | 17 ++++ .../migration/V40__create_user_ban_table.sql | 14 ++++ 15 files changed, 374 insertions(+) create mode 100644 src/main/java/com/example/solidconnection/admin/controller/AdminUserBanController.java create mode 100644 src/main/java/com/example/solidconnection/admin/dto/UserBanRequest.java create mode 100644 src/main/java/com/example/solidconnection/admin/service/AdminUserBanService.java create mode 100644 src/main/java/com/example/solidconnection/admin/service/UserBanScheduler.java create mode 100644 src/main/java/com/example/solidconnection/common/interceptor/BannedUserInterceptor.java create mode 100644 src/main/java/com/example/solidconnection/siteuser/domain/UserBan.java create mode 100644 src/main/java/com/example/solidconnection/siteuser/domain/UserBanDuration.java create mode 100644 src/main/java/com/example/solidconnection/siteuser/domain/UserStatus.java create mode 100644 src/main/java/com/example/solidconnection/siteuser/repository/UserBanRepository.java create mode 100644 src/main/resources/db/migration/V40__create_user_ban_table.sql diff --git a/src/main/java/com/example/solidconnection/admin/controller/AdminUserBanController.java b/src/main/java/com/example/solidconnection/admin/controller/AdminUserBanController.java new file mode 100644 index 000000000..b444178ca --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/controller/AdminUserBanController.java @@ -0,0 +1,38 @@ +package com.example.solidconnection.admin.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import com.example.solidconnection.admin.dto.UserBanRequest; +import com.example.solidconnection.admin.service.AdminUserBanService; +import com.example.solidconnection.common.resolver.AuthorizedUser; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RequiredArgsConstructor +@RequestMapping("/admin/users") +@RestController +@Slf4j +public class AdminUserBanController { + private final AdminUserBanService adminUserBanService; + + @PostMapping("/{userId}/ban") + public ResponseEntity banUser( + @PathVariable long userId, + @Valid @RequestBody UserBanRequest request + ) { + adminUserBanService.banUser(userId, request); + return ResponseEntity.ok().build(); + } + + @PatchMapping("/{userId}/unban") + public ResponseEntity unbanUser( + @AuthorizedUser long adminId, + @PathVariable long userId + ) { + adminUserBanService.unbanUser(userId, adminId); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/example/solidconnection/admin/dto/UserBanRequest.java b/src/main/java/com/example/solidconnection/admin/dto/UserBanRequest.java new file mode 100644 index 000000000..e96e9cf04 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/dto/UserBanRequest.java @@ -0,0 +1,12 @@ +package com.example.solidconnection.admin.dto; + + +import com.example.solidconnection.siteuser.domain.UserBanDuration; + +import jakarta.validation.constraints.NotNull; + +public record UserBanRequest( + @NotNull(message = "차단 기간을 입력해주세요.") + UserBanDuration duration +) { +} diff --git a/src/main/java/com/example/solidconnection/admin/service/AdminUserBanService.java b/src/main/java/com/example/solidconnection/admin/service/AdminUserBanService.java new file mode 100644 index 000000000..7c6f31640 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/service/AdminUserBanService.java @@ -0,0 +1,80 @@ +package com.example.solidconnection.admin.service; + +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.domain.UserStatus; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import java.time.ZonedDateTime; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.solidconnection.admin.dto.UserBanRequest; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.common.exception.ErrorCode; +import com.example.solidconnection.report.repository.ReportRepository; +import com.example.solidconnection.siteuser.domain.UserBan; +import com.example.solidconnection.siteuser.repository.UserBanRepository; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Service +public class AdminUserBanService { + + private final UserBanRepository userBanRepository; + private final ReportRepository reportRepository; + private final SiteUserRepository siteUserRepository; + + @Transactional + public void banUser(long userId, UserBanRequest request) { + ZonedDateTime now = ZonedDateTime.now(); + validateNotAlreadyBanned(userId, now); + validateReportExists(userId); + + deleteReportedContent(); + createUserBan(userId, request, now); + updateUserStatus(userId, UserStatus.BANNED); + } + + @Transactional + public void unbanUser(long userId, long adminId) { + ZonedDateTime now = ZonedDateTime.now(); + + UserBan userBan = findBannedUser(userId, now); + userBan.manuallyUnban(adminId); + + updateUserStatus(userId, UserStatus.REPORTED); + } + + private void validateNotAlreadyBanned(long userId, ZonedDateTime now) { + if (userBanRepository.existsByBannedUserIdAndIsUnbannedFalseAndExpiredAtAfter(userId, now)) { + throw new CustomException(ErrorCode.ALREADY_BANNED_USER); + } + } + + private void validateReportExists(long userId) { + reportRepository.findByTargetId(userId) + .orElseThrow(() -> new CustomException(ErrorCode.REPORT_NOT_FOUND)); + } + + private void deleteReportedContent() { + //TODO report 타입에 따라서 콘텐츠 삭제 처리 로직 추가 + } + + private void createUserBan(long userId, UserBanRequest request, ZonedDateTime now) { + ZonedDateTime expiredAt = now.plusDays(request.duration().getDays()); + UserBan userBan = new UserBan(userId, expiredAt); + userBanRepository.save(userBan); + } + + private UserBan findBannedUser(long userId, ZonedDateTime now) { + return userBanRepository + .findTopByBannedUserIdAndIsUnbannedFalseAndExpiredAtAfterOrderByCreatedAtDesc(userId, now) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_BANNED_USER)); + } + + private void updateUserStatus(long userId, UserStatus status) { + SiteUser user = siteUserRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + user.updateUserStatus(status); + } +} diff --git a/src/main/java/com/example/solidconnection/admin/service/UserBanScheduler.java b/src/main/java/com/example/solidconnection/admin/service/UserBanScheduler.java new file mode 100644 index 000000000..e0f664e4a --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/service/UserBanScheduler.java @@ -0,0 +1,40 @@ +package com.example.solidconnection.admin.service; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.common.exception.ErrorCode; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.domain.UserBan; +import com.example.solidconnection.siteuser.domain.UserStatus; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.siteuser.repository.UserBanRepository; +import jakarta.transaction.Transactional; +import java.time.ZonedDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class UserBanScheduler { + + private final UserBanRepository userBanRepository; + private final SiteUserRepository siteUserRepository; + + @Transactional + @Scheduled(cron = "0 0 * * * *") + public void expireUserBans() { + ZonedDateTime current = ZonedDateTime.now(); + List expiredBans = userBanRepository.findAllByIsUnbannedFalseAndExpiredAtBefore(current); + + for (UserBan userBan : expiredBans) { + updateUserStatus(userBan); + } + } + + private void updateUserStatus(UserBan userBan) { + SiteUser user = siteUserRepository.findById(userBan.getBannedUserId()) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + user.updateUserStatus(UserStatus.REPORTED); // 다시 신고됨 상태로 변경 + } +} diff --git a/src/main/java/com/example/solidconnection/common/config/web/WebMvcConfig.java b/src/main/java/com/example/solidconnection/common/config/web/WebMvcConfig.java index 56bb288e8..510894217 100644 --- a/src/main/java/com/example/solidconnection/common/config/web/WebMvcConfig.java +++ b/src/main/java/com/example/solidconnection/common/config/web/WebMvcConfig.java @@ -1,11 +1,13 @@ package com.example.solidconnection.common.config.web; +import com.example.solidconnection.common.interceptor.BannedUserInterceptor; import com.example.solidconnection.common.resolver.AuthorizedUserResolver; import com.example.solidconnection.common.resolver.CustomPageableHandlerMethodArgumentResolver; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration @@ -14,6 +16,7 @@ public class WebMvcConfig implements WebMvcConfigurer { private final AuthorizedUserResolver authorizedUserResolver; private final CustomPageableHandlerMethodArgumentResolver customPageableHandlerMethodArgumentResolver; + private final BannedUserInterceptor bannedUserInterceptor; @Override public void addArgumentResolvers(List resolvers) { @@ -22,4 +25,10 @@ public void addArgumentResolvers(List resolvers) customPageableHandlerMethodArgumentResolver )); } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(bannedUserInterceptor) + .addPathPatterns("/posts/**", "/comments/**", "/chats/**", "/community/**"); + } } diff --git a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java index 9ab95778a..52d94e851 100644 --- a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java +++ b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java @@ -53,6 +53,7 @@ public enum ErrorCode { TERM_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 학기입니다."), CURRENT_TERM_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "현재 학기를 찾을 수 없습니다."), MENTOR_APPLICATION_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "멘토 지원서가 존재하지 않습니다."), + REPORT_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "신고 내역이 존재하지 않습니다."), // auth USER_ALREADY_SIGN_OUT(HttpStatus.UNAUTHORIZED.value(), "로그아웃 되었습니다."), @@ -149,6 +150,11 @@ public enum ErrorCode { // chat INVALID_CHAT_ROOM_STATE(HttpStatus.BAD_REQUEST.value(), "잘못된 채팅방 상태입니다."), + // ban + ALREADY_BANNED_USER(HttpStatus.CONFLICT.value(), "이미 차단된 사용자입니다."), + NOT_BANNED_USER(HttpStatus.BAD_REQUEST.value(), "차단되지 않은 사용자입니다."), + BANNED_USER_ACCESS_DENIED(HttpStatus.FORBIDDEN.value(), "차단된 사용자는 커뮤니티 및 채팅을 이용할 수 없습니다."), + // database DATA_INTEGRITY_VIOLATION(HttpStatus.CONFLICT.value(), "데이터베이스 무결성 제약조건 위반이 발생했습니다."), diff --git a/src/main/java/com/example/solidconnection/common/interceptor/BannedUserInterceptor.java b/src/main/java/com/example/solidconnection/common/interceptor/BannedUserInterceptor.java new file mode 100644 index 000000000..67b0925b4 --- /dev/null +++ b/src/main/java/com/example/solidconnection/common/interceptor/BannedUserInterceptor.java @@ -0,0 +1,37 @@ +package com.example.solidconnection.common.interceptor; + +import static com.example.solidconnection.common.exception.ErrorCode.BANNED_USER_ACCESS_DENIED; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.security.userdetails.SiteUserDetails; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.domain.UserStatus; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +@Component +public class BannedUserInterceptor implements HandlerInterceptor { + + @Override + public boolean preHandle( + HttpServletRequest request, + HttpServletResponse response, + Object handler + ) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication != null && authentication.getPrincipal() instanceof SiteUserDetails) { + SiteUserDetails userDetails = (SiteUserDetails) authentication.getPrincipal(); + SiteUser siteUser = userDetails.getSiteUser(); + + if (siteUser.getUserStatus() == UserStatus.BANNED) { + throw new CustomException(BANNED_USER_ACCESS_DENIED); + } + } + return true; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/solidconnection/report/repository/ReportRepository.java b/src/main/java/com/example/solidconnection/report/repository/ReportRepository.java index c32d3cd5f..eb3ea42b6 100644 --- a/src/main/java/com/example/solidconnection/report/repository/ReportRepository.java +++ b/src/main/java/com/example/solidconnection/report/repository/ReportRepository.java @@ -1,5 +1,7 @@ package com.example.solidconnection.report.repository; +import java.util.Optional; + import com.example.solidconnection.report.domain.Report; import com.example.solidconnection.report.domain.TargetType; import org.springframework.data.jpa.repository.JpaRepository; @@ -7,4 +9,6 @@ public interface ReportRepository extends JpaRepository { boolean existsByReporterIdAndTargetTypeAndTargetId(long reporterId, TargetType targetType, long targetId); + + Optional findByTargetId(long targetId); } diff --git a/src/main/java/com/example/solidconnection/report/service/ReportService.java b/src/main/java/com/example/solidconnection/report/service/ReportService.java index 205ca293d..5288ecbfc 100644 --- a/src/main/java/com/example/solidconnection/report/service/ReportService.java +++ b/src/main/java/com/example/solidconnection/report/service/ReportService.java @@ -1,13 +1,19 @@ package com.example.solidconnection.report.service; +import com.example.solidconnection.chat.domain.ChatMessage; +import com.example.solidconnection.chat.domain.ChatParticipant; import com.example.solidconnection.chat.repository.ChatMessageRepository; +import com.example.solidconnection.chat.repository.ChatParticipantRepository; import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.common.exception.ErrorCode; +import com.example.solidconnection.community.post.domain.Post; import com.example.solidconnection.community.post.repository.PostRepository; import com.example.solidconnection.report.domain.Report; import com.example.solidconnection.report.domain.TargetType; import com.example.solidconnection.report.dto.ReportRequest; import com.example.solidconnection.report.repository.ReportRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.domain.UserStatus; import com.example.solidconnection.siteuser.repository.SiteUserRepository; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; @@ -21,12 +27,14 @@ public class ReportService { private final SiteUserRepository siteUserRepository; private final PostRepository postRepository; private final ChatMessageRepository chatMessageRepository; + private final ChatParticipantRepository chatParticipantRepository; @Transactional public void createReport(long reporterId, ReportRequest request) { validateReporterExists(reporterId); validateTargetExists(request.targetType(), request.targetId()); validateFirstReportByUser(reporterId, request.targetType(), request.targetId()); + updateReportedUserStatus(request.targetType(), request.targetId()); Report report = new Report(reporterId, request.reportType(), request.targetType(), request.targetId()); reportRepository.save(report); @@ -54,4 +62,36 @@ private void validateFirstReportByUser(long reporterId, TargetType targetType, l throw new CustomException(ErrorCode.ALREADY_REPORTED_BY_CURRENT_USER); } } + + private void updateReportedUserStatus(TargetType targetType, long targetId) { + long targetUserId = findTargetUserId(targetType, targetId); + updateUserStatusToReported(targetUserId); + } + + private long findTargetUserId(TargetType targetType, long targetId) { + return switch (targetType) { + case POST -> findPostAuthorId(targetId); + case CHAT -> findChatMessageSenderId(targetId); + }; + } + + private long findPostAuthorId(long postId) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new CustomException(ErrorCode.REPORT_TARGET_NOT_FOUND)); + return post.getSiteUserId(); + } + + private long findChatMessageSenderId(long chatMessageId) { + ChatMessage chatMessage = chatMessageRepository.findById(chatMessageId) + .orElseThrow(() -> new CustomException(ErrorCode.REPORT_TARGET_NOT_FOUND)); + ChatParticipant chatParticipant = chatParticipantRepository.findById(chatMessage.getSenderId()) + .orElseThrow(() -> new CustomException(ErrorCode.CHAT_PARTICIPANT_NOT_FOUND)); + return chatParticipant.getSiteUserId(); + } + + private void updateUserStatusToReported(long userId) { + SiteUser user = siteUserRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + user.updateUserStatus(UserStatus.REPORTED); + } } diff --git a/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java b/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java index 30afc423e..d201cd3b0 100644 --- a/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java +++ b/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java @@ -70,6 +70,10 @@ public class SiteUser extends BaseEntity { @Column(nullable = true) private String password; + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private UserStatus userStatus; + public SiteUser( String email, String nickname, @@ -120,4 +124,8 @@ public SiteUser( public void updatePassword(String newEncodedPassword) { this.password = newEncodedPassword; } + + public void updateUserStatus(UserStatus status) { + this.userStatus = status; + } } diff --git a/src/main/java/com/example/solidconnection/siteuser/domain/UserBan.java b/src/main/java/com/example/solidconnection/siteuser/domain/UserBan.java new file mode 100644 index 000000000..b189b2c63 --- /dev/null +++ b/src/main/java/com/example/solidconnection/siteuser/domain/UserBan.java @@ -0,0 +1,48 @@ +package com.example.solidconnection.siteuser.domain; + +import java.time.ZonedDateTime; + +import com.example.solidconnection.common.BaseEntity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@AllArgsConstructor +public class UserBan extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "banned_user_id", nullable = false) + private Long bannedUserId; + + @Column(name = "expired_at", nullable = false) + private ZonedDateTime expiredAt; + + @Column(name = "is_unbanned", nullable = false) + private boolean isUnbanned = false; + + @Column(name = "unbanned_by") + private Long unbannedBy; + + @Column(name = "unbanned_at") + private ZonedDateTime unbannedAt; + + public UserBan(Long bannedUserId, ZonedDateTime expiredAt) { + this.bannedUserId = bannedUserId; + this.expiredAt = expiredAt; + } + + public void manuallyUnban(Long adminId) { + this.isUnbanned = true; + this.unbannedBy = adminId; + this.unbannedAt = ZonedDateTime.now(); + } +} diff --git a/src/main/java/com/example/solidconnection/siteuser/domain/UserBanDuration.java b/src/main/java/com/example/solidconnection/siteuser/domain/UserBanDuration.java new file mode 100644 index 000000000..2bbe64fe7 --- /dev/null +++ b/src/main/java/com/example/solidconnection/siteuser/domain/UserBanDuration.java @@ -0,0 +1,14 @@ +package com.example.solidconnection.siteuser.domain; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public enum UserBanDuration { + ONE_DAY(1), + THREE_DAYS(3), + SEVEN_DAYS(7); + + private final int days; +} diff --git a/src/main/java/com/example/solidconnection/siteuser/domain/UserStatus.java b/src/main/java/com/example/solidconnection/siteuser/domain/UserStatus.java new file mode 100644 index 000000000..50cbfb236 --- /dev/null +++ b/src/main/java/com/example/solidconnection/siteuser/domain/UserStatus.java @@ -0,0 +1,7 @@ +package com.example.solidconnection.siteuser.domain; + +public enum UserStatus { + ACTIVE, + REPORTED, + BANNED +} diff --git a/src/main/java/com/example/solidconnection/siteuser/repository/UserBanRepository.java b/src/main/java/com/example/solidconnection/siteuser/repository/UserBanRepository.java new file mode 100644 index 000000000..bbd4542c6 --- /dev/null +++ b/src/main/java/com/example/solidconnection/siteuser/repository/UserBanRepository.java @@ -0,0 +1,17 @@ +package com.example.solidconnection.siteuser.repository; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +import com.example.solidconnection.siteuser.domain.UserBan; + +public interface UserBanRepository extends JpaRepository { + + boolean existsByBannedUserIdAndIsUnbannedFalseAndExpiredAtAfter(long bannedUserId, ZonedDateTime current); + + List findAllByIsUnbannedFalseAndExpiredAtBefore(ZonedDateTime current); + + Optional findTopByBannedUserIdAndIsUnbannedFalseAndExpiredAtAfterOrderByCreatedAtDesc(long bannedUserId, ZonedDateTime current); +} diff --git a/src/main/resources/db/migration/V40__create_user_ban_table.sql b/src/main/resources/db/migration/V40__create_user_ban_table.sql new file mode 100644 index 000000000..5f7b6f4da --- /dev/null +++ b/src/main/resources/db/migration/V40__create_user_ban_table.sql @@ -0,0 +1,14 @@ +CREATE TABLE user_ban +( + id BIGINT NOT NULL AUTO_INCREMENT, + banned_user_id BIGINT NOT NULL, + expired_at DATETIME(6) NOT NULL, + is_unbanned TINYINT(1) NOT NULL DEFAULT 0, + unbanned_by BIGINT NULL, + unbanned_at DATETIME(6) NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + CONSTRAINT fk_user_ban_banned_user_id FOREIGN KEY (banned_user_id) REFERENCES site_user (id), + CONSTRAINT fk_user_ban_unbanned_by_id FOREIGN KEY (unbanned_by) REFERENCES site_user (id) +) From b0867cc784b5c579903c07aaa30fa8321428a713 Mon Sep 17 00:00:00 2001 From: JAEHEE25 Date: Fri, 2 Jan 2026 00:05:05 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EC=B0=A8=EB=8B=A8=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - soft delete 로직 추가 --- .../admin/service/AdminUserBanService.java | 55 +++++++++++++------ .../admin/service/UserBanScheduler.java | 40 -------------- .../chat/domain/ChatMessage.java | 5 ++ .../repository/ChatMessageRepository.java | 9 +++ .../common/config/web/WebMvcConfig.java | 2 +- .../community/post/domain/Post.java | 4 ++ .../post/repository/PostRepository.java | 8 +++ .../report/repository/ReportRepository.java | 18 +++++- .../migration/V40__create_user_ban_table.sql | 5 +- ...dd_is_deleted_to_post_and_chat_message.sql | 3 + 10 files changed, 88 insertions(+), 61 deletions(-) delete mode 100644 src/main/java/com/example/solidconnection/admin/service/UserBanScheduler.java create mode 100644 src/main/resources/db/migration/V41__add_is_deleted_to_post_and_chat_message.sql diff --git a/src/main/java/com/example/solidconnection/admin/service/AdminUserBanService.java b/src/main/java/com/example/solidconnection/admin/service/AdminUserBanService.java index 7c6f31640..adb9bf9ee 100644 --- a/src/main/java/com/example/solidconnection/admin/service/AdminUserBanService.java +++ b/src/main/java/com/example/solidconnection/admin/service/AdminUserBanService.java @@ -1,21 +1,29 @@ package com.example.solidconnection.admin.service; -import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.domain.UserStatus; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; -import java.time.ZonedDateTime; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - import com.example.solidconnection.admin.dto.UserBanRequest; +import com.example.solidconnection.chat.repository.ChatMessageRepository; import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.common.exception.ErrorCode; +import com.example.solidconnection.community.post.repository.PostRepository; import com.example.solidconnection.report.repository.ReportRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.domain.UserBan; +import com.example.solidconnection.siteuser.domain.UserStatus; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; import com.example.solidconnection.siteuser.repository.UserBanRepository; +import static java.time.ZoneOffset.UTC; + +import java.time.ZonedDateTime; +import java.util.List; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +@Slf4j @RequiredArgsConstructor @Service public class AdminUserBanService { @@ -23,25 +31,38 @@ public class AdminUserBanService { private final UserBanRepository userBanRepository; private final ReportRepository reportRepository; private final SiteUserRepository siteUserRepository; + private final PostRepository postRepository; + private final ChatMessageRepository chatMessageRepository; @Transactional public void banUser(long userId, UserBanRequest request) { - ZonedDateTime now = ZonedDateTime.now(); + ZonedDateTime now = ZonedDateTime.now(UTC); //TODO UTC 확인 validateNotAlreadyBanned(userId, now); validateReportExists(userId); - deleteReportedContent(); + updateReportedContentIsDeleted(userId, true); createUserBan(userId, request, now); updateUserStatus(userId, UserStatus.BANNED); } @Transactional public void unbanUser(long userId, long adminId) { - ZonedDateTime now = ZonedDateTime.now(); - - UserBan userBan = findBannedUser(userId, now); + UserBan userBan = findBannedUser(userId, ZonedDateTime.now(UTC)); userBan.manuallyUnban(adminId); + processUnban(userId); + } + @Transactional + @Scheduled(cron = "0 0 0 * * *") + public void expireUserBans() { + List expiredBans = userBanRepository.findAllByIsUnbannedFalseAndExpiredAtBefore(ZonedDateTime.now(UTC)); + for (UserBan userBan : expiredBans) { + processUnban(userBan.getBannedUserId()); + } + } + + public void processUnban(long userId) { + updateReportedContentIsDeleted(userId, false); updateUserStatus(userId, UserStatus.REPORTED); } @@ -52,12 +73,14 @@ private void validateNotAlreadyBanned(long userId, ZonedDateTime now) { } private void validateReportExists(long userId) { - reportRepository.findByTargetId(userId) - .orElseThrow(() -> new CustomException(ErrorCode.REPORT_NOT_FOUND)); + if (!reportRepository.existsReportByUserId(userId)) { + throw new CustomException(ErrorCode.REPORT_NOT_FOUND); + } } - private void deleteReportedContent() { - //TODO report 타입에 따라서 콘텐츠 삭제 처리 로직 추가 + private void updateReportedContentIsDeleted(long userId, boolean isDeleted) { + postRepository.updateReportedPostsIsDeleted(userId, isDeleted); + chatMessageRepository.updateReportedChatMessagesIsDeleted(userId, isDeleted); } private void createUserBan(long userId, UserBanRequest request, ZonedDateTime now) { diff --git a/src/main/java/com/example/solidconnection/admin/service/UserBanScheduler.java b/src/main/java/com/example/solidconnection/admin/service/UserBanScheduler.java deleted file mode 100644 index e0f664e4a..000000000 --- a/src/main/java/com/example/solidconnection/admin/service/UserBanScheduler.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.example.solidconnection.admin.service; - -import com.example.solidconnection.common.exception.CustomException; -import com.example.solidconnection.common.exception.ErrorCode; -import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.domain.UserBan; -import com.example.solidconnection.siteuser.domain.UserStatus; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; -import com.example.solidconnection.siteuser.repository.UserBanRepository; -import jakarta.transaction.Transactional; -import java.time.ZonedDateTime; -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -@RequiredArgsConstructor -@Component -public class UserBanScheduler { - - private final UserBanRepository userBanRepository; - private final SiteUserRepository siteUserRepository; - - @Transactional - @Scheduled(cron = "0 0 * * * *") - public void expireUserBans() { - ZonedDateTime current = ZonedDateTime.now(); - List expiredBans = userBanRepository.findAllByIsUnbannedFalseAndExpiredAtBefore(current); - - for (UserBan userBan : expiredBans) { - updateUserStatus(userBan); - } - } - - private void updateUserStatus(UserBan userBan) { - SiteUser user = siteUserRepository.findById(userBan.getBannedUserId()) - .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - user.updateUserStatus(UserStatus.REPORTED); // 다시 신고됨 상태로 변경 - } -} diff --git a/src/main/java/com/example/solidconnection/chat/domain/ChatMessage.java b/src/main/java/com/example/solidconnection/chat/domain/ChatMessage.java index aa7369451..f2ec4d820 100644 --- a/src/main/java/com/example/solidconnection/chat/domain/ChatMessage.java +++ b/src/main/java/com/example/solidconnection/chat/domain/ChatMessage.java @@ -15,10 +15,12 @@ import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.Where; @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) +@Where(clause = "is_deleted = false") public class ChatMessage extends BaseEntity { @Id @@ -33,6 +35,9 @@ public class ChatMessage extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) private ChatRoom chatRoom; + @Column(name = "is_deleted", columnDefinition = "boolean default false", nullable = false) + private boolean isDeleted = false; + @OneToMany(mappedBy = "chatMessage", cascade = CascadeType.ALL, orphanRemoval = true) private final List chatAttachments = new ArrayList<>(); diff --git a/src/main/java/com/example/solidconnection/chat/repository/ChatMessageRepository.java b/src/main/java/com/example/solidconnection/chat/repository/ChatMessageRepository.java index e27e3e86d..cbd0dd874 100644 --- a/src/main/java/com/example/solidconnection/chat/repository/ChatMessageRepository.java +++ b/src/main/java/com/example/solidconnection/chat/repository/ChatMessageRepository.java @@ -6,6 +6,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -48,4 +49,12 @@ SELECT MAX(cm2.id) GROUP BY cm.chatRoom.id """) List countUnreadMessagesBatch(@Param("chatRoomIds") List chatRoomIds, @Param("userId") long userId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(value = """ + UPDATE chat_message cm SET cm.is_deleted = :isDeleted + WHERE cm.id IN (SELECT r.target_id FROM report r WHERE r.target_type = 'CHAT') + AND cm.sender_id IN (SELECT cp.id FROM chat_participant cp WHERE cp.site_user_id = :siteUserId) + """, nativeQuery = true) + void updateReportedChatMessagesIsDeleted(@Param("siteUserId") long siteUserId, @Param("isDeleted") boolean isDeleted); } diff --git a/src/main/java/com/example/solidconnection/common/config/web/WebMvcConfig.java b/src/main/java/com/example/solidconnection/common/config/web/WebMvcConfig.java index 510894217..6312214dc 100644 --- a/src/main/java/com/example/solidconnection/common/config/web/WebMvcConfig.java +++ b/src/main/java/com/example/solidconnection/common/config/web/WebMvcConfig.java @@ -29,6 +29,6 @@ public void addArgumentResolvers(List resolvers) @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(bannedUserInterceptor) - .addPathPatterns("/posts/**", "/comments/**", "/chats/**", "/community/**"); + .addPathPatterns("/posts/**", "/comments/**", "/chats/**"); } } diff --git a/src/main/java/com/example/solidconnection/community/post/domain/Post.java b/src/main/java/com/example/solidconnection/community/post/domain/Post.java index 190861131..7b3f72745 100644 --- a/src/main/java/com/example/solidconnection/community/post/domain/Post.java +++ b/src/main/java/com/example/solidconnection/community/post/domain/Post.java @@ -18,11 +18,13 @@ import lombok.Getter; import lombok.NoArgsConstructor; import org.hibernate.annotations.BatchSize; +import org.hibernate.annotations.Where; @Entity @Getter @NoArgsConstructor @EqualsAndHashCode(of = "id") +@Where(clause = "is_deleted = false") public class Post extends BaseEntity { @Id @@ -50,6 +52,8 @@ public class Post extends BaseEntity { @Column private long siteUserId; + @Column(name = "is_deleted", columnDefinition = "boolean default false", nullable = false) + private boolean isDeleted = false; @BatchSize(size = 20) @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) diff --git a/src/main/java/com/example/solidconnection/community/post/repository/PostRepository.java b/src/main/java/com/example/solidconnection/community/post/repository/PostRepository.java index cca590270..366f8572b 100644 --- a/src/main/java/com/example/solidconnection/community/post/repository/PostRepository.java +++ b/src/main/java/com/example/solidconnection/community/post/repository/PostRepository.java @@ -50,6 +50,14 @@ AND p.siteUserId NOT IN ( """) void increaseViewCount(@Param("postId") Long postId, @Param("count") Long count); + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(value = """ + UPDATE post p SET p.is_deleted = :isDeleted + WHERE p.site_user_id = :siteUserId + AND p.id IN (SELECT r.target_id FROM report r WHERE r.target_type = 'POST') + """, nativeQuery = true) + void updateReportedPostsIsDeleted(@Param("siteUserId") long siteUserId, @Param("isDeleted") boolean isDeleted); + default Post getByIdUsingEntityGraph(Long id) { return findPostById(id) .orElseThrow(() -> new CustomException(INVALID_POST_ID)); diff --git a/src/main/java/com/example/solidconnection/report/repository/ReportRepository.java b/src/main/java/com/example/solidconnection/report/repository/ReportRepository.java index eb3ea42b6..ed57617e9 100644 --- a/src/main/java/com/example/solidconnection/report/repository/ReportRepository.java +++ b/src/main/java/com/example/solidconnection/report/repository/ReportRepository.java @@ -1,14 +1,26 @@ package com.example.solidconnection.report.repository; -import java.util.Optional; - import com.example.solidconnection.report.domain.Report; import com.example.solidconnection.report.domain.TargetType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface ReportRepository extends JpaRepository { boolean existsByReporterIdAndTargetTypeAndTargetId(long reporterId, TargetType targetType, long targetId); - Optional findByTargetId(long targetId); + @Query(""" + SELECT CASE WHEN COUNT(r) > 0 THEN true ELSE false END + FROM Report r + WHERE (r.targetType = 'POST' AND EXISTS ( + SELECT 1 FROM Post p WHERE p.id = r.targetId AND p.siteUserId = :userId + )) + OR (r.targetType = 'CHAT' AND EXISTS ( + SELECT 1 FROM ChatMessage cm + JOIN ChatParticipant cp ON cp.id = cm.senderId + WHERE cm.id = r.targetId AND cp.siteUserId = :userId + )) + """) + boolean existsReportByUserId(@Param("userId") long userId); } diff --git a/src/main/resources/db/migration/V40__create_user_ban_table.sql b/src/main/resources/db/migration/V40__create_user_ban_table.sql index 5f7b6f4da..fd3b2ddcc 100644 --- a/src/main/resources/db/migration/V40__create_user_ban_table.sql +++ b/src/main/resources/db/migration/V40__create_user_ban_table.sql @@ -11,4 +11,7 @@ CREATE TABLE user_ban PRIMARY KEY (id), CONSTRAINT fk_user_ban_banned_user_id FOREIGN KEY (banned_user_id) REFERENCES site_user (id), CONSTRAINT fk_user_ban_unbanned_by_id FOREIGN KEY (unbanned_by) REFERENCES site_user (id) -) +); + +ALTER TABLE site_user + ADD COLUMN user_status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE'; diff --git a/src/main/resources/db/migration/V41__add_is_deleted_to_post_and_chat_message.sql b/src/main/resources/db/migration/V41__add_is_deleted_to_post_and_chat_message.sql new file mode 100644 index 000000000..5444af27c --- /dev/null +++ b/src/main/resources/db/migration/V41__add_is_deleted_to_post_and_chat_message.sql @@ -0,0 +1,3 @@ +ALTER TABLE post ADD COLUMN is_deleted BOOLEAN NOT NULL DEFAULT FALSE; + +ALTER TABLE chat_message ADD COLUMN is_deleted BOOLEAN NOT NULL DEFAULT FALSE; From e391cc50d9d3a4455a66d25d3a8467bf184efe36 Mon Sep 17 00:00:00 2001 From: jhjung31 Date: Sat, 3 Jan 2026 00:17:43 +0900 Subject: [PATCH 3/6] =?UTF-8?q?test:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EC=B0=A8=EB=8B=A8=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/dto/SignUpRequest.java | 26 -- .../auth/service/signup/SignUpService.java | 4 +- .../common/config/web/WebMvcConfig.java | 2 +- .../siteuser/domain/SiteUser.java | 4 +- .../service/AdminUserBanServiceTest.java | 264 ++++++++++++++++++ .../BannedUserInterceptorTest.java | 153 ++++++++++ .../siteuser/fixture/SiteUserFixture.java | 19 ++ .../fixture/SiteUserFixtureBuilder.java | 10 +- .../siteuser/fixture/UserBanFixture.java | 36 +++ .../fixture/UserBanFixtureBuilder.java | 36 +++ 10 files changed, 524 insertions(+), 30 deletions(-) create mode 100644 src/test/java/com/example/solidconnection/admin/service/AdminUserBanServiceTest.java create mode 100644 src/test/java/com/example/solidconnection/common/interceptor/BannedUserInterceptorTest.java create mode 100644 src/test/java/com/example/solidconnection/siteuser/fixture/UserBanFixture.java create mode 100644 src/test/java/com/example/solidconnection/siteuser/fixture/UserBanFixtureBuilder.java diff --git a/src/main/java/com/example/solidconnection/auth/dto/SignUpRequest.java b/src/main/java/com/example/solidconnection/auth/dto/SignUpRequest.java index bafb9b4c8..81991fd90 100644 --- a/src/main/java/com/example/solidconnection/auth/dto/SignUpRequest.java +++ b/src/main/java/com/example/solidconnection/auth/dto/SignUpRequest.java @@ -1,9 +1,6 @@ package com.example.solidconnection.auth.dto; -import com.example.solidconnection.siteuser.domain.AuthType; import com.example.solidconnection.siteuser.domain.ExchangeStatus; -import com.example.solidconnection.siteuser.domain.Role; -import com.example.solidconnection.siteuser.domain.SiteUser; import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.validation.constraints.NotBlank; import java.util.List; @@ -20,27 +17,4 @@ public record SignUpRequest( @NotBlank(message = "닉네임을 입력해주세요.") String nickname) { - - public SiteUser toOAuthSiteUser(String email, AuthType authType) { - return new SiteUser( - email, - this.nickname, - this.profileImageUrl, - this.exchangeStatus, - Role.MENTEE, - authType - ); - } - - public SiteUser toEmailSiteUser(String email, String encodedPassword) { - return new SiteUser( - email, - this.nickname, - this.profileImageUrl, - this.exchangeStatus, - Role.MENTEE, - AuthType.EMAIL, - encodedPassword - ); - } } diff --git a/src/main/java/com/example/solidconnection/auth/service/signup/SignUpService.java b/src/main/java/com/example/solidconnection/auth/service/signup/SignUpService.java index 86415d913..8f814be4a 100644 --- a/src/main/java/com/example/solidconnection/auth/service/signup/SignUpService.java +++ b/src/main/java/com/example/solidconnection/auth/service/signup/SignUpService.java @@ -13,6 +13,7 @@ import com.example.solidconnection.siteuser.domain.AuthType; 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.repository.SiteUserRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -56,7 +57,8 @@ public SignInResponse signUp(SignUpRequest signUpRequest) { signUpRequest.exchangeStatus(), Role.MENTEE, authType, - password + password, + UserStatus.ACTIVE )); // 관심 지역, 국가 저장 diff --git a/src/main/java/com/example/solidconnection/common/config/web/WebMvcConfig.java b/src/main/java/com/example/solidconnection/common/config/web/WebMvcConfig.java index 6312214dc..47d70689d 100644 --- a/src/main/java/com/example/solidconnection/common/config/web/WebMvcConfig.java +++ b/src/main/java/com/example/solidconnection/common/config/web/WebMvcConfig.java @@ -29,6 +29,6 @@ public void addArgumentResolvers(List resolvers) @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(bannedUserInterceptor) - .addPathPatterns("/posts/**", "/comments/**", "/chats/**"); + .addPathPatterns("/posts/**", "/comments/**", "/chats/**", "/boards/**"); } } diff --git a/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java b/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java index d201cd3b0..6d2dee67c 100644 --- a/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java +++ b/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java @@ -111,7 +111,8 @@ public SiteUser( ExchangeStatus exchangeStatus, Role role, AuthType authType, - String password) { + String password, + UserStatus userStatus) { this.email = email; this.nickname = nickname; this.profileImageUrl = profileImageUrl; @@ -119,6 +120,7 @@ public SiteUser( this.role = role; this.authType = authType; this.password = password; + this.userStatus = userStatus; } public void updatePassword(String newEncodedPassword) { diff --git a/src/test/java/com/example/solidconnection/admin/service/AdminUserBanServiceTest.java b/src/test/java/com/example/solidconnection/admin/service/AdminUserBanServiceTest.java new file mode 100644 index 000000000..e535bdf42 --- /dev/null +++ b/src/test/java/com/example/solidconnection/admin/service/AdminUserBanServiceTest.java @@ -0,0 +1,264 @@ +package com.example.solidconnection.admin.service; + +import static com.example.solidconnection.common.exception.ErrorCode.ALREADY_BANNED_USER; +import static com.example.solidconnection.common.exception.ErrorCode.NOT_BANNED_USER; +import static com.example.solidconnection.common.exception.ErrorCode.REPORT_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.UserBanRequest; +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.report.domain.TargetType; +import com.example.solidconnection.report.fixture.ReportFixture; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.domain.UserBan; +import com.example.solidconnection.siteuser.domain.UserBanDuration; +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.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.siteuser.repository.UserBanRepository; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import java.time.ZonedDateTime; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +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; + +@TestContainerSpringBootTest +@DisplayName("어드민 유저 차단 서비스 테스트") +class AdminUserBanServiceTest { + + @Autowired + private AdminUserBanService adminUserBanService; + + @Autowired + private SiteUserRepository siteUserRepository; + + @Autowired + private UserBanRepository userBanRepository; + + @Autowired + private SiteUserFixture siteUserFixture; + + @Autowired + private UserBanFixture userBanFixture; + + @Autowired + private ReportFixture reportFixture; + + @Autowired + private PostFixture postFixture; + + @Autowired + private BoardFixture boardFixture; + + private SiteUser admin; + private SiteUser reportedUser; + private SiteUser reporter; + private Post reportedPost; + + @BeforeEach + void setUp() { + admin = siteUserFixture.관리자(); + reportedUser = siteUserFixture.사용자(1, "신고당한유저"); + reporter = siteUserFixture.사용자(2, "신고자"); + reportedPost = postFixture.게시글( + "신고될 게시글", + "신고될 내용", + false, + PostCategory.자유, + boardFixture.자유게시판(), + reportedUser + ); + } + + @Nested + @DisplayName("사용자 차단") + class 사용자_차단 { + + @Test + void 사용자를_차단한다() { + // given + reportFixture.신고(reporter.getId(), TargetType.POST, reportedPost.getId()); + UserBanRequest request = new UserBanRequest(UserBanDuration.SEVEN_DAYS); + + // when + adminUserBanService.banUser(reportedUser.getId(), request); + + // then + SiteUser bannedUser = siteUserRepository.findById(reportedUser.getId()).orElseThrow(); + assertThat(bannedUser.getUserStatus()).isEqualTo(UserStatus.BANNED); + } + + @Test + void 이미_차단된_사용자는_다시_차단할_수_없다() { + // given + reportFixture.신고(reporter.getId(), TargetType.POST, reportedPost.getId()); + UserBanRequest request = new UserBanRequest(UserBanDuration.SEVEN_DAYS); + adminUserBanService.banUser(reportedUser.getId(), request); + + // when & then + assertThatCode(() -> adminUserBanService.banUser(reportedUser.getId(), request)) + .isInstanceOf(CustomException.class) + .hasMessage(ALREADY_BANNED_USER.getMessage()); + } + + @Test + void 신고가_없는_사용자는_차단할_수_없다() { + // given + SiteUser userWithoutReport = siteUserFixture.사용자(3, "신고없는유저"); + UserBanRequest request = new UserBanRequest(UserBanDuration.SEVEN_DAYS); + + // when & then + assertThatCode(() -> adminUserBanService.banUser(userWithoutReport.getId(), request)) + .isInstanceOf(CustomException.class) + .hasMessage(REPORT_NOT_FOUND.getMessage()); + } + + @Nested + class 사용자_차단_해제 { + + @Test + void 차단된_사용자를_수동으로_해제한다() { + // given + reportFixture.신고(reporter.getId(), TargetType.POST, reportedPost.getId()); + UserBanRequest request = new UserBanRequest(UserBanDuration.SEVEN_DAYS); + adminUserBanService.banUser(reportedUser.getId(), request); + + // when + adminUserBanService.unbanUser(reportedUser.getId(), admin.getId()); + + // then + SiteUser unbannedUser = siteUserRepository.findById(reportedUser.getId()).orElseThrow(); + assertThat(unbannedUser.getUserStatus()).isEqualTo(UserStatus.REPORTED); + } + + @Test + void 차단_해제_정보가_올바르게_저장된다() { + // given + reportFixture.신고(reporter.getId(), TargetType.POST, reportedPost.getId()); + UserBanRequest request = new UserBanRequest(UserBanDuration.SEVEN_DAYS); + adminUserBanService.banUser(reportedUser.getId(), request); + ZonedDateTime beforeUnban = ZonedDateTime.now(); + + // when + adminUserBanService.unbanUser(reportedUser.getId(), admin.getId()); + + // then + List allBans = userBanRepository.findAll(); + UserBan unbannedUserBan = allBans.stream() + .filter(ban -> ban.getBannedUserId().equals(reportedUser.getId())) + .findFirst() + .orElseThrow(); + + assertAll( + () -> assertThat(unbannedUserBan.isUnbanned()).isTrue(), + () -> assertThat(unbannedUserBan.getUnbannedBy()).isEqualTo(admin.getId()), + () -> assertThat(unbannedUserBan.getUnbannedAt()).isAfter(beforeUnban) + ); + } + + @Test + void 차단되지_않은_사용자는_차단_해제할_수_없다() { + // given + SiteUser notBannedUser = siteUserFixture.사용자(3, "차단안된유저"); + + // when & then + assertThatCode(() -> adminUserBanService.unbanUser(notBannedUser.getId(), admin.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(NOT_BANNED_USER.getMessage()); + } + + @Test + void 만료된_차단은_해제할_수_없다() { + // given + userBanFixture.만료된_차단(reportedUser.getId()); + + // when & then + assertThatCode(() -> adminUserBanService.unbanUser(reportedUser.getId(), admin.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(NOT_BANNED_USER.getMessage()); + } + } + + @Nested + @DisplayName("만료된 차단 자동 해제") + class 만료된_차단_자동_해제 { + + @Test + void 만료된_차단들을_자동으로_해제한다() { + // given + SiteUser user1 = siteUserFixture.사용자(10, "유저1"); + SiteUser user2 = siteUserFixture.사용자(11, "유저2"); + + userBanFixture.만료된_차단(user1.getId()); + userBanFixture.만료된_차단(user2.getId()); + + user1.updateUserStatus(UserStatus.BANNED); + user2.updateUserStatus(UserStatus.BANNED); + + // when + adminUserBanService.expireUserBans(); + + // then + SiteUser unbannedUser1 = siteUserRepository.findById(user1.getId()).orElseThrow(); + SiteUser unbannedUser2 = siteUserRepository.findById(user2.getId()).orElseThrow(); + + assertAll( + () -> assertThat(unbannedUser1.getUserStatus()).isEqualTo(UserStatus.REPORTED), + () -> assertThat(unbannedUser2.getUserStatus()).isEqualTo(UserStatus.REPORTED) + ); + } + + @Test + void 만료되지_않은_차단은_유지된다() { + // given + Post reportedPost = postFixture.게시글( + "신고될 게시글", + "신고될 내용", + false, + PostCategory.자유, + boardFixture.자유게시판(), + reportedUser + ); + reportFixture.신고(reporter.getId(), TargetType.POST, reportedPost.getId()); + adminUserBanService.banUser(reportedUser.getId(), new UserBanRequest(UserBanDuration.SEVEN_DAYS)); + + // when + adminUserBanService.expireUserBans(); + + // then + SiteUser stillBannedUser = siteUserRepository.findById(reportedUser.getId()).orElseThrow(); + assertThat(stillBannedUser.getUserStatus()).isEqualTo(UserStatus.BANNED); + } + + @Test + void 이미_수동으로_해제된_차단은_처리하지_않는다() { + // given + userBanFixture.수동_차단_해제(reportedUser.getId(), admin.getId()); + reportedUser.updateUserStatus(UserStatus.REPORTED); + + long beforeUnbannedCount = userBanRepository.findAll().stream() + .filter(UserBan::isUnbanned) + .count(); + + // when + adminUserBanService.expireUserBans(); + + // then + long afterUnbannedCount = userBanRepository.findAll().stream() + .filter(UserBan::isUnbanned) + .count(); + assertThat(afterUnbannedCount).isEqualTo(beforeUnbannedCount); + } + } + } +} \ No newline at end of file diff --git a/src/test/java/com/example/solidconnection/common/interceptor/BannedUserInterceptorTest.java b/src/test/java/com/example/solidconnection/common/interceptor/BannedUserInterceptorTest.java new file mode 100644 index 000000000..1511c508e --- /dev/null +++ b/src/test/java/com/example/solidconnection/common/interceptor/BannedUserInterceptorTest.java @@ -0,0 +1,153 @@ +package com.example.solidconnection.common.interceptor; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +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.security.authentication.TokenAuthentication; +import com.example.solidconnection.security.userdetails.SiteUserDetails; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.web.servlet.MockMvc; + +@TestContainerSpringBootTest +@AutoConfigureMockMvc(addFilters = false) +@DisplayName("차단된 유저 인터셉터 테스트") +class BannedUserInterceptorTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private SiteUserFixture siteUserFixture; + @Autowired + private PostFixture postFixture; + @Autowired + private BoardFixture boardFixture; + + @BeforeEach + void setUp() { + SecurityContextHolder.clearContext(); + } + + @Test + void 차단된_사용자는_게시판_관련_접근이_차단된다() throws Exception { + // given + SiteUser bannedUser = siteUserFixture.차단된_사용자("차단된유저"); + setAuthentication(bannedUser); + + // when & then + mockMvc.perform(get("/boards")) + .andExpect(status().isForbidden()); + } + + @Test + void 차단된_사용자는_게시글_관련_접근이_차단된다() throws Exception { + // given + SiteUser bannedUser = siteUserFixture.차단된_사용자("차단된유저"); + setAuthentication(bannedUser); + + // when & then + mockMvc.perform(get("/posts/1")) + .andExpect(status().isForbidden()); + + mockMvc.perform(post("/posts")) + .andExpect(status().isForbidden()); + } + + @Test + void 차단된_사용자는_댓글_관련_접근이_차단된다() throws Exception { + // given + SiteUser bannedUser = siteUserFixture.차단된_사용자("차단된유저"); + setAuthentication(bannedUser); + + // when & then + mockMvc.perform(post("/comments") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "postId": 1, + "content": "테스트 댓글 내용", + "parentId": null + } + """)) + .andExpect(status().isForbidden()); + } + + @Test + void 차단된_사용자는_채팅_관련_접근이_차단된다() throws Exception { + // given + SiteUser bannedUser = siteUserFixture.차단된_사용자("차단된유저"); + setAuthentication(bannedUser); + + // when & then + mockMvc.perform(get("/chats/rooms")) + .andExpect(status().isForbidden()); + } + + @Test + void 정상_사용자는_모든_경로_접근이_가능하다() throws Exception { + // given + SiteUser normalUser = siteUserFixture.사용자(1, "정상 유저1"); + Post post1 = postFixture.게시글( + "제목1", + "내용1", + false, + PostCategory.자유, + boardFixture.자유게시판(), + siteUserFixture.사용자(2, "정상 유저2") + ); + setAuthentication(normalUser); + + // when & then + mockMvc.perform(get("/boards")) + .andExpect(status().isOk()); + + mockMvc.perform(get("/posts/" + post1.getId())) + .andExpect(status().isOk()); + + mockMvc.perform(post("/comments") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "postId": 1, + "content": "테스트 댓글 내용", + "parentId": null + } + """)) + .andExpect(status().isOk()); + + mockMvc.perform(get("/chats/rooms")) + .andExpect(status().isOk()); + } + + @Test + void 차단된_사용자도_다른_경로_접근은_가능하다() throws Exception { + // given + SiteUser bannedUser = siteUserFixture.차단된_사용자("차단된유저"); + setAuthentication(bannedUser); + + // when & then + mockMvc.perform(get("/my")) + .andExpect(status().isOk()); + } + + private void setAuthentication(SiteUser user) { + SiteUserDetails userDetails = new SiteUserDetails(user); + Authentication authentication = new TokenAuthentication("token", userDetails); + SecurityContextHolder.getContext().setAuthentication(authentication); + } +} 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 9c2eb12bc..599c20b27 100644 --- a/src/test/java/com/example/solidconnection/siteuser/fixture/SiteUserFixture.java +++ b/src/test/java/com/example/solidconnection/siteuser/fixture/SiteUserFixture.java @@ -3,6 +3,7 @@ import com.example.solidconnection.siteuser.domain.AuthType; import com.example.solidconnection.siteuser.domain.Role; import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.domain.UserStatus; import lombok.RequiredArgsConstructor; import org.springframework.boot.test.context.TestComponent; @@ -20,6 +21,7 @@ public class SiteUserFixture { .profileImageUrl("profileImageUrl") .role(Role.MENTEE) .password("password123") + .userStatus(UserStatus.ACTIVE) .create(); } @@ -31,6 +33,7 @@ public class SiteUserFixture { .profileImageUrl("profileImageUrl") .role(Role.MENTEE) .password("password123") + .userStatus(UserStatus.ACTIVE) .create(); } @@ -42,6 +45,7 @@ public class SiteUserFixture { .profileImageUrl("profileImageUrl") .role(Role.MENTEE) .password("password123") + .userStatus(UserStatus.ACTIVE) .create(); } @@ -53,6 +57,7 @@ public class SiteUserFixture { .profileImageUrl("profileImageUrl") .role(Role.MENTEE) .password(password) + .userStatus(UserStatus.ACTIVE) .create(); } @@ -64,6 +69,7 @@ public class SiteUserFixture { .profileImageUrl("profileImageUrl") .role(Role.MENTOR) .password("mentor123") + .userStatus(UserStatus.ACTIVE) .create(); } @@ -75,6 +81,19 @@ public class SiteUserFixture { .profileImageUrl("profileImageUrl") .role(Role.ADMIN) .password("admin123") + .userStatus(UserStatus.ACTIVE) + .create(); + } + + public SiteUser 차단된_사용자(String nickname) { + return siteUserFixtureBuilder.siteUser() + .email("banned@example.com") + .authType(AuthType.EMAIL) + .nickname(nickname) + .profileImageUrl("profileImageUrl") + .role(Role.MENTEE) + .password("banned123") + .userStatus(UserStatus.BANNED) .create(); } } diff --git a/src/test/java/com/example/solidconnection/siteuser/fixture/SiteUserFixtureBuilder.java b/src/test/java/com/example/solidconnection/siteuser/fixture/SiteUserFixtureBuilder.java index 901de4d6a..e4497f24c 100644 --- a/src/test/java/com/example/solidconnection/siteuser/fixture/SiteUserFixtureBuilder.java +++ b/src/test/java/com/example/solidconnection/siteuser/fixture/SiteUserFixtureBuilder.java @@ -4,6 +4,7 @@ import com.example.solidconnection.siteuser.domain.ExchangeStatus; 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.repository.SiteUserRepository; import lombok.RequiredArgsConstructor; import org.springframework.boot.test.context.TestComponent; @@ -22,6 +23,7 @@ public class SiteUserFixtureBuilder { private String profileImageUrl; private Role role; private String password; + private UserStatus userStatus; public SiteUserFixtureBuilder siteUser() { return new SiteUserFixtureBuilder(siteUserRepository, passwordEncoder); @@ -57,6 +59,11 @@ public SiteUserFixtureBuilder password(String password) { return this; } + public SiteUserFixtureBuilder userStatus(UserStatus userStatus) { + this.userStatus = userStatus; + return this; + } + public SiteUser create() { SiteUser siteUser = new SiteUser( email, @@ -65,7 +72,8 @@ public SiteUser create() { ExchangeStatus.CONSIDERING, role, authType, - passwordEncoder.encode(password) + passwordEncoder.encode(password), + userStatus != null ? userStatus : UserStatus.ACTIVE ); return siteUserRepository.save(siteUser); } diff --git a/src/test/java/com/example/solidconnection/siteuser/fixture/UserBanFixture.java b/src/test/java/com/example/solidconnection/siteuser/fixture/UserBanFixture.java new file mode 100644 index 000000000..d13773ecb --- /dev/null +++ b/src/test/java/com/example/solidconnection/siteuser/fixture/UserBanFixture.java @@ -0,0 +1,36 @@ +package com.example.solidconnection.siteuser.fixture; + +import com.example.solidconnection.siteuser.domain.UserBan; +import java.time.ZonedDateTime; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class UserBanFixture { + + private final UserBanFixtureBuilder userBanFixtureBuilder; + + public UserBan 차단(long bannedUserId, int days) { + return userBanFixtureBuilder.userBan() + .bannedUserId(bannedUserId) + .expiredAt(ZonedDateTime.now().plusDays(days)) + .create(); + } + + public UserBan 만료된_차단(long bannedUserId) { + return userBanFixtureBuilder.userBan() + .bannedUserId(bannedUserId) + .expiredAt(ZonedDateTime.now().minusDays(1)) + .create(); + } + + public UserBan 수동_차단_해제(long bannedUserId, long adminId) { + UserBan userBan = userBanFixtureBuilder.userBan() + .bannedUserId(bannedUserId) + .expiredAt(ZonedDateTime.now().plusDays(7)) + .create(); + userBan.manuallyUnban(adminId); + return userBan; + } +} \ No newline at end of file diff --git a/src/test/java/com/example/solidconnection/siteuser/fixture/UserBanFixtureBuilder.java b/src/test/java/com/example/solidconnection/siteuser/fixture/UserBanFixtureBuilder.java new file mode 100644 index 000000000..83175b133 --- /dev/null +++ b/src/test/java/com/example/solidconnection/siteuser/fixture/UserBanFixtureBuilder.java @@ -0,0 +1,36 @@ +package com.example.solidconnection.siteuser.fixture; + +import com.example.solidconnection.siteuser.domain.UserBan; +import com.example.solidconnection.siteuser.repository.UserBanRepository; +import java.time.ZonedDateTime; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class UserBanFixtureBuilder { + + private final UserBanRepository userBanRepository; + + private Long bannedUserId; + private ZonedDateTime expiredAt; + + public UserBanFixtureBuilder userBan() { + return new UserBanFixtureBuilder(userBanRepository); + } + + public UserBanFixtureBuilder bannedUserId(Long bannedUserId) { + this.bannedUserId = bannedUserId; + return this; + } + + public UserBanFixtureBuilder expiredAt(ZonedDateTime expiredAt) { + this.expiredAt = expiredAt; + return this; + } + + public UserBan create() { + UserBan userBan = new UserBan(bannedUserId, expiredAt); + return userBanRepository.save(userBan); + } +} \ No newline at end of file From b17a36b4a5142a013ae3490759f594c33a47a33f Mon Sep 17 00:00:00 2001 From: JAEHEE25 Date: Sat, 3 Jan 2026 13:37:33 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EC=B0=A8=EB=8B=A8=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/AdminUserBanController.java | 7 ++- .../admin/service/AdminUserBanService.java | 52 +++++++++---------- .../siteuser/domain/SiteUser.java | 2 +- .../report/service/ReportServiceTest.java | 8 ++- 4 files changed, 40 insertions(+), 29 deletions(-) diff --git a/src/main/java/com/example/solidconnection/admin/controller/AdminUserBanController.java b/src/main/java/com/example/solidconnection/admin/controller/AdminUserBanController.java index b444178ca..72f6c02a0 100644 --- a/src/main/java/com/example/solidconnection/admin/controller/AdminUserBanController.java +++ b/src/main/java/com/example/solidconnection/admin/controller/AdminUserBanController.java @@ -1,7 +1,12 @@ package com.example.solidconnection.admin.controller; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; import com.example.solidconnection.admin.dto.UserBanRequest; import com.example.solidconnection.admin.service.AdminUserBanService; diff --git a/src/main/java/com/example/solidconnection/admin/service/AdminUserBanService.java b/src/main/java/com/example/solidconnection/admin/service/AdminUserBanService.java index adb9bf9ee..321d4ad10 100644 --- a/src/main/java/com/example/solidconnection/admin/service/AdminUserBanService.java +++ b/src/main/java/com/example/solidconnection/admin/service/AdminUserBanService.java @@ -36,7 +36,7 @@ public class AdminUserBanService { @Transactional public void banUser(long userId, UserBanRequest request) { - ZonedDateTime now = ZonedDateTime.now(UTC); //TODO UTC 확인 + ZonedDateTime now = ZonedDateTime.now(UTC); validateNotAlreadyBanned(userId, now); validateReportExists(userId); @@ -45,27 +45,6 @@ public void banUser(long userId, UserBanRequest request) { updateUserStatus(userId, UserStatus.BANNED); } - @Transactional - public void unbanUser(long userId, long adminId) { - UserBan userBan = findBannedUser(userId, ZonedDateTime.now(UTC)); - userBan.manuallyUnban(adminId); - processUnban(userId); - } - - @Transactional - @Scheduled(cron = "0 0 0 * * *") - public void expireUserBans() { - List expiredBans = userBanRepository.findAllByIsUnbannedFalseAndExpiredAtBefore(ZonedDateTime.now(UTC)); - for (UserBan userBan : expiredBans) { - processUnban(userBan.getBannedUserId()); - } - } - - public void processUnban(long userId) { - updateReportedContentIsDeleted(userId, false); - updateUserStatus(userId, UserStatus.REPORTED); - } - private void validateNotAlreadyBanned(long userId, ZonedDateTime now) { if (userBanRepository.existsByBannedUserIdAndIsUnbannedFalseAndExpiredAtAfter(userId, now)) { throw new CustomException(ErrorCode.ALREADY_BANNED_USER); @@ -89,15 +68,36 @@ private void createUserBan(long userId, UserBanRequest request, ZonedDateTime no userBanRepository.save(userBan); } + private void updateUserStatus(long userId, UserStatus status) { + SiteUser user = siteUserRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + user.updateUserStatus(status); + } + + @Transactional + public void unbanUser(long userId, long adminId) { + UserBan userBan = findBannedUser(userId, ZonedDateTime.now(UTC)); + userBan.manuallyUnban(adminId); + processUnban(userId); + } + private UserBan findBannedUser(long userId, ZonedDateTime now) { return userBanRepository .findTopByBannedUserIdAndIsUnbannedFalseAndExpiredAtAfterOrderByCreatedAtDesc(userId, now) .orElseThrow(() -> new CustomException(ErrorCode.NOT_BANNED_USER)); } - private void updateUserStatus(long userId, UserStatus status) { - SiteUser user = siteUserRepository.findById(userId) - .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - user.updateUserStatus(status); + @Transactional + @Scheduled(cron = "0 0 0 * * *") + public void expireUserBans() { + List expiredBans = userBanRepository.findAllByIsUnbannedFalseAndExpiredAtBefore(ZonedDateTime.now(UTC)); + for (UserBan userBan : expiredBans) { + processUnban(userBan.getBannedUserId()); + } + } + + private void processUnban(long userId) { + updateReportedContentIsDeleted(userId, false); + updateUserStatus(userId, UserStatus.REPORTED); } } diff --git a/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java b/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java index 6d2dee67c..a82291d75 100644 --- a/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java +++ b/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java @@ -72,7 +72,7 @@ public class SiteUser extends BaseEntity { @Column(nullable = false) @Enumerated(EnumType.STRING) - private UserStatus userStatus; + private UserStatus userStatus = UserStatus.ACTIVE; public SiteUser( String email, diff --git a/src/test/java/com/example/solidconnection/report/service/ReportServiceTest.java b/src/test/java/com/example/solidconnection/report/service/ReportServiceTest.java index cdc9b875f..5dad25850 100644 --- a/src/test/java/com/example/solidconnection/report/service/ReportServiceTest.java +++ b/src/test/java/com/example/solidconnection/report/service/ReportServiceTest.java @@ -4,8 +4,10 @@ import static org.assertj.core.api.Assertions.assertThatCode; import com.example.solidconnection.chat.domain.ChatMessage; +import com.example.solidconnection.chat.domain.ChatParticipant; import com.example.solidconnection.chat.domain.ChatRoom; import com.example.solidconnection.chat.fixture.ChatMessageFixture; +import com.example.solidconnection.chat.fixture.ChatParticipantFixture; import com.example.solidconnection.chat.fixture.ChatRoomFixture; import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.common.exception.ErrorCode; @@ -52,6 +54,9 @@ class ReportServiceTest { @Autowired private ChatRoomFixture chatRoomFixture; + @Autowired + private ChatParticipantFixture chatParticipantFixture; + @Autowired private ChatMessageFixture chatMessageFixture; @@ -65,7 +70,8 @@ void setUp() { Board board = boardFixture.자유게시판(); post = postFixture.게시글(board, siteUser); ChatRoom chatRoom = chatRoomFixture.채팅방(false); - chatMessage = chatMessageFixture.메시지("채팅", siteUser.getId(), chatRoom); + ChatParticipant chatParticipant = chatParticipantFixture.참여자(siteUser.getId(), chatRoom); + chatMessage = chatMessageFixture.메시지("채팅", chatParticipant.getId(), chatRoom); } @Nested From adf4c9b1374e722bbfb88b3d614492db16afcab7 Mon Sep 17 00:00:00 2001 From: JAEHEE25 Date: Sat, 3 Jan 2026 16:39:37 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EC=9C=A0=EC=A0=80=20=EA=B4=80=EB=A6=AC=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Report, UserBan 컬럼 수정 --- .../admin/service/AdminUserBanService.java | 4 ++-- .../solidconnection/report/domain/Report.java | 6 +++++- .../report/repository/ReportRepository.java | 14 +------------ .../report/service/ReportService.java | 20 +++++++++---------- .../siteuser/domain/UserBan.java | 15 ++++++++++++-- .../migration/V40__create_user_ban_table.sql | 6 +++++- .../service/AdminUserBanServiceTest.java | 14 ++++++------- .../report/fixture/ReportFixture.java | 3 ++- .../report/fixture/ReportFixtureBuilder.java | 7 +++++++ .../report/service/ReportServiceTest.java | 6 ++++-- .../siteuser/fixture/SiteUserFixture.java | 12 +++++++++++ .../siteuser/fixture/UserBanFixture.java | 13 +++++------- .../fixture/UserBanFixtureBuilder.java | 11 ++++++++-- 13 files changed, 82 insertions(+), 49 deletions(-) diff --git a/src/main/java/com/example/solidconnection/admin/service/AdminUserBanService.java b/src/main/java/com/example/solidconnection/admin/service/AdminUserBanService.java index 321d4ad10..ed71ce3e9 100644 --- a/src/main/java/com/example/solidconnection/admin/service/AdminUserBanService.java +++ b/src/main/java/com/example/solidconnection/admin/service/AdminUserBanService.java @@ -52,7 +52,7 @@ private void validateNotAlreadyBanned(long userId, ZonedDateTime now) { } private void validateReportExists(long userId) { - if (!reportRepository.existsReportByUserId(userId)) { + if (!reportRepository.existsByReportedId(userId)) { throw new CustomException(ErrorCode.REPORT_NOT_FOUND); } } @@ -64,7 +64,7 @@ private void updateReportedContentIsDeleted(long userId, boolean isDeleted) { private void createUserBan(long userId, UserBanRequest request, ZonedDateTime now) { ZonedDateTime expiredAt = now.plusDays(request.duration().getDays()); - UserBan userBan = new UserBan(userId, expiredAt); + UserBan userBan = new UserBan(userId, request.duration(), expiredAt); userBanRepository.save(userBan); } diff --git a/src/main/java/com/example/solidconnection/report/domain/Report.java b/src/main/java/com/example/solidconnection/report/domain/Report.java index f6c17837b..d76d155f0 100644 --- a/src/main/java/com/example/solidconnection/report/domain/Report.java +++ b/src/main/java/com/example/solidconnection/report/domain/Report.java @@ -33,6 +33,9 @@ public class Report extends BaseEntity { @Column(name = "reporter_id") private long reporterId; + @Column(name = "reported_id") + private long reportedId; + @Column(name = "report_type") @Enumerated(value = EnumType.STRING) private ReportType reportType; @@ -44,9 +47,10 @@ public class Report extends BaseEntity { @Column(name = "target_id") private long targetId; - public Report(long reporterId, ReportType reportType, TargetType targetType, long targetId) { + public Report(long reporterId, long reportedId, ReportType reportType, TargetType targetType, long targetId) { this.reportType = reportType; this.reporterId = reporterId; + this.reportedId = reportedId; this.targetType = targetType; this.targetId = targetId; } diff --git a/src/main/java/com/example/solidconnection/report/repository/ReportRepository.java b/src/main/java/com/example/solidconnection/report/repository/ReportRepository.java index ed57617e9..85447a188 100644 --- a/src/main/java/com/example/solidconnection/report/repository/ReportRepository.java +++ b/src/main/java/com/example/solidconnection/report/repository/ReportRepository.java @@ -10,17 +10,5 @@ public interface ReportRepository extends JpaRepository { boolean existsByReporterIdAndTargetTypeAndTargetId(long reporterId, TargetType targetType, long targetId); - @Query(""" - SELECT CASE WHEN COUNT(r) > 0 THEN true ELSE false END - FROM Report r - WHERE (r.targetType = 'POST' AND EXISTS ( - SELECT 1 FROM Post p WHERE p.id = r.targetId AND p.siteUserId = :userId - )) - OR (r.targetType = 'CHAT' AND EXISTS ( - SELECT 1 FROM ChatMessage cm - JOIN ChatParticipant cp ON cp.id = cm.senderId - WHERE cm.id = r.targetId AND cp.siteUserId = :userId - )) - """) - boolean existsReportByUserId(@Param("userId") long userId); + boolean existsByReportedId(long reportedId); } diff --git a/src/main/java/com/example/solidconnection/report/service/ReportService.java b/src/main/java/com/example/solidconnection/report/service/ReportService.java index 5288ecbfc..9cfa1e389 100644 --- a/src/main/java/com/example/solidconnection/report/service/ReportService.java +++ b/src/main/java/com/example/solidconnection/report/service/ReportService.java @@ -31,19 +31,24 @@ public class ReportService { @Transactional public void createReport(long reporterId, ReportRequest request) { - validateReporterExists(reporterId); + long reportedId = findReportedId(request.targetType(), request.targetId()); + validateReporterAndReportedExists(reporterId, reportedId); validateTargetExists(request.targetType(), request.targetId()); validateFirstReportByUser(reporterId, request.targetType(), request.targetId()); - updateReportedUserStatus(request.targetType(), request.targetId()); + updateUserStatusToReported(reportedId); - Report report = new Report(reporterId, request.reportType(), request.targetType(), request.targetId()); + Report report = new Report(reporterId, reportedId, request.reportType(), request.targetType(), request.targetId()); reportRepository.save(report); } - private void validateReporterExists(long reporterId) { + private void validateReporterAndReportedExists(long reporterId, long reportedId) { if (!siteUserRepository.existsById(reporterId)) { throw new CustomException(ErrorCode.USER_NOT_FOUND); } + + if (!siteUserRepository.existsById(reportedId)) { + throw new CustomException(ErrorCode.USER_NOT_FOUND); + } } private void validateTargetExists(TargetType targetType, long targetId) { @@ -63,12 +68,7 @@ private void validateFirstReportByUser(long reporterId, TargetType targetType, l } } - private void updateReportedUserStatus(TargetType targetType, long targetId) { - long targetUserId = findTargetUserId(targetType, targetId); - updateUserStatusToReported(targetUserId); - } - - private long findTargetUserId(TargetType targetType, long targetId) { + private long findReportedId(TargetType targetType, long targetId) { return switch (targetType) { case POST -> findPostAuthorId(targetId); case CHAT -> findChatMessageSenderId(targetId); diff --git a/src/main/java/com/example/solidconnection/siteuser/domain/UserBan.java b/src/main/java/com/example/solidconnection/siteuser/domain/UserBan.java index b189b2c63..0fa3df624 100644 --- a/src/main/java/com/example/solidconnection/siteuser/domain/UserBan.java +++ b/src/main/java/com/example/solidconnection/siteuser/domain/UserBan.java @@ -4,7 +4,13 @@ import com.example.solidconnection.common.BaseEntity; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; @@ -23,6 +29,10 @@ public class UserBan extends BaseEntity { @Column(name = "banned_user_id", nullable = false) private Long bannedUserId; + @Column(name = "duration", nullable = false) + @Enumerated(EnumType.STRING) + private UserBanDuration duration; + @Column(name = "expired_at", nullable = false) private ZonedDateTime expiredAt; @@ -35,8 +45,9 @@ public class UserBan extends BaseEntity { @Column(name = "unbanned_at") private ZonedDateTime unbannedAt; - public UserBan(Long bannedUserId, ZonedDateTime expiredAt) { + public UserBan(Long bannedUserId, UserBanDuration duration, ZonedDateTime expiredAt) { this.bannedUserId = bannedUserId; + this.duration = duration; this.expiredAt = expiredAt; } diff --git a/src/main/resources/db/migration/V40__create_user_ban_table.sql b/src/main/resources/db/migration/V40__create_user_ban_table.sql index fd3b2ddcc..058cc726d 100644 --- a/src/main/resources/db/migration/V40__create_user_ban_table.sql +++ b/src/main/resources/db/migration/V40__create_user_ban_table.sql @@ -2,6 +2,7 @@ CREATE TABLE user_ban ( id BIGINT NOT NULL AUTO_INCREMENT, banned_user_id BIGINT NOT NULL, + duration VARCHAR(30) NOT NULL, expired_at DATETIME(6) NOT NULL, is_unbanned TINYINT(1) NOT NULL DEFAULT 0, unbanned_by BIGINT NULL, @@ -14,4 +15,7 @@ CREATE TABLE user_ban ); ALTER TABLE site_user - ADD COLUMN user_status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE'; + ADD COLUMN user_status VARCHAR(10) NOT NULL DEFAULT 'ACTIVE'; + +ALTER TABLE report + ADD COLUMN reported_id BIGINT; diff --git a/src/test/java/com/example/solidconnection/admin/service/AdminUserBanServiceTest.java b/src/test/java/com/example/solidconnection/admin/service/AdminUserBanServiceTest.java index e535bdf42..896ff72c2 100644 --- a/src/test/java/com/example/solidconnection/admin/service/AdminUserBanServiceTest.java +++ b/src/test/java/com/example/solidconnection/admin/service/AdminUserBanServiceTest.java @@ -68,7 +68,7 @@ class AdminUserBanServiceTest { @BeforeEach void setUp() { admin = siteUserFixture.관리자(); - reportedUser = siteUserFixture.사용자(1, "신고당한유저"); + reportedUser = siteUserFixture.신고된_사용자("신고된사용자"); reporter = siteUserFixture.사용자(2, "신고자"); reportedPost = postFixture.게시글( "신고될 게시글", @@ -87,7 +87,7 @@ class 사용자_차단 { @Test void 사용자를_차단한다() { // given - reportFixture.신고(reporter.getId(), TargetType.POST, reportedPost.getId()); + reportFixture.신고(reporter.getId(), reportedUser.getId(), TargetType.POST, reportedPost.getId()); UserBanRequest request = new UserBanRequest(UserBanDuration.SEVEN_DAYS); // when @@ -101,7 +101,7 @@ class 사용자_차단 { @Test void 이미_차단된_사용자는_다시_차단할_수_없다() { // given - reportFixture.신고(reporter.getId(), TargetType.POST, reportedPost.getId()); + reportFixture.신고(reporter.getId(), reportedUser.getId(), TargetType.POST, reportedPost.getId()); UserBanRequest request = new UserBanRequest(UserBanDuration.SEVEN_DAYS); adminUserBanService.banUser(reportedUser.getId(), request); @@ -129,7 +129,7 @@ class 사용자_차단_해제 { @Test void 차단된_사용자를_수동으로_해제한다() { // given - reportFixture.신고(reporter.getId(), TargetType.POST, reportedPost.getId()); + reportFixture.신고(reporter.getId(), reportedUser.getId(), TargetType.POST, reportedPost.getId()); UserBanRequest request = new UserBanRequest(UserBanDuration.SEVEN_DAYS); adminUserBanService.banUser(reportedUser.getId(), request); @@ -144,7 +144,7 @@ class 사용자_차단_해제 { @Test void 차단_해제_정보가_올바르게_저장된다() { // given - reportFixture.신고(reporter.getId(), TargetType.POST, reportedPost.getId()); + reportFixture.신고(reporter.getId(), reportedUser.getId(), TargetType.POST, reportedPost.getId()); UserBanRequest request = new UserBanRequest(UserBanDuration.SEVEN_DAYS); adminUserBanService.banUser(reportedUser.getId(), request); ZonedDateTime beforeUnban = ZonedDateTime.now(); @@ -229,7 +229,7 @@ class 만료된_차단_자동_해제 { boardFixture.자유게시판(), reportedUser ); - reportFixture.신고(reporter.getId(), TargetType.POST, reportedPost.getId()); + reportFixture.신고(reporter.getId(), reportedUser.getId(), TargetType.POST, reportedPost.getId()); adminUserBanService.banUser(reportedUser.getId(), new UserBanRequest(UserBanDuration.SEVEN_DAYS)); // when @@ -261,4 +261,4 @@ class 만료된_차단_자동_해제 { } } } -} \ No newline at end of file +} diff --git a/src/test/java/com/example/solidconnection/report/fixture/ReportFixture.java b/src/test/java/com/example/solidconnection/report/fixture/ReportFixture.java index 91c837bf3..67a95e0e4 100644 --- a/src/test/java/com/example/solidconnection/report/fixture/ReportFixture.java +++ b/src/test/java/com/example/solidconnection/report/fixture/ReportFixture.java @@ -11,9 +11,10 @@ public class ReportFixture { private final ReportFixtureBuilder reportFixtureBuilder; - public Report 신고(long reporterId, TargetType targetType, long targetId) { + public Report 신고(long reporterId, long reportedId, TargetType targetType, long targetId) { return reportFixtureBuilder.report() .reporterId(reporterId) + .reportedId(reportedId) .targetType(targetType) .targetId(targetId) .create(); diff --git a/src/test/java/com/example/solidconnection/report/fixture/ReportFixtureBuilder.java b/src/test/java/com/example/solidconnection/report/fixture/ReportFixtureBuilder.java index 08d0b276c..0c7705dcf 100644 --- a/src/test/java/com/example/solidconnection/report/fixture/ReportFixtureBuilder.java +++ b/src/test/java/com/example/solidconnection/report/fixture/ReportFixtureBuilder.java @@ -14,6 +14,7 @@ public class ReportFixtureBuilder { private final ReportRepository reportRepository; private long reporterId; + private long reportedId; private TargetType targetType; private long targetId; private ReportType reportType = ReportType.ADVERTISEMENT; @@ -27,6 +28,11 @@ public ReportFixtureBuilder reporterId(long reporterId) { return this; } + public ReportFixtureBuilder reportedId(long reportedId) { + this.reportedId = reportedId; + return this; + } + public ReportFixtureBuilder targetType(TargetType targetType) { this.targetType = targetType; return this; @@ -45,6 +51,7 @@ public ReportFixtureBuilder reasonType(ReportType reportType) { public Report create() { Report report = new Report( reporterId, + reportedId, reportType, targetType, targetId diff --git a/src/test/java/com/example/solidconnection/report/service/ReportServiceTest.java b/src/test/java/com/example/solidconnection/report/service/ReportServiceTest.java index 5dad25850..4a463ba35 100644 --- a/src/test/java/com/example/solidconnection/report/service/ReportServiceTest.java +++ b/src/test/java/com/example/solidconnection/report/service/ReportServiceTest.java @@ -61,12 +61,14 @@ class ReportServiceTest { private ChatMessageFixture chatMessageFixture; private SiteUser siteUser; + private SiteUser reportedUser; private Post post; private ChatMessage chatMessage; @BeforeEach void setUp() { siteUser = siteUserFixture.사용자(); + reportedUser = siteUserFixture.신고된_사용자("신고된사용자"); Board board = boardFixture.자유게시판(); post = postFixture.게시글(board, siteUser); ChatRoom chatRoom = chatRoomFixture.채팅방(false); @@ -106,7 +108,7 @@ class 포스트_신고 { @Test void 이미_신고한_경우_예외가_발생한다() { // given - reportFixture.신고(siteUser.getId(), TargetType.POST, post.getId()); + reportFixture.신고(siteUser.getId(), reportedUser.getId(), TargetType.POST, post.getId()); ReportRequest request = new ReportRequest(ReportType.INSULT, TargetType.POST, post.getId()); // when & then @@ -148,7 +150,7 @@ class 채팅_신고 { @Test void 이미_신고한_경우_예외가_발생한다() { // given - reportFixture.신고(siteUser.getId(), TargetType.CHAT, chatMessage.getId()); + reportFixture.신고(siteUser.getId(), reportedUser.getId(), TargetType.CHAT, chatMessage.getId()); ReportRequest request = new ReportRequest(ReportType.INSULT, TargetType.CHAT, chatMessage.getId()); // when & then 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 599c20b27..cdf48a024 100644 --- a/src/test/java/com/example/solidconnection/siteuser/fixture/SiteUserFixture.java +++ b/src/test/java/com/example/solidconnection/siteuser/fixture/SiteUserFixture.java @@ -85,6 +85,18 @@ public class SiteUserFixture { .create(); } + public SiteUser 신고된_사용자(String nickname) { + return siteUserFixtureBuilder.siteUser() + .email("reported@example.com") + .authType(AuthType.EMAIL) + .nickname(nickname) + .profileImageUrl("profileImageUrl") + .role(Role.MENTEE) + .password("reported123") + .userStatus(UserStatus.REPORTED) + .create(); + } + public SiteUser 차단된_사용자(String nickname) { return siteUserFixtureBuilder.siteUser() .email("banned@example.com") diff --git a/src/test/java/com/example/solidconnection/siteuser/fixture/UserBanFixture.java b/src/test/java/com/example/solidconnection/siteuser/fixture/UserBanFixture.java index d13773ecb..1de458d7d 100644 --- a/src/test/java/com/example/solidconnection/siteuser/fixture/UserBanFixture.java +++ b/src/test/java/com/example/solidconnection/siteuser/fixture/UserBanFixture.java @@ -1,6 +1,8 @@ package com.example.solidconnection.siteuser.fixture; import com.example.solidconnection.siteuser.domain.UserBan; +import com.example.solidconnection.siteuser.domain.UserBanDuration; + import java.time.ZonedDateTime; import lombok.RequiredArgsConstructor; import org.springframework.boot.test.context.TestComponent; @@ -11,16 +13,10 @@ public class UserBanFixture { private final UserBanFixtureBuilder userBanFixtureBuilder; - public UserBan 차단(long bannedUserId, int days) { - return userBanFixtureBuilder.userBan() - .bannedUserId(bannedUserId) - .expiredAt(ZonedDateTime.now().plusDays(days)) - .create(); - } - public UserBan 만료된_차단(long bannedUserId) { return userBanFixtureBuilder.userBan() .bannedUserId(bannedUserId) + .duration(UserBanDuration.ONE_DAY) .expiredAt(ZonedDateTime.now().minusDays(1)) .create(); } @@ -28,9 +24,10 @@ public class UserBanFixture { public UserBan 수동_차단_해제(long bannedUserId, long adminId) { UserBan userBan = userBanFixtureBuilder.userBan() .bannedUserId(bannedUserId) + .duration(UserBanDuration.SEVEN_DAYS) .expiredAt(ZonedDateTime.now().plusDays(7)) .create(); userBan.manuallyUnban(adminId); return userBan; } -} \ No newline at end of file +} diff --git a/src/test/java/com/example/solidconnection/siteuser/fixture/UserBanFixtureBuilder.java b/src/test/java/com/example/solidconnection/siteuser/fixture/UserBanFixtureBuilder.java index 83175b133..ee08f401e 100644 --- a/src/test/java/com/example/solidconnection/siteuser/fixture/UserBanFixtureBuilder.java +++ b/src/test/java/com/example/solidconnection/siteuser/fixture/UserBanFixtureBuilder.java @@ -1,6 +1,7 @@ package com.example.solidconnection.siteuser.fixture; import com.example.solidconnection.siteuser.domain.UserBan; +import com.example.solidconnection.siteuser.domain.UserBanDuration; import com.example.solidconnection.siteuser.repository.UserBanRepository; import java.time.ZonedDateTime; import lombok.RequiredArgsConstructor; @@ -13,6 +14,7 @@ public class UserBanFixtureBuilder { private final UserBanRepository userBanRepository; private Long bannedUserId; + private UserBanDuration duration; private ZonedDateTime expiredAt; public UserBanFixtureBuilder userBan() { @@ -24,13 +26,18 @@ public UserBanFixtureBuilder bannedUserId(Long bannedUserId) { return this; } + public UserBanFixtureBuilder duration(UserBanDuration duration) { + this.duration = duration; + return this; + } + public UserBanFixtureBuilder expiredAt(ZonedDateTime expiredAt) { this.expiredAt = expiredAt; return this; } public UserBan create() { - UserBan userBan = new UserBan(bannedUserId, expiredAt); + UserBan userBan = new UserBan(bannedUserId, duration, expiredAt); return userBanRepository.save(userBan); } -} \ No newline at end of file +} From c5fe54e057da68636ad523785412d71517fc7c80 Mon Sep 17 00:00:00 2001 From: JAEHEE25 Date: Fri, 9 Jan 2026 17:36:31 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EC=9C=A0=EC=A0=80=20=EA=B4=80=EB=A6=AC=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 코드리뷰 반영 --- .../controller/AdminUserBanController.java | 15 +- .../admin/dto/UserBanRequest.java | 1 - .../admin/service/AdminUserBanService.java | 68 +++-- .../repository/ChatMessageRepository.java | 8 + .../interceptor/BannedUserInterceptor.java | 2 +- .../post/repository/PostRepository.java | 8 + .../report/repository/ReportRepository.java | 2 - .../siteuser/domain/UserBan.java | 20 +- .../repository/SiteUserRepository.java | 6 + .../repository/UserBanRepository.java | 17 +- .../migration/V40__create_user_ban_table.sql | 12 +- .../service/AdminUserBanServiceTest.java | 280 +++++++++--------- .../BannedUserInterceptorTest.java | 2 + .../siteuser/fixture/UserBanFixture.java | 4 + .../fixture/UserBanFixtureBuilder.java | 8 +- 15 files changed, 251 insertions(+), 202 deletions(-) diff --git a/src/main/java/com/example/solidconnection/admin/controller/AdminUserBanController.java b/src/main/java/com/example/solidconnection/admin/controller/AdminUserBanController.java index 72f6c02a0..f0a699b13 100644 --- a/src/main/java/com/example/solidconnection/admin/controller/AdminUserBanController.java +++ b/src/main/java/com/example/solidconnection/admin/controller/AdminUserBanController.java @@ -14,28 +14,27 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; @RequiredArgsConstructor @RequestMapping("/admin/users") @RestController -@Slf4j public class AdminUserBanController { private final AdminUserBanService adminUserBanService; - @PostMapping("/{userId}/ban") + @PostMapping("/{user-id}/ban") public ResponseEntity banUser( - @PathVariable long userId, - @Valid @RequestBody UserBanRequest request + @AuthorizedUser long adminId, + @PathVariable(name = "user-id") long userId, + @Valid @RequestBody UserBanRequest request ) { - adminUserBanService.banUser(userId, request); + adminUserBanService.banUser(userId, adminId, request); return ResponseEntity.ok().build(); } - @PatchMapping("/{userId}/unban") + @PatchMapping("/{user-id}/unban") public ResponseEntity unbanUser( @AuthorizedUser long adminId, - @PathVariable long userId + @PathVariable(name = "user-id") long userId ) { adminUserBanService.unbanUser(userId, adminId); return ResponseEntity.ok().build(); diff --git a/src/main/java/com/example/solidconnection/admin/dto/UserBanRequest.java b/src/main/java/com/example/solidconnection/admin/dto/UserBanRequest.java index e96e9cf04..eaf57df20 100644 --- a/src/main/java/com/example/solidconnection/admin/dto/UserBanRequest.java +++ b/src/main/java/com/example/solidconnection/admin/dto/UserBanRequest.java @@ -1,6 +1,5 @@ package com.example.solidconnection.admin.dto; - import com.example.solidconnection.siteuser.domain.UserBanDuration; import jakarta.validation.constraints.NotNull; diff --git a/src/main/java/com/example/solidconnection/admin/service/AdminUserBanService.java b/src/main/java/com/example/solidconnection/admin/service/AdminUserBanService.java index ed71ce3e9..1f775acc8 100644 --- a/src/main/java/com/example/solidconnection/admin/service/AdminUserBanService.java +++ b/src/main/java/com/example/solidconnection/admin/service/AdminUserBanService.java @@ -1,5 +1,7 @@ package com.example.solidconnection.admin.service; +import static java.time.ZoneOffset.UTC; + import com.example.solidconnection.admin.dto.UserBanRequest; import com.example.solidconnection.chat.repository.ChatMessageRepository; import com.example.solidconnection.common.exception.CustomException; @@ -11,14 +13,10 @@ import com.example.solidconnection.siteuser.domain.UserStatus; import com.example.solidconnection.siteuser.repository.SiteUserRepository; import com.example.solidconnection.siteuser.repository.UserBanRepository; -import static java.time.ZoneOffset.UTC; - import java.time.ZonedDateTime; import java.util.List; - import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; - import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -35,18 +33,19 @@ public class AdminUserBanService { private final ChatMessageRepository chatMessageRepository; @Transactional - public void banUser(long userId, UserBanRequest request) { - ZonedDateTime now = ZonedDateTime.now(UTC); - validateNotAlreadyBanned(userId, now); + public void banUser(long userId, long adminId, UserBanRequest request) { + SiteUser user = siteUserRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + validateNotAlreadyBanned(userId); validateReportExists(userId); + user.updateUserStatus(UserStatus.BANNED); updateReportedContentIsDeleted(userId, true); - createUserBan(userId, request, now); - updateUserStatus(userId, UserStatus.BANNED); + createUserBan(userId, adminId, request); } - private void validateNotAlreadyBanned(long userId, ZonedDateTime now) { - if (userBanRepository.existsByBannedUserIdAndIsUnbannedFalseAndExpiredAtAfter(userId, now)) { + private void validateNotAlreadyBanned(long userId) { + if (userBanRepository.existsByBannedUserIdAndIsExpiredFalseAndExpiredAtAfter(userId, ZonedDateTime.now(UTC))) { throw new CustomException(ErrorCode.ALREADY_BANNED_USER); } } @@ -62,42 +61,53 @@ private void updateReportedContentIsDeleted(long userId, boolean isDeleted) { chatMessageRepository.updateReportedChatMessagesIsDeleted(userId, isDeleted); } - private void createUserBan(long userId, UserBanRequest request, ZonedDateTime now) { + private void createUserBan(long userId, long adminId, UserBanRequest request) { + ZonedDateTime now = ZonedDateTime.now(UTC); ZonedDateTime expiredAt = now.plusDays(request.duration().getDays()); - UserBan userBan = new UserBan(userId, request.duration(), expiredAt); + UserBan userBan = new UserBan(userId, adminId, request.duration(), expiredAt); userBanRepository.save(userBan); } - private void updateUserStatus(long userId, UserStatus status) { - SiteUser user = siteUserRepository.findById(userId) - .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - user.updateUserStatus(status); - } - @Transactional public void unbanUser(long userId, long adminId) { - UserBan userBan = findBannedUser(userId, ZonedDateTime.now(UTC)); + SiteUser user = siteUserRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + UserBan userBan = findActiveBan(userId); userBan.manuallyUnban(adminId); - processUnban(userId); + + user.updateUserStatus(UserStatus.REPORTED); + updateReportedContentIsDeleted(userId, false); } - private UserBan findBannedUser(long userId, ZonedDateTime now) { + private UserBan findActiveBan(long userId) { return userBanRepository - .findTopByBannedUserIdAndIsUnbannedFalseAndExpiredAtAfterOrderByCreatedAtDesc(userId, now) + .findByBannedUserIdAndIsExpiredFalseAndExpiredAtAfter(userId, ZonedDateTime.now(UTC)) .orElseThrow(() -> new CustomException(ErrorCode.NOT_BANNED_USER)); } @Transactional @Scheduled(cron = "0 0 0 * * *") public void expireUserBans() { - List expiredBans = userBanRepository.findAllByIsUnbannedFalseAndExpiredAtBefore(ZonedDateTime.now(UTC)); - for (UserBan userBan : expiredBans) { - processUnban(userBan.getBannedUserId()); + try { + ZonedDateTime now = ZonedDateTime.now(UTC); + List expiredUserIds = userBanRepository.findExpiredBannedUserIds(now); + + if (expiredUserIds.isEmpty()) { + return; + } + + userBanRepository.bulkExpireUserBans(now); + siteUserRepository.bulkUpdateUserStatus(expiredUserIds, UserStatus.REPORTED); + bulkUpdateReportedContentIsDeleted(expiredUserIds); + log.info("Finished processing expired blocks:: userIds={}", expiredUserIds); + } catch (Exception e) { + log.error("Failed to process expired blocks", e); } } - private void processUnban(long userId) { - updateReportedContentIsDeleted(userId, false); - updateUserStatus(userId, UserStatus.REPORTED); + private void bulkUpdateReportedContentIsDeleted(List expiredUserIds) { + postRepository.bulkUpdateReportedPostsIsDeleted(expiredUserIds, false); + chatMessageRepository.bulkUpdateReportedChatMessagesIsDeleted(expiredUserIds, false); } + } diff --git a/src/main/java/com/example/solidconnection/chat/repository/ChatMessageRepository.java b/src/main/java/com/example/solidconnection/chat/repository/ChatMessageRepository.java index cbd0dd874..ae81a3341 100644 --- a/src/main/java/com/example/solidconnection/chat/repository/ChatMessageRepository.java +++ b/src/main/java/com/example/solidconnection/chat/repository/ChatMessageRepository.java @@ -57,4 +57,12 @@ WHERE cm.id IN (SELECT r.target_id FROM report r WHERE r.target_type = 'CHAT') AND cm.sender_id IN (SELECT cp.id FROM chat_participant cp WHERE cp.site_user_id = :siteUserId) """, nativeQuery = true) void updateReportedChatMessagesIsDeleted(@Param("siteUserId") long siteUserId, @Param("isDeleted") boolean isDeleted); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(value = """ + UPDATE chat_message cm SET cm.is_deleted = :isDeleted + WHERE cm.id IN (SELECT r.target_id FROM report r WHERE r.target_type = 'CHAT') + AND cm.sender_id IN (SELECT cp.id FROM chat_participant cp WHERE cp.site_user_id IN :siteUserIds) + """, nativeQuery = true) + void bulkUpdateReportedChatMessagesIsDeleted(@Param("siteUserIds") List siteUserIds, @Param("isDeleted") boolean isDeleted); } diff --git a/src/main/java/com/example/solidconnection/common/interceptor/BannedUserInterceptor.java b/src/main/java/com/example/solidconnection/common/interceptor/BannedUserInterceptor.java index 67b0925b4..de4d673fd 100644 --- a/src/main/java/com/example/solidconnection/common/interceptor/BannedUserInterceptor.java +++ b/src/main/java/com/example/solidconnection/common/interceptor/BannedUserInterceptor.java @@ -34,4 +34,4 @@ public boolean preHandle( } return true; } -} \ No newline at end of file +} diff --git a/src/main/java/com/example/solidconnection/community/post/repository/PostRepository.java b/src/main/java/com/example/solidconnection/community/post/repository/PostRepository.java index 366f8572b..c2601b101 100644 --- a/src/main/java/com/example/solidconnection/community/post/repository/PostRepository.java +++ b/src/main/java/com/example/solidconnection/community/post/repository/PostRepository.java @@ -58,6 +58,14 @@ AND p.id IN (SELECT r.target_id FROM report r WHERE r.target_type = 'POST') """, nativeQuery = true) void updateReportedPostsIsDeleted(@Param("siteUserId") long siteUserId, @Param("isDeleted") boolean isDeleted); + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(value = """ + UPDATE post p SET p.is_deleted = :isDeleted + WHERE p.site_user_id IN :siteUserIds + AND p.id IN (SELECT r.target_id FROM report r WHERE r.target_type = 'POST') + """, nativeQuery = true) + void bulkUpdateReportedPostsIsDeleted(@Param("siteUserIds") List siteUserIds, @Param("isDeleted") boolean isDeleted); + default Post getByIdUsingEntityGraph(Long id) { return findPostById(id) .orElseThrow(() -> new CustomException(INVALID_POST_ID)); diff --git a/src/main/java/com/example/solidconnection/report/repository/ReportRepository.java b/src/main/java/com/example/solidconnection/report/repository/ReportRepository.java index 85447a188..81319f3bc 100644 --- a/src/main/java/com/example/solidconnection/report/repository/ReportRepository.java +++ b/src/main/java/com/example/solidconnection/report/repository/ReportRepository.java @@ -3,8 +3,6 @@ import com.example.solidconnection.report.domain.Report; import com.example.solidconnection.report.domain.TargetType; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; public interface ReportRepository extends JpaRepository { diff --git a/src/main/java/com/example/solidconnection/siteuser/domain/UserBan.java b/src/main/java/com/example/solidconnection/siteuser/domain/UserBan.java index 0fa3df624..8dab3ea8a 100644 --- a/src/main/java/com/example/solidconnection/siteuser/domain/UserBan.java +++ b/src/main/java/com/example/solidconnection/siteuser/domain/UserBan.java @@ -1,9 +1,9 @@ package com.example.solidconnection.siteuser.domain; -import java.time.ZonedDateTime; +import static java.time.ZoneOffset.UTC; +import java.time.ZonedDateTime; import com.example.solidconnection.common.BaseEntity; - import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -12,14 +12,12 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import lombok.AccessLevel; -import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity -@AllArgsConstructor public class UserBan extends BaseEntity { @Id @@ -29,6 +27,9 @@ public class UserBan extends BaseEntity { @Column(name = "banned_user_id", nullable = false) private Long bannedUserId; + @Column(name = "banned_by", nullable = false) + private Long bannedBy; + @Column(name = "duration", nullable = false) @Enumerated(EnumType.STRING) private UserBanDuration duration; @@ -36,8 +37,8 @@ public class UserBan extends BaseEntity { @Column(name = "expired_at", nullable = false) private ZonedDateTime expiredAt; - @Column(name = "is_unbanned", nullable = false) - private boolean isUnbanned = false; + @Column(name = "is_expired", nullable = false) + private boolean isExpired = false; @Column(name = "unbanned_by") private Long unbannedBy; @@ -45,15 +46,16 @@ public class UserBan extends BaseEntity { @Column(name = "unbanned_at") private ZonedDateTime unbannedAt; - public UserBan(Long bannedUserId, UserBanDuration duration, ZonedDateTime expiredAt) { + public UserBan(Long bannedUserId, Long bannedBy, UserBanDuration duration, ZonedDateTime expiredAt) { this.bannedUserId = bannedUserId; + this.bannedBy = bannedBy; this.duration = duration; this.expiredAt = expiredAt; } public void manuallyUnban(Long adminId) { - this.isUnbanned = true; + this.isExpired = true; this.unbannedBy = adminId; - this.unbannedAt = ZonedDateTime.now(); + this.unbannedAt = ZonedDateTime.now(UTC); } } diff --git a/src/main/java/com/example/solidconnection/siteuser/repository/SiteUserRepository.java b/src/main/java/com/example/solidconnection/siteuser/repository/SiteUserRepository.java index 73422ba9f..123c1ab2b 100644 --- a/src/main/java/com/example/solidconnection/siteuser/repository/SiteUserRepository.java +++ b/src/main/java/com/example/solidconnection/siteuser/repository/SiteUserRepository.java @@ -2,10 +2,12 @@ import com.example.solidconnection.siteuser.domain.AuthType; import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.domain.UserStatus; import java.time.LocalDate; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -21,4 +23,8 @@ public interface SiteUserRepository extends JpaRepository { List findUsersToBeRemoved(@Param("cutoffDate") LocalDate cutoffDate); List findAllByIdIn(List ids); + + @Modifying + @Query("UPDATE SiteUser u SET u.userStatus = :status WHERE u.id IN :userIds") + void bulkUpdateUserStatus(@Param("userIds") List userIds, @Param("status") UserStatus status); } diff --git a/src/main/java/com/example/solidconnection/siteuser/repository/UserBanRepository.java b/src/main/java/com/example/solidconnection/siteuser/repository/UserBanRepository.java index bbd4542c6..b897d29cf 100644 --- a/src/main/java/com/example/solidconnection/siteuser/repository/UserBanRepository.java +++ b/src/main/java/com/example/solidconnection/siteuser/repository/UserBanRepository.java @@ -1,17 +1,24 @@ package com.example.solidconnection.siteuser.repository; +import com.example.solidconnection.siteuser.domain.UserBan; import java.time.ZonedDateTime; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; - -import com.example.solidconnection.siteuser.domain.UserBan; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface UserBanRepository extends JpaRepository { - boolean existsByBannedUserIdAndIsUnbannedFalseAndExpiredAtAfter(long bannedUserId, ZonedDateTime current); + boolean existsByBannedUserIdAndIsExpiredFalseAndExpiredAtAfter(long bannedUserId, ZonedDateTime now); + + Optional findByBannedUserIdAndIsExpiredFalseAndExpiredAtAfter(long bannedUserId, ZonedDateTime now); - List findAllByIsUnbannedFalseAndExpiredAtBefore(ZonedDateTime current); + @Query("SELECT ub.bannedUserId FROM UserBan ub WHERE ub.isExpired = false AND ub.expiredAt < :current") + List findExpiredBannedUserIds(@Param("current") ZonedDateTime current); - Optional findTopByBannedUserIdAndIsUnbannedFalseAndExpiredAtAfterOrderByCreatedAtDesc(long bannedUserId, ZonedDateTime current); + @Modifying + @Query("UPDATE UserBan ub SET ub.isExpired = true WHERE ub.isExpired = false AND ub.expiredAt < :current") + void bulkExpireUserBans(@Param("current") ZonedDateTime current); } diff --git a/src/main/resources/db/migration/V40__create_user_ban_table.sql b/src/main/resources/db/migration/V40__create_user_ban_table.sql index 058cc726d..4a695fe62 100644 --- a/src/main/resources/db/migration/V40__create_user_ban_table.sql +++ b/src/main/resources/db/migration/V40__create_user_ban_table.sql @@ -2,15 +2,17 @@ CREATE TABLE user_ban ( id BIGINT NOT NULL AUTO_INCREMENT, banned_user_id BIGINT NOT NULL, + banned_by BIGINT NOT NULL, duration VARCHAR(30) NOT NULL, - expired_at DATETIME(6) NOT NULL, - is_unbanned TINYINT(1) NOT NULL DEFAULT 0, + expired_at DATETIME(6) NOT NULL, + is_expired TINYINT(1) NOT NULL DEFAULT 0, unbanned_by BIGINT NULL, - unbanned_at DATETIME(6) NULL, - created_at DATETIME(6) NOT NULL, - updated_at DATETIME(6) NOT NULL, + unbanned_at DATETIME(6) NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, PRIMARY KEY (id), CONSTRAINT fk_user_ban_banned_user_id FOREIGN KEY (banned_user_id) REFERENCES site_user (id), + CONSTRAINT fk_user_ban_banned_by_id FOREIGN KEY (banned_by) REFERENCES site_user (id), CONSTRAINT fk_user_ban_unbanned_by_id FOREIGN KEY (unbanned_by) REFERENCES site_user (id) ); diff --git a/src/test/java/com/example/solidconnection/admin/service/AdminUserBanServiceTest.java b/src/test/java/com/example/solidconnection/admin/service/AdminUserBanServiceTest.java index 896ff72c2..60808ca3e 100644 --- a/src/test/java/com/example/solidconnection/admin/service/AdminUserBanServiceTest.java +++ b/src/test/java/com/example/solidconnection/admin/service/AdminUserBanServiceTest.java @@ -81,7 +81,6 @@ void setUp() { } @Nested - @DisplayName("사용자 차단") class 사용자_차단 { @Test @@ -91,7 +90,7 @@ class 사용자_차단 { UserBanRequest request = new UserBanRequest(UserBanDuration.SEVEN_DAYS); // when - adminUserBanService.banUser(reportedUser.getId(), request); + adminUserBanService.banUser(reportedUser.getId(), admin.getId(), request); // then SiteUser bannedUser = siteUserRepository.findById(reportedUser.getId()).orElseThrow(); @@ -99,166 +98,165 @@ class 사용자_차단 { } @Test - void 이미_차단된_사용자는_다시_차단할_수_없다() { + void 이미_차단된_사용자일_경우_예외가_발생한다() { // given reportFixture.신고(reporter.getId(), reportedUser.getId(), TargetType.POST, reportedPost.getId()); UserBanRequest request = new UserBanRequest(UserBanDuration.SEVEN_DAYS); - adminUserBanService.banUser(reportedUser.getId(), request); + adminUserBanService.banUser(reportedUser.getId(), admin.getId(), request); // when & then - assertThatCode(() -> adminUserBanService.banUser(reportedUser.getId(), request)) + assertThatCode(() -> adminUserBanService.banUser(reportedUser.getId(), admin.getId(), request)) .isInstanceOf(CustomException.class) .hasMessage(ALREADY_BANNED_USER.getMessage()); } @Test - void 신고가_없는_사용자는_차단할_수_없다() { + void 신고가_없는_사용자일_경우_예외가_발생한다() { // given SiteUser userWithoutReport = siteUserFixture.사용자(3, "신고없는유저"); UserBanRequest request = new UserBanRequest(UserBanDuration.SEVEN_DAYS); // when & then - assertThatCode(() -> adminUserBanService.banUser(userWithoutReport.getId(), request)) + assertThatCode(() -> adminUserBanService.banUser(userWithoutReport.getId(), admin.getId(), request)) .isInstanceOf(CustomException.class) .hasMessage(REPORT_NOT_FOUND.getMessage()); } + } + + @Nested + class 사용자_차단_해제 { - @Nested - class 사용자_차단_해제 { - - @Test - void 차단된_사용자를_수동으로_해제한다() { - // given - reportFixture.신고(reporter.getId(), reportedUser.getId(), TargetType.POST, reportedPost.getId()); - UserBanRequest request = new UserBanRequest(UserBanDuration.SEVEN_DAYS); - adminUserBanService.banUser(reportedUser.getId(), request); - - // when - adminUserBanService.unbanUser(reportedUser.getId(), admin.getId()); - - // then - SiteUser unbannedUser = siteUserRepository.findById(reportedUser.getId()).orElseThrow(); - assertThat(unbannedUser.getUserStatus()).isEqualTo(UserStatus.REPORTED); - } - - @Test - void 차단_해제_정보가_올바르게_저장된다() { - // given - reportFixture.신고(reporter.getId(), reportedUser.getId(), TargetType.POST, reportedPost.getId()); - UserBanRequest request = new UserBanRequest(UserBanDuration.SEVEN_DAYS); - adminUserBanService.banUser(reportedUser.getId(), request); - ZonedDateTime beforeUnban = ZonedDateTime.now(); - - // when - adminUserBanService.unbanUser(reportedUser.getId(), admin.getId()); - - // then - List allBans = userBanRepository.findAll(); - UserBan unbannedUserBan = allBans.stream() - .filter(ban -> ban.getBannedUserId().equals(reportedUser.getId())) - .findFirst() - .orElseThrow(); - - assertAll( - () -> assertThat(unbannedUserBan.isUnbanned()).isTrue(), - () -> assertThat(unbannedUserBan.getUnbannedBy()).isEqualTo(admin.getId()), - () -> assertThat(unbannedUserBan.getUnbannedAt()).isAfter(beforeUnban) - ); - } - - @Test - void 차단되지_않은_사용자는_차단_해제할_수_없다() { - // given - SiteUser notBannedUser = siteUserFixture.사용자(3, "차단안된유저"); - - // when & then - assertThatCode(() -> adminUserBanService.unbanUser(notBannedUser.getId(), admin.getId())) - .isInstanceOf(CustomException.class) - .hasMessage(NOT_BANNED_USER.getMessage()); - } - - @Test - void 만료된_차단은_해제할_수_없다() { - // given - userBanFixture.만료된_차단(reportedUser.getId()); - - // when & then - assertThatCode(() -> adminUserBanService.unbanUser(reportedUser.getId(), admin.getId())) - .isInstanceOf(CustomException.class) - .hasMessage(NOT_BANNED_USER.getMessage()); - } + @Test + void 차단된_사용자를_수동으로_해제한다() { + // given + reportFixture.신고(reporter.getId(), reportedUser.getId(), TargetType.POST, reportedPost.getId()); + UserBanRequest request = new UserBanRequest(UserBanDuration.SEVEN_DAYS); + adminUserBanService.banUser(reportedUser.getId(), admin.getId(), request); + + // when + adminUserBanService.unbanUser(reportedUser.getId(), admin.getId()); + + // then + SiteUser unbannedUser = siteUserRepository.findById(reportedUser.getId()).orElseThrow(); + assertThat(unbannedUser.getUserStatus()).isEqualTo(UserStatus.REPORTED); } - @Nested - @DisplayName("만료된 차단 자동 해제") - class 만료된_차단_자동_해제 { - - @Test - void 만료된_차단들을_자동으로_해제한다() { - // given - SiteUser user1 = siteUserFixture.사용자(10, "유저1"); - SiteUser user2 = siteUserFixture.사용자(11, "유저2"); - - userBanFixture.만료된_차단(user1.getId()); - userBanFixture.만료된_차단(user2.getId()); - - user1.updateUserStatus(UserStatus.BANNED); - user2.updateUserStatus(UserStatus.BANNED); - - // when - adminUserBanService.expireUserBans(); - - // then - SiteUser unbannedUser1 = siteUserRepository.findById(user1.getId()).orElseThrow(); - SiteUser unbannedUser2 = siteUserRepository.findById(user2.getId()).orElseThrow(); - - assertAll( - () -> assertThat(unbannedUser1.getUserStatus()).isEqualTo(UserStatus.REPORTED), - () -> assertThat(unbannedUser2.getUserStatus()).isEqualTo(UserStatus.REPORTED) - ); - } - - @Test - void 만료되지_않은_차단은_유지된다() { - // given - Post reportedPost = postFixture.게시글( - "신고될 게시글", - "신고될 내용", - false, - PostCategory.자유, - boardFixture.자유게시판(), - reportedUser - ); - reportFixture.신고(reporter.getId(), reportedUser.getId(), TargetType.POST, reportedPost.getId()); - adminUserBanService.banUser(reportedUser.getId(), new UserBanRequest(UserBanDuration.SEVEN_DAYS)); - - // when - adminUserBanService.expireUserBans(); - - // then - SiteUser stillBannedUser = siteUserRepository.findById(reportedUser.getId()).orElseThrow(); - assertThat(stillBannedUser.getUserStatus()).isEqualTo(UserStatus.BANNED); - } - - @Test - void 이미_수동으로_해제된_차단은_처리하지_않는다() { - // given - userBanFixture.수동_차단_해제(reportedUser.getId(), admin.getId()); - reportedUser.updateUserStatus(UserStatus.REPORTED); - - long beforeUnbannedCount = userBanRepository.findAll().stream() - .filter(UserBan::isUnbanned) - .count(); - - // when - adminUserBanService.expireUserBans(); - - // then - long afterUnbannedCount = userBanRepository.findAll().stream() - .filter(UserBan::isUnbanned) - .count(); - assertThat(afterUnbannedCount).isEqualTo(beforeUnbannedCount); - } + @Test + void 차단_해제_정보가_올바르게_저장된다() { + // given + reportFixture.신고(reporter.getId(), reportedUser.getId(), TargetType.POST, reportedPost.getId()); + UserBanRequest request = new UserBanRequest(UserBanDuration.SEVEN_DAYS); + adminUserBanService.banUser(reportedUser.getId(), admin.getId(), request); + ZonedDateTime beforeUnban = ZonedDateTime.now(); + + // when + adminUserBanService.unbanUser(reportedUser.getId(), admin.getId()); + + // then + List allBans = userBanRepository.findAll(); + UserBan unbannedUserBan = allBans.stream() + .filter(ban -> ban.getBannedUserId().equals(reportedUser.getId())) + .findFirst() + .orElseThrow(); + + assertAll( + () -> assertThat(unbannedUserBan.isExpired()).isTrue(), + () -> assertThat(unbannedUserBan.getUnbannedBy()).isEqualTo(admin.getId()), + () -> assertThat(unbannedUserBan.getUnbannedAt()).isAfter(beforeUnban) + ); + } + + @Test + void 차단되지_않은_사용자일_경우_예외가_발생한다() { + // given + SiteUser notBannedUser = siteUserFixture.사용자(3, "차단안된유저"); + + // when & then + assertThatCode(() -> adminUserBanService.unbanUser(notBannedUser.getId(), admin.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(NOT_BANNED_USER.getMessage()); + } + + @Test + void 만료된_차단일_경우_예외가_발생한다() { + // given + userBanFixture.만료된_차단(reportedUser.getId()); + + // when & then + assertThatCode(() -> adminUserBanService.unbanUser(reportedUser.getId(), admin.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(NOT_BANNED_USER.getMessage()); + } + } + + @Nested + class 만료된_차단_자동_해제 { + + @Test + void 만료된_차단들을_자동으로_해제한다() { + // given + SiteUser user1 = siteUserFixture.사용자(10, "유저1"); + SiteUser user2 = siteUserFixture.사용자(11, "유저2"); + + userBanFixture.만료된_차단(user1.getId()); + userBanFixture.만료된_차단(user2.getId()); + + user1.updateUserStatus(UserStatus.BANNED); + user2.updateUserStatus(UserStatus.BANNED); + + // when + adminUserBanService.expireUserBans(); + + // then + SiteUser unbannedUser1 = siteUserRepository.findById(user1.getId()).orElseThrow(); + SiteUser unbannedUser2 = siteUserRepository.findById(user2.getId()).orElseThrow(); + + assertAll( + () -> assertThat(unbannedUser1.getUserStatus()).isEqualTo(UserStatus.REPORTED), + () -> assertThat(unbannedUser2.getUserStatus()).isEqualTo(UserStatus.REPORTED) + ); + } + + @Test + void 만료되지_않은_차단은_유지된다() { + // given + Post reportedPost = postFixture.게시글( + "신고될 게시글", + "신고될 내용", + false, + PostCategory.자유, + boardFixture.자유게시판(), + reportedUser + ); + reportFixture.신고(reporter.getId(), reportedUser.getId(), TargetType.POST, reportedPost.getId()); + adminUserBanService.banUser(reportedUser.getId(), admin.getId(), new UserBanRequest(UserBanDuration.SEVEN_DAYS)); + + // when + adminUserBanService.expireUserBans(); + + // then + SiteUser stillBannedUser = siteUserRepository.findById(reportedUser.getId()).orElseThrow(); + assertThat(stillBannedUser.getUserStatus()).isEqualTo(UserStatus.BANNED); + } + + @Test + void 이미_수동으로_해제된_차단은_처리하지_않는다() { + // given + userBanFixture.수동_차단_해제(reportedUser.getId(), admin.getId()); + reportedUser.updateUserStatus(UserStatus.REPORTED); + + long beforeExpiredCount = userBanRepository.findAll().stream() + .filter(UserBan::isExpired) + .count(); + + // when + adminUserBanService.expireUserBans(); + + // then + long afterExpiredCount = userBanRepository.findAll().stream() + .filter(UserBan::isExpired) + .count(); + assertThat(afterExpiredCount).isEqualTo(beforeExpiredCount); } } } diff --git a/src/test/java/com/example/solidconnection/common/interceptor/BannedUserInterceptorTest.java b/src/test/java/com/example/solidconnection/common/interceptor/BannedUserInterceptorTest.java index 1511c508e..d6337f55f 100644 --- a/src/test/java/com/example/solidconnection/common/interceptor/BannedUserInterceptorTest.java +++ b/src/test/java/com/example/solidconnection/common/interceptor/BannedUserInterceptorTest.java @@ -33,8 +33,10 @@ class BannedUserInterceptorTest { @Autowired private SiteUserFixture siteUserFixture; + @Autowired private PostFixture postFixture; + @Autowired private BoardFixture boardFixture; diff --git a/src/test/java/com/example/solidconnection/siteuser/fixture/UserBanFixture.java b/src/test/java/com/example/solidconnection/siteuser/fixture/UserBanFixture.java index 1de458d7d..b73e4f055 100644 --- a/src/test/java/com/example/solidconnection/siteuser/fixture/UserBanFixture.java +++ b/src/test/java/com/example/solidconnection/siteuser/fixture/UserBanFixture.java @@ -13,9 +13,12 @@ public class UserBanFixture { private final UserBanFixtureBuilder userBanFixtureBuilder; + private static final long DEFAULT_ADMIN_ID = 1L; + public UserBan 만료된_차단(long bannedUserId) { return userBanFixtureBuilder.userBan() .bannedUserId(bannedUserId) + .bannedBy(DEFAULT_ADMIN_ID) .duration(UserBanDuration.ONE_DAY) .expiredAt(ZonedDateTime.now().minusDays(1)) .create(); @@ -24,6 +27,7 @@ public class UserBanFixture { public UserBan 수동_차단_해제(long bannedUserId, long adminId) { UserBan userBan = userBanFixtureBuilder.userBan() .bannedUserId(bannedUserId) + .bannedBy(adminId) .duration(UserBanDuration.SEVEN_DAYS) .expiredAt(ZonedDateTime.now().plusDays(7)) .create(); diff --git a/src/test/java/com/example/solidconnection/siteuser/fixture/UserBanFixtureBuilder.java b/src/test/java/com/example/solidconnection/siteuser/fixture/UserBanFixtureBuilder.java index ee08f401e..6ad095979 100644 --- a/src/test/java/com/example/solidconnection/siteuser/fixture/UserBanFixtureBuilder.java +++ b/src/test/java/com/example/solidconnection/siteuser/fixture/UserBanFixtureBuilder.java @@ -14,6 +14,7 @@ public class UserBanFixtureBuilder { private final UserBanRepository userBanRepository; private Long bannedUserId; + private Long bannedBy; private UserBanDuration duration; private ZonedDateTime expiredAt; @@ -26,6 +27,11 @@ public UserBanFixtureBuilder bannedUserId(Long bannedUserId) { return this; } + public UserBanFixtureBuilder bannedBy(Long bannedBy) { + this.bannedBy = bannedBy; + return this; + } + public UserBanFixtureBuilder duration(UserBanDuration duration) { this.duration = duration; return this; @@ -37,7 +43,7 @@ public UserBanFixtureBuilder expiredAt(ZonedDateTime expiredAt) { } public UserBan create() { - UserBan userBan = new UserBan(bannedUserId, duration, expiredAt); + UserBan userBan = new UserBan(bannedUserId, bannedBy, duration, expiredAt); return userBanRepository.save(userBan); } }