diff --git a/src/main/java/com/umc/timeto/auth/controller/AuthController.java b/src/main/java/com/umc/timeto/auth/controller/AuthController.java index bb84a22..006c8f3 100644 --- a/src/main/java/com/umc/timeto/auth/controller/AuthController.java +++ b/src/main/java/com/umc/timeto/auth/controller/AuthController.java @@ -1,12 +1,24 @@ package com.umc.timeto.auth.controller; -import com.umc.timeto.auth.dto.*; +import com.umc.timeto.auth.dto.KakaoLoginRequest; +import com.umc.timeto.auth.dto.KakaoLoginResponse; +import com.umc.timeto.auth.dto.LogoutResponse; +import com.umc.timeto.auth.dto.TokenRefreshRequest; +import com.umc.timeto.auth.dto.TokenRefreshResponse; import com.umc.timeto.auth.service.AuthService; +import com.umc.timeto.global.apiPayload.code.ErrorCode; import com.umc.timeto.global.apiPayload.code.ResponseCode; import com.umc.timeto.global.apiPayload.dto.ResponseDTO; +import com.umc.timeto.global.apiPayload.exception.GlobalException; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; @RestController @RequiredArgsConstructor @@ -20,16 +32,13 @@ public class AuthController { public ResponseEntity> kakaoLogin( @RequestBody KakaoLoginRequest request ) { - if (request.getAuthorizationCode() == null || - request.getAuthorizationCode().isBlank()) { - + if (request.getAuthorizationCode() == null || request.getAuthorizationCode().isBlank()) { return ResponseEntity .status(ResponseCode.COMMON400.getStatus()) .body(new ResponseDTO<>(ResponseCode.COMMON400)); } - AuthService.LoginResult result = - authService.kakaoLogin(request.getAuthorizationCode()); + AuthService.LoginResult result = authService.kakaoLogin(request.getAuthorizationCode()); ResponseCode responseCode = result.isNewMember() ? ResponseCode.COMMON201 @@ -40,29 +49,24 @@ public ResponseEntity> kakaoLogin( .body(new ResponseDTO<>(responseCode, result.response())); } - //로그아웃 + // 로그아웃 @PostMapping("/logout") public ResponseEntity> logout( @RequestHeader(value = "Authorization", required = false) String authorization ) { - System.out.println("Authorization header = " + authorization); - if (authorization == null || !authorization.startsWith("Bearer ")) { - return ResponseEntity - .status(ResponseCode.COMMON401.getStatus()) - .body(new ResponseDTO<>(ResponseCode.COMMON401)); + throw new GlobalException(ErrorCode.AUTHORIZATION_HEADER_MISSING); } String accessToken = authorization.substring(7).trim(); - System.out.println("accessToken = " + accessToken); - authService.logout(accessToken); return ResponseEntity - .status(ResponseCode.COMMON200.getStatus()) - .body(new ResponseDTO<>(ResponseCode.COMMON200, new LogoutResponse("로그아웃 성공"))); + .status(ResponseCode.AUTH_LOGOUT_SUCCESS.getStatus()) + .body(new ResponseDTO<>(ResponseCode.AUTH_LOGOUT_SUCCESS, new LogoutResponse("로그아웃 성공"))); } + // 토큰 재발급 @PostMapping("/refresh") public ResponseEntity> refresh( @RequestBody TokenRefreshRequest request @@ -70,23 +74,38 @@ public ResponseEntity> refresh( String refreshToken = request.refreshToken(); if (refreshToken == null || refreshToken.isBlank()) { - return ResponseEntity - .status(ResponseCode.COMMON401.getStatus()) - .body(new ResponseDTO<>(ResponseCode.COMMON401)); + throw new GlobalException(ErrorCode.BAD_REQUEST); } - try { - TokenRefreshResponse response = authService.refresh(refreshToken.trim()); + TokenRefreshResponse response = authService.refresh(refreshToken.trim()); - return ResponseEntity - .status(ResponseCode.AUTH200.getStatus()) - .body(new ResponseDTO<>(ResponseCode.AUTH200, response)); + return ResponseEntity + .status(ResponseCode.AUTH200.getStatus()) + .body(new ResponseDTO<>(ResponseCode.AUTH200, response)); + } - } catch (IllegalArgumentException e) { - return ResponseEntity - .status(ResponseCode.COMMON401.getStatus()) - .body(new ResponseDTO<>(ResponseCode.COMMON401)); + // 회원 탈퇴(소프트 딜리트) + @DeleteMapping("/delete") + public ResponseEntity> deleteAccount(Authentication authentication) { + if (authentication == null || authentication.getPrincipal() == null) { + throw new GlobalException(ErrorCode.AUTH_INVALID_TOKEN); } - } + Object principal = authentication.getPrincipal(); + Long memberId; + + if (principal instanceof Long) { + memberId = (Long) principal; + } else if (principal instanceof String) { + memberId = Long.parseLong((String) principal); + } else { + throw new GlobalException(ErrorCode.AUTH_INVALID_TOKEN); + } + + authService.requestDelete(memberId); + + return ResponseEntity + .status(ResponseCode.AUTH_DELETE_SUCCESS.getStatus()) + .body(new ResponseDTO<>(ResponseCode.AUTH_DELETE_SUCCESS, "성공")); + } } diff --git a/src/main/java/com/umc/timeto/auth/scheduler/AccountCleanupScheduler.java b/src/main/java/com/umc/timeto/auth/scheduler/AccountCleanupScheduler.java new file mode 100644 index 0000000..a16eb2b --- /dev/null +++ b/src/main/java/com/umc/timeto/auth/scheduler/AccountCleanupScheduler.java @@ -0,0 +1,33 @@ +package com.umc.timeto.auth.scheduler; + +import com.umc.timeto.auth.service.AuthService; +import com.umc.timeto.member.entity.Member; +import com.umc.timeto.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class AccountCleanupScheduler { + + private final MemberRepository memberRepository; + private final AuthService authService; + + private static final int deleteGraceDays = 14; + + // 탈퇴 2주 경과 회원 완전 삭제 + @Scheduled(cron = "0 0 3 * * *") // 매일 03:00 + public void hardDeleteExpiredMembers() { + LocalDateTime cutoff = LocalDateTime.now().minusDays(deleteGraceDays); + List expiredMembers = + memberRepository.findAllByDeletedAtIsNotNullAndDeletedAtBefore(cutoff); + + for (Member member : expiredMembers) { + authService.hardDeleteMember(member.getMemberId()); + } + } +} diff --git a/src/main/java/com/umc/timeto/auth/service/AuthService.java b/src/main/java/com/umc/timeto/auth/service/AuthService.java index c31d612..7680366 100644 --- a/src/main/java/com/umc/timeto/auth/service/AuthService.java +++ b/src/main/java/com/umc/timeto/auth/service/AuthService.java @@ -7,12 +7,19 @@ import com.umc.timeto.auth.entity.RefreshToken; import com.umc.timeto.auth.jwt.JwtProvider; import com.umc.timeto.auth.repository.RefreshTokenRepository; +import com.umc.timeto.dailyLog.repository.DailyLogRepository; +import com.umc.timeto.folder.repository.FolderRepository; +import com.umc.timeto.global.apiPayload.code.ErrorCode; +import com.umc.timeto.global.apiPayload.exception.GlobalException; +import com.umc.timeto.goal.repository.GoalRepository; import com.umc.timeto.member.entity.Member; import com.umc.timeto.member.repository.MemberRepository; +import com.umc.timeto.todo.repository.TodoRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; import java.util.Optional; @Service @@ -24,6 +31,13 @@ public class AuthService { private final JwtProvider jwtProvider; private final RefreshTokenRepository refreshTokenRepository; + private static final int deleteGraceDays = 14; + + private final DailyLogRepository dailyLogRepository; + private final TodoRepository todoRepository; + private final FolderRepository folderRepository; + private final GoalRepository goalRepository; + // 카카오 로그인(신규면 회원 생성) @Transactional public LoginResult kakaoLogin(String authorizationCode) { @@ -39,6 +53,10 @@ public LoginResult kakaoLogin(String authorizationCode) { if (existingMember.isPresent()) { member = existingMember.get(); isNewMember = false; + + // 회원탈퇴(소프트딜리트)된 계정이라면 14일 내 재로그인 시 복구 + restoreIfWithinGracePeriod(member); + } else { member = memberRepository.save( Member.createKakaoMember( @@ -73,7 +91,7 @@ public LoginResult kakaoLogin(String authorizationCode) { @Transactional public void logout(String accessToken) { if (!jwtProvider.validateToken(accessToken)) { - throw new IllegalArgumentException("Invalid access token"); + throw new GlobalException(ErrorCode.AUTH_INVALID_TOKEN); } Long memberId = jwtProvider.getMemberId(accessToken); @@ -85,24 +103,20 @@ public record LoginResult( boolean isNewMember ) { } - // accessToken 재발급 + // accessToken 재발급 @Transactional public TokenRefreshResponse refresh(String refreshToken) { if (!jwtProvider.validateToken(refreshToken)) { - throw new IllegalArgumentException("Invalid refresh token"); + throw new GlobalException(ErrorCode.AUTH_INVALID_TOKEN); } Long memberId = jwtProvider.getMemberId(refreshToken); RefreshToken savedToken = refreshTokenRepository.findById(memberId) - .orElseThrow(() -> new IllegalArgumentException("Refresh token not found")); - - System.out.println("requestRefreshToken = " + refreshToken); - System.out.println("dbRefreshToken = " + savedToken.getToken()); - System.out.println("tokenEqual = " + savedToken.getToken().equals(refreshToken)); + .orElseThrow(() -> new GlobalException(ErrorCode.AUTH_REFRESH_TOKEN_NOT_FOUND)); if (!savedToken.getToken().equals(refreshToken)) { - throw new IllegalArgumentException("Refresh token mismatch"); + throw new GlobalException(ErrorCode.AUTH_REFRESH_TOKEN_MISMATCH); } String newAccessToken = jwtProvider.createAccessToken(memberId); @@ -110,4 +124,56 @@ public TokenRefreshResponse refresh(String refreshToken) { return new TokenRefreshResponse(memberId, newAccessToken, refreshToken); } + // 회원 탈퇴 요청(소프트 딜리트 14일) + @Transactional + public void requestDelete(Long memberId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new GlobalException(ErrorCode.MEMBER_NOT_FOUND)); + + if (member.isDeleted()) { + throw new GlobalException(ErrorCode.BAD_REQUEST); + } + + member.markDeleted(LocalDateTime.now()); + refreshTokenRepository.deleteById(memberId); + } + + // 탈퇴 계정 14일 내 재로그인 시 복구 + private void restoreIfWithinGracePeriod(Member member) { + if (!member.isDeleted()) { + return; + } + + LocalDateTime deletedAt = member.getDeletedAt(); + if (deletedAt == null) { + return; + } + + LocalDateTime now = LocalDateTime.now(); + LocalDateTime restoreDeadline = deletedAt.plusDays(deleteGraceDays); + + if (now.isBefore(restoreDeadline) || now.isEqual(restoreDeadline)) { + member.restore(); + } + } + + // 회원 완전 삭제(스케줄러에서 호출) + @Transactional + public void hardDeleteMember(Long memberId) { + + // 1) member FK 가진 테이블들 먼저 삭제 + dailyLogRepository.deleteAllByMemberId(memberId); + + // 2) goal → folder → todo 방향으로 FK 걸려있으니, 역순으로 삭제 + // todo 삭제 시 block은 orphanRemoval로 자동 삭제됨 + todoRepository.deleteAllByMemberId(memberId); + folderRepository.deleteAllByMemberId(memberId); + goalRepository.deleteAllByMemberId(memberId); + + // 3) auth 토큰 삭제 + refreshTokenRepository.deleteById(memberId); + + // 4) 마지막에 member 삭제 + memberRepository.deleteById(memberId); + } } diff --git a/src/main/java/com/umc/timeto/dailyLog/repository/DailyLogRepository.java b/src/main/java/com/umc/timeto/dailyLog/repository/DailyLogRepository.java index ea2fdff..3a73420 100644 --- a/src/main/java/com/umc/timeto/dailyLog/repository/DailyLogRepository.java +++ b/src/main/java/com/umc/timeto/dailyLog/repository/DailyLogRepository.java @@ -3,6 +3,9 @@ import com.umc.timeto.dailyLog.entity.DailyLog; import com.umc.timeto.member.entity.Member; 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 org.springframework.stereotype.Repository; import java.time.LocalDate; @@ -13,4 +16,9 @@ public interface DailyLogRepository extends JpaRepository { Optional findByMemberAndCreatedAt(Member member, LocalDate createdAt); List findByMemberAndCreatedAtBetween(Member member, LocalDate start, LocalDate end); + + // 회원의 일지 전체 삭제 + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("delete from DailyLog d where d.member.memberId = :memberId") + void deleteAllByMemberId(@Param("memberId") Long memberId); } \ No newline at end of file diff --git a/src/main/java/com/umc/timeto/folder/repository/FolderRepository.java b/src/main/java/com/umc/timeto/folder/repository/FolderRepository.java index 46c7064..d0d8ceb 100644 --- a/src/main/java/com/umc/timeto/folder/repository/FolderRepository.java +++ b/src/main/java/com/umc/timeto/folder/repository/FolderRepository.java @@ -3,6 +3,9 @@ import com.umc.timeto.folder.entity.Folder; import com.umc.timeto.goal.entity.Goal; 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 org.springframework.stereotype.Repository; import java.util.Optional; import java.util.List; @@ -14,4 +17,14 @@ public interface FolderRepository extends JpaRepository { List findAllByGoalOrderBySortOrderAsc(Goal goal); Optional findTopByGoalOrderBySortOrderDesc(Goal goal); + // 회원의 폴더 전체 삭제 + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + delete from Folder f + where f.goal.id in ( + select g.id from Goal g where g.member.memberId = :memberId + ) + """) + void deleteAllByMemberId(@Param("memberId") Long memberId); + } \ No newline at end of file diff --git a/src/main/java/com/umc/timeto/global/apiPayload/code/ErrorCode.java b/src/main/java/com/umc/timeto/global/apiPayload/code/ErrorCode.java index 2526e3e..22b25fb 100644 --- a/src/main/java/com/umc/timeto/global/apiPayload/code/ErrorCode.java +++ b/src/main/java/com/umc/timeto/global/apiPayload/code/ErrorCode.java @@ -20,6 +20,11 @@ public enum ErrorCode { * 401 UNAUTHORIZED - 인증 실패 */ + AUTHORIZATION_HEADER_MISSING(HttpStatus.UNAUTHORIZED, "Authorization 헤더가 필요합니다."), + AUTH_INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다."), + AUTH_REFRESH_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "리프레시 토큰이 존재하지 않습니다."), + AUTH_REFRESH_TOKEN_MISMATCH(HttpStatus.UNAUTHORIZED, "리프레시 토큰이 일치하지 않습니다."), + /** * 403 FORBIDDEN - 권한 없음 diff --git a/src/main/java/com/umc/timeto/global/apiPayload/code/ResponseCode.java b/src/main/java/com/umc/timeto/global/apiPayload/code/ResponseCode.java index 23cbae7..ba71bc5 100644 --- a/src/main/java/com/umc/timeto/global/apiPayload/code/ResponseCode.java +++ b/src/main/java/com/umc/timeto/global/apiPayload/code/ResponseCode.java @@ -40,12 +40,13 @@ public enum ResponseCode { COMMON201(HttpStatus.CREATED, "회원 가입 및 로그인 성공"), COMMON400(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."), COMMON401(HttpStatus.UNAUTHORIZED, "인증에 실패하였습니다."), - // Auth - AUTH200(HttpStatus.OK, "토큰 재발급에 성공하였습니다."), - // Auth + AUTH200(HttpStatus.OK, "토큰 재발급에 성공하였습니다."), AUTH_LOGIN_SUCCESS(HttpStatus.OK, "로그인에 성공하였습니다."), + AUTH_LOGOUT_SUCCESS(HttpStatus.OK, "로그아웃에 성공하였습니다."), + AUTH_DELETE_SUCCESS(HttpStatus.OK, "회원 탈퇴가 완료되었습니다."), + // DailyLog SUCCESS_SAVE_LOG(HttpStatus.CREATED, "일지를 성공적으로 저장했습니다."), diff --git a/src/main/java/com/umc/timeto/goal/repository/GoalRepository.java b/src/main/java/com/umc/timeto/goal/repository/GoalRepository.java index 0231493..a061b19 100644 --- a/src/main/java/com/umc/timeto/goal/repository/GoalRepository.java +++ b/src/main/java/com/umc/timeto/goal/repository/GoalRepository.java @@ -3,6 +3,9 @@ import com.umc.timeto.goal.entity.Goal; import com.umc.timeto.member.entity.Member; 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 org.springframework.stereotype.Repository; import java.util.List; @@ -10,4 +13,9 @@ @Repository public interface GoalRepository extends JpaRepository { List findALlByMember(Member member); + + // 회원의 목표 전체 삭제 + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("delete from Goal g where g.member.memberId = :memberId") + void deleteAllByMemberId(@Param("memberId") Long memberId); } diff --git a/src/main/java/com/umc/timeto/member/entity/Member.java b/src/main/java/com/umc/timeto/member/entity/Member.java index 30ff855..5ad360b 100644 --- a/src/main/java/com/umc/timeto/member/entity/Member.java +++ b/src/main/java/com/umc/timeto/member/entity/Member.java @@ -2,6 +2,8 @@ import jakarta.persistence.*; import lombok.*; +import java.time.LocalDateTime; + @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -22,6 +24,10 @@ public class Member { @Column(name = "kakao_id", unique = true) private Long kakaoId; + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + // 카카오 회원 생성 public static Member createKakaoMember(Long kakaoId, String email, String name) { Member member = new Member(); member.kakaoId = kakaoId; @@ -29,4 +35,19 @@ public static Member createKakaoMember(Long kakaoId, String email, String name) member.name = name; return member; } + + // 회원 탈퇴 처리 + public void markDeleted(LocalDateTime deletedAt) { + this.deletedAt = deletedAt; + } + + // 회원 복구 처리 + public void restore() { + this.deletedAt = null; + } + + // 탈퇴 여부 확인 + public boolean isDeleted() { + return deletedAt != null; + } } \ No newline at end of file diff --git a/src/main/java/com/umc/timeto/member/repository/MemberRepository.java b/src/main/java/com/umc/timeto/member/repository/MemberRepository.java index 0cfdcee..7688446 100644 --- a/src/main/java/com/umc/timeto/member/repository/MemberRepository.java +++ b/src/main/java/com/umc/timeto/member/repository/MemberRepository.java @@ -3,6 +3,8 @@ import com.umc.timeto.member.entity.Member; import org.springframework.data.jpa.repository.JpaRepository; +import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; public interface MemberRepository extends JpaRepository { @@ -12,4 +14,6 @@ public interface MemberRepository extends JpaRepository { // 이메일로 회원 조회 Optional findByEmail(String email); + + List findAllByDeletedAtIsNotNullAndDeletedAtBefore(LocalDateTime cutoff); } diff --git a/src/main/java/com/umc/timeto/todo/repository/TodoRepository.java b/src/main/java/com/umc/timeto/todo/repository/TodoRepository.java index 626dfbe..810a865 100644 --- a/src/main/java/com/umc/timeto/todo/repository/TodoRepository.java +++ b/src/main/java/com/umc/timeto/todo/repository/TodoRepository.java @@ -3,6 +3,7 @@ import com.umc.timeto.todo.domain.Todo; import com.umc.timeto.todo.domain.enums.TodoState; 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; @@ -41,4 +42,17 @@ List countTodosGroupByFolder( @Param("goalId") Long goalId, @Param("state") TodoState state ); + + // 회원의 할일 전체 삭제(폴더 → 목표 → 회원 기준으로 조인) + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + delete from Todo t + where t.folder.folderId in ( + select f.folderId from Folder f + where f.goal.id in ( + select g.id from Goal g where g.member.memberId = :memberId + ) + ) + """) + void deleteAllByMemberId(@Param("memberId") Long memberId); }