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/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..be4ba74d --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/application/AuthServiceV2.java @@ -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 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); + 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, + effectiveWithdrawnAt + )); + } + 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..72de20b9 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/core/entity/UserWithdrawalReason.java @@ -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; + } +} 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/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/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/V20260531_1800__IS_create_user_withdrawal_reasons_table.sql b/src/main/resources/db/migration/V20260531_1800__IS_create_user_withdrawal_reasons_table.sql new file mode 100644 index 00000000..66c14f08 --- /dev/null +++ b/src/main/resources/db/migration/V20260531_1800__IS_create_user_withdrawal_reasons_table.sql @@ -0,0 +1,23 @@ +CREATE TABLE user_withdrawal_reasons ( + id BIGSERIAL PRIMARY KEY, + 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 + 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); +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/V20260601_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 new file mode 100644 index 00000000..a2c33637 --- /dev/null +++ b/src/main/resources/db/migration/V20260601_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개 이벤트를 표시합니다.
+
+
+
+ + + + + + + + + + + + + + + +
탈퇴 시각선택 사유기타 사유
+
+
표시할 탈퇴 이벤트가 없습니다.
+
+
+
+ + + + 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; + } +}