From 48600808d992ab281eb2d613ab073e8e4cf92333 Mon Sep 17 00:00:00 2001 From: 1Seob Date: Sun, 24 May 2026 20:08:35 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(auth):=20=ED=83=88=ED=87=B4=20V2=20API?= =?UTF-8?q?=20=EB=B0=8F=20=ED=83=88=ED=87=B4=20=EC=82=AC=EC=9C=A0=20DB=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5/=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/auth/api/AuthControllerV2.java | 94 +++++++++++++++++++ .../api/dto/request/WithdrawalRequestV2.java | 25 +++++ .../auth/application/AuthServiceV2.java | 84 +++++++++++++++++ .../core/entity/UserWithdrawalReason.java | 38 ++++++++ .../core/entity/WithdrawalReasonType.java | 10 ++ .../UserWithdrawalReasonRepository.java | 7 ++ .../nadab/global/core/response/ErrorCode.java | 6 ++ ...S_create_user_withdrawal_reasons_table.sql | 20 ++++ 8 files changed, 284 insertions(+) create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/auth/api/AuthControllerV2.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/auth/api/dto/request/WithdrawalRequestV2.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/auth/core/entity/UserWithdrawalReason.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/auth/core/entity/WithdrawalReasonType.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/auth/core/repository/UserWithdrawalReasonRepository.java create mode 100644 src/main/resources/db/migration/V20260524_1800__IS_create_user_withdrawal_reasons_table.sql diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/api/AuthControllerV2.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/api/AuthControllerV2.java new file mode 100644 index 00000000..22143fb8 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/api/AuthControllerV2.java @@ -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일 동안 복구 가능합니다.
+ - 모든 기기에서 자동 로그아웃됩니다.
+ - 14일 후 자동으로 완전 삭제됩니다.
+ - 탈퇴 사유를 함께 저장합니다.
+ 이때, reasons 필드에 OTHER가 포함된 경우 customReason 필드는 필수입니다.
+ + ****
+ DAILY_LOGGING_BURDEN, // 매일 기록이 부담
+ INSUFFICIENT_QUESTION_ANALYSIS, // 질문·분석 부족
+ LOSS_OF_INTEREST_IN_WRITING, // 글쓰기 흥미 상실
+ PRIVACY_RECORD_CONCERN, // 감정·기록 보안 우려
+ APP_ERROR_OR_SLOWNESS, // 오류·속도 문제
+ OTHER // 기타(직접 입력)
+ """, + 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> withdrawUser( + @AuthenticationPrincipal UserPrincipal principal, + @RequestBody @Valid WithdrawalRequestV2 request, + HttpServletResponse response + ) { + authServiceV2.withdrawUser(principal.getId(), request.reasons(), request.customReason()); + cookieManager.removeRefreshTokenCookie(response); + return ApiResponseEntity.noContent(); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/api/dto/request/WithdrawalRequestV2.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/api/dto/request/WithdrawalRequestV2.java new file mode 100644 index 00000000..ce820b95 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/api/dto/request/WithdrawalRequestV2.java @@ -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 reasons, + + @Schema( + description = "기타 사유 직접 입력 (reasons에 OTHER가 포함된 경우 필수)", + example = "앱이 저에게 맞지 않았어요." + ) + @Size(max = 200, message = "기타 사유는 최대 200자까지 입력할 수 있습니다.") + String customReason +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2.java new file mode 100644 index 00000000..e3e80ff8 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2.java @@ -0,0 +1,84 @@ +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.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 reasons, String customReason) { + List validatedReasons = validateReasons(reasons); + String normalizedCustomReason = normalizeCustomReason(customReason); + validateCustomReason(validatedReasons, normalizedCustomReason); + + // 기존 탈퇴 처리(소프트 삭제/토큰 revoke/Apple revoke) + withdrawalService.withdrawUser(userId); + + // 탈퇴 사유 저장(집계용) + User user = userRepository.getReferenceById(userId); + List entities = new ArrayList<>(validatedReasons.size()); + for (WithdrawalReasonType reason : validatedReasons) { + String detail = reason == WithdrawalReasonType.OTHER ? normalizedCustomReason : null; + entities.add(UserWithdrawalReason.create(user, reason, detail)); + } + userWithdrawalReasonRepository.saveAll(entities); + } + + private List validateReasons(List reasons) { + if (reasons == null || reasons.isEmpty()) { + throw new BadRequestException(ErrorCode.AUTH_WITHDRAWAL_REASON_REQUIRED); + } + + Set uniqueReasons = EnumSet.copyOf(reasons); + if (uniqueReasons.size() != reasons.size()) { + throw new BadRequestException(ErrorCode.AUTH_WITHDRAWAL_REASON_DUPLICATED); + } + return reasons; + } + + private void validateCustomReason(List 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(); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/core/entity/UserWithdrawalReason.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/core/entity/UserWithdrawalReason.java new file mode 100644 index 00000000..7eaa47d7 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/core/entity/UserWithdrawalReason.java @@ -0,0 +1,38 @@ +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; + +@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; + + public static UserWithdrawalReason create(User user, WithdrawalReasonType reason, String customReason) { + UserWithdrawalReason userWithdrawalReason = new UserWithdrawalReason(); + userWithdrawalReason.user = user; + userWithdrawalReason.reason = reason; + userWithdrawalReason.customReason = customReason; + return userWithdrawalReason; + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/core/entity/WithdrawalReasonType.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/core/entity/WithdrawalReasonType.java new file mode 100644 index 00000000..eabfff7e --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/core/entity/WithdrawalReasonType.java @@ -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 // 기타(직접 입력) +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/core/repository/UserWithdrawalReasonRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/core/repository/UserWithdrawalReasonRepository.java new file mode 100644 index 00000000..745450ea --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/core/repository/UserWithdrawalReasonRepository.java @@ -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 { +} diff --git a/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java b/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java index bbaed66d..c8b4595e 100644 --- a/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java +++ b/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java @@ -28,6 +28,12 @@ public enum ErrorCode { AUTH_SOCIAL_ACCOUNT_RESTORE_FORBIDDEN(HttpStatus.BAD_REQUEST, "소셜 로그인 계정은 일반 계정 복구를 사용할 수 없습니다"), AUTH_UNSUPPORTED_OAUTH2_PROVIDER(HttpStatus.BAD_REQUEST, "지원하지 않는 OAuth2 제공자입니다"), + AUTH_WITHDRAWAL_REASON_REQUIRED(HttpStatus.BAD_REQUEST, "탈퇴 사유는 최소 1개 이상 선택해야 합니다"), + AUTH_WITHDRAWAL_REASON_DUPLICATED(HttpStatus.BAD_REQUEST, "탈퇴 사유는 중복 선택할 수 없습니다"), + AUTH_WITHDRAWAL_OTHER_REASON_REQUIRED(HttpStatus.BAD_REQUEST, "기타 사유를 선택한 경우 직접 입력이 필요합니다"), + AUTH_WITHDRAWAL_OTHER_REASON_TOO_LONG(HttpStatus.BAD_REQUEST, "기타 사유는 200자 이하로 입력해야 합니다"), + AUTH_WITHDRAWAL_OTHER_REASON_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "기타 사유는 OTHER 선택 시에만 입력할 수 있습니다"), + // 401 Unauthorized AUTH_INVALID_PASSWORD(HttpStatus.UNAUTHORIZED, "비밀번호가 일치하지 않습니다"), AUTH_INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않거나 만료된 Refresh Token입니다"), diff --git a/src/main/resources/db/migration/V20260524_1800__IS_create_user_withdrawal_reasons_table.sql b/src/main/resources/db/migration/V20260524_1800__IS_create_user_withdrawal_reasons_table.sql new file mode 100644 index 00000000..08369bf5 --- /dev/null +++ b/src/main/resources/db/migration/V20260524_1800__IS_create_user_withdrawal_reasons_table.sql @@ -0,0 +1,20 @@ +CREATE TABLE user_withdrawal_reasons ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + reason VARCHAR(50) NOT NULL, + custom_reason VARCHAR(200), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT fk_user_withdrawal_reasons_user + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + + CONSTRAINT chk_user_withdrawal_reasons_other_custom_reason + CHECK ( + (reason = 'OTHER' AND custom_reason IS NOT NULL AND LENGTH(BTRIM(custom_reason)) > 0) + OR + (reason <> 'OTHER' AND custom_reason IS NULL) + ) +); + +CREATE INDEX idx_user_withdrawal_reasons_user_id ON user_withdrawal_reasons (user_id); +CREATE INDEX idx_user_withdrawal_reasons_reason_created_at ON user_withdrawal_reasons (reason, created_at DESC); From 9af4f2ec283e3e5808ee089a66e33352c4a0d6b7 Mon Sep 17 00:00:00 2001 From: 1Seob Date: Wed, 27 May 2026 15:11:48 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=ED=83=88=ED=87=B4=20V2=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20-=20=EC=82=AC=EC=9C=A0=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=EA=B3=BC=20=ED=83=88=ED=87=B4=20=ED=86=B5?= =?UTF-8?q?=EA=B3=84=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../nadab/domain/auth/api/AuthController.java | 2 + .../auth/application/AuthServiceV2.java | 11 +- .../core/entity/UserWithdrawalReason.java | 13 +- .../application/WithdrawalStatsService.java | 151 ++++++++ .../stats/controller/StatsController.java | 11 + .../WithdrawalEventRowViewModel.java | 8 + .../withdrawal/WithdrawalStatsViewModel.java | 13 + .../repository/WithdrawalStatsRepository.java | 51 +++ ...ithdrawn_at_to_user_withdrawal_reasons.sql | 15 + src/main/resources/templates/stats/daily.html | 3 + .../resources/templates/stats/monthly.html | 3 + src/main/resources/templates/stats/total.html | 3 + src/main/resources/templates/stats/type.html | 3 + .../resources/templates/stats/weekly.html | 3 + .../resources/templates/stats/withdrawal.html | 322 ++++++++++++++++++ 15 files changed, 610 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/stats/application/WithdrawalStatsService.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/withdrawal/WithdrawalEventRowViewModel.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/withdrawal/WithdrawalStatsViewModel.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/WithdrawalStatsRepository.java create mode 100644 src/main/resources/db/migration/V20260524_2000__IS_add_withdrawn_at_to_user_withdrawal_reasons.sql create mode 100644 src/main/resources/templates/stats/withdrawal.html diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/api/AuthController.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/api/AuthController.java index 82a2197a..0f9f8063 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/auth/api/AuthController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/api/AuthController.java @@ -713,9 +713,11 @@ public ResponseEntity> changePassword( ); } + @Deprecated @PostMapping("/withdrawal") @PreAuthorize("isAuthenticated()") @Operation( + deprecated = true, summary = "회원 탈퇴", description = """ 회원 탈퇴를 진행합니다.
diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2.java index e3e80ff8..be4ba74d 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2.java @@ -11,6 +11,7 @@ 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; @@ -37,10 +38,18 @@ public void withdrawUser(Long userId, List reasons, String // 탈퇴 사유 저장(집계용) User user = userRepository.getReferenceById(userId); + OffsetDateTime effectiveWithdrawnAt = user.getDeletedAt() != null + ? user.getDeletedAt() + : OffsetDateTime.now(); List entities = new ArrayList<>(validatedReasons.size()); for (WithdrawalReasonType reason : validatedReasons) { String detail = reason == WithdrawalReasonType.OTHER ? normalizedCustomReason : null; - entities.add(UserWithdrawalReason.create(user, reason, detail)); + entities.add(UserWithdrawalReason.create( + user, + reason, + detail, + effectiveWithdrawnAt + )); } userWithdrawalReasonRepository.saveAll(entities); } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/core/entity/UserWithdrawalReason.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/core/entity/UserWithdrawalReason.java index 7eaa47d7..72de20b9 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/auth/core/entity/UserWithdrawalReason.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/core/entity/UserWithdrawalReason.java @@ -7,6 +7,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.time.OffsetDateTime; + @Entity @Table(name = "user_withdrawal_reasons") @Getter @@ -28,11 +30,20 @@ public class UserWithdrawalReason extends CreatableEntity { @Column(name = "custom_reason", length = 200) private String customReason; - public static UserWithdrawalReason create(User user, WithdrawalReasonType reason, 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; } } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/WithdrawalStatsService.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/WithdrawalStatsService.java new file mode 100644 index 00000000..2674a93a --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/WithdrawalStatsService.java @@ -0,0 +1,151 @@ +package com.devkor.ifive.nadab.domain.stats.application; + +import com.devkor.ifive.nadab.domain.auth.core.entity.WithdrawalReasonType; +import com.devkor.ifive.nadab.domain.stats.core.dto.withdrawal.WithdrawalEventRowViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.withdrawal.WithdrawalStatsViewModel; +import com.devkor.ifive.nadab.domain.stats.core.repository.WithdrawalStatsRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.sql.Timestamp; +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.*; + +@Service +@RequiredArgsConstructor +public class WithdrawalStatsService { + + private static final int RECENT_WITHDRAWAL_EVENT_LIMIT = 100; + private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul"); + private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private final WithdrawalStatsRepository repo; + + public WithdrawalStatsViewModel getWithdrawalStats() { + List rows = repo.findLatestWithdrawalReasonRows(RECENT_WITHDRAWAL_EVENT_LIMIT); + List totalReasonRows = repo.countAllWithdrawalReasons(); + + Map eventMap = new LinkedHashMap<>(); + Map reasonCountMap = new EnumMap<>(WithdrawalReasonType.class); + + for (Object[] row : rows) { + long userId = toLong(row[0]); + OffsetDateTime withdrawnAt = toOffsetDateTime(row[1]); + String reasonCode = String.valueOf(row[2]); + WithdrawalReasonType reasonType = parseReasonType(reasonCode); + String customReason = row[3] == null ? null : String.valueOf(row[3]).trim(); + + OffsetDateTime normalizedWithdrawnAt = withdrawnAt.truncatedTo(ChronoUnit.SECONDS); + EventKey key = new EventKey(userId, normalizedWithdrawnAt); + EventAccumulator accumulator = eventMap.computeIfAbsent( + key, + k -> new EventAccumulator(formatDateTime(normalizedWithdrawnAt)) + ); + + accumulator.reasons.add(toReasonLabel(reasonType, reasonCode)); + if (customReason != null && !customReason.isEmpty()) { + accumulator.customReason = customReason; + } + + } + + for (Object[] row : totalReasonRows) { + String reasonCode = String.valueOf(row[0]); + WithdrawalReasonType reasonType = parseReasonType(reasonCode); + if (reasonType == null) { + continue; + } + reasonCountMap.put(reasonType, toLong(row[1])); + } + + List reasonTypes = Arrays.stream(WithdrawalReasonType.values()).toList(); + List reasonLabels = reasonTypes.stream() + .map(this::toReasonLabel) + .toList(); + List reasonCounts = reasonTypes.stream() + .map(type -> reasonCountMap.getOrDefault(type, 0L)) + .toList(); + + List eventRows = eventMap.values().stream() + .map(event -> new WithdrawalEventRowViewModel( + event.withdrawnAt, + String.join(", ", event.reasons), + event.customReason == null ? "-" : event.customReason + )) + .toList(); + + return new WithdrawalStatsViewModel( + RECENT_WITHDRAWAL_EVENT_LIMIT, + eventRows.size(), + reasonLabels, + reasonCounts, + eventRows, + OffsetDateTime.now(SEOUL).format(FMT) + ); + } + + private long toLong(Object value) { + if (value instanceof Number n) { + return n.longValue(); + } + return Long.parseLong(String.valueOf(value)); + } + + private OffsetDateTime toOffsetDateTime(Object value) { + if (value instanceof OffsetDateTime odt) { + return odt; + } + if (value instanceof LocalDateTime ldt) { + return ldt.atZone(SEOUL).toOffsetDateTime(); + } + if (value instanceof Timestamp ts) { + return ts.toInstant().atZone(SEOUL).toOffsetDateTime(); + } + return OffsetDateTime.parse(String.valueOf(value)); + } + + private String formatDateTime(OffsetDateTime value) { + return value.atZoneSameInstant(SEOUL).format(FMT); + } + + private WithdrawalReasonType parseReasonType(String reasonCode) { + try { + return WithdrawalReasonType.valueOf(reasonCode); + } catch (Exception ignored) { + return null; + } + } + + private String toReasonLabel(WithdrawalReasonType reasonType) { + return switch (reasonType) { + case DAILY_LOGGING_BURDEN -> "매일 기록이 부담"; + case INSUFFICIENT_QUESTION_ANALYSIS -> "질문·분석 부족"; + case LOSS_OF_INTEREST_IN_WRITING -> "흥미 상실"; + case PRIVACY_RECORD_CONCERN -> "기록 보안 우려"; + case APP_ERROR_OR_SLOWNESS -> "오류·속도 문제"; + case OTHER -> "기타(직접 입력)"; + }; + } + + private String toReasonLabel(WithdrawalReasonType reasonType, String rawReasonCode) { + if (reasonType == null) { + return rawReasonCode; + } + return toReasonLabel(reasonType); + } + + private record EventKey(long userId, OffsetDateTime withdrawnAt) { + } + + private static class EventAccumulator { + private final String withdrawnAt; + private final List reasons = new ArrayList<>(); + private String customReason; + + private EventAccumulator(String withdrawnAt) { + this.withdrawnAt = withdrawnAt; + } + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java index b0650050..2e5a18ed 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java @@ -4,11 +4,13 @@ import com.devkor.ifive.nadab.domain.stats.application.MonthlyStatsService; import com.devkor.ifive.nadab.domain.stats.application.TotalStatsService; import com.devkor.ifive.nadab.domain.stats.application.TypeStatsService; +import com.devkor.ifive.nadab.domain.stats.application.WithdrawalStatsService; import com.devkor.ifive.nadab.domain.stats.application.WeeklyStatsService; import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DailyStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.monthly.MonthlyStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.total.TotalStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.type.TypeStatsViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.withdrawal.WithdrawalStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.weekly.WeeklyStatsViewModel; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Controller; @@ -24,6 +26,7 @@ public class StatsController { private final MonthlyStatsService monthlyStatsService; private final TotalStatsService totalStatsService; private final TypeStatsService typeStatsService; + private final WithdrawalStatsService withdrawalStatsService; @GetMapping("stats/daily") @@ -65,4 +68,12 @@ public String typeStats(Model model) { model.addAttribute("activeTab", "type"); return "stats/type"; } + + @GetMapping("/stats/withdrawal") + public String withdrawalStats(Model model) { + WithdrawalStatsViewModel vm = withdrawalStatsService.getWithdrawalStats(); + model.addAttribute("vm", vm); + model.addAttribute("activeTab", "withdrawal"); + return "stats/withdrawal"; + } } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/withdrawal/WithdrawalEventRowViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/withdrawal/WithdrawalEventRowViewModel.java new file mode 100644 index 00000000..393f258c --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/withdrawal/WithdrawalEventRowViewModel.java @@ -0,0 +1,8 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.withdrawal; + +public record WithdrawalEventRowViewModel( + String withdrawnAt, + String reasons, + String customReason +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/withdrawal/WithdrawalStatsViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/withdrawal/WithdrawalStatsViewModel.java new file mode 100644 index 00000000..aec10d82 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/withdrawal/WithdrawalStatsViewModel.java @@ -0,0 +1,13 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.withdrawal; + +import java.util.List; + +public record WithdrawalStatsViewModel( + int recentEventLimit, + long eventCount, + List reasonLabels, + List reasonCounts, + List rows, + String refreshedAt +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/WithdrawalStatsRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/WithdrawalStatsRepository.java new file mode 100644 index 00000000..c9a6fcf9 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/WithdrawalStatsRepository.java @@ -0,0 +1,51 @@ +package com.devkor.ifive.nadab.domain.stats.core.repository; + +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class WithdrawalStatsRepository { + + private final EntityManager em; + + public List findLatestWithdrawalReasonRows(int limitEvents) { + return em.createNativeQuery(""" + with ranked_events as ( + select + uwr.user_id, + uwr.withdrawn_at, + row_number() over (order by uwr.withdrawn_at desc, uwr.user_id desc) as rn + from user_withdrawal_reasons uwr + group by uwr.user_id, uwr.withdrawn_at + ) + select + uwr.user_id, + uwr.withdrawn_at, + uwr.reason, + uwr.custom_reason + from user_withdrawal_reasons uwr + join ranked_events re + on re.user_id = uwr.user_id + and re.withdrawn_at = uwr.withdrawn_at + where re.rn <= :limitEvents + order by uwr.withdrawn_at desc, uwr.user_id desc, uwr.reason asc + """) + .setParameter("limitEvents", limitEvents) + .getResultList(); + } + + public List countAllWithdrawalReasons() { + return em.createNativeQuery(""" + select + uwr.reason, + count(*) as cnt + from user_withdrawal_reasons uwr + group by uwr.reason + """) + .getResultList(); + } +} diff --git a/src/main/resources/db/migration/V20260524_2000__IS_add_withdrawn_at_to_user_withdrawal_reasons.sql b/src/main/resources/db/migration/V20260524_2000__IS_add_withdrawn_at_to_user_withdrawal_reasons.sql new file mode 100644 index 00000000..a2c33637 --- /dev/null +++ b/src/main/resources/db/migration/V20260524_2000__IS_add_withdrawn_at_to_user_withdrawal_reasons.sql @@ -0,0 +1,15 @@ +ALTER TABLE user_withdrawal_reasons + ADD COLUMN IF NOT EXISTS withdrawn_at TIMESTAMPTZ; + +UPDATE user_withdrawal_reasons +SET withdrawn_at = created_at +WHERE withdrawn_at IS NULL; + +ALTER TABLE user_withdrawal_reasons + ALTER COLUMN withdrawn_at SET NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_user_withdrawal_reasons_withdrawn_at + ON user_withdrawal_reasons (withdrawn_at DESC); + +CREATE INDEX IF NOT EXISTS idx_user_withdrawal_reasons_user_withdrawn_at + ON user_withdrawal_reasons (user_id, withdrawn_at DESC); diff --git a/src/main/resources/templates/stats/daily.html b/src/main/resources/templates/stats/daily.html index 3378b75e..391d3dd8 100644 --- a/src/main/resources/templates/stats/daily.html +++ b/src/main/resources/templates/stats/daily.html @@ -280,6 +280,9 @@ 유형 + 탈퇴 전체 diff --git a/src/main/resources/templates/stats/monthly.html b/src/main/resources/templates/stats/monthly.html index 09a6c87f..4a2aa44e 100644 --- a/src/main/resources/templates/stats/monthly.html +++ b/src/main/resources/templates/stats/monthly.html @@ -257,6 +257,9 @@ 유형 + 탈퇴 전체 diff --git a/src/main/resources/templates/stats/total.html b/src/main/resources/templates/stats/total.html index f99411eb..96e7038d 100644 --- a/src/main/resources/templates/stats/total.html +++ b/src/main/resources/templates/stats/total.html @@ -289,6 +289,9 @@ 유형 + 탈퇴 전체 diff --git a/src/main/resources/templates/stats/type.html b/src/main/resources/templates/stats/type.html index 8396ed5f..390f5998 100644 --- a/src/main/resources/templates/stats/type.html +++ b/src/main/resources/templates/stats/type.html @@ -245,6 +245,9 @@ 유형 + 탈퇴 전체 diff --git a/src/main/resources/templates/stats/weekly.html b/src/main/resources/templates/stats/weekly.html index 0b022fc2..7ebc1192 100644 --- a/src/main/resources/templates/stats/weekly.html +++ b/src/main/resources/templates/stats/weekly.html @@ -257,6 +257,9 @@ 유형 + 탈퇴 전체 diff --git a/src/main/resources/templates/stats/withdrawal.html b/src/main/resources/templates/stats/withdrawal.html new file mode 100644 index 00000000..ff99c806 --- /dev/null +++ b/src/main/resources/templates/stats/withdrawal.html @@ -0,0 +1,322 @@ + + + + + + NADAB · Stats + + + + + + + + + + +
+
+
+
탈퇴 사유 분포
+
전체 탈퇴 사유 집계
+
+
+ +
+
+ +
+
+
탈퇴 이벤트 목록 (최신순)
+
표는 최신 100개 이벤트를 표시합니다.
+
+
+
+ + + + + + + + + + + + + + + +
탈퇴 시각선택 사유기타 사유
+
+
표시할 탈퇴 이벤트가 없습니다.
+
+
+
+ + + + From fff6e6124e700cbb0613dcb3a526bac52d132c66 Mon Sep 17 00:00:00 2001 From: 1Seob Date: Mon, 8 Jun 2026 14:40:49 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix(db):=20user=20withdrawal=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=ED=8C=8C=EC=9D=BC=EB=AA=85=20=EC=8B=9C=EA=B0=84?= =?UTF-8?q?=EB=8C=80=20=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실제 실행 순서에 맞게 버전 타임스탬프 조정 --- ...20260531_1800__IS_create_user_withdrawal_reasons_table.sql} | 3 +++ ...1_2000__IS_add_withdrawn_at_to_user_withdrawal_reasons.sql} | 0 2 files changed, 3 insertions(+) rename src/main/resources/db/migration/{V20260524_1800__IS_create_user_withdrawal_reasons_table.sql => V20260531_1800__IS_create_user_withdrawal_reasons_table.sql} (75%) rename src/main/resources/db/migration/{V20260524_2000__IS_add_withdrawn_at_to_user_withdrawal_reasons.sql => V20260601_2000__IS_add_withdrawn_at_to_user_withdrawal_reasons.sql} (100%) diff --git a/src/main/resources/db/migration/V20260524_1800__IS_create_user_withdrawal_reasons_table.sql b/src/main/resources/db/migration/V20260531_1800__IS_create_user_withdrawal_reasons_table.sql similarity index 75% rename from src/main/resources/db/migration/V20260524_1800__IS_create_user_withdrawal_reasons_table.sql rename to src/main/resources/db/migration/V20260531_1800__IS_create_user_withdrawal_reasons_table.sql index 08369bf5..66c14f08 100644 --- a/src/main/resources/db/migration/V20260524_1800__IS_create_user_withdrawal_reasons_table.sql +++ b/src/main/resources/db/migration/V20260531_1800__IS_create_user_withdrawal_reasons_table.sql @@ -3,6 +3,7 @@ CREATE TABLE user_withdrawal_reasons ( user_id BIGINT NOT NULL, reason VARCHAR(50) NOT NULL, custom_reason VARCHAR(200), + withdrawn_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT fk_user_withdrawal_reasons_user @@ -18,3 +19,5 @@ CREATE TABLE user_withdrawal_reasons ( CREATE INDEX idx_user_withdrawal_reasons_user_id ON user_withdrawal_reasons (user_id); CREATE INDEX idx_user_withdrawal_reasons_reason_created_at ON user_withdrawal_reasons (reason, created_at DESC); +CREATE INDEX idx_user_withdrawal_reasons_withdrawn_at ON user_withdrawal_reasons (withdrawn_at DESC); +CREATE INDEX idx_user_withdrawal_reasons_user_withdrawn_at ON user_withdrawal_reasons (user_id, withdrawn_at DESC); diff --git a/src/main/resources/db/migration/V20260524_2000__IS_add_withdrawn_at_to_user_withdrawal_reasons.sql b/src/main/resources/db/migration/V20260601_2000__IS_add_withdrawn_at_to_user_withdrawal_reasons.sql similarity index 100% rename from src/main/resources/db/migration/V20260524_2000__IS_add_withdrawn_at_to_user_withdrawal_reasons.sql rename to src/main/resources/db/migration/V20260601_2000__IS_add_withdrawn_at_to_user_withdrawal_reasons.sql From e8d3c9d1aaf5133bf03ac35ec9b0085ae006cace Mon Sep 17 00:00:00 2001 From: 1Seob Date: Mon, 8 Jun 2026 15:02:29 +0900 Subject: [PATCH 4/4] =?UTF-8?q?test(auth):=20=ED=83=88=ED=87=B4=20V2=20?= =?UTF-8?q?=EC=82=AC=EC=9C=A0=20=EC=A0=80=EC=9E=A5=20=EB=B0=8F=20=ED=86=B5?= =?UTF-8?q?=EA=B3=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/application/AuthServiceV2Test.java | 154 ++++++++++++++++++ .../WithdrawalStatsServiceTest.java | 63 +++++++ 2 files changed, 217 insertions(+) create mode 100644 src/test/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2Test.java create mode 100644 src/test/java/com/devkor/ifive/nadab/domain/stats/application/WithdrawalStatsServiceTest.java diff --git a/src/test/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2Test.java b/src/test/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2Test.java new file mode 100644 index 00000000..8d2112db --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2Test.java @@ -0,0 +1,154 @@ +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 org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.OffsetDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AuthServiceV2Test { + + @Mock + WithdrawalService withdrawalService; + + @Mock + UserRepository userRepository; + + @Mock + UserWithdrawalReasonRepository userWithdrawalReasonRepository; + + AuthServiceV2 authServiceV2; + + @BeforeEach + void setUp() { + authServiceV2 = new AuthServiceV2( + withdrawalService, + userRepository, + userWithdrawalReasonRepository + ); + } + + @Test + void withdrawUser_saves_selected_reasons_with_effective_withdrawn_at() { + // given + Long userId = 1L; + User user = User.createUser("test@example.com", "hashed_password"); + doAnswer(invocation -> { + user.softDelete(); + return null; + }).when(withdrawalService).withdrawUser(userId); + when(userRepository.getReferenceById(userId)).thenReturn(user); + + // when + authServiceV2.withdrawUser( + userId, + List.of(WithdrawalReasonType.DAILY_LOGGING_BURDEN, WithdrawalReasonType.OTHER), + " custom reason " + ); + + // then + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(withdrawalService).withdrawUser(userId); + verify(userWithdrawalReasonRepository).saveAll(captor.capture()); + + List savedReasons = captor.getValue(); + OffsetDateTime deletedAt = user.getDeletedAt(); + + assertThat(savedReasons).hasSize(2); + assertThat(savedReasons) + .extracting(UserWithdrawalReason::getUser) + .containsOnly(user); + assertThat(savedReasons) + .extracting(UserWithdrawalReason::getWithdrawnAt) + .containsOnly(deletedAt); + assertThat(savedReasons) + .extracting(UserWithdrawalReason::getReason) + .containsExactly( + WithdrawalReasonType.DAILY_LOGGING_BURDEN, + WithdrawalReasonType.OTHER + ); + assertThat(savedReasons.get(0).getCustomReason()).isNull(); + assertThat(savedReasons.get(1).getCustomReason()).isEqualTo("custom reason"); + } + + @Test + void withdrawUser_rejects_empty_reasons_before_withdrawal() { + assertValidationFailure( + List.of(), + null, + ErrorCode.AUTH_WITHDRAWAL_REASON_REQUIRED + ); + } + + @Test + void withdrawUser_rejects_duplicated_reasons_before_withdrawal() { + assertValidationFailure( + List.of(WithdrawalReasonType.OTHER, WithdrawalReasonType.OTHER), + "custom reason", + ErrorCode.AUTH_WITHDRAWAL_REASON_DUPLICATED + ); + } + + @Test + void withdrawUser_rejects_other_without_custom_reason_before_withdrawal() { + assertValidationFailure( + List.of(WithdrawalReasonType.OTHER), + " ", + ErrorCode.AUTH_WITHDRAWAL_OTHER_REASON_REQUIRED + ); + } + + @Test + void withdrawUser_rejects_custom_reason_without_other_before_withdrawal() { + assertValidationFailure( + List.of(WithdrawalReasonType.APP_ERROR_OR_SLOWNESS), + "custom reason", + ErrorCode.AUTH_WITHDRAWAL_OTHER_REASON_NOT_ALLOWED + ); + } + + @Test + void withdrawUser_rejects_too_long_custom_reason_before_withdrawal() { + assertValidationFailure( + List.of(WithdrawalReasonType.OTHER), + "a".repeat(201), + ErrorCode.AUTH_WITHDRAWAL_OTHER_REASON_TOO_LONG + ); + } + + private void assertValidationFailure( + List reasons, + String customReason, + ErrorCode expectedErrorCode + ) { + assertThatThrownBy(() -> authServiceV2.withdrawUser(1L, reasons, customReason)) + .isInstanceOfSatisfying(BadRequestException.class, e -> + assertThat(e.getErrorCode()).isEqualTo(expectedErrorCode) + ); + + verify(withdrawalService, never()).withdrawUser(1L); + verify(userRepository, never()).getReferenceById(1L); + verify(userWithdrawalReasonRepository, never()).saveAll(anyList()); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/stats/application/WithdrawalStatsServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/stats/application/WithdrawalStatsServiceTest.java new file mode 100644 index 00000000..a87dc158 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/stats/application/WithdrawalStatsServiceTest.java @@ -0,0 +1,63 @@ +package com.devkor.ifive.nadab.domain.stats.application; + +import com.devkor.ifive.nadab.domain.auth.core.entity.WithdrawalReasonType; +import com.devkor.ifive.nadab.domain.stats.core.dto.withdrawal.WithdrawalEventRowViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.withdrawal.WithdrawalStatsViewModel; +import com.devkor.ifive.nadab.domain.stats.core.repository.WithdrawalStatsRepository; +import org.junit.jupiter.api.Test; + +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class WithdrawalStatsServiceTest { + + @Test + void getWithdrawalStats_groups_latest_rows_by_user_and_withdrawn_at() { + // given + WithdrawalStatsRepository repo = mock(WithdrawalStatsRepository.class); + WithdrawalStatsService service = new WithdrawalStatsService(repo); + + OffsetDateTime withdrawnAt = OffsetDateTime.of( + 2026, 6, 1, 12, 30, 5, 900_000_000, ZoneOffset.UTC + ); + when(repo.findLatestWithdrawalReasonRows(100)).thenReturn(List.of( + row(1L, withdrawnAt, "DAILY_LOGGING_BURDEN", null), + row(1L, withdrawnAt, "OTHER", " custom reason "), + row(2L, Timestamp.valueOf(LocalDateTime.of(2026, 6, 2, 10, 0, 0)), "UNKNOWN_REASON", null) + )); + when(repo.countAllWithdrawalReasons()).thenReturn(List.of( + row("DAILY_LOGGING_BURDEN", 2L), + row("OTHER", 1L), + row("UNKNOWN_REASON", 99L) + )); + + // when + WithdrawalStatsViewModel vm = service.getWithdrawalStats(); + + // then + assertThat(vm.recentEventLimit()).isEqualTo(100); + assertThat(vm.eventCount()).isEqualTo(2); + assertThat(vm.reasonLabels()).hasSize(WithdrawalReasonType.values().length); + assertThat(vm.reasonCounts()).containsExactly(2L, 0L, 0L, 0L, 0L, 1L); + + WithdrawalEventRowViewModel first = vm.rows().get(0); + assertThat(first.withdrawnAt()).isEqualTo("2026-06-01 21:30:05"); + assertThat(first.reasons()).contains(", "); + assertThat(first.customReason()).isEqualTo("custom reason"); + + WithdrawalEventRowViewModel second = vm.rows().get(1); + assertThat(second.reasons()).isEqualTo("UNKNOWN_REASON"); + assertThat(second.customReason()).isEqualTo("-"); + } + + private Object[] row(Object... values) { + return values; + } +}