From 6a430c8465668a97265101761924fefa707e2b42 Mon Sep 17 00:00:00 2001 From: whqtker Date: Tue, 22 Sep 2026 22:54:27 +0900 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20=ED=83=88=ED=87=B4=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EB=AC=BC=EB=A6=AC=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=EB=A5=BC=20=EB=A7=89=EB=8D=98=20=EC=99=B8=EB=9E=98=ED=82=A4=20?= =?UTF-8?q?=EB=88=84=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; From b93fe6fd0bf0e47374388752cf5787ef454a6aea Mon Sep 17 00:00:00 2001 From: whqtker Date: Sun, 18 Oct 2026 12:29:30 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20=EA=B7=B8=EB=A3=B9=20=EC=B1=84?= =?UTF-8?q?=ED=8C=85=EB=B0=A9=EC=9D=80=20=ED=83=88=ED=87=B4=EC=9E=90?= =?UTF-8?q?=EC=9D=98=20=EC=B0=B8=EC=97=AC=20=EA=B8=B0=EB=A1=9D=EB=A7=8C=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=ED=95=98=EB=8F=84=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존에는 탈퇴자가 속한 채팅방이면 1:1 과 그룹을 구분하지 않고 방 전체를 삭제했다. 그룹 채팅방을 만드는 경로가 아직 없어 영향은 없었지만, 그룹 채팅이 도입되면 멤버 한 명의 탈퇴로 남은 멤버들의 대화가 통째로 사라진다. - 1:1 채팅방은 참여자가 사라지면 유지될 수 없으므로 방 전체를 삭제한다 - 그룹 채팅방은 탈퇴자의 메시지, 읽음 상태, 참여자 행만 삭제하고 방은 유지한다 그룹방을 삭제 대상에서 제외하기만 하면 탈퇴자의 참여자 행이 남아 site_user 삭제가 다시 외래키에 막히므로, 참여 기록은 반드시 정리해야 한다. Co-Authored-By: Claude Opus 5 --- .../repository/ChatMessageRepository.java | 9 ++++++ .../repository/ChatParticipantRepository.java | 13 ++++++-- .../repository/ChatReadStatusRepository.java | 2 ++ .../scheduler/UserRemovalScheduler.java | 31 ++++++++++++++++--- 4 files changed, 48 insertions(+), 7 deletions(-) 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 9b1072a1b..c90f94e03 100644 --- a/src/main/java/com/example/solidconnection/chat/repository/ChatMessageRepository.java +++ b/src/main/java/com/example/solidconnection/chat/repository/ChatMessageRepository.java @@ -74,4 +74,13 @@ AND cm.sender_id IN (SELECT cp.id FROM chat_participant cp WHERE cp.site_user_id void unmarkDeletedByChatRoomIdIn(@Param("chatRoomIds") List chatRoomIds); void deleteAllByChatRoomIdIn(List chatRoomIds); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(value = """ + UPDATE chat_message cm SET cm.is_deleted = false + WHERE cm.sender_id IN :senderIds + """, nativeQuery = true) + void unmarkDeletedBySenderIdIn(@Param("senderIds") List senderIds); + + void deleteAllBySenderIdIn(List senderIds); } 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 463e64d4d..7a554e125 100644 --- a/src/main/java/com/example/solidconnection/chat/repository/ChatParticipantRepository.java +++ b/src/main/java/com/example/solidconnection/chat/repository/ChatParticipantRepository.java @@ -15,6 +15,15 @@ public interface ChatParticipantRepository extends JpaRepository chatRoomIds); - @Query("SELECT cp.chatRoom.id FROM ChatParticipant cp WHERE cp.siteUserId = :siteUserId") - List findAllChatRoomIdsBySiteUserId(@Param("siteUserId") long siteUserId); + @Query(""" + SELECT cp.chatRoom.id FROM ChatParticipant cp + WHERE cp.siteUserId = :siteUserId AND cp.chatRoom.isGroup = :isGroup + """) + List findAllChatRoomIdsBySiteUserIdAndIsGroup(@Param("siteUserId") long siteUserId, @Param("isGroup") boolean isGroup); + + @Query(""" + SELECT cp.id FROM ChatParticipant cp + WHERE cp.siteUserId = :siteUserId AND cp.chatRoom.isGroup = :isGroup + """) + List findAllIdsBySiteUserIdAndIsGroup(@Param("siteUserId") long siteUserId, @Param("isGroup") boolean isGroup); } 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 40b76e9d7..9643d5228 100644 --- a/src/main/java/com/example/solidconnection/chat/repository/ChatReadStatusRepository.java +++ b/src/main/java/com/example/solidconnection/chat/repository/ChatReadStatusRepository.java @@ -18,4 +18,6 @@ INSERT INTO chat_read_status (chat_room_id, chat_participant_id, created_at, upd void upsertReadStatus(@Param("chatRoomId") long chatRoomId, @Param("chatParticipantId") long chatParticipantId); void deleteAllByChatRoomIdIn(List chatRoomIds); + + void deleteAllByChatParticipantIdIn(List chatParticipantIds); } diff --git a/src/main/java/com/example/solidconnection/scheduler/UserRemovalScheduler.java b/src/main/java/com/example/solidconnection/scheduler/UserRemovalScheduler.java index e785a36a8..cdad2ac13 100644 --- a/src/main/java/com/example/solidconnection/scheduler/UserRemovalScheduler.java +++ b/src/main/java/com/example/solidconnection/scheduler/UserRemovalScheduler.java @@ -84,7 +84,7 @@ private void deleteUserAndRelatedData(SiteUser user) { commentRepository.deleteAllBySiteUserId(siteUserId); deletePosts(siteUserId); - deleteChatRooms(siteUserId); + deleteChatData(siteUserId); deleteMentorings(siteUserId); mentorApplicationRepository.deleteAllBySiteUserId(siteUserId); @@ -123,13 +123,20 @@ private void deletePosts(long siteUserId) { postRepository.deleteAllBySiteUserId(siteUserId); } + /* + * 신고로 가려진 메시지는 조회 필터에 걸리므로, 플래그를 되돌린 뒤 삭제한다. + * 채팅방이 멘토링을 참조하므로 멘토링보다 먼저 삭제한다. + * */ + private void deleteChatData(long siteUserId) { + deleteOneOnOneChatRooms(siteUserId); + deleteGroupChatParticipation(siteUserId); + } + /* * 1:1 채팅방은 참여자 한 명이 사라지면 유지될 수 없으므로 방 전체를 삭제한다. - * - 신고로 가려진 메시지는 조회 필터에 걸리므로, 플래그를 되돌린 뒤 삭제한다. - * - 채팅방이 멘토링을 참조하므로 멘토링보다 먼저 삭제한다. * */ - private void deleteChatRooms(long siteUserId) { - List chatRoomIds = chatParticipantRepository.findAllChatRoomIdsBySiteUserId(siteUserId); + private void deleteOneOnOneChatRooms(long siteUserId) { + List chatRoomIds = chatParticipantRepository.findAllChatRoomIdsBySiteUserIdAndIsGroup(siteUserId, false); if (chatRoomIds.isEmpty()) { return; } @@ -140,6 +147,20 @@ private void deleteChatRooms(long siteUserId) { chatRoomRepository.deleteAllById(chatRoomIds); } + /* + * 그룹 채팅방은 남은 참여자들의 대화가 유지되어야 하므로 탈퇴자의 참여 기록만 삭제한다. + * */ + private void deleteGroupChatParticipation(long siteUserId) { + List chatParticipantIds = chatParticipantRepository.findAllIdsBySiteUserIdAndIsGroup(siteUserId, true); + if (chatParticipantIds.isEmpty()) { + return; + } + chatMessageRepository.unmarkDeletedBySenderIdIn(chatParticipantIds); + chatMessageRepository.deleteAllBySenderIdIn(chatParticipantIds); + chatReadStatusRepository.deleteAllByChatParticipantIdIn(chatParticipantIds); + chatParticipantRepository.deleteAllById(chatParticipantIds); + } + /* * 멘티로 참여한 멘토링과 멘토로 참여한 멘토링을 모두 삭제한 뒤 멘토를 삭제한다. * */ From 87b90f7c07da13275b2b546bcc13b5b4039f6066 Mon Sep 17 00:00:00 2001 From: whqtker Date: Sun, 18 Oct 2026 14:55:08 +0900 Subject: [PATCH 3/5] =?UTF-8?q?refactor:=20UserBan.bannedBy=20=EC=9D=98=20?= =?UTF-8?q?nullable=20=EC=9D=84=20=EB=AA=85=EC=8B=9C=EC=A0=81=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=84=A0=EC=96=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit banned_by 를 nullable 로 바꾸는 변경이므로, 애노테이션 기본값에 기대지 않고 매핑에 계약을 드러낸다. Co-Authored-By: Claude Opus 5 --- .../com/example/solidconnection/siteuser/domain/UserBan.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 285c18c8e..8a8f4b844 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") + @Column(name = "banned_by", nullable = true) private Long bannedBy; @Column(name = "duration", nullable = false) From 80e14b31d62e3ffb1eb1e047b145da819e8ff827 Mon Sep 17 00:00:00 2001 From: whqtker Date: Sun, 18 Oct 2026 15:25:21 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20=ED=83=88=ED=87=B4=EC=9E=90=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EC=8B=A0=EA=B3=A0=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=EC=9D=84=20=EB=B0=98=EB=8C=80=20=EB=B0=A9?= =?UTF-8?q?=ED=96=A5=EC=9C=BC=EB=A1=9C=20=EC=A0=95=EB=A6=AC=ED=95=98?= =?UTF-8?q?=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존에는 탈퇴자가 접수한 신고를 삭제하고, 탈퇴자가 신고당한 기록은 남겼다. 신고 한 건의 주인은 신고당한 사람이므로 방향이 반대였다. - 탈퇴자를 대상으로 한 신고는 탈퇴자의 이력이므로 삭제한다 - 탈퇴자가 접수한 신고는 신고당한 사용자의 이력이므로 신고자 참조만 해제하고 보존한다 기존 동작에서는 누군가를 신고한 뒤 탈퇴하면 그 신고가 관리자 화면의 피신고 이력에서 함께 사라졌다. 정지 기록과 같은 기준으로 맞춘다. Co-Authored-By: Claude Opus 5 --- .../example/solidconnection/report/domain/Report.java | 4 ++-- .../report/repository/ReportRepository.java | 9 ++++++++- .../scheduler/UserRemovalScheduler.java | 11 ++++++++++- .../V61__alter_report_reporter_id_nullable.sql | 2 ++ 4 files changed, 22 insertions(+), 4 deletions(-) create mode 100644 src/main/resources/db/migration/V61__alter_report_reporter_id_nullable.sql 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 ca13ebeda..d82ea190d 100644 --- a/src/main/java/com/example/solidconnection/report/domain/Report.java +++ b/src/main/java/com/example/solidconnection/report/domain/Report.java @@ -30,8 +30,8 @@ public class Report extends BaseEntity { @Column(name = "id") private Long id; - @Column(name = "reporter_id", nullable = false) - private long reporterId; + @Column(name = "reporter_id", nullable = true) + private Long reporterId; @Column(name = "reported_id", nullable = false) private long reportedId; 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 8a359450b..52a86fd6d 100644 --- a/src/main/java/com/example/solidconnection/report/repository/ReportRepository.java +++ b/src/main/java/com/example/solidconnection/report/repository/ReportRepository.java @@ -3,6 +3,9 @@ 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.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface ReportRepository extends JpaRepository { @@ -10,5 +13,9 @@ public interface ReportRepository extends JpaRepository { boolean existsByReportedId(long reportedId); - void deleteAllByReporterId(long reporterId); + void deleteAllByReportedId(long reportedId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("UPDATE Report r SET r.reporterId = null WHERE r.reporterId = :siteUserId") + void clearReporter(@Param("siteUserId") long siteUserId); } diff --git a/src/main/java/com/example/solidconnection/scheduler/UserRemovalScheduler.java b/src/main/java/com/example/solidconnection/scheduler/UserRemovalScheduler.java index cdad2ac13..c4db5a5b8 100644 --- a/src/main/java/com/example/solidconnection/scheduler/UserRemovalScheduler.java +++ b/src/main/java/com/example/solidconnection/scheduler/UserRemovalScheduler.java @@ -88,7 +88,7 @@ private void deleteUserAndRelatedData(SiteUser user) { deleteMentorings(siteUserId); mentorApplicationRepository.deleteAllBySiteUserId(siteUserId); - reportRepository.deleteAllByReporterId(siteUserId); + deleteReports(siteUserId); userBlockRepository.deleteAllByBlockerIdOrBlockedId(siteUserId, siteUserId); deleteUserBans(siteUserId); @@ -173,6 +173,15 @@ private void deleteMentorings(long siteUserId) { mentorRepository.deleteAllBySiteUserId(siteUserId); } + /* + * 탈퇴자를 대상으로 한 신고는 삭제한다. + * 탈퇴자가 접수한 신고는 신고당한 사용자의 이력이므로, 신고자 참조만 해제하고 보존한다. + * */ + private void deleteReports(long siteUserId) { + reportRepository.deleteAllByReportedId(siteUserId); + reportRepository.clearReporter(siteUserId); + } + /* * 탈퇴자를 대상으로 한 정지 기록은 삭제한다. * 탈퇴자가 집행한 정지 기록은 다른 사용자의 이력이므로, 집행자 참조만 해제하고 보존한다. diff --git a/src/main/resources/db/migration/V61__alter_report_reporter_id_nullable.sql b/src/main/resources/db/migration/V61__alter_report_reporter_id_nullable.sql new file mode 100644 index 000000000..5c4279b0f --- /dev/null +++ b/src/main/resources/db/migration/V61__alter_report_reporter_id_nullable.sql @@ -0,0 +1,2 @@ +ALTER TABLE report + MODIFY COLUMN reporter_id BIGINT NULL; From adc99773ab4e1d3b3451766d34e0d4fad42a77fd Mon Sep 17 00:00:00 2001 From: whqtker Date: Sun, 18 Oct 2026 15:54:04 +0900 Subject: [PATCH 5/5] =?UTF-8?q?chore:=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20=EB=B2=84=EC=A0=84=20=EC=B6=A9?= =?UTF-8?q?=EB=8F=8C=20=ED=95=B4=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit develop 에 V60 이 추가되어 번호가 겹치므로 한 칸씩 밀어준다. - V60__alter_user_ban_banned_by_nullable.sql -> V61 - V61__alter_report_reporter_id_nullable.sql -> V62 Co-Authored-By: Claude Opus 5 --- ...by_nullable.sql => V61__alter_user_ban_banned_by_nullable.sql} | 0 ...id_nullable.sql => V62__alter_report_reporter_id_nullable.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename src/main/resources/db/migration/{V60__alter_user_ban_banned_by_nullable.sql => V61__alter_user_ban_banned_by_nullable.sql} (100%) rename src/main/resources/db/migration/{V61__alter_report_reporter_id_nullable.sql => V62__alter_report_reporter_id_nullable.sql} (100%) diff --git a/src/main/resources/db/migration/V60__alter_user_ban_banned_by_nullable.sql b/src/main/resources/db/migration/V61__alter_user_ban_banned_by_nullable.sql similarity index 100% rename from src/main/resources/db/migration/V60__alter_user_ban_banned_by_nullable.sql rename to src/main/resources/db/migration/V61__alter_user_ban_banned_by_nullable.sql diff --git a/src/main/resources/db/migration/V61__alter_report_reporter_id_nullable.sql b/src/main/resources/db/migration/V62__alter_report_reporter_id_nullable.sql similarity index 100% rename from src/main/resources/db/migration/V61__alter_report_reporter_id_nullable.sql rename to src/main/resources/db/migration/V62__alter_report_reporter_id_nullable.sql