From 85d0e6441763004d6a224474be003b8164a2b8c0 Mon Sep 17 00:00:00 2001 From: chaewss Date: Wed, 24 Apr 2024 22:05:24 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=ED=83=88=ED=87=B4=2030=EC=9D=BC=20=ED=9B=84=20userId=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=ED=99=94=20=EC=8A=A4=EC=BC=80=EC=A4=84?= =?UTF-8?q?=EB=9F=AC=20=EC=B6=94=EA=B0=80=20#121?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sql/schema.sql | 3 ++- .../com/cvsgo/config/SchedulerConfig.java | 21 +++++++++++++++++++ src/main/java/com/cvsgo/entity/User.java | 15 ++++++++++--- .../com/cvsgo/repository/UserRepository.java | 4 ++++ .../java/com/cvsgo/service/UserService.java | 12 ++++++++++- 5 files changed, 50 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/cvsgo/config/SchedulerConfig.java 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/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/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/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); + } + /** * 회원 팔로우를 생성한다. * From 4bf143d18047e79c0546faa74f0931ae6ad37c5f Mon Sep 17 00:00:00 2001 From: chaewss Date: Wed, 24 Apr 2024 22:06:24 +0900 Subject: [PATCH 2/5] =?UTF-8?q?test:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=ED=83=88=ED=87=B4=2030=EC=9D=BC=20=ED=9B=84=20userId=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=ED=99=94=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1=20#121?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cvsgo/repository/UserRepositoryTest.java | 36 +++++++++++++++---- .../com/cvsgo/service/UserServiceTest.java | 25 ++++++++++++- 2 files changed, 54 insertions(+), 7 deletions(-) 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/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() { From 0e535c39f16f6a26f5f37753fe5a075fe0d5a8d3 Mon Sep 17 00:00:00 2001 From: chaewss Date: Wed, 24 Apr 2024 22:09:23 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=83=81=ED=83=9C=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=A5=B8=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20#121?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/cvsgo/exception/ErrorCode.java | 1 + src/main/java/com/cvsgo/exception/ExceptionConstants.java | 1 + src/main/java/com/cvsgo/service/AuthService.java | 7 +++++-- 3 files changed, 7 insertions(+), 2 deletions(-) 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/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); From 61e9ba3085051db029c176f2b483ba8bd353f561 Mon Sep 17 00:00:00 2001 From: chaewss Date: Wed, 24 Apr 2024 22:09:49 +0900 Subject: [PATCH 4/5] =?UTF-8?q?test:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=83=81=ED=83=9C=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=A5=B8=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20#121?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/cvsgo/controller/AuthControllerTest.java | 8 ++++---- src/test/java/com/cvsgo/service/AuthServiceTest.java | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) 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/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()); } From 276a63ce261d8e0af109a32ced3f33c96a075891 Mon Sep 17 00:00:00 2001 From: chaewss Date: Wed, 24 Apr 2024 22:10:30 +0900 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20API?= =?UTF-8?q?=20Error=20Response=20=EC=B6=94=EA=B0=80=20#121?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/docs/asciidoc/api-doc.adoc | 10 ++++++++++ 1 file changed, 10 insertions(+) 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[]