diff --git a/src/main/java/umc/cockple/demo/Application.java b/src/main/java/umc/cockple/demo/Application.java index 934d856b5..5fbaf64e1 100644 --- a/src/main/java/umc/cockple/demo/Application.java +++ b/src/main/java/umc/cockple/demo/Application.java @@ -4,10 +4,12 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cache.annotation.EnableCaching; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableJpaAuditing @EnableCaching +@EnableScheduling public class Application { public static void main(String[] args) { diff --git a/src/main/java/umc/cockple/demo/domain/bookmark/repository/ExerciseBookmarkRepository.java b/src/main/java/umc/cockple/demo/domain/bookmark/repository/ExerciseBookmarkRepository.java index 02307a394..cfab07fcb 100644 --- a/src/main/java/umc/cockple/demo/domain/bookmark/repository/ExerciseBookmarkRepository.java +++ b/src/main/java/umc/cockple/demo/domain/bookmark/repository/ExerciseBookmarkRepository.java @@ -1,6 +1,7 @@ package umc.cockple.demo.domain.bookmark.repository; 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; import umc.cockple.demo.domain.bookmark.domain.ExerciseBookmark; @@ -32,5 +33,7 @@ List findAllExerciseIdsByMemberIdAndExerciseIds( Optional findFirstByMemberOrderByCreatedAtAsc(Member member); - + @Modifying + @Query("DELETE FROM ExerciseBookmark eb WHERE eb.member.id IN :memberIds") + void deleteByMemberIds(@Param("memberIds") List memberIds); } diff --git a/src/main/java/umc/cockple/demo/domain/bookmark/repository/PartyBookmarkRepository.java b/src/main/java/umc/cockple/demo/domain/bookmark/repository/PartyBookmarkRepository.java index 75059b2d4..1567f9f92 100644 --- a/src/main/java/umc/cockple/demo/domain/bookmark/repository/PartyBookmarkRepository.java +++ b/src/main/java/umc/cockple/demo/domain/bookmark/repository/PartyBookmarkRepository.java @@ -1,6 +1,7 @@ package umc.cockple.demo.domain.bookmark.repository; 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; import umc.cockple.demo.domain.bookmark.domain.PartyBookmark; @@ -37,4 +38,8 @@ public interface PartyBookmarkRepository extends JpaRepository findAllByMember(Member member); Optional findFirstByMemberOrderByCreatedAtAsc(Member member); + + @Modifying + @Query("DELETE FROM PartyBookmark pb WHERE pb.member.id IN :memberIds") + void deleteByMemberIds(@Param("memberIds") List memberIds); } diff --git a/src/main/java/umc/cockple/demo/domain/chat/repository/ChatMessageRepository.java b/src/main/java/umc/cockple/demo/domain/chat/repository/ChatMessageRepository.java index 6e511a3be..a3d9b0fbc 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/repository/ChatMessageRepository.java +++ b/src/main/java/umc/cockple/demo/domain/chat/repository/ChatMessageRepository.java @@ -2,6 +2,7 @@ import org.springframework.data.domain.Pageable; 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; import umc.cockple.demo.domain.chat.domain.ChatMessage; @@ -49,4 +50,9 @@ List findByRoomIdAndIdLessThanOrderByCreatedAtDesc( Pageable pageable); int countByChatRoomId(Long chatRoomId); + + // 탈퇴 회원 하드 딜리트 전 sender_id null 처리 (메시지 보존) + @Modifying + @Query("UPDATE ChatMessage m SET m.sender = null WHERE m.sender.id IN :memberIds") + void nullifySenderByMemberIds(@Param("memberIds") List memberIds); } diff --git a/src/main/java/umc/cockple/demo/domain/chat/repository/ChatRoomMemberRepository.java b/src/main/java/umc/cockple/demo/domain/chat/repository/ChatRoomMemberRepository.java index 14884030a..d3b8f30f2 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/repository/ChatRoomMemberRepository.java +++ b/src/main/java/umc/cockple/demo/domain/chat/repository/ChatRoomMemberRepository.java @@ -74,6 +74,10 @@ List findByChatRoomIdAndStatusWithMember( @Param("status") ChatRoomMemberStatus status ); + @Modifying + @Query("DELETE FROM ChatRoomMember crm WHERE crm.member.id IN :memberIds") + void deleteByMemberIds(@Param("memberIds") List memberIds); + @Query(""" SELECT counterPart FROM ChatRoomMember counterPart WHERE counterPart.chatRoom.type = 'DIRECT' diff --git a/src/main/java/umc/cockple/demo/domain/chat/repository/MessageReadStatusRepository.java b/src/main/java/umc/cockple/demo/domain/chat/repository/MessageReadStatusRepository.java index 9392be1ea..628f8ef87 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/repository/MessageReadStatusRepository.java +++ b/src/main/java/umc/cockple/demo/domain/chat/repository/MessageReadStatusRepository.java @@ -63,4 +63,8 @@ int countUnreadByMessageId( ORDER BY mrs.chatMessageId ASC """) List findUnreadMessageIdsByMember(Long chatRoomId, Long memberId); + + @Modifying + @Query("DELETE FROM MessageReadStatus mrs WHERE mrs.memberId IN :memberIds") + void deleteByMemberIds(@Param("memberIds") List memberIds); } diff --git a/src/main/java/umc/cockple/demo/domain/contest/repository/ContestRepository.java b/src/main/java/umc/cockple/demo/domain/contest/repository/ContestRepository.java index 799fab005..a8b61b32b 100644 --- a/src/main/java/umc/cockple/demo/domain/contest/repository/ContestRepository.java +++ b/src/main/java/umc/cockple/demo/domain/contest/repository/ContestRepository.java @@ -1,6 +1,7 @@ package umc.cockple.demo.domain.contest.repository; 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; import umc.cockple.demo.domain.contest.domain.Contest; @@ -24,4 +25,16 @@ public interface ContestRepository extends JpaRepository { @Query("SELECT COUNT(c) FROM Contest c WHERE c.member.id = :memberId AND c.medalType = 'BRONZE'") int countBronzeMedalsByMemberId(@Param("memberId") Long memberId); + + @Modifying + @Query("DELETE FROM ContestVideo cv WHERE cv.contest.id IN (SELECT c.id FROM Contest c WHERE c.member.id IN :memberIds)") + void deleteContestVideosByMemberIds(@Param("memberIds") List memberIds); + + @Modifying + @Query("DELETE FROM ContestImg ci WHERE ci.contest.id IN (SELECT c.id FROM Contest c WHERE c.member.id IN :memberIds)") + void deleteContestImgsByMemberIds(@Param("memberIds") List memberIds); + + @Modifying + @Query("DELETE FROM Contest c WHERE c.member.id IN :memberIds") + void deleteByMemberIds(@Param("memberIds") List memberIds); } diff --git a/src/main/java/umc/cockple/demo/domain/member/controller/MemberController.java b/src/main/java/umc/cockple/demo/domain/member/controller/MemberController.java index 55d1150d0..7799e0844 100644 --- a/src/main/java/umc/cockple/demo/domain/member/controller/MemberController.java +++ b/src/main/java/umc/cockple/demo/domain/member/controller/MemberController.java @@ -103,7 +103,7 @@ public BaseResponse registerMemberDetailInfo(@RequestBody @Valid MemberD @PostMapping("/auth/refresh") @Operation(summary = "토큰 재발급 API", - description = "액세스 토큰을 재발급 하고 리프레시 토큰 또한 만료일이 3일 이하로 남았을 경우 재발급 해주는 api입니다. 리프레시토큰은 헤더에 쿠키로 들어갑니다.") + description = "액세스 토큰과 리프레시 토큰을 모두 재발급합니다 (Refresh Token Rotation). 리프레시 토큰은 응답 쿠키로 전달됩니다.") public ResponseEntity refresh(@CookieValue("refreshToken") String refreshToken) { if (refreshToken == null || refreshToken.isBlank()) { @@ -130,11 +130,13 @@ public ResponseEntity refresh(@CookieValue("refreshToken") @PatchMapping(value = "/member") @Operation(summary = "회원 탈퇴 API", description = "사용자 회원 탈퇴") - public BaseResponse withdraw(@AuthenticationPrincipal CustomUserDetails member) { + public BaseResponse withdraw( + @AuthenticationPrincipal CustomUserDetails member, + @CookieValue(value = "refreshToken", required = false) String refreshToken) { Long memberId = member.getMemberId(); - memberCommandService.withdrawMember(memberId); + memberCommandService.withdrawMember(memberId, refreshToken); return BaseResponse.success(CommonSuccessCode.NO_CONTENT); } diff --git a/src/main/java/umc/cockple/demo/domain/member/domain/Member.java b/src/main/java/umc/cockple/demo/domain/member/domain/Member.java index fac20e02c..60a590295 100644 --- a/src/main/java/umc/cockple/demo/domain/member/domain/Member.java +++ b/src/main/java/umc/cockple/demo/domain/member/domain/Member.java @@ -17,6 +17,7 @@ import umc.cockple.demo.global.common.BaseEntity; import java.time.LocalDate; +import java.time.LocalDateTime; import java.time.Period; import java.util.ArrayList; import java.util.List; @@ -51,7 +52,7 @@ public class Member extends BaseEntity { @Enumerated(EnumType.STRING) private MemberStatus isActive; - private String refreshToken; + private LocalDateTime deletedAt; @Column(nullable = false) private Long socialId; // 카카오에서 받아온 고유id @@ -179,14 +180,10 @@ public boolean isWithdrawn() { public void withdraw() { this.isActive = MemberStatus.INACTIVE; - this.refreshToken = null; + this.deletedAt = LocalDateTime.now(); this.fcmToken = null; } - public void setRefreshToken(String refreshToken) { - this.refreshToken = refreshToken; - } - // FCM 토큰 업데이트 메서드 public void updateFcmToken(String fcmToken) { this.fcmToken = fcmToken; diff --git a/src/main/java/umc/cockple/demo/domain/member/repository/MemberAddrRepository.java b/src/main/java/umc/cockple/demo/domain/member/repository/MemberAddrRepository.java index 60c8581ab..d85950dc2 100644 --- a/src/main/java/umc/cockple/demo/domain/member/repository/MemberAddrRepository.java +++ b/src/main/java/umc/cockple/demo/domain/member/repository/MemberAddrRepository.java @@ -1,11 +1,19 @@ package umc.cockple.demo.domain.member.repository; 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; import umc.cockple.demo.domain.member.domain.Member; import umc.cockple.demo.domain.member.domain.MemberAddr; +import java.util.List; import java.util.Optional; public interface MemberAddrRepository extends JpaRepository { Optional findByMemberAndIsMain(Member member, Boolean isMain); + + @Modifying + @Query("DELETE FROM MemberAddr ma WHERE ma.member.id IN :memberIds") + void deleteByMemberIds(@Param("memberIds") List memberIds); } diff --git a/src/main/java/umc/cockple/demo/domain/member/repository/MemberExerciseRepository.java b/src/main/java/umc/cockple/demo/domain/member/repository/MemberExerciseRepository.java index 4f86a2d58..d898d4fb9 100644 --- a/src/main/java/umc/cockple/demo/domain/member/repository/MemberExerciseRepository.java +++ b/src/main/java/umc/cockple/demo/domain/member/repository/MemberExerciseRepository.java @@ -61,4 +61,8 @@ SELECT me.member.id, MAX(e.date) List findLastExerciseDateByMemberIdsAndPartyId( @Param("memberIds") List memberIds, @Param("partyId") Long partyId); + + @Modifying + @Query("DELETE FROM MemberExercise me WHERE me.member.id IN :memberIds") + void deleteByMemberIds(@Param("memberIds") List memberIds); } diff --git a/src/main/java/umc/cockple/demo/domain/member/repository/MemberKeywordRepository.java b/src/main/java/umc/cockple/demo/domain/member/repository/MemberKeywordRepository.java index 930259ffb..d00fa020d 100644 --- a/src/main/java/umc/cockple/demo/domain/member/repository/MemberKeywordRepository.java +++ b/src/main/java/umc/cockple/demo/domain/member/repository/MemberKeywordRepository.java @@ -1,6 +1,9 @@ package umc.cockple.demo.domain.member.repository; 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; import umc.cockple.demo.domain.member.domain.Member; import umc.cockple.demo.domain.member.domain.MemberKeyword; @@ -11,4 +14,8 @@ public interface MemberKeywordRepository extends JpaRepository findAllByMemberId(Long memberId); + + @Modifying + @Query("DELETE FROM MemberKeyword mk WHERE mk.member.id IN :memberIds") + void deleteByMemberIds(@Param("memberIds") List memberIds); } diff --git a/src/main/java/umc/cockple/demo/domain/member/repository/MemberPartyRepository.java b/src/main/java/umc/cockple/demo/domain/member/repository/MemberPartyRepository.java index a88a73790..07ba28cd4 100644 --- a/src/main/java/umc/cockple/demo/domain/member/repository/MemberPartyRepository.java +++ b/src/main/java/umc/cockple/demo/domain/member/repository/MemberPartyRepository.java @@ -3,6 +3,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; import umc.cockple.demo.domain.member.domain.Member; @@ -56,4 +57,8 @@ List findAllPartyIdsByMemberAndPartyIds(@Param("memberId") Long memberId, List findAllByPartyIdWithMember(@Param("partyId") Long partyId); Optional findByPartyIdAndRole(Long partyId, Role role); + + @Modifying + @Query("DELETE FROM MemberParty mp WHERE mp.member.id IN :memberIds") + void deleteByMemberIds(@Param("memberIds") List memberIds); } diff --git a/src/main/java/umc/cockple/demo/domain/member/repository/MemberRepository.java b/src/main/java/umc/cockple/demo/domain/member/repository/MemberRepository.java index cb87a6294..55b06f18c 100644 --- a/src/main/java/umc/cockple/demo/domain/member/repository/MemberRepository.java +++ b/src/main/java/umc/cockple/demo/domain/member/repository/MemberRepository.java @@ -1,10 +1,13 @@ package umc.cockple.demo.domain.member.repository; 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; import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.enums.MemberStatus; +import java.time.LocalDateTime; import java.util.List; import java.util.Map; import java.util.Optional; @@ -34,9 +37,18 @@ SELECT new map(m.id as id, m.memberName as name) FROM Member m Optional findBySocialId(Long socialId); - Optional findByRefreshToken(String refreshToken); + @Query(""" + SELECT m FROM Member m + LEFT JOIN FETCH m.profileImg + WHERE m.isActive = :isActive + AND m.deletedAt < :threshold + """) + List findAllByIsActiveAndDeletedAtBefore(@Param("isActive") MemberStatus isActive, @Param("threshold") LocalDateTime threshold); + + @Modifying + @Query("DELETE FROM Member m WHERE m.id IN :memberIds") + void deleteByMemberIds(@Param("memberIds") List memberIds); - @Query(""" SELECT m FROM Member m LEFT JOIN FETCH m.addresses addr diff --git a/src/main/java/umc/cockple/demo/domain/member/repository/MemberTermsRepository.java b/src/main/java/umc/cockple/demo/domain/member/repository/MemberTermsRepository.java new file mode 100644 index 000000000..cfa1b3d23 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/member/repository/MemberTermsRepository.java @@ -0,0 +1,16 @@ +package umc.cockple.demo.domain.member.repository; + +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; +import umc.cockple.demo.domain.member.domain.MemberTerms; + +import java.util.List; + +public interface MemberTermsRepository extends JpaRepository { + + @Modifying + @Query("DELETE FROM MemberTerms mt WHERE mt.member.id IN :memberIds") + void deleteByMemberIds(@Param("memberIds") List memberIds); +} diff --git a/src/main/java/umc/cockple/demo/domain/member/repository/ProfileImgRepository.java b/src/main/java/umc/cockple/demo/domain/member/repository/ProfileImgRepository.java new file mode 100644 index 000000000..6a6160d2e --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/member/repository/ProfileImgRepository.java @@ -0,0 +1,16 @@ +package umc.cockple.demo.domain.member.repository; + +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; +import umc.cockple.demo.domain.member.domain.ProfileImg; + +import java.util.List; + +public interface ProfileImgRepository extends JpaRepository { + + @Modifying + @Query("DELETE FROM ProfileImg pi WHERE pi.member.id IN :memberIds") + void deleteByMemberIds(@Param("memberIds") List memberIds); +} diff --git a/src/main/java/umc/cockple/demo/domain/member/scheduler/WithdrawnMemberCleanupScheduler.java b/src/main/java/umc/cockple/demo/domain/member/scheduler/WithdrawnMemberCleanupScheduler.java new file mode 100644 index 000000000..abca82635 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/member/scheduler/WithdrawnMemberCleanupScheduler.java @@ -0,0 +1,92 @@ +package umc.cockple.demo.domain.member.scheduler; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.bookmark.repository.ExerciseBookmarkRepository; +import umc.cockple.demo.domain.bookmark.repository.PartyBookmarkRepository; +import umc.cockple.demo.domain.chat.repository.ChatMessageRepository; +import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository; +import umc.cockple.demo.domain.chat.repository.MessageReadStatusRepository; +import umc.cockple.demo.domain.contest.repository.ContestRepository; +import umc.cockple.demo.domain.file.service.FileService; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.enums.MemberStatus; +import umc.cockple.demo.domain.member.repository.*; +import umc.cockple.demo.domain.notification.repository.NotificationRepository; +import umc.cockple.demo.domain.party.repository.PartyJoinRequestRepository; + +import java.time.LocalDateTime; +import java.util.List; + +@Component +@RequiredArgsConstructor +@Slf4j +public class WithdrawnMemberCleanupScheduler { + + private final MemberRepository memberRepository; + private final ProfileImgRepository profileImgRepository; + private final MemberKeywordRepository memberKeywordRepository; + private final MemberAddrRepository memberAddrRepository; + private final MemberPartyRepository memberPartyRepository; + private final MemberExerciseRepository memberExerciseRepository; + private final MemberTermsRepository memberTermsRepository; + private final ContestRepository contestRepository; + private final NotificationRepository notificationRepository; + private final ExerciseBookmarkRepository exerciseBookmarkRepository; + private final PartyBookmarkRepository partyBookmarkRepository; + private final ChatRoomMemberRepository chatRoomMemberRepository; + private final ChatMessageRepository chatMessageRepository; + private final MessageReadStatusRepository messageReadStatusRepository; + private final PartyJoinRequestRepository partyJoinRequestRepository; + private final FileService fileService; + + // 매일 새벽 3시에 탈퇴 후 14일이 지난 회원 데이터 하드 딜리트 + @Scheduled(cron = "0 0 3 * * *") + @Transactional + public void deleteExpiredWithdrawnMembers() { + LocalDateTime threshold = LocalDateTime.now().minusDays(14); + List targets = memberRepository.findAllByIsActiveAndDeletedAtBefore( + MemberStatus.INACTIVE, threshold); + + if (targets.isEmpty()) { + return; + } + + List memberIds = targets.stream().map(Member::getId).toList(); + + // S3 프로필 이미지 삭제 (DB 삭제 전에 처리) + targets.stream() + .filter(m -> m.getProfileImg() != null) + .forEach(m -> fileService.delete(m.getProfileImg().getImgKey())); + + // Contest 자식 테이블 먼저 (FK: contest_id) + contestRepository.deleteContestVideosByMemberIds(memberIds); + contestRepository.deleteContestImgsByMemberIds(memberIds); + contestRepository.deleteByMemberIds(memberIds); + + // sender_id null 처리 (메시지 보존) + chatMessageRepository.nullifySenderByMemberIds(memberIds); + + // 나머지 자식 테이블 일괄 삭제 + messageReadStatusRepository.deleteByMemberIds(memberIds); + partyJoinRequestRepository.deleteByMemberIds(memberIds); + memberTermsRepository.deleteByMemberIds(memberIds); + notificationRepository.deleteByMemberIds(memberIds); + memberKeywordRepository.deleteByMemberIds(memberIds); + memberAddrRepository.deleteByMemberIds(memberIds); + chatRoomMemberRepository.deleteByMemberIds(memberIds); + exerciseBookmarkRepository.deleteByMemberIds(memberIds); + partyBookmarkRepository.deleteByMemberIds(memberIds); + memberPartyRepository.deleteByMemberIds(memberIds); + memberExerciseRepository.deleteByMemberIds(memberIds); + profileImgRepository.deleteByMemberIds(memberIds); + + // Member 일괄 삭제 + memberRepository.deleteByMemberIds(memberIds); + + log.info("[CLEANUP] 탈퇴 회원 하드 딜리트 완료 - {}명", memberIds.size()); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/member/service/MemberCommandService.java b/src/main/java/umc/cockple/demo/domain/member/service/MemberCommandService.java index 7457c6020..b245d336e 100644 --- a/src/main/java/umc/cockple/demo/domain/member/service/MemberCommandService.java +++ b/src/main/java/umc/cockple/demo/domain/member/service/MemberCommandService.java @@ -19,6 +19,7 @@ import java.time.LocalDate; import java.time.LocalTime; import java.util.List; +import umc.cockple.demo.global.auth.RefreshTokenRepository; import umc.cockple.demo.global.oauth2.service.KakaoOauthService; import static umc.cockple.demo.domain.member.dto.CreateMemberAddrDTO.*; @@ -38,6 +39,7 @@ public class MemberCommandService { private final KakaoOauthService kakaoOauthService; private final FileService fileService; + private final RefreshTokenRepository refreshTokenRepository; // ==================== 회원 관련 =================== @@ -76,7 +78,7 @@ public void memberDetailInfo(Long memberId, MemberDetailInfoRequestDTO requestDT } - public void withdrawMember(Long memberId) { + public void withdrawMember(Long memberId, String refreshToken) { // 회원 찾기 Member member = findByMemberId(memberId); @@ -91,8 +93,13 @@ public void withdrawMember(Long memberId) { // 카카오 연결 끊기 kakaoOauthService.unlinkAccess(member); - // 활성화 여부 해제, 리프레시 토큰 삭제 + // 활성화 여부 해제 member.withdraw(); + + // Redis 리프레시 토큰 즉시 삭제 + if (refreshToken != null) { + refreshTokenRepository.delete(refreshToken); + } } diff --git a/src/main/java/umc/cockple/demo/domain/notification/repository/NotificationRepository.java b/src/main/java/umc/cockple/demo/domain/notification/repository/NotificationRepository.java index de619694d..66923423c 100644 --- a/src/main/java/umc/cockple/demo/domain/notification/repository/NotificationRepository.java +++ b/src/main/java/umc/cockple/demo/domain/notification/repository/NotificationRepository.java @@ -20,4 +20,8 @@ public interface NotificationRepository extends JpaRepository memberIds); } diff --git a/src/main/java/umc/cockple/demo/domain/party/repository/PartyJoinRequestRepository.java b/src/main/java/umc/cockple/demo/domain/party/repository/PartyJoinRequestRepository.java index 913005db5..cd2c281ea 100644 --- a/src/main/java/umc/cockple/demo/domain/party/repository/PartyJoinRequestRepository.java +++ b/src/main/java/umc/cockple/demo/domain/party/repository/PartyJoinRequestRepository.java @@ -3,12 +3,21 @@ 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; import umc.cockple.demo.domain.member.domain.Member; import umc.cockple.demo.domain.party.domain.Party; import umc.cockple.demo.domain.party.domain.PartyJoinRequest; import umc.cockple.demo.domain.party.enums.RequestStatus; +import java.util.List; + public interface PartyJoinRequestRepository extends JpaRepository { boolean existsByPartyAndMemberAndStatus(Party party, Member member, RequestStatus requestStatus); Slice findByPartyAndStatus(Party party, RequestStatus status, Pageable pageable); + + @Modifying + @Query("DELETE FROM PartyJoinRequest pjr WHERE pjr.member.id IN :memberIds") + void deleteByMemberIds(@Param("memberIds") List memberIds); } diff --git a/src/main/java/umc/cockple/demo/global/auth/RefreshTokenRepository.java b/src/main/java/umc/cockple/demo/global/auth/RefreshTokenRepository.java new file mode 100644 index 000000000..fd177c297 --- /dev/null +++ b/src/main/java/umc/cockple/demo/global/auth/RefreshTokenRepository.java @@ -0,0 +1,38 @@ +package umc.cockple.demo.global.auth; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; +import umc.cockple.demo.global.jwt.properties.JwtProperties; + +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +@Repository +@RequiredArgsConstructor +public class RefreshTokenRepository { + + private static final String KEY_PREFIX = "refresh:"; + + private final StringRedisTemplate stringRedisTemplate; + private final JwtProperties jwtProperties; + + public void save(String refreshToken, Long memberId) { + stringRedisTemplate.opsForValue().set( + KEY_PREFIX + refreshToken, + String.valueOf(memberId), + jwtProperties.getRefreshTokenValidity(), + TimeUnit.MILLISECONDS + ); + } + + public Optional findMemberIdByToken(String refreshToken) { + String value = stringRedisTemplate.opsForValue().get(KEY_PREFIX + refreshToken); + if (value == null) return Optional.empty(); + return Optional.of(Long.valueOf(value)); + } + + public void delete(String refreshToken) { + stringRedisTemplate.delete(KEY_PREFIX + refreshToken); + } +} diff --git a/src/main/java/umc/cockple/demo/global/jwt/domain/JwtTokenProvider.java b/src/main/java/umc/cockple/demo/global/jwt/domain/JwtTokenProvider.java index ab3f4a89a..020aeabc0 100644 --- a/src/main/java/umc/cockple/demo/global/jwt/domain/JwtTokenProvider.java +++ b/src/main/java/umc/cockple/demo/global/jwt/domain/JwtTokenProvider.java @@ -18,7 +18,6 @@ import umc.cockple.demo.global.security.domain.CustomUserDetails; import java.security.Key; -import java.time.Duration; import java.util.Date; @Component @@ -108,23 +107,6 @@ public boolean validateToken(String token) { } - public boolean isTokenExpiringSoon(String refreshToken, long thresholdMillis) { - try { - Claims claims = Jwts.parserBuilder() - .setSigningKey(key) - .build() - .parseClaimsJws(refreshToken) - .getBody(); - - Date expiration = claims.getExpiration(); - long now = System.currentTimeMillis(); - - return expiration.getTime() - now < thresholdMillis; - } catch (JwtException e) { - throw new MemberException(MemberErrorCode.INVALID_TOKEN); - } - } - public Long getUserId(String token) { Claims claims = Jwts.parserBuilder() .setSigningKey(key) diff --git a/src/main/java/umc/cockple/demo/global/oauth2/service/KakaoOauthService.java b/src/main/java/umc/cockple/demo/global/oauth2/service/KakaoOauthService.java index 079cc69bc..dd36e905f 100644 --- a/src/main/java/umc/cockple/demo/global/oauth2/service/KakaoOauthService.java +++ b/src/main/java/umc/cockple/demo/global/oauth2/service/KakaoOauthService.java @@ -1,145 +1,151 @@ -package umc.cockple.demo.global.oauth2.service; - -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseCookie; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import umc.cockple.demo.domain.member.domain.Member; -import umc.cockple.demo.domain.member.dto.kakao.KakaoLoginDTO; -import umc.cockple.demo.domain.member.enums.MemberStatus; -import umc.cockple.demo.domain.member.exception.MemberErrorCode; -import umc.cockple.demo.domain.member.exception.MemberException; -import umc.cockple.demo.domain.member.repository.MemberRepository; -import umc.cockple.demo.global.jwt.domain.JwtTokenProvider; -import umc.cockple.demo.global.jwt.domain.TokenRefreshResponse; -import umc.cockple.demo.global.oauth2.domain.KakaoClient; -import umc.cockple.demo.global.oauth2.domain.info.KakaoClientInfo; - -import java.time.Duration; -import java.util.Optional; - -import static umc.cockple.demo.domain.member.dto.kakao.KakaoLoginDTO.*; - -@Service -@RequiredArgsConstructor -public class KakaoOauthService { - - private final KakaoClient kakaoClient; - private final MemberRepository memberRepository; - private final JwtTokenProvider jwtTokenProvider; - - private static final long EXPIRED = 7 * 24 * 60 * 60 * 1000L; - - @Transactional - public KakaoLoginResponseDTO signup(String code) { - // 1. accessToken 발급 - String kakaoAccessToken = kakaoClient.getAccessToken(code); - - // 2. 카카오에 사용자 정보 요청 - KakaoClientInfo info = kakaoClient.getClientInfo(kakaoAccessToken); - - // 3. 기존 유저 여부 확인 - Optional optionalMember = memberRepository.findBySocialId(info.kakaoId()); - boolean newMember = optionalMember.isEmpty(); - - Member member = optionalMember.orElseGet(() -> - memberRepository.save(Member.builder() - .socialId(info.kakaoId()) - .nickname(info.nickname()) - .isActive(MemberStatus.ACTIVE) - .build()) - ); - - if (member.getIsActive() == MemberStatus.INACTIVE) { - member.rejoin(); - newMember = true; - } - - // 4. jwt 발급 - String accessToken = jwtTokenProvider.createAccessToken(member.getId(), member.getNickname()); - String refreshToken = jwtTokenProvider.createRefreshToken(member.getId(), member.getNickname()); - - // 5. refresh는 db에 저장 - member.setRefreshToken(refreshToken); - - // jwt개발할 때 넣기 - return new KakaoLoginResponseDTO(accessToken, refreshToken, member.getId(), member.getNickname(), newMember); - } - - public void unlinkAccess(Member member) { - if (member.getSocialId() != null) { - try { - kakaoClient.unlinkByAdmin(member.getSocialId()); - } catch (Exception e) { - throw new MemberException(MemberErrorCode.OAUTH_UNLINK_FAIL); - } - } - - } - - public KakaoLoginResponseDTO createDevToken() { - // 특정 member 가져오기 - Member member = memberRepository.findById(1L) - .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); - - // accessToken: 2주 만료 - String accessToken = jwtTokenProvider.createDevToken(member.getId(), member.getNickname()); - - // refreshToken: 기본 만료 - String refreshToken = jwtTokenProvider.createRefreshToken(member.getId(), member.getNickname()); - - // refreshToken DB에 저장 - member.setRefreshToken(refreshToken); - - return KakaoLoginResponseDTO.builder() - .accessToken(accessToken) - .refreshToken(refreshToken) - .memberId(member.getId()) - .nickname(member.getNickname()) - .isNewMember(false) - .build() - ; - } - - public KakaoLoginResponseDTO createOtherDevToken() { - // 특정 member 가져오기 - Member member = memberRepository.findById(2L) - .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); - - // accessToken: 2주 만료 - String accessToken = jwtTokenProvider.createDevToken(member.getId(), member.getNickname()); - - // refreshToken: 기본 만료 - String refreshToken = jwtTokenProvider.createRefreshToken(member.getId(), member.getNickname()); - - // refreshToken DB에 저장 - member.setRefreshToken(refreshToken); - - return KakaoLoginResponseDTO.builder() - .accessToken(accessToken) - .refreshToken(refreshToken) - .memberId(member.getId()) - .nickname(member.getNickname()) - .isNewMember(false) - .build() - ; - } - - public TokenRefreshResponse validateMember(String refreshToken) { - Member member = memberRepository.findByRefreshToken(refreshToken) - .orElseThrow(() -> new MemberException(MemberErrorCode.INVALID_REFRESH_TOKEN)); - - // 액세스 토큰 재발급 - String newAccessToken = jwtTokenProvider.createAccessToken(member.getId(), member.getNickname()); - - // 리프레시 토큰 만료가 3일 이하로 남은 경우 갱신 (sliding session) - if (jwtTokenProvider.isTokenExpiringSoon(refreshToken, EXPIRED)) { - String newRefreshToken = jwtTokenProvider.createRefreshToken(member.getId(), member.getNickname()); - member.setRefreshToken(newRefreshToken); - - return new TokenRefreshResponse(newAccessToken, newRefreshToken); - } - - return new TokenRefreshResponse(newAccessToken, refreshToken); - } -} +package umc.cockple.demo.global.oauth2.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.dto.kakao.KakaoLoginDTO; +import umc.cockple.demo.domain.member.enums.MemberStatus; +import umc.cockple.demo.domain.member.exception.MemberErrorCode; +import umc.cockple.demo.domain.member.exception.MemberException; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.global.auth.RefreshTokenRepository; +import umc.cockple.demo.global.jwt.domain.JwtTokenProvider; +import umc.cockple.demo.global.jwt.domain.TokenRefreshResponse; +import umc.cockple.demo.global.oauth2.domain.KakaoClient; +import umc.cockple.demo.global.oauth2.domain.info.KakaoClientInfo; + +import java.util.Optional; + +import static umc.cockple.demo.domain.member.dto.kakao.KakaoLoginDTO.*; + +@Service +@RequiredArgsConstructor +public class KakaoOauthService { + + private final KakaoClient kakaoClient; + private final MemberRepository memberRepository; + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenRepository refreshTokenRepository; + + @Transactional + public KakaoLoginResponseDTO signup(String code) { + // 1. accessToken 발급 + String kakaoAccessToken = kakaoClient.getAccessToken(code); + + // 2. 카카오에 사용자 정보 요청 + KakaoClientInfo info = kakaoClient.getClientInfo(kakaoAccessToken); + + // 3. 기존 유저 여부 확인 + Optional optionalMember = memberRepository.findBySocialId(info.kakaoId()); + boolean newMember = optionalMember.isEmpty(); + + Member member = optionalMember.orElseGet(() -> + memberRepository.save(Member.builder() + .socialId(info.kakaoId()) + .nickname(info.nickname()) + .isActive(MemberStatus.ACTIVE) + .build()) + ); + + if (member.getIsActive() == MemberStatus.INACTIVE) { + member.rejoin(); + newMember = true; + } + + // 4. jwt 발급 + String accessToken = jwtTokenProvider.createAccessToken(member.getId(), member.getNickname()); + String refreshToken = jwtTokenProvider.createRefreshToken(member.getId(), member.getNickname()); + + // 5. refresh는 redis에 저장 + refreshTokenRepository.save(refreshToken, member.getId()); + + // jwt개발할 때 넣기 + return new KakaoLoginResponseDTO(accessToken, refreshToken, member.getId(), member.getNickname(), newMember); + } + + public void unlinkAccess(Member member) { + if (member.getSocialId() != null) { + try { + kakaoClient.unlinkByAdmin(member.getSocialId()); + } catch (Exception e) { + throw new MemberException(MemberErrorCode.OAUTH_UNLINK_FAIL); + } + } + + } + + public KakaoLoginResponseDTO createDevToken() { + // 특정 member 가져오기 + Member member = memberRepository.findById(1L) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + + // accessToken: 2주 만료 + String accessToken = jwtTokenProvider.createDevToken(member.getId(), member.getNickname()); + + // refreshToken: 기본 만료 + String refreshToken = jwtTokenProvider.createRefreshToken(member.getId(), member.getNickname()); + + // refreshToken Redis에 저장 + refreshTokenRepository.save(refreshToken, member.getId()); + + return KakaoLoginResponseDTO.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .memberId(member.getId()) + .nickname(member.getNickname()) + .isNewMember(false) + .build() + ; + } + + public KakaoLoginResponseDTO createOtherDevToken() { + // 특정 member 가져오기 + Member member = memberRepository.findById(2L) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + + // accessToken: 2주 만료 + String accessToken = jwtTokenProvider.createDevToken(member.getId(), member.getNickname()); + + // refreshToken: 기본 만료 + String refreshToken = jwtTokenProvider.createRefreshToken(member.getId(), member.getNickname()); + + // refreshToken Redis에 저장 + refreshTokenRepository.save(refreshToken, member.getId()); + + return KakaoLoginResponseDTO.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .memberId(member.getId()) + .nickname(member.getNickname()) + .isNewMember(false) + .build() + ; + } + + public TokenRefreshResponse validateMember(String refreshToken) { + // Redis에서 memberId 조회 + Long memberId = refreshTokenRepository.findMemberIdByToken(refreshToken) + .orElseThrow(() -> new MemberException(MemberErrorCode.INVALID_REFRESH_TOKEN)); + + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + + // 탈퇴한 회원 차단 + if (member.getIsActive() == MemberStatus.INACTIVE) { + throw new MemberException(MemberErrorCode.INVALID_REFRESH_TOKEN); + } + + // 기존 토큰 삭제 (Rotation) + refreshTokenRepository.delete(refreshToken); + + // 액세스 토큰 재발급 + String newAccessToken = jwtTokenProvider.createAccessToken(member.getId(), member.getNickname()); + + // 새 리프레시 토큰 발급 및 Redis 저장 + String newRefreshToken = jwtTokenProvider.createRefreshToken(member.getId(), member.getNickname()); + refreshTokenRepository.save(newRefreshToken, member.getId()); + + return new TokenRefreshResponse(newAccessToken, newRefreshToken); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 23feadeab..6a0916987 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -82,7 +82,7 @@ kakao: jwt: secret: ${JWT_SECRET_KEY} - access-token-validity: 3600000 + access-token-validity: 900000 refresh-token-validity: 1209600000 logging: diff --git a/src/main/resources/db/migration/V2026.05.18.00.00__drop_member_refresh_token.sql b/src/main/resources/db/migration/V2026.05.18.00.00__drop_member_refresh_token.sql new file mode 100644 index 000000000..9fd08e0df --- /dev/null +++ b/src/main/resources/db/migration/V2026.05.18.00.00__drop_member_refresh_token.sql @@ -0,0 +1 @@ +ALTER TABLE member DROP COLUMN refresh_token; diff --git a/src/main/resources/db/migration/V2026.05.18.22.00__add_member_deleted_at.sql b/src/main/resources/db/migration/V2026.05.18.22.00__add_member_deleted_at.sql new file mode 100644 index 000000000..d78e212ba --- /dev/null +++ b/src/main/resources/db/migration/V2026.05.18.22.00__add_member_deleted_at.sql @@ -0,0 +1 @@ +ALTER TABLE member ADD COLUMN deleted_at DATETIME; diff --git a/src/test/java/umc/cockple/demo/domain/member/service/MemberCommandServiceTest.java b/src/test/java/umc/cockple/demo/domain/member/service/MemberCommandServiceTest.java index f74620c03..956541ce4 100644 --- a/src/test/java/umc/cockple/demo/domain/member/service/MemberCommandServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/member/service/MemberCommandServiceTest.java @@ -28,6 +28,7 @@ import umc.cockple.demo.global.enums.Keyword; import umc.cockple.demo.global.enums.Level; import umc.cockple.demo.global.enums.Role; +import umc.cockple.demo.global.auth.RefreshTokenRepository; import umc.cockple.demo.global.oauth2.service.KakaoOauthService; import umc.cockple.demo.support.fixture.MemberAddrFixture; import umc.cockple.demo.support.fixture.MemberFixture; @@ -62,6 +63,7 @@ class MemberCommandServiceTest { @Mock private ChatRoomMemberRepository chatRoomMemberRepository; @Mock private FileService fileService; @Mock private KakaoOauthService kakaoOauthService; + @Mock private RefreshTokenRepository refreshTokenRepository; private Member normalMember; @@ -603,7 +605,7 @@ class WithdrawMember { given(memberRepository.findById(normalMember.getId())).willReturn(Optional.of(normalMember)); // when - memberCommandService.withdrawMember(normalMember.getId()); + memberCommandService.withdrawMember(normalMember.getId(), null); // then then(memberExerciseRepository).should() @@ -624,7 +626,7 @@ class Success { .willReturn(Optional.of(normalMember)); // when - memberCommandService.withdrawMember(normalMember.getId()); + memberCommandService.withdrawMember(normalMember.getId(), null); // then then(memberExerciseRepository).should() @@ -635,19 +637,18 @@ class Success { } @Test - @DisplayName("탈퇴_후_회원_상태가_INACTIVE가_되고_refreshToken이_null이_된다") - void 탈퇴_후_회원_상태가_INACTIVE가_되고_refreshToken이_null이_된다() { + @DisplayName("탈퇴_후_회원_상태가_INACTIVE가_되고_deletedAt이_설정된다") + void 탈퇴_후_회원_상태가_INACTIVE가_되고_deletedAt이_설정된다() { // given - normalMember.setRefreshToken("existing-refresh-token"); given(memberRepository.findById(normalMember.getId())) .willReturn(Optional.of(normalMember)); // when - memberCommandService.withdrawMember(normalMember.getId()); + memberCommandService.withdrawMember(normalMember.getId(), null); // then assertThat(normalMember.getIsActive()).isEqualTo(MemberStatus.INACTIVE); - assertThat(normalMember.getRefreshToken()).isNull(); + assertThat(normalMember.getDeletedAt()).isNotNull(); } @Test @@ -658,7 +659,7 @@ class Success { .willReturn(Optional.of(normalMember)); // when - memberCommandService.withdrawMember(normalMember.getId()); + memberCommandService.withdrawMember(normalMember.getId(), null); // then then(kakaoOauthService).should().unlinkAccess(normalMember); @@ -677,7 +678,7 @@ class Failure { .willReturn(Optional.empty()); // when & then - assertThatThrownBy(() -> memberCommandService.withdrawMember(999L)) + assertThatThrownBy(() -> memberCommandService.withdrawMember(999L, null)) .isInstanceOf(MemberException.class) .hasFieldOrPropertyWithValue("code", MemberErrorCode.MEMBER_NOT_FOUND); } @@ -693,7 +694,7 @@ class Failure { .willReturn(Optional.of(withdrawnMember)); // when & then - assertThatThrownBy(() -> memberCommandService.withdrawMember(withdrawnMember.getId())) + assertThatThrownBy(() -> memberCommandService.withdrawMember(withdrawnMember.getId(), null)) .isInstanceOf(MemberException.class) .hasFieldOrPropertyWithValue("code", MemberErrorCode.ALREADY_WITHDRAW); } @@ -713,7 +714,7 @@ class Failure { .willReturn(Optional.of(normalMember)); // when & then - assertThatThrownBy(() -> memberCommandService.withdrawMember(normalMember.getId())) + assertThatThrownBy(() -> memberCommandService.withdrawMember(normalMember.getId(), null)) .isInstanceOf(MemberException.class) .hasFieldOrPropertyWithValue("code", MemberErrorCode.MANAGER_CANNOT_LEAVE); } @@ -733,7 +734,7 @@ class Failure { .willReturn(Optional.of(normalMember)); // when & then - assertThatThrownBy(() -> memberCommandService.withdrawMember(normalMember.getId())) + assertThatThrownBy(() -> memberCommandService.withdrawMember(normalMember.getId(), null)) .isInstanceOf(MemberException.class) .hasFieldOrPropertyWithValue("code", MemberErrorCode.SUBMANAGER_CANNOT_LEAVE); } @@ -753,7 +754,7 @@ class Failure { .willReturn(Optional.of(normalMember)); // when - memberCommandService.withdrawMember(normalMember.getId()); + memberCommandService.withdrawMember(normalMember.getId(), null); // then: 예외 없이 탈퇴 처리됨 assertThat(normalMember.getIsActive()).isEqualTo(MemberStatus.INACTIVE);