From 198f49a47eb22284a3a26d5a3b1526d1a2a5babb Mon Sep 17 00:00:00 2001 From: ekgns33 Date: Mon, 7 Jul 2025 16:25:38 +0900 Subject: [PATCH 1/7] :hammer: chore : add add_withdrawal_reason table migration file --- .../db/migration/V20250707__add_withdraw_reason.sql | 7 +++++++ .../db/migration/h2/V20250707__add_withdraw_reason.sql | 7 +++++++ 2 files changed, 14 insertions(+) create mode 100644 src/main/resources/db/migration/V20250707__add_withdraw_reason.sql create mode 100644 src/test/resources/db/migration/h2/V20250707__add_withdraw_reason.sql diff --git a/src/main/resources/db/migration/V20250707__add_withdraw_reason.sql b/src/main/resources/db/migration/V20250707__add_withdraw_reason.sql new file mode 100644 index 0000000..1b16485 --- /dev/null +++ b/src/main/resources/db/migration/V20250707__add_withdraw_reason.sql @@ -0,0 +1,7 @@ +create table user_withdrawal_reason +( + `id` BIGINT NOT NULL PRIMARY KEY AUTO_INCREMENT, + `user_id` BIGINT NOT NULL, + `reason` VARCHAR(32) NOT NULL, + `custom_reason` VARCHAR(255) +); diff --git a/src/test/resources/db/migration/h2/V20250707__add_withdraw_reason.sql b/src/test/resources/db/migration/h2/V20250707__add_withdraw_reason.sql new file mode 100644 index 0000000..1b16485 --- /dev/null +++ b/src/test/resources/db/migration/h2/V20250707__add_withdraw_reason.sql @@ -0,0 +1,7 @@ +create table user_withdrawal_reason +( + `id` BIGINT NOT NULL PRIMARY KEY AUTO_INCREMENT, + `user_id` BIGINT NOT NULL, + `reason` VARCHAR(32) NOT NULL, + `custom_reason` VARCHAR(255) +); From f09dc254c9e27fd1f6dab3a2f6aae2348664c92a Mon Sep 17 00:00:00 2001 From: ekgns33 Date: Mon, 7 Jul 2025 16:30:18 +0900 Subject: [PATCH 2/7] :sparkles: feat : add UserWithdrawReason --- .../user/domain/UserWithdrawReason.java | 49 +++++++++++++++++++ .../runimo/user/domain/WithdrawalReason.java | 18 +++++++ 2 files changed, 67 insertions(+) create mode 100644 src/main/java/org/runimo/runimo/user/domain/UserWithdrawReason.java create mode 100644 src/main/java/org/runimo/runimo/user/domain/WithdrawalReason.java diff --git a/src/main/java/org/runimo/runimo/user/domain/UserWithdrawReason.java b/src/main/java/org/runimo/runimo/user/domain/UserWithdrawReason.java new file mode 100644 index 0000000..5e4a466 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/domain/UserWithdrawReason.java @@ -0,0 +1,49 @@ +package org.runimo.runimo.user.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.NoArgsConstructor; + +@Table(name = "user_withdrawal_reason") +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserWithdrawReason { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "reason", nullable = false) + @Enumerated(EnumType.STRING) + private WithdrawalReason reason; + + @Column(name = "custom_reason") + private String customReason; + + @Builder + public UserWithdrawReason(Long id, Long userId, WithdrawalReason reason, String customReason) { + this.id = id; + this.userId = userId; + this.reason = reason; + this.customReason = customReason; + validateReason(); + } + + private void validateReason() { + if (reason != WithdrawalReason.OTHER && (customReason != null && !customReason.isEmpty())) { + throw new IllegalArgumentException("customReason는 OTHER 타입일 때만 필요합니다."); + } + } + +} diff --git a/src/main/java/org/runimo/runimo/user/domain/WithdrawalReason.java b/src/main/java/org/runimo/runimo/user/domain/WithdrawalReason.java new file mode 100644 index 0000000..cb8a51a --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/domain/WithdrawalReason.java @@ -0,0 +1,18 @@ +package org.runimo.runimo.user.domain; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum WithdrawalReason { + NOT_ENOUGH_CHARACTERS("수집할 캐릭터가 부족해요"), + NO_LONGER_NEEDED("러닝 목표에 도움이 되지 않아요"), + RECORD_NOT_EXPECTED("기록 기능이 기대와 달라요"), + COMPLEX_USAGE("앱 사용이 복잡해요"), + USING_OTHER_SERVICE("다른 러닝 앱을 사용하고 있어요"), + PRIVACY_CONCERN("개인정보가 걱정돼요"), + OTHER("기타 (직접 입력)"); + + private final String description; +} From 51d96cbbd8a82a1b169a84a43469f0d6cc9c9a76 Mon Sep 17 00:00:00 2001 From: ekgns33 Date: Mon, 7 Jul 2025 16:30:29 +0900 Subject: [PATCH 3/7] :sparkles: feat : add UserWithdrawReasonRepository --- .../user/repository/UserWithdrawReasonRepository.java | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 src/main/java/org/runimo/runimo/user/repository/UserWithdrawReasonRepository.java diff --git a/src/main/java/org/runimo/runimo/user/repository/UserWithdrawReasonRepository.java b/src/main/java/org/runimo/runimo/user/repository/UserWithdrawReasonRepository.java new file mode 100644 index 0000000..9ed90a0 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/repository/UserWithdrawReasonRepository.java @@ -0,0 +1,8 @@ +package org.runimo.runimo.user.repository; + +import org.runimo.runimo.user.domain.UserWithdrawReason; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserWithdrawReasonRepository extends JpaRepository { + +} \ No newline at end of file From 34ea3638aeebddeffbd2b7d79972630dbb9f9c0b Mon Sep 17 00:00:00 2001 From: ekgns33 Date: Mon, 7 Jul 2025 16:31:21 +0900 Subject: [PATCH 4/7] :sparkles: feat : add save-reason-logic to `withdraw` method --- .../runimo/user/service/WithdrawService.java | 18 ++++++++++++++++-- .../service/dto/command/WithdrawCommand.java | 11 +++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 src/main/java/org/runimo/runimo/user/service/dto/command/WithdrawCommand.java diff --git a/src/main/java/org/runimo/runimo/user/service/WithdrawService.java b/src/main/java/org/runimo/runimo/user/service/WithdrawService.java index ec0272a..d0fb330 100644 --- a/src/main/java/org/runimo/runimo/user/service/WithdrawService.java +++ b/src/main/java/org/runimo/runimo/user/service/WithdrawService.java @@ -9,9 +9,12 @@ import org.runimo.runimo.user.domain.OAuthInfo; import org.runimo.runimo.user.domain.SocialProvider; import org.runimo.runimo.user.domain.User; +import org.runimo.runimo.user.domain.UserWithdrawReason; import org.runimo.runimo.user.repository.AppleUserTokenRepository; import org.runimo.runimo.user.repository.OAuthInfoRepository; import org.runimo.runimo.user.repository.UserRepository; +import org.runimo.runimo.user.repository.UserWithdrawReasonRepository; +import org.runimo.runimo.user.service.dto.command.WithdrawCommand; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -25,15 +28,18 @@ public class WithdrawService { private final AppleUserTokenRepository appleUserTokenRepository; private final EncryptUtil encryptUtil; private final TokenRefreshService tokenRefreshService; + private final UserWithdrawReasonRepository userWithdrawReasonRepository; @Transactional - public void withdraw(Long userId) { - OAuthInfo oAuthInfo = oAuthInfoRepository.findByUserId(userId) + public void withdraw(WithdrawCommand command) { + OAuthInfo oAuthInfo = oAuthInfoRepository.findByUserId(command.userId()) .orElseThrow(NoSuchElementException::new); User user = oAuthInfo.getUser(); if (oAuthInfo.getProvider() == SocialProvider.APPLE) { withdrawAppleUser(user); } + UserWithdrawReason reason = createUserWithdrawReason(command); + userWithdrawReasonRepository.save(reason); oAuthInfoRepository.delete(oAuthInfo); userRepository.delete(user); tokenRefreshService.removeRefreshToken(user.getId()); @@ -55,4 +61,12 @@ private String getDecryptedToken(String encryptedRefreshToken) { throw new RuntimeException("Failed to decrypt ID token", e); } } + + private UserWithdrawReason createUserWithdrawReason(WithdrawCommand command) { + return UserWithdrawReason.builder() + .userId(command.userId()) + .reason(command.reason()) + .customReason(command.reasonDetail()) + .build(); + } } diff --git a/src/main/java/org/runimo/runimo/user/service/dto/command/WithdrawCommand.java b/src/main/java/org/runimo/runimo/user/service/dto/command/WithdrawCommand.java new file mode 100644 index 0000000..d88ad9c --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/service/dto/command/WithdrawCommand.java @@ -0,0 +1,11 @@ +package org.runimo.runimo.user.service.dto.command; + +import org.runimo.runimo.user.domain.WithdrawalReason; + +public record WithdrawCommand( + Long userId, + WithdrawalReason reason, + String reasonDetail +) { + +} From b76f8da7a3f126ace8f2ada7d24bcb4adcee1be4 Mon Sep 17 00:00:00 2001 From: ekgns33 Date: Mon, 7 Jul 2025 16:31:48 +0900 Subject: [PATCH 5/7] :sparkles: feat : add WithdrawRequest, mapping logic --- .../controller/UserWithdrawController.java | 8 +++-- .../controller/request/WithdrawRequest.java | 31 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 src/main/java/org/runimo/runimo/user/controller/request/WithdrawRequest.java diff --git a/src/main/java/org/runimo/runimo/user/controller/UserWithdrawController.java b/src/main/java/org/runimo/runimo/user/controller/UserWithdrawController.java index 6abc073..d281652 100644 --- a/src/main/java/org/runimo/runimo/user/controller/UserWithdrawController.java +++ b/src/main/java/org/runimo/runimo/user/controller/UserWithdrawController.java @@ -4,10 +4,13 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.runimo.runimo.user.controller.request.WithdrawRequest; import org.runimo.runimo.user.service.WithdrawService; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -26,9 +29,10 @@ public class UserWithdrawController { }) @DeleteMapping() public ResponseEntity deleteUser( - @UserId Long userId + @UserId Long userId, + @Valid @RequestBody WithdrawRequest request ) { - withdrawService.withdraw(userId); + withdrawService.withdraw(WithdrawRequest.toCommand(userId, request)); return ResponseEntity.noContent().build(); } } diff --git a/src/main/java/org/runimo/runimo/user/controller/request/WithdrawRequest.java b/src/main/java/org/runimo/runimo/user/controller/request/WithdrawRequest.java new file mode 100644 index 0000000..762717b --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/controller/request/WithdrawRequest.java @@ -0,0 +1,31 @@ +package org.runimo.runimo.user.controller.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.validator.constraints.Length; +import org.runimo.runimo.user.domain.WithdrawalReason; +import org.runimo.runimo.user.service.dto.command.WithdrawCommand; + +@Schema(description = "회원 탈퇴 DTO") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class WithdrawRequest { + + @Schema(description = "탈퇴 이유", example = "FOUND_BETTER_SERVICE") + private String withdrawReason; + @Schema(description = "기타 항목에 적는 문자열", example = "기타 항목에 적는 문자열") + @Length(max = 255, message = "기타 항목에 적는 문자열은 255자 이하로 입력해주세요.") + private String reasonDetail; + + public static WithdrawCommand toCommand(Long id, WithdrawRequest request) { + return new WithdrawCommand( + id, + WithdrawalReason.valueOf(request.getWithdrawReason()), + request.getReasonDetail() + ); + } +} From e8986700dc6f598295360226cd2216249066ca5d Mon Sep 17 00:00:00 2001 From: ekgns33 Date: Mon, 7 Jul 2025 16:32:48 +0900 Subject: [PATCH 6/7] :white_check_mark: test : add test for saving withdrawal reason --- .../user/api/UserWithdrawAcceptanceTest.java | 55 ++++++++++++++++++- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/src/test/java/org/runimo/runimo/user/api/UserWithdrawAcceptanceTest.java b/src/test/java/org/runimo/runimo/user/api/UserWithdrawAcceptanceTest.java index 6361f10..bcba2f8 100644 --- a/src/test/java/org/runimo/runimo/user/api/UserWithdrawAcceptanceTest.java +++ b/src/test/java/org/runimo/runimo/user/api/UserWithdrawAcceptanceTest.java @@ -3,6 +3,8 @@ import static io.restassured.RestAssured.given; import static org.runimo.runimo.TestConsts.TEST_USER_UUID; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import io.restassured.RestAssured; import io.restassured.http.ContentType; import org.junit.jupiter.api.AfterEach; @@ -10,6 +12,7 @@ import org.junit.jupiter.api.Test; import org.runimo.runimo.CleanUpUtil; import org.runimo.runimo.TokenUtils; +import org.runimo.runimo.user.controller.request.WithdrawRequest; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.server.LocalServerPort; @@ -27,6 +30,9 @@ class UserWithdrawAcceptanceTest { @Autowired private CleanUpUtil cleanUpUtil; + @Autowired + private ObjectMapper objectMapper; + @Autowired private TokenUtils tokenUtils; @@ -46,7 +52,12 @@ void tearDown() { @Test @Sql(scripts = "/sql/user_mypage_test_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) - void 회원_탈퇴_성공_시_유저_조회_불가() { + void 회원_탈퇴_성공_시_유저_조회_불가() throws JsonProcessingException { + + WithdrawRequest request = new WithdrawRequest( + "LACK_OF_IMPROVEMENT", + "" + ); given() .header("Authorization", token) @@ -58,10 +69,9 @@ void tearDown() { given() .header("Authorization", token) .contentType(ContentType.JSON) - + .body(objectMapper.writeValueAsString(request)) .when() .delete("/api/v1/users") - .then() .log().all() .statusCode(204); @@ -75,4 +85,43 @@ void tearDown() { } + @Test + @Sql(scripts = "/sql/user_mypage_test_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + void 탈퇴_사유가_기타가_아닌데_상세_사유가_있으면_에러() throws JsonProcessingException { + + WithdrawRequest request = new WithdrawRequest( + "LACK_OF_IMPROVEMENT", + "예시 탈퇴 사유" + ); + + given() + .header("Authorization", token) + .contentType(ContentType.JSON) + .body(objectMapper.writeValueAsString(request)) + .when() + .delete("/api/v1/users") + .then() + .log().all() + .statusCode(400); + } + + + @Test + @Sql(scripts = "/sql/user_mypage_test_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + void 탈퇴_사유가_255자_이상이면_에러() throws JsonProcessingException { + WithdrawRequest request = new WithdrawRequest( + "OTHER", + "a".repeat(512) + ); + + given() + .header("Authorization", token) + .contentType(ContentType.JSON) + .body(objectMapper.writeValueAsString(request)) + .when() + .delete("/api/v1/users") + .then() + .log().all() + .statusCode(400); + } } From 013fb24e2f5091e81a0d3ab9fd0c06f5547e26b3 Mon Sep 17 00:00:00 2001 From: ekgns33 Date: Mon, 7 Jul 2025 16:58:55 +0900 Subject: [PATCH 7/7] :white_check_mark: test : fix incorrect request-input --- .../org/runimo/runimo/user/api/UserWithdrawAcceptanceTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/java/org/runimo/runimo/user/api/UserWithdrawAcceptanceTest.java b/src/test/java/org/runimo/runimo/user/api/UserWithdrawAcceptanceTest.java index bcba2f8..1d07745 100644 --- a/src/test/java/org/runimo/runimo/user/api/UserWithdrawAcceptanceTest.java +++ b/src/test/java/org/runimo/runimo/user/api/UserWithdrawAcceptanceTest.java @@ -13,6 +13,7 @@ import org.runimo.runimo.CleanUpUtil; import org.runimo.runimo.TokenUtils; import org.runimo.runimo.user.controller.request.WithdrawRequest; +import org.runimo.runimo.user.domain.WithdrawalReason; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.server.LocalServerPort; @@ -55,7 +56,7 @@ void tearDown() { void 회원_탈퇴_성공_시_유저_조회_불가() throws JsonProcessingException { WithdrawRequest request = new WithdrawRequest( - "LACK_OF_IMPROVEMENT", + WithdrawalReason.NO_LONGER_NEEDED.name(), "" );