diff --git a/sql/schema.sql b/sql/schema.sql index d0e7c86f..104071ba 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -179,11 +179,12 @@ create table user ( nickname varchar(16) not null unique, password varchar(255) not null, role varchar(20) not null, - user_id varchar(50) not null unique, + user_id varchar(50) unique, profile_image_url varchar(255), created_at datetime, modified_at datetime, is_deleted tinyint(1), + deleted_date date, primary key (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/docs/asciidoc/api-doc.adoc b/src/docs/asciidoc/api-doc.adoc index f8f2cc58..f545147b 100644 --- a/src/docs/asciidoc/api-doc.adoc +++ b/src/docs/asciidoc/api-doc.adoc @@ -193,6 +193,15 @@ include::{snippets}/auth-controller-test/respond_200_when_login_succeed/http-req include::{snippets}/auth-controller-test/respond_200_when_login_succeed/response-fields.adoc[] ==== Sample Response include::{snippets}/auth-controller-test/respond_200_when_login_succeed/http-response.adoc[] +==== Error Response +|=== +| HTTP Status | Error Code | Detail + +| `401 UNAUTHORIZED` | `INVALID_PASSWORD` | 비밀번호가 일치하지 않는 경우 +| `403 FORBIDDEN` | `FORBIDDEN_USER` | 탈퇴한 회원인 경우 +| `404 NOT FOUND` | `NOT_FOUND_USER` | 해당하는 아이디를 가진 사용자가 없는 경우 +|=== + === 2-2. 로그아웃 ==== Request Headers include::{snippets}/auth-controller-test/respond_200_when_succeed_to_logout/request-headers.adoc[] @@ -202,6 +211,7 @@ include::{snippets}/auth-controller-test/respond_200_when_succeed_to_logout/requ include::{snippets}/auth-controller-test/respond_200_when_succeed_to_logout/http-request.adoc[] ==== Sample Response include::{snippets}/auth-controller-test/respond_200_when_succeed_to_logout/http-response.adoc[] + === 2-3. 토큰 재발급 ==== Request Headers include::{snippets}/auth-controller-test/respond_200_when_succeed_to_reissue_tokens/request-headers.adoc[] diff --git a/src/main/java/com/cvsgo/config/SchedulerConfig.java b/src/main/java/com/cvsgo/config/SchedulerConfig.java new file mode 100644 index 00000000..512e9949 --- /dev/null +++ b/src/main/java/com/cvsgo/config/SchedulerConfig.java @@ -0,0 +1,21 @@ +package com.cvsgo.config; + +import com.cvsgo.service.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; + +@RequiredArgsConstructor +@EnableScheduling +@Configuration +public class SchedulerConfig { + + private final UserService userService; + + @Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul") + public void deleteUserId() { + userService.deleteUserId(); + } + +} diff --git a/src/main/java/com/cvsgo/entity/User.java b/src/main/java/com/cvsgo/entity/User.java index 32fda485..d474d778 100644 --- a/src/main/java/com/cvsgo/entity/User.java +++ b/src/main/java/com/cvsgo/entity/User.java @@ -12,6 +12,7 @@ import jakarta.persistence.Id; import jakarta.persistence.OneToMany; import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; import java.util.ArrayList; import java.util.List; import lombok.AccessLevel; @@ -22,7 +23,7 @@ import org.springframework.security.crypto.password.PasswordEncoder; @Getter -@SQLDelete(sql = "UPDATE user SET is_deleted = true WHERE id = ?") +@SQLDelete(sql = "UPDATE user SET is_deleted = true, deleted_date = NOW() WHERE id = ?") @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity public class User extends BaseTimeEntity { @@ -31,7 +32,6 @@ public class User extends BaseTimeEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @NotNull @Column(unique = true) private String userId; @@ -50,17 +50,21 @@ public class User extends BaseTimeEntity { private Boolean isDeleted = Boolean.FALSE; + private LocalDate deletedDate; + @OneToMany(mappedBy = "user", cascade = CascadeType.PERSIST) private List userTags = new ArrayList<>(); @Builder - public User(Long id, String userId, String password, String nickname, Role role, Boolean isDeleted) { + public User(Long id, String userId, String password, String nickname, Role role, + Boolean isDeleted, LocalDate deletedDate) { this.id = id; this.userId = userId; this.password = password; this.nickname = nickname; this.role = role; this.isDeleted = isDeleted; + this.deletedDate = deletedDate; } public static User create(String userId, String password, String nickname, List tags) { @@ -69,6 +73,7 @@ public static User create(String userId, String password, String nickname, List< .password(password) .nickname(nickname) .role(Role.ASSOCIATE) + .isDeleted(false) .build(); for (Tag tag : tags) { user.addTag(tag); @@ -111,4 +116,8 @@ public void updateProfileImageUrl(String profileImageUrl) { this.profileImageUrl = profileImageUrl; } + public void deleteUserId() { + this.userId = null; + } + } diff --git a/src/main/java/com/cvsgo/exception/ErrorCode.java b/src/main/java/com/cvsgo/exception/ErrorCode.java index 5caa832b..735596d4 100644 --- a/src/main/java/com/cvsgo/exception/ErrorCode.java +++ b/src/main/java/com/cvsgo/exception/ErrorCode.java @@ -27,6 +27,7 @@ public enum ErrorCode { DUPLICATE_REVIEW_LIKE( "하나의 리뷰에 좋아요는 한 번만 할 수 있습니다."), /* 403 FORBIDDEN */ + FORBIDDEN_USER("탈퇴한 회원입니다."), FORBIDDEN_REVIEW("해당 리뷰에 대한 권한이 없습니다."), /* 404 NOT_FOUND */ diff --git a/src/main/java/com/cvsgo/exception/ExceptionConstants.java b/src/main/java/com/cvsgo/exception/ExceptionConstants.java index d1538683..19eb3baa 100644 --- a/src/main/java/com/cvsgo/exception/ExceptionConstants.java +++ b/src/main/java/com/cvsgo/exception/ExceptionConstants.java @@ -8,6 +8,7 @@ public interface ExceptionConstants { UnauthorizedException UNAUTHORIZED_USER = new UnauthorizedException(ErrorCode.UNAUTHORIZED_USER); BadRequestException INVALID_FILE_SIZE = new BadRequestException(ErrorCode.INVALID_FILE_SIZE); + ForbiddenException FORBIDDEN_USER = new ForbiddenException(ErrorCode.FORBIDDEN_USER); ForbiddenException FORBIDDEN_REVIEW = new ForbiddenException(ErrorCode.FORBIDDEN_REVIEW); diff --git a/src/main/java/com/cvsgo/repository/UserRepository.java b/src/main/java/com/cvsgo/repository/UserRepository.java index 7d6f5613..b9a85bae 100644 --- a/src/main/java/com/cvsgo/repository/UserRepository.java +++ b/src/main/java/com/cvsgo/repository/UserRepository.java @@ -1,6 +1,8 @@ package com.cvsgo.repository; import com.cvsgo.entity.User; +import java.time.LocalDate; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; @@ -10,4 +12,6 @@ public interface UserRepository extends JpaRepository { Optional findByUserId(String userId); Optional findByNickname(String nickname); + + List findByDeletedDate(LocalDate privacyDate); } diff --git a/src/main/java/com/cvsgo/service/AuthService.java b/src/main/java/com/cvsgo/service/AuthService.java index 57c802f1..6952d4a8 100644 --- a/src/main/java/com/cvsgo/service/AuthService.java +++ b/src/main/java/com/cvsgo/service/AuthService.java @@ -1,5 +1,6 @@ package com.cvsgo.service; +import static com.cvsgo.exception.ExceptionConstants.FORBIDDEN_USER; import static com.cvsgo.exception.ExceptionConstants.NOT_FOUND_USER; import static com.cvsgo.exception.ExceptionConstants.UNAUTHORIZED_USER; import static com.cvsgo.util.AuthConstants.ACCESS_TOKEN_TTL_MILLISECOND; @@ -11,6 +12,7 @@ import com.cvsgo.dto.auth.TokenDto; import com.cvsgo.entity.RefreshToken; import com.cvsgo.entity.User; +import com.cvsgo.exception.ForbiddenException; import com.cvsgo.exception.NotFoundException; import com.cvsgo.exception.UnauthorizedException; import com.cvsgo.repository.RefreshTokenRepository; @@ -54,14 +56,15 @@ public AuthService(@Value("${jwt.secret-key}") final String secretKey, * * @param request 로그인 요청 정보 * @return 토큰 정보 - * @throws NotFoundException 해당하는 아이디를 가진 사용자가 없는 경우 * @throws UnauthorizedException 비밀번호가 일치하지 않는 경우 + * @throws ForbiddenException 탈퇴한 회원인 경우 + * @throws NotFoundException 해당하는 아이디를 가진 사용자가 없는 경우 */ @Transactional public LoginResponseDto login(LoginRequestDto request) { User user = userRepository.findByUserId(request.getEmail()) .orElseThrow(() -> NOT_FOUND_USER); - if (Boolean.TRUE.equals(user.getIsDeleted())) throw NOT_FOUND_USER; + if (Boolean.TRUE.equals(user.getIsDeleted())) throw FORBIDDEN_USER; user.validatePassword(request.getPassword(), passwordEncoder); String accessToken = createAccessToken(user, key, ACCESS_TOKEN_TTL_MILLISECOND); diff --git a/src/main/java/com/cvsgo/service/UserService.java b/src/main/java/com/cvsgo/service/UserService.java index cfaa54e3..234edd72 100644 --- a/src/main/java/com/cvsgo/service/UserService.java +++ b/src/main/java/com/cvsgo/service/UserService.java @@ -26,6 +26,7 @@ import com.cvsgo.repository.UserRepository; import com.cvsgo.repository.UserTagRepository; import jakarta.persistence.EntityManager; +import java.time.LocalDate; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -145,7 +146,7 @@ public void updateUser(User user, UpdateUserRequestDto request) { /** * 사용자를 논리 삭제한다. * - * @param user 로그인한 사용자 + * @param user 로그인한 사용자 */ @Transactional public void deleteUser(User user) { @@ -153,6 +154,15 @@ public void deleteUser(User user) { userRepository.delete(user); } + /** + * 탈퇴 후 30일이 지나면 userId가 초기화된다. + */ + @Transactional + public void deleteUserId() { + userRepository.findByDeletedDate(LocalDate.now().minusDays(30)) + .forEach(User::deleteUserId); + } + /** * 회원 팔로우를 생성한다. * diff --git a/src/test/java/com/cvsgo/controller/AuthControllerTest.java b/src/test/java/com/cvsgo/controller/AuthControllerTest.java index 7658a17f..356ce296 100644 --- a/src/test/java/com/cvsgo/controller/AuthControllerTest.java +++ b/src/test/java/com/cvsgo/controller/AuthControllerTest.java @@ -134,19 +134,19 @@ void respond_400_when_login_but_user_does_not_exist() throws Exception { } @Test - @DisplayName("탈퇴한 계정이면 로그인 API 호출시 HTTP 400를 응답한다") - void respond_400_when_login_but_user_is_deleted() throws Exception { + @DisplayName("탈퇴한 계정이면 로그인 API 호출시 HTTP 403를 응답한다") + void respond_403_when_login_but_user_is_deleted() throws Exception { LoginRequestDto loginRequestDto = LoginRequestDto.builder() .email("abc@naver.com") .password("password1!") .build(); - given(authService.login(any())).willThrow(ExceptionConstants.NOT_FOUND_USER); + given(authService.login(any())).willThrow(ExceptionConstants.FORBIDDEN_USER); mockMvc.perform(post("/api/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(loginRequestDto))) - .andExpect(status().isNotFound()) + .andExpect(status().isForbidden()) .andDo(print()); } diff --git a/src/test/java/com/cvsgo/repository/UserRepositoryTest.java b/src/test/java/com/cvsgo/repository/UserRepositoryTest.java index 0d8d5698..756ff5b7 100644 --- a/src/test/java/com/cvsgo/repository/UserRepositoryTest.java +++ b/src/test/java/com/cvsgo/repository/UserRepositoryTest.java @@ -1,24 +1,24 @@ package com.cvsgo.repository; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; + import com.cvsgo.config.TestConfig; import com.cvsgo.entity.Role; import com.cvsgo.entity.User; import jakarta.persistence.EntityManager; +import java.time.LocalDate; import java.util.ArrayList; +import java.util.List; +import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; - -import java.util.Optional; import org.springframework.context.annotation.Import; -import static org.assertj.core.api.Assertions.as; -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertTrue; - @Import(TestConfig.class) @DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @@ -71,6 +71,30 @@ void succeed_to_find_user_by_email() { assertThat(foundUser).isPresent(); } + @Test + @DisplayName("탈퇴 후 30일이 경과한 사용자를 찾는다") + void succeed_to_find_privacy_date_equal() { + User invalidUser = User.builder() + .userId("invalidUser") + .password("111111111a!") + .nickname("탈퇴 30일") + .role(Role.ASSOCIATE) + .deletedDate(LocalDate.now().minusDays(30)) + .build(); + User validUser = User.builder() + .userId("validUser") + .password("111111111a!") + .nickname("탈퇴 29일") + .role(Role.ASSOCIATE) + .deletedDate(LocalDate.now().minusDays(29)) + .build(); + userRepository.saveAll(List.of(invalidUser, validUser)); + + List foundUser = userRepository.findByDeletedDate(LocalDate.now().minusDays(30)); + + assertThat(foundUser).hasSize(1); + } + @Test @DisplayName("사용자를 soft delete 한다") void succeed_to_soft_delete_user() { diff --git a/src/test/java/com/cvsgo/service/AuthServiceTest.java b/src/test/java/com/cvsgo/service/AuthServiceTest.java index eecafc71..f17cd61f 100644 --- a/src/test/java/com/cvsgo/service/AuthServiceTest.java +++ b/src/test/java/com/cvsgo/service/AuthServiceTest.java @@ -5,6 +5,7 @@ import com.cvsgo.dto.auth.TokenDto; import com.cvsgo.entity.RefreshToken; import com.cvsgo.entity.User; +import com.cvsgo.exception.ForbiddenException; import com.cvsgo.exception.NotFoundException; import com.cvsgo.exception.UnauthorizedException; import com.cvsgo.repository.RefreshTokenRepository; @@ -95,7 +96,7 @@ void should_throw_NotFoundException_when_user_tries_to_login_but_user_does_not_e } @Test - @DisplayName("탈퇴한 사용자이면 NotFoundException이 발생한다") + @DisplayName("탈퇴한 사용자이면 ForbiddenException이 발생한다") void should_throw_NotFoundException_when_user_tries_to_login_but_user_is_deleted() { LoginRequestDto loginRequestDto = LoginRequestDto.builder() .email("abc@naver.com") @@ -110,7 +111,7 @@ void should_throw_NotFoundException_when_user_tries_to_login_but_user_is_deleted given(userRepository.findByUserId(loginRequestDto.getEmail())) .willReturn(Optional.of(user)); - assertThrows(NotFoundException.class, () -> authService.login(loginRequestDto)); + assertThrows(ForbiddenException.class, () -> authService.login(loginRequestDto)); then(userRepository).should(times(1)).findByUserId(any()); } diff --git a/src/test/java/com/cvsgo/service/UserServiceTest.java b/src/test/java/com/cvsgo/service/UserServiceTest.java index bafba71f..4a9c18c0 100644 --- a/src/test/java/com/cvsgo/service/UserServiceTest.java +++ b/src/test/java/com/cvsgo/service/UserServiceTest.java @@ -2,6 +2,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -9,7 +11,6 @@ import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; import com.cvsgo.dto.user.SignUpRequestDto; import com.cvsgo.dto.user.UpdateUserRequestDto; @@ -29,6 +30,7 @@ import com.cvsgo.repository.UserRepository; import com.cvsgo.repository.UserTagRepository; import jakarta.persistence.EntityManager; +import java.time.LocalDate; import java.util.Arrays; import java.util.List; import java.util.Optional; @@ -240,6 +242,27 @@ void succeed_to_soft_delete_user() { then(userRepository).should(times(1)).delete(any()); } + @Test + @DisplayName("탈퇴 후 30일이 경과하면 userId를 초기화한다") + void succeed_to_delete_user_id() { + User invalidUser = User.builder() + .userId("testUserBeforeMidnight") + .deletedDate(LocalDate.now().minusDays(30)) + .build(); + User validUser = User.builder() + .userId("testUserAfterMidnight") + .deletedDate(LocalDate.now().minusDays(29)) + .build(); + + given(userRepository.findByDeletedDate(LocalDate.now().minusDays(30))).willReturn(List.of(invalidUser)); + + userService.deleteUserId(); + + then(userRepository).should(times(1)).findByDeletedDate(any()); + assertNull(invalidUser.getUserId()); + assertNotNull(validUser.getUserId()); + } + @Test @DisplayName("회원 팔로우를 정상적으로 생성한다") void succeed_to_create_user_follow() {