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
Original file line number Diff line number Diff line change
Expand Up @@ -713,9 +713,11 @@ public ResponseEntity<ApiResponseDto<TokenResponse>> changePassword(
);
}

@Deprecated
@PostMapping("/withdrawal")
@PreAuthorize("isAuthenticated()")
@Operation(
deprecated = true,
summary = "회원 탈퇴",
description = """
회원 탈퇴를 진행합니다.<br>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package com.devkor.ifive.nadab.domain.auth.api;

import com.devkor.ifive.nadab.domain.auth.api.dto.request.WithdrawalRequestV2;
import com.devkor.ifive.nadab.domain.auth.application.AuthServiceV2;
import com.devkor.ifive.nadab.domain.auth.infra.cookie.CookieManager;
import com.devkor.ifive.nadab.global.core.response.ApiResponseDto;
import com.devkor.ifive.nadab.global.core.response.ApiResponseEntity;
import com.devkor.ifive.nadab.global.security.principal.UserPrincipal;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import jakarta.servlet.http.HttpServletResponse;

@Tag(name = "인증 API V2", description = "인증 관련 API V2")
@RestController
@RequestMapping("/api/v2/auth")
@RequiredArgsConstructor
public class AuthControllerV2 {

private final AuthServiceV2 authServiceV2;
private final CookieManager cookieManager;

@PostMapping("/withdrawal")
@PreAuthorize("isAuthenticated()")
@Operation(
summary = "회원 탈퇴 V2",
description = """
회원 탈퇴를 진행합니다.
- 탈퇴 후 14일 동안 복구 가능합니다.<br>
- 모든 기기에서 자동 로그아웃됩니다.<br>
- 14일 후 자동으로 완전 삭제됩니다. <br>
- 탈퇴 사유를 함께 저장합니다. <br>
이때, reasons 필드에 OTHER가 포함된 경우 customReason 필드는 필수입니다. <br>

**<reasons 필드 enum>** <br>
DAILY_LOGGING_BURDEN, // 매일 기록이 부담 <br>
INSUFFICIENT_QUESTION_ANALYSIS, // 질문·분석 부족 <br>
LOSS_OF_INTEREST_IN_WRITING, // 글쓰기 흥미 상실 <br>
PRIVACY_RECORD_CONCERN, // 감정·기록 보안 우려 <br>
APP_ERROR_OR_SLOWNESS, // 오류·속도 문제 <br>
OTHER // 기타(직접 입력) <br>
""",
security = @SecurityRequirement(name = "bearerAuth"),
responses = {
@ApiResponse(responseCode = "204", description = "탈퇴 성공"),
@ApiResponse(
responseCode = "400",
description = """
- ErrorCode: AUTH_WITHDRAWAL_REASON_REQUIRED - 사유 미선택
- ErrorCode: AUTH_WITHDRAWAL_REASON_DUPLICATED - 사유 중복 선택
- ErrorCode: AUTH_WITHDRAWAL_OTHER_REASON_REQUIRED - OTHER 선택 후 기타 사유 미입력
- ErrorCode: AUTH_WITHDRAWAL_OTHER_REASON_TOO_LONG - 기타 사유 200자 초과
- ErrorCode: AUTH_WITHDRAWAL_OTHER_REASON_NOT_ALLOWED - OTHER 미선택인데 기타 사유 입력
- ErrorCode: AUTH_ALREADY_WITHDRAWN - 이미 탈퇴된 계정
""",
content = @Content
),
@ApiResponse(
responseCode = "401",
description = """
인증 실패 (JWT 토큰 관련)
- ErrorCode: AUTH_TOKEN_EXPIRED - JWT Access Token 만료
- ErrorCode: AUTH_TOKEN_SIGNATURE_INVALID - 토큰 서명 검증 실패
- ErrorCode: AUTH_TOKEN_MALFORMED - 토큰 형식 오류
- ErrorCode: AUTH_TOKEN_VERIFICATION_FAILED - 토큰 검증 실패
- ErrorCode: AUTH_TOKEN_USERID_INVALID - 토큰의 유저 ID 형식 오류
- ErrorCode: AUTH_TOKEN_ROLES_MISSING - 토큰에 권한 정보 없음
""",
content = @Content
)
}
)
public ResponseEntity<ApiResponseDto<Void>> withdrawUser(
@AuthenticationPrincipal UserPrincipal principal,
@RequestBody @Valid WithdrawalRequestV2 request,
HttpServletResponse response
) {
authServiceV2.withdrawUser(principal.getId(), request.reasons(), request.customReason());
cookieManager.removeRefreshTokenCookie(response);
return ApiResponseEntity.noContent();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.devkor.ifive.nadab.domain.auth.api.dto.request;

import com.devkor.ifive.nadab.domain.auth.core.entity.WithdrawalReasonType;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;

import java.util.List;

public record WithdrawalRequestV2(
@Schema(
description = "탈퇴 사유 목록 (다중 선택 가능)",
example = "[\"DAILY_LOGGING_BURDEN\", \"OTHER\"]"
)
@NotEmpty(message = "탈퇴 사유는 최소 1개 이상 선택해야 합니다.")
List<WithdrawalReasonType> reasons,

@Schema(
description = "기타 사유 직접 입력 (reasons에 OTHER가 포함된 경우 필수)",
example = "앱이 저에게 맞지 않았어요."
)
@Size(max = 200, message = "기타 사유는 최대 200자까지 입력할 수 있습니다.")
String customReason
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package com.devkor.ifive.nadab.domain.auth.application;

import com.devkor.ifive.nadab.domain.auth.core.entity.UserWithdrawalReason;
import com.devkor.ifive.nadab.domain.auth.core.entity.WithdrawalReasonType;
import com.devkor.ifive.nadab.domain.auth.core.repository.UserWithdrawalReasonRepository;
import com.devkor.ifive.nadab.domain.user.core.entity.User;
import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository;
import com.devkor.ifive.nadab.global.core.response.ErrorCode;
import com.devkor.ifive.nadab.global.exception.BadRequestException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.Set;

@Service
@RequiredArgsConstructor
@Transactional
public class AuthServiceV2 {

private static final int MAX_CUSTOM_REASON_LENGTH = 200;

private final WithdrawalService withdrawalService;
private final UserRepository userRepository;
private final UserWithdrawalReasonRepository userWithdrawalReasonRepository;

public void withdrawUser(Long userId, List<WithdrawalReasonType> reasons, String customReason) {
List<WithdrawalReasonType> validatedReasons = validateReasons(reasons);
String normalizedCustomReason = normalizeCustomReason(customReason);
validateCustomReason(validatedReasons, normalizedCustomReason);

// 기존 탈퇴 처리(소프트 삭제/토큰 revoke/Apple revoke)
withdrawalService.withdrawUser(userId);

// 탈퇴 사유 저장(집계용)
User user = userRepository.getReferenceById(userId);
OffsetDateTime effectiveWithdrawnAt = user.getDeletedAt() != null
? user.getDeletedAt()
: OffsetDateTime.now();
List<UserWithdrawalReason> entities = new ArrayList<>(validatedReasons.size());
for (WithdrawalReasonType reason : validatedReasons) {
String detail = reason == WithdrawalReasonType.OTHER ? normalizedCustomReason : null;
entities.add(UserWithdrawalReason.create(
user,
reason,
detail,
effectiveWithdrawnAt
));
}
userWithdrawalReasonRepository.saveAll(entities);
}

private List<WithdrawalReasonType> validateReasons(List<WithdrawalReasonType> reasons) {
if (reasons == null || reasons.isEmpty()) {
throw new BadRequestException(ErrorCode.AUTH_WITHDRAWAL_REASON_REQUIRED);
}

Set<WithdrawalReasonType> uniqueReasons = EnumSet.copyOf(reasons);
if (uniqueReasons.size() != reasons.size()) {
throw new BadRequestException(ErrorCode.AUTH_WITHDRAWAL_REASON_DUPLICATED);
}
return reasons;
}

private void validateCustomReason(List<WithdrawalReasonType> reasons, String customReason) {
boolean hasOther = reasons.contains(WithdrawalReasonType.OTHER);

if (hasOther) {
if (customReason == null || customReason.isEmpty()) {
throw new BadRequestException(ErrorCode.AUTH_WITHDRAWAL_OTHER_REASON_REQUIRED);
}
if (customReason.length() > MAX_CUSTOM_REASON_LENGTH) {
throw new BadRequestException(ErrorCode.AUTH_WITHDRAWAL_OTHER_REASON_TOO_LONG);
}
return;
}

if (customReason != null && !customReason.isEmpty()) {
throw new BadRequestException(ErrorCode.AUTH_WITHDRAWAL_OTHER_REASON_NOT_ALLOWED);
}
}

private String normalizeCustomReason(String customReason) {
if (customReason == null) {
return null;
}
return customReason.trim();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.devkor.ifive.nadab.domain.auth.core.entity;

import com.devkor.ifive.nadab.domain.user.core.entity.User;
import com.devkor.ifive.nadab.global.shared.entity.CreatableEntity;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.time.OffsetDateTime;

@Entity
@Table(name = "user_withdrawal_reasons")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class UserWithdrawalReason extends CreatableEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id", nullable = false)
private User user;

@Enumerated(EnumType.STRING)
@Column(name = "reason", nullable = false, length = 50)
private WithdrawalReasonType reason;

@Column(name = "custom_reason", length = 200)
private String customReason;

@Column(name = "withdrawn_at", nullable = false)
private OffsetDateTime withdrawnAt;

public static UserWithdrawalReason create(
User user,
WithdrawalReasonType reason,
String customReason,
OffsetDateTime withdrawnAt
) {
UserWithdrawalReason userWithdrawalReason = new UserWithdrawalReason();
userWithdrawalReason.user = user;
userWithdrawalReason.reason = reason;
userWithdrawalReason.customReason = customReason;
userWithdrawalReason.withdrawnAt = withdrawnAt;
return userWithdrawalReason;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.devkor.ifive.nadab.domain.auth.core.entity;

public enum WithdrawalReasonType {
DAILY_LOGGING_BURDEN, // 매일 기록이 부담
INSUFFICIENT_QUESTION_ANALYSIS, // 질문·분석 부족
LOSS_OF_INTEREST_IN_WRITING, // 글쓰기 흥미 상실
PRIVACY_RECORD_CONCERN, // 감정·기록 보안 우려
APP_ERROR_OR_SLOWNESS, // 오류·속도 문제
OTHER // 기타(직접 입력)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.devkor.ifive.nadab.domain.auth.core.repository;

import com.devkor.ifive.nadab.domain.auth.core.entity.UserWithdrawalReason;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserWithdrawalReasonRepository extends JpaRepository<UserWithdrawalReason, Long> {
}
Loading
Loading