diff --git a/build.gradle b/build.gradle index f36f941c..b75381ac 100644 --- a/build.gradle +++ b/build.gradle @@ -29,6 +29,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-devtools' + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' implementation 'org.springframework.boot:spring-boot-starter-data-redis' //블랙리스트 관련 redis diff --git a/src/main/java/com/divary/domain/avatar/entity/Avatar.java b/src/main/java/com/divary/domain/avatar/entity/Avatar.java index 6d7861f5..40d39cd5 100644 --- a/src/main/java/com/divary/domain/avatar/entity/Avatar.java +++ b/src/main/java/com/divary/domain/avatar/entity/Avatar.java @@ -5,6 +5,8 @@ import com.divary.domain.avatar.enums.*; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; @Entity @Getter @@ -16,6 +18,7 @@ public class Avatar extends BaseEntity { @OneToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") + @OnDelete(action = OnDeleteAction.CASCADE) private Member user; @Column(length = 20) diff --git a/src/main/java/com/divary/domain/device_session/entity/DeviceSession.java b/src/main/java/com/divary/domain/device_session/entity/DeviceSession.java index 641468d6..5aa8e541 100644 --- a/src/main/java/com/divary/domain/device_session/entity/DeviceSession.java +++ b/src/main/java/com/divary/domain/device_session/entity/DeviceSession.java @@ -10,6 +10,8 @@ import lombok.AllArgsConstructor; import lombok.Builder; import lombok.NoArgsConstructor; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; @Entity @Table(name = "device_session", uniqueConstraints = { @@ -24,6 +26,7 @@ public class DeviceSession extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") + @OnDelete(action = OnDeleteAction.CASCADE) private Member user; @Column(nullable = false) diff --git a/src/main/java/com/divary/domain/logbase/LogBaseInfo.java b/src/main/java/com/divary/domain/logbase/LogBaseInfo.java index 53660d40..77363706 100644 --- a/src/main/java/com/divary/domain/logbase/LogBaseInfo.java +++ b/src/main/java/com/divary/domain/logbase/LogBaseInfo.java @@ -26,6 +26,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; @Getter @Schema(description = "다이빙 로그 기본정보") @@ -41,6 +43,7 @@ public class LogBaseInfo extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id", nullable = false) @Schema(description = "유저 id", example = "1L") + @OnDelete(action = OnDeleteAction.CASCADE) private Member member; @OneToMany(mappedBy = "logBaseInfo", cascade = CascadeType.REMOVE, orphanRemoval = true) diff --git a/src/main/java/com/divary/domain/member/entity/Member.java b/src/main/java/com/divary/domain/member/entity/Member.java index 229faa79..1e143352 100644 --- a/src/main/java/com/divary/domain/member/entity/Member.java +++ b/src/main/java/com/divary/domain/member/entity/Member.java @@ -4,6 +4,7 @@ import com.divary.domain.member.enums.Levels; import com.divary.domain.member.enums.Role; import com.divary.common.enums.SocialType; +import com.divary.domain.member.enums.Status; import jakarta.annotation.Nullable; import jakarta.persistence.Entity; import jakarta.persistence.*; @@ -11,6 +12,8 @@ import jakarta.validation.constraints.Null; import lombok.*; +import java.time.LocalDateTime; + @Entity @Builder @Getter @@ -29,5 +32,26 @@ public class Member extends BaseEntity { @Enumerated(EnumType.STRING) private Levels level; - + + @Enumerated(EnumType.STRING) + @NotNull + Status status = Status.ACTIVE; // 사용자 상태 + + private LocalDateTime deactivatedAt; //비활성화 된 시간과 날짜 + + @Version + private Long version; //버전을통해 레이스 컨디션 해결 + + + // 탈퇴 요청 처리 + public void requestDeletion() { + this.status = Status.DEACTIVATED; + this.deactivatedAt = LocalDateTime.now(); + } + + // 탈퇴 요청 취소 (계정 복구) + public void cancelDeletion() { + this.status = Status.ACTIVE; + this.deactivatedAt = null; + } } diff --git a/src/main/java/com/divary/domain/member/enums/Status.java b/src/main/java/com/divary/domain/member/enums/Status.java new file mode 100644 index 00000000..1024c56c --- /dev/null +++ b/src/main/java/com/divary/domain/member/enums/Status.java @@ -0,0 +1,5 @@ +package com.divary.domain.member.enums; + +public enum Status { + ACTIVE, DEACTIVATED +} diff --git a/src/main/java/com/divary/domain/member/repository/MemberRepository.java b/src/main/java/com/divary/domain/member/repository/MemberRepository.java index ddd57088..497b951b 100644 --- a/src/main/java/com/divary/domain/member/repository/MemberRepository.java +++ b/src/main/java/com/divary/domain/member/repository/MemberRepository.java @@ -1,11 +1,15 @@ package com.divary.domain.member.repository; import com.divary.domain.member.entity.Member; +import com.divary.domain.member.enums.Status; import org.springframework.data.jpa.repository.JpaRepository; +import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; public interface MemberRepository extends JpaRepository { Optional findByEmail(String email); Optional findById(Long id); + List findByStatusAndDeactivatedAtBefore(Status status, LocalDateTime cutoffDate); } diff --git a/src/main/java/com/divary/domain/member/service/MemberService.java b/src/main/java/com/divary/domain/member/service/MemberService.java index f54a9938..ac57de97 100644 --- a/src/main/java/com/divary/domain/member/service/MemberService.java +++ b/src/main/java/com/divary/domain/member/service/MemberService.java @@ -3,6 +3,7 @@ import com.divary.domain.member.dto.response.MyPageImageResponseDTO; import com.divary.domain.member.entity.Member; import com.divary.domain.member.dto.requestDTO.MyPageLevelRequestDTO; +import com.divary.global.oauth.dto.response.DeactivateResponse; import org.springframework.web.multipart.MultipartFile; public interface MemberService { @@ -11,4 +12,9 @@ public interface MemberService { Member saveMember(Member member); void updateLevel(Long userId, MyPageLevelRequestDTO requestDTO); MyPageImageResponseDTO uploadLicense(MultipartFile image, Long userId); + DeactivateResponse requestToDeleteMember(Long memberId); + void cancelDeleteMember(Long memberId); + public Member findOrCreateMember(String email); + + } diff --git a/src/main/java/com/divary/domain/member/service/MemberServiceImpl.java b/src/main/java/com/divary/domain/member/service/MemberServiceImpl.java index 1b2ba555..1e1277dc 100644 --- a/src/main/java/com/divary/domain/member/service/MemberServiceImpl.java +++ b/src/main/java/com/divary/domain/member/service/MemberServiceImpl.java @@ -5,11 +5,18 @@ import com.divary.domain.image.service.ImageService; import com.divary.domain.member.dto.requestDTO.MyPageLevelRequestDTO; import com.divary.domain.member.dto.response.MyPageImageResponseDTO; +import com.divary.domain.member.enums.Role; +import com.divary.domain.member.enums.Status; import com.divary.global.exception.BusinessException; import com.divary.global.exception.ErrorCode; +import com.divary.global.oauth.dto.response.DeactivateResponse; +import com.divary.global.redis.service.TokenBlackListService; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.security.core.token.TokenService; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.divary.domain.member.repository.MemberRepository; @@ -17,13 +24,19 @@ import com.divary.domain.member.enums.Levels; import org.springframework.web.multipart.MultipartFile; +import java.time.LocalDateTime; +import java.util.Optional; + @Service @RequiredArgsConstructor @Transactional public class MemberServiceImpl implements MemberService { private final MemberRepository memberRepository; private final ImageService imageService; - String additionalPath = "qualifications"; + private final TokenBlackListService tokenBlackListService; + + @Value("${jobs.user-deletion.grace-period-days}") + private int gracePeriodDays; @Override public Member findMemberByEmail(String email) { @@ -68,4 +81,48 @@ public MyPageImageResponseDTO uploadLicense(MultipartFile image, Long userId) { return new MyPageImageResponseDTO(fileUrl); } + @Override + @Transactional + @CacheEvict(cacheNames = com.divary.global.config.CacheConfig.CACHE_MEMBER_BY_ID, key = "#memberId") + public DeactivateResponse requestToDeleteMember(Long memberId) { + Member member = memberRepository.findById(memberId).orElseThrow(()-> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); + + if (member.getStatus() == Status.DEACTIVATED) { + return new DeactivateResponse(member.getDeactivatedAt()); + } + member.requestDeletion(); + + LocalDateTime scheduledDeletionAt = member.getDeactivatedAt() + .plusDays(gracePeriodDays); + + return new DeactivateResponse(scheduledDeletionAt); + } + + @Override + @Transactional + @CacheEvict(cacheNames = com.divary.global.config.CacheConfig.CACHE_MEMBER_BY_ID, key = "#memberId") + public void cancelDeleteMember(Long memberId) { + Member member = memberRepository.findById(memberId).orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); + + // DEACTIVATED 상태일 때만 취소 가능 + if (member.getStatus() == Status.DEACTIVATED) { + member.cancelDeletion(); + } + } + @Override + @Transactional + public Member findOrCreateMember(String email) { + // 1. Optional을 사용하여 회원을 조회합니다. + Optional optionalMember = memberRepository.findByEmail(email); + + // 2. 회원이 존재하면 그대로 반환하고, 존재하지 않으면 새로 생성하여 저장한 뒤 반환합니다. + return optionalMember.orElseGet(() -> { + Member newMember = Member.builder() + .email(email) + .status(Status.ACTIVE) + .role(Role.USER) + .build(); + return memberRepository.save(newMember); + }); + } } diff --git a/src/main/java/com/divary/domain/notification/entity/Notification.java b/src/main/java/com/divary/domain/notification/entity/Notification.java index bad7214f..a0817700 100644 --- a/src/main/java/com/divary/domain/notification/entity/Notification.java +++ b/src/main/java/com/divary/domain/notification/entity/Notification.java @@ -7,6 +7,8 @@ import jakarta.annotation.Nullable; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; @Entity @Getter @@ -18,6 +20,7 @@ public class Notification extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "receiver_id") + @OnDelete(action = OnDeleteAction.CASCADE) private Member receiver; @Enumerated(EnumType.STRING) diff --git a/src/main/java/com/divary/domain/system/controller/SystemController.java b/src/main/java/com/divary/domain/system/controller/SystemController.java index 8257b180..5ae59e98 100644 --- a/src/main/java/com/divary/domain/system/controller/SystemController.java +++ b/src/main/java/com/divary/domain/system/controller/SystemController.java @@ -3,6 +3,7 @@ import com.divary.common.response.ApiResponse; import com.divary.domain.member.entity.Member; import com.divary.domain.member.enums.Role; +import com.divary.domain.member.enums.Status; import com.divary.domain.member.repository.MemberRepository; import com.divary.domain.image.enums.ImageType; import com.divary.domain.image.service.ImageService; @@ -99,6 +100,7 @@ public ApiResponse createTestUser(@RequestParam(defaultValue = "test@div if (memberRepository.findByEmail(email).isEmpty()) { Member testUser = Member.builder() .email(email) + .status(Status.ACTIVE) .role(Role.USER) .build(); memberRepository.save(testUser); diff --git a/src/main/java/com/divary/global/config/jwt/JwtAuthenticationFilter.java b/src/main/java/com/divary/global/config/jwt/JwtAuthenticationFilter.java index 124f6431..1c47a5cd 100644 --- a/src/main/java/com/divary/global/config/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/divary/global/config/jwt/JwtAuthenticationFilter.java @@ -1,6 +1,9 @@ package com.divary.global.config.jwt; import com.divary.common.response.ApiResponse; +import com.divary.domain.member.entity.Member; +import com.divary.domain.member.enums.Status; +import com.divary.domain.member.service.MemberService; import com.divary.global.exception.BusinessException; import com.divary.global.exception.ErrorCode; import com.divary.global.redis.service.TokenBlackListService; @@ -19,7 +22,6 @@ import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; -import java.util.Arrays; /** * 클라이언트의 모든 API 요청을 가로채 Access Token의 유효성을 검증하는 필터입니다. @@ -33,6 +35,9 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; private final JwtResolver jwtResolver; private final TokenBlackListService tokenBlackListService; + private final MemberService memberService; + private static final String REACTIVATE_MEMBER_URI = "/api/v1/auth/reactivate"; + private static final String REACTIVATE_MEMBER_METHOD = "POST"; //todo 하드코딩 안하게 변경 @Override protected void doFilterInternal(@NonNull HttpServletRequest request, @@ -45,9 +50,23 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, // 2. 헤더에서 Access Token을 추출합니다. String accessToken = jwtResolver.resolveAccessToken(request); + // 3. Access Token이 존재하는 경우에만 검증을 시작합니다. if (StringUtils.hasText(accessToken)) { + Long userId = jwtTokenProvider.getUserIdFromToken(accessToken); + + Member member = memberService.findById(userId); + + // 1. 현재 요청이 회원 복구 API인지 확인합니다. + boolean isRecoveryRequest = request.getRequestURI().equals(REACTIVATE_MEMBER_URI) && + request.getMethod().equalsIgnoreCase(REACTIVATE_MEMBER_METHOD); + + // 2. 복구 요청이 아닌 경우에만 비활성화 상태를 체크합니다. + if (!isRecoveryRequest && member.getStatus() == Status.DEACTIVATED) { + throw new BusinessException(ErrorCode.MEMBER_IS_DEACTIVATE); + } + //토큰이 유효한지 검증합니다. if (jwtTokenProvider.validateToken(accessToken)) { // 토큰이 유효하면, 로그아웃 처리된 토큰인지 블랙리스트를 확인하고 인증 정보를 SecurityContext에 등록합니다. diff --git a/src/main/java/com/divary/global/config/security/SecurityConfig.java b/src/main/java/com/divary/global/config/security/SecurityConfig.java index f6ef95f3..acccf93e 100644 --- a/src/main/java/com/divary/global/config/security/SecurityConfig.java +++ b/src/main/java/com/divary/global/config/security/SecurityConfig.java @@ -7,7 +7,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; @@ -87,4 +86,5 @@ public AccessDeniedHandler customAccessDeniedHandler() { public BCryptPasswordEncoder bCryptPasswordEncoder() { return new BCryptPasswordEncoder(); } + } \ No newline at end of file diff --git a/src/main/java/com/divary/global/exception/ErrorCode.java b/src/main/java/com/divary/global/exception/ErrorCode.java index bd36198a..b6c52a37 100644 --- a/src/main/java/com/divary/global/exception/ErrorCode.java +++ b/src/main/java/com/divary/global/exception/ErrorCode.java @@ -42,7 +42,8 @@ public enum ErrorCode { MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER_002", "유저를 찾을 수 없습니다."), MEMBER_ALREADY_EXISTS(HttpStatus.NOT_FOUND, "MEMBER_003", "이미 가입된 이메일입니다."), DEVICE_ID_NOT_FOUND(HttpStatus.NOT_FOUND, "DEVICE_001", "디바이스 아이디를 찾을 수 없습니다"), - + MEMBER_IS_DEACTIVATE(HttpStatus.FORBIDDEN, "MEMBER_004", "탈퇴 예정인 계정입니다."), + CONCURRENT_REQUEST_ERROR(HttpStatus.CONFLICT, "MEMBER_005", "탈퇴 요청 처리 중 데이터 충돌이 발생했습니다"), //소셜 로그인 관련 GOOGLE_BAD_GATEWAY(HttpStatus.BAD_GATEWAY, "GOOGLE_001", "구글 유저를 찾을 수 없습니다"), @@ -78,8 +79,10 @@ public enum ErrorCode { //device session 관련 - REFRESH_TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "DEVICE_001", "refresh token을 찾을 수 없습니다."); + REFRESH_TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "DEVICE_001", "refresh token을 찾을 수 없습니다."), + //충돌 오류 + CONCURRENCY_CONFLICT(HttpStatus.CONFLICT, "Conflict_001", "요청이 다른 사용자와 충돌했습니다. 페이지를 새로고침 후 다시 시도해주세요."); // TODO: 비즈니스 로직 개발하면서 필요한 에러코드들 추가 private final HttpStatus status; private final String code; diff --git a/src/main/java/com/divary/global/exception/GlobalExceptionHandler.java b/src/main/java/com/divary/global/exception/GlobalExceptionHandler.java index 3c71aab0..58df2b5d 100644 --- a/src/main/java/com/divary/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/divary/global/exception/GlobalExceptionHandler.java @@ -4,6 +4,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.validation.BindException; import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.bind.MethodArgumentNotValidException; @@ -72,6 +73,19 @@ protected ResponseEntity> handleMethodNotSupportedException(Ht .body(ApiResponse.error(ErrorCode.METHOD_NOT_ALLOWED, request.getRequestURI())); } + /** + * JPA Optimistic Lock 버전 충돌 예외 처리 + */ + @ExceptionHandler(ObjectOptimisticLockingFailureException.class) + protected ResponseEntity> handleOptimisticLockingFailureException(ObjectOptimisticLockingFailureException e) { + log.warn("handleOptimisticLockingFailureException", e); // 충돌 발생 로깅 + + // 409 Conflict 상태 코드로 응답 + return ResponseEntity + .status(HttpStatus.CONFLICT) + .body(ApiResponse.error(ErrorCode.CONCURRENCY_CONFLICT)); + } + /** * 기타 모든 예외 처리 */ diff --git a/src/main/java/com/divary/global/oauth/controller/OauthController.java b/src/main/java/com/divary/global/oauth/controller/OauthController.java index 59188acc..6b62ccb9 100644 --- a/src/main/java/com/divary/global/oauth/controller/OauthController.java +++ b/src/main/java/com/divary/global/oauth/controller/OauthController.java @@ -3,6 +3,7 @@ import com.divary.common.enums.SocialType; import com.divary.common.response.ApiResponse; +import com.divary.domain.member.service.MemberService; import com.divary.global.config.SwaggerConfig.ApiErrorExamples; import com.divary.global.config.SwaggerConfig.ApiSuccessResponse; import com.divary.global.exception.ErrorCode; @@ -10,8 +11,10 @@ import com.divary.global.config.security.CustomUserPrincipal; import com.divary.global.oauth.dto.request.LogoutRequestDto; import com.divary.global.oauth.dto.request.LoginRequestDto; +import com.divary.global.oauth.dto.response.DeactivateResponse; import com.divary.global.oauth.dto.response.LoginResponseDTO; import com.divary.global.oauth.service.OauthService; +import com.divary.global.redis.service.TokenBlackListService; import io.swagger.v3.oas.annotations.Operation; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; @@ -28,6 +31,8 @@ public class OauthController { private final OauthService oauthService; private final JwtResolver jwtResolver; + private final MemberService memberService; + private final TokenBlackListService tokenBlackListService; @PostMapping(value = "/{socialLoginType}/login") @@ -51,7 +56,8 @@ public ApiResponse login(@PathVariable(name = "socialLoginType public ApiResponse logout(@AuthenticationPrincipal CustomUserPrincipal userPrincipal, @PathVariable(name = "socialLoginType") SocialType socialLoginType, HttpServletRequest request, @RequestBody LogoutRequestDto logoutRequestDto) { String accessToken = jwtResolver.resolveAccessToken(request); - oauthService.logout(socialLoginType, logoutRequestDto.getDeviceId(), userPrincipal.getId(), accessToken); + oauthService.logout(socialLoginType, logoutRequestDto.getDeviceId(), userPrincipal.getId()); + tokenBlackListService.addToBlacklist(accessToken); return ApiResponse.success("로그아웃에 성공했습니다."); } @@ -67,4 +73,36 @@ public ApiResponse reissueToken(HttpServletRequest request) { return ApiResponse.success(newTokens); } + + @PostMapping(value = "/deactivate") + @Operation(summary = "회원 탈퇴를 요청합니다.") + @ApiSuccessResponse(dataType = DeactivateResponse.class) + @ApiErrorExamples(value = {ErrorCode.MEMBER_NOT_FOUND}) + public ApiResponse deactivateUser(@AuthenticationPrincipal CustomUserPrincipal userPrincipal, HttpServletRequest request) { + Long userId = userPrincipal.getId(); + String accessToken = jwtResolver.resolveAccessToken(request); + + DeactivateResponse response = memberService.requestToDeleteMember(userId); + + /** + * Redis의 SADD 명령어는 Set에 멤버를 추가하는데, 이미 멤버가 존재하면 아무 작업도 하지 않고 성공을 반환합니다. 에러가 발생하지 않습니다. + *이 경우, isContainToken을 호출하는 것은 불필요한 DB 조회(네트워크 왕복)를 한 번 더 하는 셈이므로 성능상 손해입니다. + * 그냥 바로 addToBlacklist를 호출하는 것이 코드도 간결하고 효율적입니다. + */ + tokenBlackListService.addToBlacklist(accessToken); + + return ApiResponse.success(response); + } + + @PostMapping(value = "/reactivate") + @Operation(summary = "회원 탈퇴를 취소합니다.") + @ApiSuccessResponse(dataType = void.class) + @ApiErrorExamples(value = {ErrorCode.MEMBER_NOT_FOUND}) + public ApiResponse reactivate(@AuthenticationPrincipal CustomUserPrincipal userPrincipal) { + Long userId = userPrincipal.getId(); + + memberService.cancelDeleteMember(userId); + return ApiResponse.success("회원 정보 복구에 성공했습니다"); + } + } \ No newline at end of file diff --git a/src/main/java/com/divary/global/oauth/dto/response/DeactivateResponse.java b/src/main/java/com/divary/global/oauth/dto/response/DeactivateResponse.java new file mode 100644 index 00000000..99423238 --- /dev/null +++ b/src/main/java/com/divary/global/oauth/dto/response/DeactivateResponse.java @@ -0,0 +1,17 @@ +package com.divary.global.oauth.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class DeactivateResponse { + // 최종 삭제 예정 시간을 담을 필드 + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss") + private final LocalDateTime scheduledDeletionAt; + + public DeactivateResponse(LocalDateTime scheduledDeletionAt) { + this.scheduledDeletionAt = scheduledDeletionAt; + } +} diff --git a/src/main/java/com/divary/global/oauth/service/OauthService.java b/src/main/java/com/divary/global/oauth/service/OauthService.java index e563fa82..cb2fae6f 100644 --- a/src/main/java/com/divary/global/oauth/service/OauthService.java +++ b/src/main/java/com/divary/global/oauth/service/OauthService.java @@ -1,10 +1,13 @@ package com.divary.global.oauth.service; import com.divary.common.enums.SocialType; +import com.divary.domain.member.entity.Member; import com.divary.domain.member.enums.Role; +import com.divary.domain.member.enums.Status; import com.divary.domain.member.repository.MemberRepository; import com.divary.domain.device_session.entity.DeviceSession; import com.divary.domain.device_session.repository.DeviceSessionRepository; +import com.divary.domain.member.service.MemberService; import com.divary.global.config.jwt.JwtTokenProvider; import com.divary.global.config.security.CustomUserPrincipal; import com.divary.global.exception.BusinessException; @@ -27,6 +30,8 @@ public class OauthService { private final JwtTokenProvider jwtTokenProvider; private final DeviceSessionRepository deviceSessionRepository; private final SocialOauthServiceFactory socialOauthServiceFactory; + private final MemberRepository memberRepository; + private final MemberService memberService; public SocialOauth findSocialOauthByType(SocialType socialType) { @@ -43,12 +48,12 @@ public LoginResponseDTO authenticateWithAccessToken(SocialType socialLoginType, } @Transactional - public void logout(SocialType socialLoginType, String deviceId, Long userId, String accessToken) { + public void logout(SocialType socialLoginType, String deviceId, Long userId) { SocialOauth socialOauth = this.findSocialOauthByType(socialLoginType); if (socialOauth == null) { throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE); } - socialOauth.logout(deviceId, userId, accessToken); + socialOauth.logout(deviceId, userId); } /** @@ -65,16 +70,23 @@ public LoginResponseDTO reissueToken(String refreshToken, String deviceId) { throw new BusinessException(ErrorCode.INVALID_TOKEN, "Refresh Token이 유효하지 않습니다."); } - // 2. DB에 저장된 토큰과 일치하는지, Device ID가 맞는지 확인 - boolean exists = deviceSessionRepository.existsByRefreshTokenAndDeviceId(refreshToken, deviceId); - if (!exists) { - throw new BusinessException(ErrorCode.REFRESH_TOKEN_NOT_FOUND, "저장소에 Refresh Token이 없거나 기기 정보가 일치하지 않습니다."); - } - // 3. 토큰에서 사용자 ID 추출 + // 2. 토큰에서 사용자 ID 추출 Long userId = jwtTokenProvider.getUserIdFromToken(refreshToken); Role role = jwtTokenProvider.getRoleFromToken(refreshToken); + Member member = memberRepository.findById(userId).orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); + + // 3. 회원의 탈퇴 여부 확인 + if (member.getStatus() == Status.DEACTIVATED){ + throw new BusinessException(ErrorCode.MEMBER_IS_DEACTIVATE); + } + + // 4. DB에 저장된 토큰과 일치하는지, Device ID가 맞는지 확인 + boolean exists = deviceSessionRepository.existsByRefreshTokenAndDeviceId(refreshToken, deviceId); + if (!exists) { + throw new BusinessException(ErrorCode.REFRESH_TOKEN_NOT_FOUND, "저장소에 Refresh Token이 없거나 기기 정보가 일치하지 않습니다."); + } diff --git a/src/main/java/com/divary/global/oauth/service/social/AppleOauth.java b/src/main/java/com/divary/global/oauth/service/social/AppleOauth.java index 312ae985..6ddafaa5 100644 --- a/src/main/java/com/divary/global/oauth/service/social/AppleOauth.java +++ b/src/main/java/com/divary/global/oauth/service/social/AppleOauth.java @@ -3,6 +3,7 @@ import com.divary.common.enums.SocialType; import com.divary.domain.member.entity.Member; import com.divary.domain.member.enums.Role; +import com.divary.domain.member.enums.Status; import com.divary.domain.member.service.MemberService; import com.divary.domain.device_session.service.DeviceSessionService; import com.divary.global.config.jwt.JwtTokenProvider; @@ -47,18 +48,7 @@ public LoginResponseDTO verifyAndLogin(String identityToken, String deviceId) { Map userInfo = appleJwtParser.parse(identityToken); String email = userInfo.get("email"); - Member member; - - try { - member = memberService.findMemberByEmail(email); - - } catch (BusinessException e) { - member = memberService.saveMember(Member.builder() - .email(email) - .role(Role.USER) - .build()); - - } + Member member = memberService.findOrCreateMember(email); CustomUserPrincipal principal = new CustomUserPrincipal(member); @@ -81,9 +71,7 @@ public LoginResponseDTO verifyAndLogin(String identityToken, String deviceId) { } @Override - public void logout(String deviceId, Long userId, String accessToken) { - // AccessToken을 블랙리스트에 추가합니다. - tokenBlackListService.addToBlacklist(accessToken); + public void logout(String deviceId, Long userId) { // DB에서 Refresh Token(디바이스 세션)을 삭제합니다. deviceSessionService.removeRefreshToken(deviceId, userId); diff --git a/src/main/java/com/divary/global/oauth/service/social/GoogleOauth.java b/src/main/java/com/divary/global/oauth/service/social/GoogleOauth.java index 40919b00..3c18fcda 100644 --- a/src/main/java/com/divary/global/oauth/service/social/GoogleOauth.java +++ b/src/main/java/com/divary/global/oauth/service/social/GoogleOauth.java @@ -3,6 +3,7 @@ import com.divary.common.enums.SocialType; import com.divary.domain.member.entity.Member; import com.divary.domain.member.enums.Role; +import com.divary.domain.member.enums.Status; import com.divary.domain.member.service.MemberService; import com.divary.domain.avatar.service.AvatarService; import com.divary.domain.device_session.service.DeviceSessionService; @@ -73,18 +74,8 @@ public LoginResponseDTO verifyAndLogin(String googleAccessToken, String deviceId Map userInfo = requestUserInfo(googleAccessToken); String email = (String) userInfo.get("email"); - Member member; - try { - member = memberService.findMemberByEmail(email); - - } catch (BusinessException e) { - member = memberService.saveMember(Member.builder() - .email(email) - .role(Role.USER) - .build()); - - } + Member member = memberService.findOrCreateMember(email); CustomUserPrincipal principal = new CustomUserPrincipal(member); @@ -105,8 +96,7 @@ public LoginResponseDTO verifyAndLogin(String googleAccessToken, String deviceId return LoginResponseDTO.builder().accessToken(accessToken).refreshToken(refreshToken).build(); } - public void logout(String deviceId, Long userId, String accessToken) { - tokenBlackListService.addToBlacklist(accessToken); + public void logout(String deviceId, Long userId) { //DB에서 Refresh Token을 삭제합니다. deviceSessionService.removeRefreshToken(deviceId, userId); diff --git a/src/main/java/com/divary/global/oauth/service/social/SocialOauth.java b/src/main/java/com/divary/global/oauth/service/social/SocialOauth.java index 9f36b447..17eac98b 100644 --- a/src/main/java/com/divary/global/oauth/service/social/SocialOauth.java +++ b/src/main/java/com/divary/global/oauth/service/social/SocialOauth.java @@ -5,7 +5,7 @@ public interface SocialOauth { LoginResponseDTO verifyAndLogin(String token, String deviceId); - void logout(String deviceId, Long userId, String accessToken); + void logout(String deviceId, Long userId); SocialType getType(); } diff --git a/src/main/java/com/divary/global/oauth/util/UserDeletionScheduler.java b/src/main/java/com/divary/global/oauth/util/UserDeletionScheduler.java new file mode 100644 index 00000000..a6387ef9 --- /dev/null +++ b/src/main/java/com/divary/global/oauth/util/UserDeletionScheduler.java @@ -0,0 +1,48 @@ +package com.divary.global.oauth.util; + +import com.divary.domain.member.entity.Member; +import com.divary.domain.member.enums.Status; +import com.divary.domain.member.repository.MemberRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; +import java.util.List; + +@Component +public class UserDeletionScheduler { + + // 유예 기간 (예: 7일) + @Value("${jobs.user-deletion.grace-period-days}") + private int gracePeriodDays; + + private final MemberRepository memberRepository; + + public UserDeletionScheduler(MemberRepository memberRepository) { + this.memberRepository = memberRepository; + } + + // 매일 밤 12시에 실행 (cron = "초 분 시 일 월 요일") + @Scheduled(cron = "${jobs.user-deletion.cron}") + @Transactional + public void cleanupDeactivatedUsers() { + System.out.println("탈퇴 유예 기간이 지난 사용자 삭제 작업을 시작합니다..."); + + // 유예 기간이 지난 탈퇴 요청 사용자 조회 + LocalDateTime cutoffDate = LocalDateTime.now().minusDays(gracePeriodDays); + + List usersToDelete = memberRepository.findByStatusAndDeactivatedAtBefore( + Status.DEACTIVATED, + cutoffDate + ); + + // 실제 데이터 영구 삭제 + if (!usersToDelete.isEmpty()) { + memberRepository.deleteAll(usersToDelete); + System.out.println(usersToDelete.size() + "명의 사용자 정보가 영구 삭제되었습니다."); + } else { + System.out.println("삭제할 사용자가 없습니다."); + } + } +}