From 6a430c8465668a97265101761924fefa707e2b42 Mon Sep 17 00:00:00 2001 From: whqtker Date: Tue, 22 Sep 2026 22:54:27 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=ED=83=88=ED=87=B4=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EB=AC=BC=EB=A6=AC=20=EC=82=AD=EC=A0=9C=EB=A5=BC=20?= =?UTF-8?q?=EB=A7=89=EB=8D=98=20=EC=99=B8=EB=9E=98=ED=82=A4=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 탈퇴 후 30일이 지난 사용자를 삭제하는 스케줄러가 외래키 제약에 걸려 매일 롤백되고 있었다. 삭제 목록에서 빠져 있던 데이터를 채우고 순서를 바로잡는다. - 1:1 채팅방은 참여자가 사라지면 유지될 수 없으므로 방 전체를 삭제한다 - 채팅방이 멘토링을 참조하므로 멘토링보다 먼저 삭제한다 - 멘토로 참여한 멘토링과 탈퇴자가 쓴 뉴스의 좋아요를 함께 삭제한다 - 신고로 가려진 게시글과 메시지는 조회 필터에 걸리므로 플래그를 되돌린 뒤 삭제한다 - 정지 기록은 대상 행만 삭제하고, 집행자 참조는 해제해 다른 사용자의 이력을 보존한다 - 프로필 이미지 key 가 비어 있으면 S3 호출을 건너뛴다 Co-Authored-By: Claude Opus 5 (1M context) --- .../repository/ChatMessageRepository.java | 9 +++ .../repository/ChatParticipantRepository.java | 6 +- .../repository/ChatReadStatusRepository.java | 2 +- .../post/repository/PostRepository.java | 7 ++ .../mentor/repository/MentorRepository.java | 3 + .../repository/MentoringRepository.java | 2 + .../news/repository/LikedNewsRepository.java | 3 + .../news/repository/NewsRepository.java | 5 ++ .../solidconnection/s3/service/S3Service.java | 3 + .../scheduler/UserRemovalScheduler.java | 76 +++++++++++++++++-- .../siteuser/domain/UserBan.java | 2 +- .../repository/UserBanRepository.java | 10 +++ ...V60__alter_user_ban_banned_by_nullable.sql | 2 + 13 files changed, 118 insertions(+), 12 deletions(-) create mode 100644 src/main/resources/db/migration/V60__alter_user_ban_banned_by_nullable.sql 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 4111e6741..9b1072a1b 100644 --- a/src/main/java/com/example/solidconnection/chat/repository/ChatMessageRepository.java +++ b/src/main/java/com/example/solidconnection/chat/repository/ChatMessageRepository.java @@ -65,4 +65,13 @@ 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); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(value = """ + UPDATE chat_message cm SET cm.is_deleted = false + WHERE cm.chat_room_id IN :chatRoomIds + """, nativeQuery = true) + void unmarkDeletedByChatRoomIdIn(@Param("chatRoomIds") List chatRoomIds); + + void deleteAllByChatRoomIdIn(List chatRoomIds); } diff --git a/src/main/java/com/example/solidconnection/chat/repository/ChatParticipantRepository.java b/src/main/java/com/example/solidconnection/chat/repository/ChatParticipantRepository.java index 19be659f6..463e64d4d 100644 --- a/src/main/java/com/example/solidconnection/chat/repository/ChatParticipantRepository.java +++ b/src/main/java/com/example/solidconnection/chat/repository/ChatParticipantRepository.java @@ -13,8 +13,8 @@ public interface ChatParticipantRepository extends JpaRepository findByChatRoomIdAndSiteUserId(long chatRoomId, long siteUserId); - void deleteAllBySiteUserId(long siteUserId); + void deleteAllByChatRoomIdIn(List chatRoomIds); - @Query("SELECT cp.id FROM ChatParticipant cp WHERE cp.siteUserId = :siteUserId") - List findAllIdsBySiteUserId(@Param("siteUserId") long siteUserId); + @Query("SELECT cp.chatRoom.id FROM ChatParticipant cp WHERE cp.siteUserId = :siteUserId") + List findAllChatRoomIdsBySiteUserId(@Param("siteUserId") long siteUserId); } diff --git a/src/main/java/com/example/solidconnection/chat/repository/ChatReadStatusRepository.java b/src/main/java/com/example/solidconnection/chat/repository/ChatReadStatusRepository.java index b6f0fec06..40b76e9d7 100644 --- a/src/main/java/com/example/solidconnection/chat/repository/ChatReadStatusRepository.java +++ b/src/main/java/com/example/solidconnection/chat/repository/ChatReadStatusRepository.java @@ -17,5 +17,5 @@ INSERT INTO chat_read_status (chat_room_id, chat_participant_id, created_at, upd """, nativeQuery = true) void upsertReadStatus(@Param("chatRoomId") long chatRoomId, @Param("chatParticipantId") long chatParticipantId); - void deleteAllByChatParticipantIdIn(List chatParticipantIds); + void deleteAllByChatRoomIdIn(List chatRoomIds); } 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 b7a5bfe7a..c5f94b429 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 @@ -76,5 +76,12 @@ default Post getById(Long id) { .orElseThrow(() -> new CustomException(INVALID_POST_ID)); } + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(value = """ + UPDATE post p SET p.is_deleted = false + WHERE p.site_user_id = :siteUserId + """, nativeQuery = true) + void unmarkDeletedBySiteUserId(@Param("siteUserId") long siteUserId); + void deleteAllBySiteUserId(long siteUserId); } diff --git a/src/main/java/com/example/solidconnection/mentor/repository/MentorRepository.java b/src/main/java/com/example/solidconnection/mentor/repository/MentorRepository.java index 0fcd2dcd6..587b002ec 100644 --- a/src/main/java/com/example/solidconnection/mentor/repository/MentorRepository.java +++ b/src/main/java/com/example/solidconnection/mentor/repository/MentorRepository.java @@ -28,5 +28,8 @@ public interface MentorRepository extends JpaRepository { List findAllBySiteUserIdIn(Set siteUserIds); + @Query("SELECT m.id FROM Mentor m WHERE m.siteUserId = :siteUserId") + List findAllIdsBySiteUserId(@Param("siteUserId") long siteUserId); + void deleteAllBySiteUserId(long siteUserId); } diff --git a/src/main/java/com/example/solidconnection/mentor/repository/MentoringRepository.java b/src/main/java/com/example/solidconnection/mentor/repository/MentoringRepository.java index 2d072be07..b57968868 100644 --- a/src/main/java/com/example/solidconnection/mentor/repository/MentoringRepository.java +++ b/src/main/java/com/example/solidconnection/mentor/repository/MentoringRepository.java @@ -41,4 +41,6 @@ public interface MentoringRepository extends JpaRepository { Slice findApprovedMentoringsByMenteeId(long menteeId, @Param("verifyStatus") VerifyStatus verifyStatus, Pageable pageable); void deleteAllByMenteeId(long menteeId); + + void deleteAllByMentorIdIn(List mentorIds); } diff --git a/src/main/java/com/example/solidconnection/news/repository/LikedNewsRepository.java b/src/main/java/com/example/solidconnection/news/repository/LikedNewsRepository.java index 0bbd60930..0599945b3 100644 --- a/src/main/java/com/example/solidconnection/news/repository/LikedNewsRepository.java +++ b/src/main/java/com/example/solidconnection/news/repository/LikedNewsRepository.java @@ -1,6 +1,7 @@ package com.example.solidconnection.news.repository; import com.example.solidconnection.news.domain.LikedNews; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -11,4 +12,6 @@ public interface LikedNewsRepository extends JpaRepository { Optional findByNewsIdAndSiteUserId(long newsId, long siteUserId); void deleteAllBySiteUserId(long siteUserId); + + void deleteAllByNewsIdIn(List newsIds); } diff --git a/src/main/java/com/example/solidconnection/news/repository/NewsRepository.java b/src/main/java/com/example/solidconnection/news/repository/NewsRepository.java index feb276f36..3d3aa6feb 100644 --- a/src/main/java/com/example/solidconnection/news/repository/NewsRepository.java +++ b/src/main/java/com/example/solidconnection/news/repository/NewsRepository.java @@ -4,6 +4,8 @@ import com.example.solidconnection.news.repository.custom.NewsCustomRepository; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface NewsRepository extends JpaRepository, NewsCustomRepository { @@ -11,5 +13,8 @@ public interface NewsRepository extends JpaRepository, NewsCustomRep List findAllBySiteUserIdOrderByUpdatedAtDesc(long siteUserId); + @Query("SELECT n.id FROM News n WHERE n.siteUserId = :siteUserId") + List findAllIdsBySiteUserId(@Param("siteUserId") long siteUserId); + void deleteAllBySiteUserId(long siteUserId); } diff --git a/src/main/java/com/example/solidconnection/s3/service/S3Service.java b/src/main/java/com/example/solidconnection/s3/service/S3Service.java index df05dab83..010b95549 100644 --- a/src/main/java/com/example/solidconnection/s3/service/S3Service.java +++ b/src/main/java/com/example/solidconnection/s3/service/S3Service.java @@ -134,6 +134,9 @@ public void deleteExProfile(long siteUserId) { SiteUser siteUser = siteUserRepository.findById(siteUserId) .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); String key = siteUser.getProfileImageUrl(); + if (key == null || key.isBlank()) { + return; + } deleteFile(key); } diff --git a/src/main/java/com/example/solidconnection/scheduler/UserRemovalScheduler.java b/src/main/java/com/example/solidconnection/scheduler/UserRemovalScheduler.java index 4428e622a..e785a36a8 100644 --- a/src/main/java/com/example/solidconnection/scheduler/UserRemovalScheduler.java +++ b/src/main/java/com/example/solidconnection/scheduler/UserRemovalScheduler.java @@ -1,8 +1,10 @@ package com.example.solidconnection.scheduler; import com.example.solidconnection.application.repository.ApplicationRepository; +import com.example.solidconnection.chat.repository.ChatMessageRepository; import com.example.solidconnection.chat.repository.ChatParticipantRepository; import com.example.solidconnection.chat.repository.ChatReadStatusRepository; +import com.example.solidconnection.chat.repository.ChatRoomRepository; import com.example.solidconnection.community.comment.repository.CommentRepository; import com.example.solidconnection.community.post.repository.PostLikeRepository; import com.example.solidconnection.community.post.repository.PostRepository; @@ -19,6 +21,7 @@ import com.example.solidconnection.score.repository.LanguageTestScoreRepository; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.siteuser.repository.UserBanRepository; import com.example.solidconnection.siteuser.repository.UserBlockRepository; import com.example.solidconnection.university.repository.LikedUnivApplyInfoRepository; import java.time.LocalDate; @@ -49,10 +52,13 @@ public class UserRemovalScheduler { private final MentoringRepository mentoringRepository; private final NewsRepository newsRepository; private final LikedNewsRepository likedNewsRepository; + private final ChatRoomRepository chatRoomRepository; private final ChatParticipantRepository chatParticipantRepository; + private final ChatMessageRepository chatMessageRepository; private final ChatReadStatusRepository chatReadStatusRepository; private final ReportRepository reportRepository; private final UserBlockRepository userBlockRepository; + private final UserBanRepository userBanRepository; private final MentorApplicationRepository mentorApplicationRepository; private final S3Service s3Service; @@ -72,21 +78,19 @@ private void deleteUserAndRelatedData(SiteUser user) { long siteUserId = user.getId(); likedNewsRepository.deleteAllBySiteUserId(siteUserId); - newsRepository.deleteAllBySiteUserId(siteUserId); + deleteNews(siteUserId); postLikeRepository.deleteAllBySiteUserId(siteUserId); commentRepository.deleteAllBySiteUserId(siteUserId); - postRepository.deleteAllBySiteUserId(siteUserId); + deletePosts(siteUserId); - mentoringRepository.deleteAllByMenteeId(siteUserId); - mentorRepository.deleteAllBySiteUserId(siteUserId); + deleteChatRooms(siteUserId); + deleteMentorings(siteUserId); mentorApplicationRepository.deleteAllBySiteUserId(siteUserId); - List chatParticipantIds = chatParticipantRepository.findAllIdsBySiteUserId(siteUserId); - chatReadStatusRepository.deleteAllByChatParticipantIdIn(chatParticipantIds); - chatParticipantRepository.deleteAllBySiteUserId(siteUserId); reportRepository.deleteAllByReporterId(siteUserId); userBlockRepository.deleteAllByBlockerIdOrBlockedId(siteUserId, siteUserId); + deleteUserBans(siteUserId); applicationRepository.deleteAllBySiteUserId(siteUserId); gpaScoreRepository.deleteAllBySiteUserId(siteUserId); @@ -99,4 +103,62 @@ private void deleteUserAndRelatedData(SiteUser user) { siteUserRepository.delete(user); } + + /* + * 다른 사용자가 누른 좋아요가 남아 있으면 뉴스를 삭제할 수 없으므로 함께 삭제한다. + * */ + private void deleteNews(long siteUserId) { + List newsIds = newsRepository.findAllIdsBySiteUserId(siteUserId); + if (!newsIds.isEmpty()) { + likedNewsRepository.deleteAllByNewsIdIn(newsIds); + } + newsRepository.deleteAllBySiteUserId(siteUserId); + } + + /* + * 신고로 가려진 게시글은 조회 필터에 걸려 삭제되지 않으므로, 플래그를 되돌린 뒤 삭제한다. + * */ + private void deletePosts(long siteUserId) { + postRepository.unmarkDeletedBySiteUserId(siteUserId); + postRepository.deleteAllBySiteUserId(siteUserId); + } + + /* + * 1:1 채팅방은 참여자 한 명이 사라지면 유지될 수 없으므로 방 전체를 삭제한다. + * - 신고로 가려진 메시지는 조회 필터에 걸리므로, 플래그를 되돌린 뒤 삭제한다. + * - 채팅방이 멘토링을 참조하므로 멘토링보다 먼저 삭제한다. + * */ + private void deleteChatRooms(long siteUserId) { + List chatRoomIds = chatParticipantRepository.findAllChatRoomIdsBySiteUserId(siteUserId); + if (chatRoomIds.isEmpty()) { + return; + } + chatMessageRepository.unmarkDeletedByChatRoomIdIn(chatRoomIds); + chatMessageRepository.deleteAllByChatRoomIdIn(chatRoomIds); + chatReadStatusRepository.deleteAllByChatRoomIdIn(chatRoomIds); + chatParticipantRepository.deleteAllByChatRoomIdIn(chatRoomIds); + chatRoomRepository.deleteAllById(chatRoomIds); + } + + /* + * 멘티로 참여한 멘토링과 멘토로 참여한 멘토링을 모두 삭제한 뒤 멘토를 삭제한다. + * */ + private void deleteMentorings(long siteUserId) { + mentoringRepository.deleteAllByMenteeId(siteUserId); + List mentorIds = mentorRepository.findAllIdsBySiteUserId(siteUserId); + if (!mentorIds.isEmpty()) { + mentoringRepository.deleteAllByMentorIdIn(mentorIds); + } + mentorRepository.deleteAllBySiteUserId(siteUserId); + } + + /* + * 탈퇴자를 대상으로 한 정지 기록은 삭제한다. + * 탈퇴자가 집행한 정지 기록은 다른 사용자의 이력이므로, 집행자 참조만 해제하고 보존한다. + * */ + private void deleteUserBans(long siteUserId) { + userBanRepository.deleteAllByBannedUserId(siteUserId); + userBanRepository.clearBannedBy(siteUserId); + userBanRepository.clearUnbannedBy(siteUserId); + } } 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 c3a4b03ed..285c18c8e 100644 --- a/src/main/java/com/example/solidconnection/siteuser/domain/UserBan.java +++ b/src/main/java/com/example/solidconnection/siteuser/domain/UserBan.java @@ -27,7 +27,7 @@ public class UserBan extends BaseEntity { @Column(name = "banned_user_id", nullable = false) private Long bannedUserId; - @Column(name = "banned_by", nullable = false) + @Column(name = "banned_by") private Long bannedBy; @Column(name = "duration", nullable = false) 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 b897d29cf..9d2bba66b 100644 --- a/src/main/java/com/example/solidconnection/siteuser/repository/UserBanRepository.java +++ b/src/main/java/com/example/solidconnection/siteuser/repository/UserBanRepository.java @@ -21,4 +21,14 @@ public interface UserBanRepository extends JpaRepository { @Modifying @Query("UPDATE UserBan ub SET ub.isExpired = true WHERE ub.isExpired = false AND ub.expiredAt < :current") void bulkExpireUserBans(@Param("current") ZonedDateTime current); + + void deleteAllByBannedUserId(long bannedUserId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("UPDATE UserBan ub SET ub.bannedBy = null WHERE ub.bannedBy = :siteUserId") + void clearBannedBy(@Param("siteUserId") long siteUserId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("UPDATE UserBan ub SET ub.unbannedBy = null WHERE ub.unbannedBy = :siteUserId") + void clearUnbannedBy(@Param("siteUserId") long siteUserId); } diff --git a/src/main/resources/db/migration/V60__alter_user_ban_banned_by_nullable.sql b/src/main/resources/db/migration/V60__alter_user_ban_banned_by_nullable.sql new file mode 100644 index 000000000..78b9d9a7e --- /dev/null +++ b/src/main/resources/db/migration/V60__alter_user_ban_banned_by_nullable.sql @@ -0,0 +1,2 @@ +ALTER TABLE user_ban + MODIFY COLUMN banned_by BIGINT NULL;