Skip to content

Commit 87265f9

Browse files
authored
feat: 탈퇴 V2 구현(사유 저장) 및 탈퇴 통계 페이지 추가 (#176)
* feat(auth): 탈퇴 V2 API 및 탈퇴 사유 DB 저장/검증 추가 * feat: 탈퇴 V2 구현 - 사유 저장 기능과 탈퇴 통계 페이지 추가 * fix(db): user withdrawal 관련 마이그레이션 파일명 시간대 보정 실제 실행 순서에 맞게 버전 타임스탬프 조정 * test(auth): 탈퇴 V2 사유 저장 및 통계 테스트 추가
1 parent a7fc885 commit 87265f9

23 files changed

Lines changed: 1112 additions & 0 deletions

src/main/java/com/devkor/ifive/nadab/domain/auth/api/AuthController.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -713,9 +713,11 @@ public ResponseEntity<ApiResponseDto<TokenResponse>> changePassword(
713713
);
714714
}
715715

716+
@Deprecated
716717
@PostMapping("/withdrawal")
717718
@PreAuthorize("isAuthenticated()")
718719
@Operation(
720+
deprecated = true,
719721
summary = "회원 탈퇴",
720722
description = """
721723
회원 탈퇴를 진행합니다.<br>
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package com.devkor.ifive.nadab.domain.auth.api;
2+
3+
import com.devkor.ifive.nadab.domain.auth.api.dto.request.WithdrawalRequestV2;
4+
import com.devkor.ifive.nadab.domain.auth.application.AuthServiceV2;
5+
import com.devkor.ifive.nadab.domain.auth.infra.cookie.CookieManager;
6+
import com.devkor.ifive.nadab.global.core.response.ApiResponseDto;
7+
import com.devkor.ifive.nadab.global.core.response.ApiResponseEntity;
8+
import com.devkor.ifive.nadab.global.security.principal.UserPrincipal;
9+
import io.swagger.v3.oas.annotations.Operation;
10+
import io.swagger.v3.oas.annotations.media.Content;
11+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
12+
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
13+
import io.swagger.v3.oas.annotations.tags.Tag;
14+
import jakarta.validation.Valid;
15+
import lombok.RequiredArgsConstructor;
16+
import org.springframework.http.ResponseEntity;
17+
import org.springframework.security.access.prepost.PreAuthorize;
18+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
19+
import org.springframework.web.bind.annotation.PostMapping;
20+
import org.springframework.web.bind.annotation.RequestBody;
21+
import org.springframework.web.bind.annotation.RequestMapping;
22+
import org.springframework.web.bind.annotation.RestController;
23+
24+
import jakarta.servlet.http.HttpServletResponse;
25+
26+
@Tag(name = "인증 API V2", description = "인증 관련 API V2")
27+
@RestController
28+
@RequestMapping("/api/v2/auth")
29+
@RequiredArgsConstructor
30+
public class AuthControllerV2 {
31+
32+
private final AuthServiceV2 authServiceV2;
33+
private final CookieManager cookieManager;
34+
35+
@PostMapping("/withdrawal")
36+
@PreAuthorize("isAuthenticated()")
37+
@Operation(
38+
summary = "회원 탈퇴 V2",
39+
description = """
40+
회원 탈퇴를 진행합니다.
41+
- 탈퇴 후 14일 동안 복구 가능합니다.<br>
42+
- 모든 기기에서 자동 로그아웃됩니다.<br>
43+
- 14일 후 자동으로 완전 삭제됩니다. <br>
44+
- 탈퇴 사유를 함께 저장합니다. <br>
45+
이때, reasons 필드에 OTHER가 포함된 경우 customReason 필드는 필수입니다. <br>
46+
47+
**<reasons 필드 enum>** <br>
48+
DAILY_LOGGING_BURDEN, // 매일 기록이 부담 <br>
49+
INSUFFICIENT_QUESTION_ANALYSIS, // 질문·분석 부족 <br>
50+
LOSS_OF_INTEREST_IN_WRITING, // 글쓰기 흥미 상실 <br>
51+
PRIVACY_RECORD_CONCERN, // 감정·기록 보안 우려 <br>
52+
APP_ERROR_OR_SLOWNESS, // 오류·속도 문제 <br>
53+
OTHER // 기타(직접 입력) <br>
54+
""",
55+
security = @SecurityRequirement(name = "bearerAuth"),
56+
responses = {
57+
@ApiResponse(responseCode = "204", description = "탈퇴 성공"),
58+
@ApiResponse(
59+
responseCode = "400",
60+
description = """
61+
- ErrorCode: AUTH_WITHDRAWAL_REASON_REQUIRED - 사유 미선택
62+
- ErrorCode: AUTH_WITHDRAWAL_REASON_DUPLICATED - 사유 중복 선택
63+
- ErrorCode: AUTH_WITHDRAWAL_OTHER_REASON_REQUIRED - OTHER 선택 후 기타 사유 미입력
64+
- ErrorCode: AUTH_WITHDRAWAL_OTHER_REASON_TOO_LONG - 기타 사유 200자 초과
65+
- ErrorCode: AUTH_WITHDRAWAL_OTHER_REASON_NOT_ALLOWED - OTHER 미선택인데 기타 사유 입력
66+
- ErrorCode: AUTH_ALREADY_WITHDRAWN - 이미 탈퇴된 계정
67+
""",
68+
content = @Content
69+
),
70+
@ApiResponse(
71+
responseCode = "401",
72+
description = """
73+
인증 실패 (JWT 토큰 관련)
74+
- ErrorCode: AUTH_TOKEN_EXPIRED - JWT Access Token 만료
75+
- ErrorCode: AUTH_TOKEN_SIGNATURE_INVALID - 토큰 서명 검증 실패
76+
- ErrorCode: AUTH_TOKEN_MALFORMED - 토큰 형식 오류
77+
- ErrorCode: AUTH_TOKEN_VERIFICATION_FAILED - 토큰 검증 실패
78+
- ErrorCode: AUTH_TOKEN_USERID_INVALID - 토큰의 유저 ID 형식 오류
79+
- ErrorCode: AUTH_TOKEN_ROLES_MISSING - 토큰에 권한 정보 없음
80+
""",
81+
content = @Content
82+
)
83+
}
84+
)
85+
public ResponseEntity<ApiResponseDto<Void>> withdrawUser(
86+
@AuthenticationPrincipal UserPrincipal principal,
87+
@RequestBody @Valid WithdrawalRequestV2 request,
88+
HttpServletResponse response
89+
) {
90+
authServiceV2.withdrawUser(principal.getId(), request.reasons(), request.customReason());
91+
cookieManager.removeRefreshTokenCookie(response);
92+
return ApiResponseEntity.noContent();
93+
}
94+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package com.devkor.ifive.nadab.domain.auth.api.dto.request;
2+
3+
import com.devkor.ifive.nadab.domain.auth.core.entity.WithdrawalReasonType;
4+
import io.swagger.v3.oas.annotations.media.Schema;
5+
import jakarta.validation.constraints.NotEmpty;
6+
import jakarta.validation.constraints.Size;
7+
8+
import java.util.List;
9+
10+
public record WithdrawalRequestV2(
11+
@Schema(
12+
description = "탈퇴 사유 목록 (다중 선택 가능)",
13+
example = "[\"DAILY_LOGGING_BURDEN\", \"OTHER\"]"
14+
)
15+
@NotEmpty(message = "탈퇴 사유는 최소 1개 이상 선택해야 합니다.")
16+
List<WithdrawalReasonType> reasons,
17+
18+
@Schema(
19+
description = "기타 사유 직접 입력 (reasons에 OTHER가 포함된 경우 필수)",
20+
example = "앱이 저에게 맞지 않았어요."
21+
)
22+
@Size(max = 200, message = "기타 사유는 최대 200자까지 입력할 수 있습니다.")
23+
String customReason
24+
) {
25+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package com.devkor.ifive.nadab.domain.auth.application;
2+
3+
import com.devkor.ifive.nadab.domain.auth.core.entity.UserWithdrawalReason;
4+
import com.devkor.ifive.nadab.domain.auth.core.entity.WithdrawalReasonType;
5+
import com.devkor.ifive.nadab.domain.auth.core.repository.UserWithdrawalReasonRepository;
6+
import com.devkor.ifive.nadab.domain.user.core.entity.User;
7+
import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository;
8+
import com.devkor.ifive.nadab.global.core.response.ErrorCode;
9+
import com.devkor.ifive.nadab.global.exception.BadRequestException;
10+
import lombok.RequiredArgsConstructor;
11+
import org.springframework.stereotype.Service;
12+
import org.springframework.transaction.annotation.Transactional;
13+
14+
import java.time.OffsetDateTime;
15+
import java.util.ArrayList;
16+
import java.util.EnumSet;
17+
import java.util.List;
18+
import java.util.Set;
19+
20+
@Service
21+
@RequiredArgsConstructor
22+
@Transactional
23+
public class AuthServiceV2 {
24+
25+
private static final int MAX_CUSTOM_REASON_LENGTH = 200;
26+
27+
private final WithdrawalService withdrawalService;
28+
private final UserRepository userRepository;
29+
private final UserWithdrawalReasonRepository userWithdrawalReasonRepository;
30+
31+
public void withdrawUser(Long userId, List<WithdrawalReasonType> reasons, String customReason) {
32+
List<WithdrawalReasonType> validatedReasons = validateReasons(reasons);
33+
String normalizedCustomReason = normalizeCustomReason(customReason);
34+
validateCustomReason(validatedReasons, normalizedCustomReason);
35+
36+
// 기존 탈퇴 처리(소프트 삭제/토큰 revoke/Apple revoke)
37+
withdrawalService.withdrawUser(userId);
38+
39+
// 탈퇴 사유 저장(집계용)
40+
User user = userRepository.getReferenceById(userId);
41+
OffsetDateTime effectiveWithdrawnAt = user.getDeletedAt() != null
42+
? user.getDeletedAt()
43+
: OffsetDateTime.now();
44+
List<UserWithdrawalReason> entities = new ArrayList<>(validatedReasons.size());
45+
for (WithdrawalReasonType reason : validatedReasons) {
46+
String detail = reason == WithdrawalReasonType.OTHER ? normalizedCustomReason : null;
47+
entities.add(UserWithdrawalReason.create(
48+
user,
49+
reason,
50+
detail,
51+
effectiveWithdrawnAt
52+
));
53+
}
54+
userWithdrawalReasonRepository.saveAll(entities);
55+
}
56+
57+
private List<WithdrawalReasonType> validateReasons(List<WithdrawalReasonType> reasons) {
58+
if (reasons == null || reasons.isEmpty()) {
59+
throw new BadRequestException(ErrorCode.AUTH_WITHDRAWAL_REASON_REQUIRED);
60+
}
61+
62+
Set<WithdrawalReasonType> uniqueReasons = EnumSet.copyOf(reasons);
63+
if (uniqueReasons.size() != reasons.size()) {
64+
throw new BadRequestException(ErrorCode.AUTH_WITHDRAWAL_REASON_DUPLICATED);
65+
}
66+
return reasons;
67+
}
68+
69+
private void validateCustomReason(List<WithdrawalReasonType> reasons, String customReason) {
70+
boolean hasOther = reasons.contains(WithdrawalReasonType.OTHER);
71+
72+
if (hasOther) {
73+
if (customReason == null || customReason.isEmpty()) {
74+
throw new BadRequestException(ErrorCode.AUTH_WITHDRAWAL_OTHER_REASON_REQUIRED);
75+
}
76+
if (customReason.length() > MAX_CUSTOM_REASON_LENGTH) {
77+
throw new BadRequestException(ErrorCode.AUTH_WITHDRAWAL_OTHER_REASON_TOO_LONG);
78+
}
79+
return;
80+
}
81+
82+
if (customReason != null && !customReason.isEmpty()) {
83+
throw new BadRequestException(ErrorCode.AUTH_WITHDRAWAL_OTHER_REASON_NOT_ALLOWED);
84+
}
85+
}
86+
87+
private String normalizeCustomReason(String customReason) {
88+
if (customReason == null) {
89+
return null;
90+
}
91+
return customReason.trim();
92+
}
93+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package com.devkor.ifive.nadab.domain.auth.core.entity;
2+
3+
import com.devkor.ifive.nadab.domain.user.core.entity.User;
4+
import com.devkor.ifive.nadab.global.shared.entity.CreatableEntity;
5+
import jakarta.persistence.*;
6+
import lombok.AccessLevel;
7+
import lombok.Getter;
8+
import lombok.NoArgsConstructor;
9+
10+
import java.time.OffsetDateTime;
11+
12+
@Entity
13+
@Table(name = "user_withdrawal_reasons")
14+
@Getter
15+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
16+
public class UserWithdrawalReason extends CreatableEntity {
17+
18+
@Id
19+
@GeneratedValue(strategy = GenerationType.IDENTITY)
20+
private Long id;
21+
22+
@ManyToOne(fetch = FetchType.LAZY, optional = false)
23+
@JoinColumn(name = "user_id", nullable = false)
24+
private User user;
25+
26+
@Enumerated(EnumType.STRING)
27+
@Column(name = "reason", nullable = false, length = 50)
28+
private WithdrawalReasonType reason;
29+
30+
@Column(name = "custom_reason", length = 200)
31+
private String customReason;
32+
33+
@Column(name = "withdrawn_at", nullable = false)
34+
private OffsetDateTime withdrawnAt;
35+
36+
public static UserWithdrawalReason create(
37+
User user,
38+
WithdrawalReasonType reason,
39+
String customReason,
40+
OffsetDateTime withdrawnAt
41+
) {
42+
UserWithdrawalReason userWithdrawalReason = new UserWithdrawalReason();
43+
userWithdrawalReason.user = user;
44+
userWithdrawalReason.reason = reason;
45+
userWithdrawalReason.customReason = customReason;
46+
userWithdrawalReason.withdrawnAt = withdrawnAt;
47+
return userWithdrawalReason;
48+
}
49+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.devkor.ifive.nadab.domain.auth.core.entity;
2+
3+
public enum WithdrawalReasonType {
4+
DAILY_LOGGING_BURDEN, // 매일 기록이 부담
5+
INSUFFICIENT_QUESTION_ANALYSIS, // 질문·분석 부족
6+
LOSS_OF_INTEREST_IN_WRITING, // 글쓰기 흥미 상실
7+
PRIVACY_RECORD_CONCERN, // 감정·기록 보안 우려
8+
APP_ERROR_OR_SLOWNESS, // 오류·속도 문제
9+
OTHER // 기타(직접 입력)
10+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.devkor.ifive.nadab.domain.auth.core.repository;
2+
3+
import com.devkor.ifive.nadab.domain.auth.core.entity.UserWithdrawalReason;
4+
import org.springframework.data.jpa.repository.JpaRepository;
5+
6+
public interface UserWithdrawalReasonRepository extends JpaRepository<UserWithdrawalReason, Long> {
7+
}

0 commit comments

Comments
 (0)