Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions src/main/java/com/divary/domain/avatar/entity/Avatar.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions src/main/java/com/divary/domain/logbase/LogBaseInfo.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "다이빙 로그 기본정보")
Expand All @@ -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)
Expand Down
26 changes: 25 additions & 1 deletion src/main/java/com/divary/domain/member/entity/Member.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@
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.*;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Null;
import lombok.*;

import java.time.LocalDateTime;

@Entity
@Builder
@Getter
Expand All @@ -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;
}
}
5 changes: 5 additions & 0 deletions src/main/java/com/divary/domain/member/enums/Status.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.divary.domain.member.enums;

public enum Status {
ACTIVE, DEACTIVATED
}
Original file line number Diff line number Diff line change
@@ -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<Member, Long> {
Optional<Member> findByEmail(String email);
Optional<Member> findById(Long id);
List<Member> findByStatusAndDeactivatedAtBefore(Status status, LocalDateTime cutoffDate);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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);


}
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,38 @@
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;
import com.divary.domain.member.entity.Member;
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) {
Expand Down Expand Up @@ -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<Member> 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);
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -99,6 +100,7 @@ public ApiResponse<String> 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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -19,7 +22,6 @@
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.Arrays;

/**
* 클라이언트의 모든 API 요청을 가로채 Access Token의 유효성을 검증하는 필터입니다.
Expand All @@ -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,
Expand All @@ -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에 등록합니다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -87,4 +86,5 @@ public AccessDeniedHandler customAccessDeniedHandler() {
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}

}
7 changes: 5 additions & 2 deletions src/main/java/com/divary/global/exception/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -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", "구글 유저를 찾을 수 없습니다"),

Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -72,6 +73,19 @@ protected ResponseEntity<ApiResponse<Void>> handleMethodNotSupportedException(Ht
.body(ApiResponse.error(ErrorCode.METHOD_NOT_ALLOWED, request.getRequestURI()));
}

/**
* JPA Optimistic Lock 버전 충돌 예외 처리
*/
@ExceptionHandler(ObjectOptimisticLockingFailureException.class)
protected ResponseEntity<ApiResponse<Void>> handleOptimisticLockingFailureException(ObjectOptimisticLockingFailureException e) {
log.warn("handleOptimisticLockingFailureException", e); // 충돌 발생 로깅

// 409 Conflict 상태 코드로 응답
return ResponseEntity
.status(HttpStatus.CONFLICT)
.body(ApiResponse.error(ErrorCode.CONCURRENCY_CONFLICT));
}

/**
* 기타 모든 예외 처리
*/
Expand Down
Loading