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
@@ -1,8 +1,10 @@
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.WithdrawalEvent;
import com.devkor.ifive.nadab.domain.auth.core.entity.WithdrawalEventReason;
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.auth.core.repository.WithdrawalEventReasonRepository;
import com.devkor.ifive.nadab.domain.auth.core.repository.WithdrawalEventRepository;
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;
Expand All @@ -26,7 +28,8 @@ public class AuthServiceV2 {

private final WithdrawalService withdrawalService;
private final UserRepository userRepository;
private final UserWithdrawalReasonRepository userWithdrawalReasonRepository;
private final WithdrawalEventRepository withdrawalEventRepository;
private final WithdrawalEventReasonRepository withdrawalEventReasonRepository;

public void withdrawUser(Long userId, List<WithdrawalReasonType> reasons, String customReason) {
List<WithdrawalReasonType> validatedReasons = validateReasons(reasons);
Expand All @@ -41,17 +44,15 @@ public void withdrawUser(Long userId, List<WithdrawalReasonType> reasons, String
OffsetDateTime effectiveWithdrawnAt = user.getDeletedAt() != null
? user.getDeletedAt()
: OffsetDateTime.now();
List<UserWithdrawalReason> entities = new ArrayList<>(validatedReasons.size());
WithdrawalEvent event = withdrawalEventRepository.save(
WithdrawalEvent.create(user, effectiveWithdrawnAt)
);
List<WithdrawalEventReason> eventReasons = new ArrayList<>(validatedReasons.size());
for (WithdrawalReasonType reason : validatedReasons) {
String detail = reason == WithdrawalReasonType.OTHER ? normalizedCustomReason : null;
entities.add(UserWithdrawalReason.create(
user,
reason,
detail,
effectiveWithdrawnAt
));
eventReasons.add(WithdrawalEventReason.create(event, reason, detail));
}
userWithdrawalReasonRepository.saveAll(entities);
withdrawalEventReasonRepository.saveAll(eventReasons);
}

private List<WithdrawalReasonType> validateReasons(List<WithdrawalReasonType> reasons) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
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 = "withdrawal_events",
uniqueConstraints = {
@UniqueConstraint(
name = "uq_withdrawal_events_user_withdrawn_at",
columnNames = {"user_id", "withdrawn_at"}
)
}
)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class WithdrawalEvent extends CreatableEntity {

private static final int RETENTION_YEARS = 1;

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

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

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

@Column(name = "anonymized_at")
private OffsetDateTime anonymizedAt;

@Column(name = "expires_at", nullable = false)
private OffsetDateTime expiresAt;

public static WithdrawalEvent create(User user, OffsetDateTime withdrawnAt) {
WithdrawalEvent event = new WithdrawalEvent();
event.user = user;
event.withdrawnAt = withdrawnAt;
event.expiresAt = withdrawnAt.plusYears(RETENTION_YEARS);
return event;
}
}
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.global.shared.entity.CreatableEntity;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Table(
name = "withdrawal_event_reasons",
uniqueConstraints = {
@UniqueConstraint(
name = "uq_withdrawal_event_reasons_event_reason",
columnNames = {"event_id", "reason"}
)
}
)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class WithdrawalEventReason extends CreatableEntity {

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

@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "event_id", nullable = false)
private WithdrawalEvent event;

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

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

public static WithdrawalEventReason create(
WithdrawalEvent event,
WithdrawalReasonType reason,
String customReason
) {
WithdrawalEventReason eventReason = new WithdrawalEventReason();
eventReason.event = event;
eventReason.reason = reason;
eventReason.customReason = customReason;
return eventReason;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@

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

import java.time.OffsetDateTime;

public interface UserWithdrawalReasonRepository extends JpaRepository<UserWithdrawalReason, Long> {

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("""
delete from UserWithdrawalReason reason
where reason.withdrawnAt < :anonymizationDate
""")
int deleteReasonsWithdrawnBefore(
@Param("anonymizationDate") OffsetDateTime anonymizationDate
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.devkor.ifive.nadab.domain.auth.core.repository;

import com.devkor.ifive.nadab.domain.auth.core.entity.WithdrawalEventReason;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.time.OffsetDateTime;

public interface WithdrawalEventReasonRepository extends JpaRepository<WithdrawalEventReason, Long> {

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(value = """
update withdrawal_event_reasons reason
set custom_reason = null
from withdrawal_events event
where event.id = reason.event_id
and event.withdrawn_at < :anonymizationDate
and reason.custom_reason is not null
""", nativeQuery = true)
int clearCustomReasonsWithdrawnBefore(
@Param("anonymizationDate") OffsetDateTime anonymizationDate
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.devkor.ifive.nadab.domain.auth.core.repository;

import com.devkor.ifive.nadab.domain.auth.core.entity.WithdrawalEvent;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.time.OffsetDateTime;

public interface WithdrawalEventRepository extends JpaRepository<WithdrawalEvent, Long> {

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("""
delete from WithdrawalEvent event
where event.expiresAt <= :expirationDate
""")
int deleteExpiredEvents(@Param("expirationDate") OffsetDateTime expirationDate);

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("""
update WithdrawalEvent event
set event.user = null,
event.anonymizedAt = :anonymizedAt
where event.withdrawnAt < :anonymizationDate
and (event.user is not null or event.anonymizedAt is null)
""")
int anonymizeEventsWithdrawnBefore(
@Param("anonymizationDate") OffsetDateTime anonymizationDate,
@Param("anonymizedAt") OffsetDateTime anonymizedAt
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.devkor.ifive.nadab.domain.auth.infra.scheduler;

import com.devkor.ifive.nadab.domain.auth.core.repository.UserWithdrawalReasonRepository;
import com.devkor.ifive.nadab.domain.auth.core.repository.WithdrawalEventReasonRepository;
import com.devkor.ifive.nadab.domain.auth.core.repository.WithdrawalEventRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.time.OffsetDateTime;

@Slf4j
@Component
@RequiredArgsConstructor
public class WithdrawalEventCleanupScheduler {

private static final int ACCOUNT_RECOVERY_PERIOD_DAYS = 14;

private final WithdrawalEventRepository withdrawalEventRepository;
private final WithdrawalEventReasonRepository withdrawalEventReasonRepository;
private final UserWithdrawalReasonRepository userWithdrawalReasonRepository;

@Scheduled(cron = "0 10 0 * * *", zone = "Asia/Seoul")
@Transactional
public void cleanupWithdrawalEvents() {
OffsetDateTime now = OffsetDateTime.now();
OffsetDateTime anonymizationDate = now.minusDays(ACCOUNT_RECOVERY_PERIOD_DAYS);

int deletedEventCount = withdrawalEventRepository.deleteExpiredEvents(now);
int deletedLegacyReasonCount = userWithdrawalReasonRepository
.deleteReasonsWithdrawnBefore(anonymizationDate);
int clearedCustomReasonCount = withdrawalEventReasonRepository
.clearCustomReasonsWithdrawnBefore(anonymizationDate);
int anonymizedEventCount = withdrawalEventRepository
.anonymizeEventsWithdrawnBefore(anonymizationDate, now);

if (deletedEventCount == 0
&& deletedLegacyReasonCount == 0
&& clearedCustomReasonCount == 0
&& anonymizedEventCount == 0) {
log.debug("정리할 탈퇴 이벤트가 없습니다.");
return;
}

log.info(
"탈퇴 이벤트 정리 완료: 만료 이벤트 {}건 삭제, 레거시 사유 {}건 삭제, "
+ "자유 입력 {}건 제거, 이벤트 {}건 비식별화",
deletedEventCount,
deletedLegacyReasonCount,
clearedCustomReasonCount,
anonymizedEventCount
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,23 +24,22 @@ public class WithdrawalStatsService {
private final WithdrawalStatsRepository repo;

public WithdrawalStatsViewModel getWithdrawalStats() {
List<Object[]> rows = repo.findLatestWithdrawalReasonRows(RECENT_WITHDRAWAL_EVENT_LIMIT);
List<Object[]> totalReasonRows = repo.countAllWithdrawalReasons();
List<Object[]> rows = repo.findLatestRetainedWithdrawalReasonRows(RECENT_WITHDRAWAL_EVENT_LIMIT);
List<Object[]> totalReasonRows = repo.countRetainedWithdrawalReasons();

Map<EventKey, EventAccumulator> eventMap = new LinkedHashMap<>();
Map<Long, EventAccumulator> eventMap = new LinkedHashMap<>();
Map<WithdrawalReasonType, Long> reasonCountMap = new EnumMap<>(WithdrawalReasonType.class);

for (Object[] row : rows) {
long userId = toLong(row[0]);
long eventId = 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,
eventId,
k -> new EventAccumulator(formatDateTime(normalizedWithdrawnAt))
);

Expand Down Expand Up @@ -136,9 +135,6 @@ private String toReasonLabel(WithdrawalReasonType reasonType, String rawReasonCo
return toReasonLabel(reasonType);
}

private record EventKey(long userId, OffsetDateTime withdrawnAt) {
}

private static class EventAccumulator {
private final String withdrawnAt;
private final List<String> reasons = new ArrayList<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,39 +12,40 @@ public class WithdrawalStatsRepository {

private final EntityManager em;

public List<Object[]> findLatestWithdrawalReasonRows(int limitEvents) {
public List<Object[]> findLatestRetainedWithdrawalReasonRows(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
we.id,
we.withdrawn_at,
row_number() over (order by we.withdrawn_at desc, we.id desc) as rn
from withdrawal_events we
where we.expires_at > current_timestamp
)
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
re.id,
re.withdrawn_at,
wer.reason,
case when we.user_id is null then null else wer.custom_reason end
from ranked_events re
join withdrawal_events we on we.id = re.id
join withdrawal_event_reasons wer on wer.event_id = re.id
where re.rn <= :limitEvents
order by uwr.withdrawn_at desc, uwr.user_id desc, uwr.reason asc
order by re.withdrawn_at desc, re.id desc, wer.reason asc
""")
.setParameter("limitEvents", limitEvents)
.getResultList();
}

public List<Object[]> countAllWithdrawalReasons() {
public List<Object[]> countRetainedWithdrawalReasons() {
return em.createNativeQuery("""
select
uwr.reason,
wer.reason,
count(*) as cnt
from user_withdrawal_reasons uwr
group by uwr.reason
from withdrawal_event_reasons wer
join withdrawal_events we on we.id = wer.event_id
where we.expires_at > current_timestamp
group by wer.reason
""")
.getResultList();
}
Expand Down
Loading
Loading