From 3c96f3c9c112b87e2266ded0b63c9de1d756a946 Mon Sep 17 00:00:00 2001 From: Desktop Date: Mon, 4 May 2026 21:21:51 +0900 Subject: [PATCH] =?UTF-8?q?feat(user):=20=ED=83=88=ED=87=B4=20=EC=9C=A0?= =?UTF-8?q?=EC=A0=80=2030=EC=9D=BC=20=ED=9B=84=20=EC=98=81=EA=B5=AC=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#131)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 매일 새벽 4시에 `deletedAt`이 30일 이상 경과한 탈퇴 사용자의 데이터를 FK 제약을 고려한 순서로 hard delete 처리하는 기능 추가 **변경 내용:** - UserHardDeleteService: 사용자별 트랜잭션으로 고용주/근로자 케이스 구분 - 고용주: 사업장 → 계약 → 하위 모든 데이터(근무/수당/급여/결제) → 정정요청 → 공지 정리 - 근로자: 본인 계약만 정리, 다른 근로자 데이터 영향 없음 - FK 의존성 역순: CorrectionRequest → Payment → Salary → WorkRecord → WeeklyAllowance → WorkerContract → Notice → Workplace → Employer/Worker → User - UserHardDeleteScheduler: 매일 새벽 4시 실행 - 30일 경과 탈퇴 유저 조회 후 유저별 hard delete 호출 - 한 명 실패 시 try-catch로 격리, 다음 유저 계속 처리 - Repository 메서드 추가 (영구 삭제용 bulk delete) - UserRepository: findAllByDeletedAtBefore - WorkRecordRepository: deleteAllByContractIdIn - WeeklyAllowanceRepository: deleteAllByContractIdIn - SalaryRepository: findIdsByContractIdIn, deleteAllByContractIdIn - PaymentRepository: deleteAllBySalaryIdIn - CorrectionRequestRepository: deleteAllByRequesterId, deleteAllByContractIdIn - NoticeRepository: deleteAllByAuthorId, deleteAllByWorkplaceIdIn - WorkplaceRepository: deleteAllByEmployerId - WorkerContractRepository: findIdsByWorkplaceIdIn, deleteAllByWorkplaceIdIn, findIdsByWorkerId, deleteAllByWorkerId - UserHardDeleteServiceTest: 고용주/근로자 케이스 및 FK 순서 검증 **선행 조건:** 이슈 #130 (detached 엔티티 버그) 완료 후 진행됨 **테스트:** - ./gradlew test --tests UserHardDeleteServiceTest: 6개 시나리오 통과 - ./gradlew clean build: 전체 테스트 통과 --- .../repository/WeeklyAllowanceRepository.java | 8 + .../repository/WorkerContractRepository.java | 26 ++ .../CorrectionRequestRepository.java | 12 + .../notice/repository/NoticeRepository.java | 15 ++ .../payment/repository/PaymentRepository.java | 8 + .../salary/repository/SalaryRepository.java | 14 ++ .../user/repository/UserRepository.java | 5 + .../scheduler/UserHardDeleteScheduler.java | 60 +++++ .../user/service/UserHardDeleteService.java | 170 +++++++++++++ .../repository/WorkplaceRepository.java | 10 + .../repository/WorkRecordRepository.java | 5 + .../service/UserHardDeleteServiceTest.java | 227 ++++++++++++++++++ 12 files changed, 560 insertions(+) create mode 100644 src/main/java/com/example/paycheck/domain/user/scheduler/UserHardDeleteScheduler.java create mode 100644 src/main/java/com/example/paycheck/domain/user/service/UserHardDeleteService.java create mode 100644 src/test/java/com/example/paycheck/domain/user/service/UserHardDeleteServiceTest.java diff --git a/src/main/java/com/example/paycheck/domain/allowance/repository/WeeklyAllowanceRepository.java b/src/main/java/com/example/paycheck/domain/allowance/repository/WeeklyAllowanceRepository.java index f010a1bb..c64f117f 100644 --- a/src/main/java/com/example/paycheck/domain/allowance/repository/WeeklyAllowanceRepository.java +++ b/src/main/java/com/example/paycheck/domain/allowance/repository/WeeklyAllowanceRepository.java @@ -2,6 +2,7 @@ import com.example.paycheck.domain.allowance.entity.WeeklyAllowance; 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 org.springframework.stereotype.Repository; @@ -64,4 +65,11 @@ List findByContractAndDateRange( @Param("contractId") Long contractId, @Param("startDate") LocalDate startDate, @Param("endDate") LocalDate endDate); + + /** + * 영구 삭제용: 여러 계약의 모든 WeeklyAllowance 일괄 삭제 + */ + @Modifying(clearAutomatically = true) + @Query("DELETE FROM WeeklyAllowance wa WHERE wa.contract.id IN :contractIds") + void deleteAllByContractIdIn(@Param("contractIds") List contractIds); } diff --git a/src/main/java/com/example/paycheck/domain/contract/repository/WorkerContractRepository.java b/src/main/java/com/example/paycheck/domain/contract/repository/WorkerContractRepository.java index 1b598017..ca6b6b37 100644 --- a/src/main/java/com/example/paycheck/domain/contract/repository/WorkerContractRepository.java +++ b/src/main/java/com/example/paycheck/domain/contract/repository/WorkerContractRepository.java @@ -62,4 +62,30 @@ public interface WorkerContractRepository extends JpaRepository= :tomorrowDay") List findActiveContractsByPaymentDayOnLastDay(@Param("tomorrowDay") int tomorrowDay); + + /** + * 영구 삭제용: 여러 사업장에 속한 모든 WorkerContract ID 조회 + */ + @Query("SELECT c.id FROM WorkerContract c WHERE c.workplace.id IN :workplaceIds") + List findIdsByWorkplaceIdIn(@Param("workplaceIds") List workplaceIds); + + /** + * 영구 삭제용: 여러 사업장에 속한 모든 WorkerContract 일괄 삭제 + */ + @org.springframework.data.jpa.repository.Modifying(clearAutomatically = true) + @Query("DELETE FROM WorkerContract c WHERE c.workplace.id IN :workplaceIds") + void deleteAllByWorkplaceIdIn(@Param("workplaceIds") List workplaceIds); + + /** + * 영구 삭제용: 특정 근로자의 모든 WorkerContract ID 조회 + */ + @Query("SELECT c.id FROM WorkerContract c WHERE c.worker.id = :workerId") + List findIdsByWorkerId(@Param("workerId") Long workerId); + + /** + * 영구 삭제용: 특정 근로자의 모든 WorkerContract 일괄 삭제 + */ + @org.springframework.data.jpa.repository.Modifying(clearAutomatically = true) + @Query("DELETE FROM WorkerContract c WHERE c.worker.id = :workerId") + void deleteAllByWorkerId(@Param("workerId") Long workerId); } diff --git a/src/main/java/com/example/paycheck/domain/correction/repository/CorrectionRequestRepository.java b/src/main/java/com/example/paycheck/domain/correction/repository/CorrectionRequestRepository.java index 50080a2b..e6e3af5c 100644 --- a/src/main/java/com/example/paycheck/domain/correction/repository/CorrectionRequestRepository.java +++ b/src/main/java/com/example/paycheck/domain/correction/repository/CorrectionRequestRepository.java @@ -149,4 +149,16 @@ void deleteByWorkRecordContractAndDateAfterAndStatus( @Param("contractId") Long contractId, @Param("date") LocalDate date, @Param("status") WorkRecordStatus status); + + // 영구 삭제용: 특정 사용자가 요청한 모든 정정요청 일괄 삭제 + @Modifying(clearAutomatically = true) + @Query("DELETE FROM CorrectionRequest cr WHERE cr.requester.id = :userId") + void deleteAllByRequesterId(@Param("userId") Long userId); + + // 영구 삭제용: 여러 계약(직접 참조 + WorkRecord 경유)에 연결된 정정요청 일괄 삭제 + @Modifying(clearAutomatically = true) + @Query("DELETE FROM CorrectionRequest cr " + + "WHERE cr.contract.id IN :contractIds " + + "OR cr.workRecord.id IN (SELECT wr.id FROM WorkRecord wr WHERE wr.contract.id IN :contractIds)") + void deleteAllByContractIdIn(@Param("contractIds") List contractIds); } diff --git a/src/main/java/com/example/paycheck/domain/notice/repository/NoticeRepository.java b/src/main/java/com/example/paycheck/domain/notice/repository/NoticeRepository.java index 0d604730..6ecdf23c 100644 --- a/src/main/java/com/example/paycheck/domain/notice/repository/NoticeRepository.java +++ b/src/main/java/com/example/paycheck/domain/notice/repository/NoticeRepository.java @@ -2,6 +2,7 @@ import com.example.paycheck.domain.notice.entity.Notice; 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 org.springframework.stereotype.Repository; @@ -30,4 +31,18 @@ List findActiveNoticesByWorkplaceId( "WHERE n.id = :noticeId " + "AND n.isDeleted = false") Optional findByIdAndIsDeletedFalse(@Param("noticeId") Long noticeId); + + /** + * 영구 삭제용: 특정 작성자가 작성한 모든 공지 일괄 삭제 + */ + @Modifying(clearAutomatically = true) + @Query("DELETE FROM Notice n WHERE n.author.id = :userId") + void deleteAllByAuthorId(@Param("userId") Long userId); + + /** + * 영구 삭제용: 여러 사업장에 속한 모든 공지 일괄 삭제 + */ + @Modifying(clearAutomatically = true) + @Query("DELETE FROM Notice n WHERE n.workplace.id IN :workplaceIds") + void deleteAllByWorkplaceIdIn(@Param("workplaceIds") List workplaceIds); } diff --git a/src/main/java/com/example/paycheck/domain/payment/repository/PaymentRepository.java b/src/main/java/com/example/paycheck/domain/payment/repository/PaymentRepository.java index 481c185c..aaa59373 100644 --- a/src/main/java/com/example/paycheck/domain/payment/repository/PaymentRepository.java +++ b/src/main/java/com/example/paycheck/domain/payment/repository/PaymentRepository.java @@ -3,6 +3,7 @@ import com.example.paycheck.domain.payment.entity.Payment; import com.example.paycheck.domain.payment.enums.PaymentStatus; 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 org.springframework.stereotype.Repository; @@ -98,4 +99,11 @@ List findByWorkerUserIdAndYearMonth( @Param("userId") Long userId, @Param("year") Integer year, @Param("month") Integer month); + + /** + * 영구 삭제용: 여러 Salary에 속한 Payment 일괄 삭제 + */ + @Modifying(clearAutomatically = true) + @Query("DELETE FROM Payment p WHERE p.salary.id IN :salaryIds") + void deleteAllBySalaryIdIn(@Param("salaryIds") List salaryIds); } diff --git a/src/main/java/com/example/paycheck/domain/salary/repository/SalaryRepository.java b/src/main/java/com/example/paycheck/domain/salary/repository/SalaryRepository.java index 24dc6bfe..f709f929 100644 --- a/src/main/java/com/example/paycheck/domain/salary/repository/SalaryRepository.java +++ b/src/main/java/com/example/paycheck/domain/salary/repository/SalaryRepository.java @@ -5,6 +5,7 @@ import jakarta.persistence.QueryHint; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.QueryHints; import org.springframework.data.repository.query.Param; @@ -81,4 +82,17 @@ Optional findByContractIdAndYearAndMonthForUpdate( @Param("year") Integer year, @Param("month") Integer month ); + + /** + * 영구 삭제용: 여러 계약의 모든 Salary ID 조회 (Payment 선삭제용) + */ + @Query("SELECT s.id FROM Salary s WHERE s.contract.id IN :contractIds") + List findIdsByContractIdIn(@Param("contractIds") List contractIds); + + /** + * 영구 삭제용: 여러 계약의 모든 Salary 일괄 삭제 + */ + @Modifying(clearAutomatically = true) + @Query("DELETE FROM Salary s WHERE s.contract.id IN :contractIds") + void deleteAllByContractIdIn(@Param("contractIds") List contractIds); } diff --git a/src/main/java/com/example/paycheck/domain/user/repository/UserRepository.java b/src/main/java/com/example/paycheck/domain/user/repository/UserRepository.java index 9e29f2d8..6135ed6d 100644 --- a/src/main/java/com/example/paycheck/domain/user/repository/UserRepository.java +++ b/src/main/java/com/example/paycheck/domain/user/repository/UserRepository.java @@ -6,6 +6,8 @@ import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; @Repository @@ -15,4 +17,7 @@ public interface UserRepository extends JpaRepository { Optional findByKakaoId(@Param("kakaoId") String kakaoId); boolean existsByKakaoId(String kakaoId); + + @Query("SELECT u FROM User u WHERE u.deletedAt IS NOT NULL AND u.deletedAt < :threshold") + List findAllByDeletedAtBefore(@Param("threshold") LocalDateTime threshold); } diff --git a/src/main/java/com/example/paycheck/domain/user/scheduler/UserHardDeleteScheduler.java b/src/main/java/com/example/paycheck/domain/user/scheduler/UserHardDeleteScheduler.java new file mode 100644 index 00000000..c58bace7 --- /dev/null +++ b/src/main/java/com/example/paycheck/domain/user/scheduler/UserHardDeleteScheduler.java @@ -0,0 +1,60 @@ +package com.example.paycheck.domain.user.scheduler; + +import com.example.paycheck.domain.user.entity.User; +import com.example.paycheck.domain.user.repository.UserRepository; +import com.example.paycheck.domain.user.service.UserHardDeleteService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 탈퇴 후 30일이 경과한 사용자 데이터를 영구 삭제하는 스케줄러. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class UserHardDeleteScheduler { + + private static final int RETENTION_DAYS = 30; + + private final UserRepository userRepository; + private final UserHardDeleteService userHardDeleteService; + + /** + * 매일 새벽 4시에 30일 경과 탈퇴 사용자 영구 삭제 + * cron: 초 분 시 일 월 요일 + * "0 0 4 * * *" = 매일 새벽 4시 정각 + */ + @Scheduled(cron = "0 0 4 * * *") + public void hardDeleteWithdrawnUsers() { + log.info("===== 탈퇴 사용자 영구 삭제 스케줄러 시작 ====="); + + LocalDateTime threshold = LocalDateTime.now().minusDays(RETENTION_DAYS); + List targets = userRepository.findAllByDeletedAtBefore(threshold); + + if (targets.isEmpty()) { + log.info("영구 삭제 대상 사용자가 없습니다."); + return; + } + + int successCount = 0; + int failCount = 0; + + for (User user : targets) { + try { + userHardDeleteService.hardDeleteUser(user.getId()); + successCount++; + } catch (Exception e) { + log.error("사용자 영구 삭제 실패: userId={}, error={}", user.getId(), e.getMessage(), e); + failCount++; + } + } + + log.info("===== 탈퇴 사용자 영구 삭제 스케줄러 완료 ===== (대상: {}, 성공: {}, 실패: {})", + targets.size(), successCount, failCount); + } +} diff --git a/src/main/java/com/example/paycheck/domain/user/service/UserHardDeleteService.java b/src/main/java/com/example/paycheck/domain/user/service/UserHardDeleteService.java new file mode 100644 index 00000000..1d1d4f02 --- /dev/null +++ b/src/main/java/com/example/paycheck/domain/user/service/UserHardDeleteService.java @@ -0,0 +1,170 @@ +package com.example.paycheck.domain.user.service; + +import com.example.paycheck.common.exception.ErrorCode; +import com.example.paycheck.common.exception.NotFoundException; +import com.example.paycheck.domain.allowance.repository.WeeklyAllowanceRepository; +import com.example.paycheck.domain.auth.repository.RefreshTokenRepository; +import com.example.paycheck.domain.contract.repository.WorkerContractRepository; +import com.example.paycheck.domain.correction.repository.CorrectionRequestRepository; +import com.example.paycheck.domain.employer.entity.Employer; +import com.example.paycheck.domain.employer.repository.EmployerRepository; +import com.example.paycheck.domain.fcm.repository.FcmTokenRepository; +import com.example.paycheck.domain.notice.repository.NoticeRepository; +import com.example.paycheck.domain.notification.repository.NotificationRepository; +import com.example.paycheck.domain.payment.repository.PaymentRepository; +import com.example.paycheck.domain.salary.repository.SalaryRepository; +import com.example.paycheck.domain.settings.repository.UserSettingsRepository; +import com.example.paycheck.domain.user.entity.User; +import com.example.paycheck.domain.user.enums.UserType; +import com.example.paycheck.domain.user.repository.UserRepository; +import com.example.paycheck.domain.worker.entity.Worker; +import com.example.paycheck.domain.worker.repository.WorkerRepository; +import com.example.paycheck.domain.workplace.entity.Workplace; +import com.example.paycheck.domain.workplace.repository.WorkplaceRepository; +import com.example.paycheck.domain.workrecord.repository.WorkRecordRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 탈퇴 30일 경과 사용자의 데이터를 영구 삭제하는 서비스. + * 트랜잭션은 사용자 1명 단위로 분리하여, 한 명의 실패가 다른 사용자에게 전파되지 않도록 한다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class UserHardDeleteService { + + private final UserRepository userRepository; + private final EmployerRepository employerRepository; + private final WorkerRepository workerRepository; + private final WorkplaceRepository workplaceRepository; + private final WorkerContractRepository workerContractRepository; + private final WorkRecordRepository workRecordRepository; + private final WeeklyAllowanceRepository weeklyAllowanceRepository; + private final SalaryRepository salaryRepository; + private final PaymentRepository paymentRepository; + private final CorrectionRequestRepository correctionRequestRepository; + private final NoticeRepository noticeRepository; + private final NotificationRepository notificationRepository; + private final FcmTokenRepository fcmTokenRepository; + private final UserSettingsRepository userSettingsRepository; + private final RefreshTokenRepository refreshTokenRepository; + + /** + * 단일 사용자 영구 삭제. + * FK 제약을 고려한 순서로 연관 엔티티를 모두 hard delete 한다. + */ + @Transactional + public void hardDeleteUser(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new NotFoundException(ErrorCode.USER_NOT_FOUND, "사용자를 찾을 수 없습니다.")); + + if (user.getUserType() == UserType.EMPLOYER) { + hardDeleteEmployer(user); + } else { + hardDeleteWorker(user); + } + + cleanupCommonData(user); + userRepository.delete(user); + + log.info("사용자 영구 삭제 완료: userId={}, userType={}", user.getId(), user.getUserType()); + } + + /** + * 고용주 영구 삭제: 사업장 및 그 산하 모든 계약/근무/급여/결제/정정요청/공지를 정리한다. + */ + private void hardDeleteEmployer(User user) { + Employer employer = employerRepository.findByUserId(user.getId()).orElse(null); + if (employer == null) { + return; + } + + List workplaces = workplaceRepository.findByEmployerId(employer.getId()); + List workplaceIds = workplaces.stream().map(Workplace::getId).toList(); + + List contractIds = workplaceIds.isEmpty() + ? List.of() + : workerContractRepository.findIdsByWorkplaceIdIn(workplaceIds); + + deleteContractDescendants(user.getId(), contractIds); + + if (!workplaceIds.isEmpty()) { + workerContractRepository.deleteAllByWorkplaceIdIn(workplaceIds); + noticeRepository.deleteAllByWorkplaceIdIn(workplaceIds); + } + // 사용자가 작성한 다른 사업장의 공지(가능성)도 정리 + noticeRepository.deleteAllByAuthorId(user.getId()); + + if (!workplaceIds.isEmpty()) { + workplaceRepository.deleteAllByEmployerId(employer.getId()); + } + + employerRepository.delete(employer); + } + + /** + * 근로자 영구 삭제: 본인의 모든 계약 및 그 산하 데이터를 정리한다. + * 다른 근로자나 고용주의 데이터에는 영향을 주지 않는다. + */ + private void hardDeleteWorker(User user) { + Worker worker = workerRepository.findByUserId(user.getId()).orElse(null); + if (worker == null) { + return; + } + + List contractIds = workerContractRepository.findIdsByWorkerId(worker.getId()); + + deleteContractDescendants(user.getId(), contractIds); + + if (!contractIds.isEmpty()) { + workerContractRepository.deleteAllByWorkerId(worker.getId()); + } + // 근로자가 작성한 공지(가능성)도 정리 + noticeRepository.deleteAllByAuthorId(user.getId()); + + workerRepository.delete(worker); + } + + /** + * 계약 산하 데이터를 FK 의존성 역순으로 삭제한다. + * Payment → Salary → WorkRecord → WeeklyAllowance → CorrectionRequest + * (CorrectionRequest는 WorkRecord/Contract를 참조하므로 가장 먼저 정리) + */ + private void deleteContractDescendants(Long userId, List contractIds) { + // 사용자가 요청한 모든 정정요청 (다른 사업장에서의 요청 포함) 정리 + correctionRequestRepository.deleteAllByRequesterId(userId); + + if (contractIds.isEmpty()) { + return; + } + + // 계약 직접 참조 + WorkRecord 경유 정정요청 정리 + correctionRequestRepository.deleteAllByContractIdIn(contractIds); + + // Payment는 Salary FK를 가지므로 먼저 삭제 + List salaryIds = salaryRepository.findIdsByContractIdIn(contractIds); + if (!salaryIds.isEmpty()) { + paymentRepository.deleteAllBySalaryIdIn(salaryIds); + } + salaryRepository.deleteAllByContractIdIn(contractIds); + + // WorkRecord는 weekly_allowance_id FK를 가지므로 WeeklyAllowance보다 먼저 삭제 + workRecordRepository.deleteAllByContractIdIn(contractIds); + weeklyAllowanceRepository.deleteAllByContractIdIn(contractIds); + } + + /** + * 사용자 단위 공통 부속 데이터 정리. + */ + private void cleanupCommonData(User user) { + notificationRepository.deleteAllByUser(user); + fcmTokenRepository.deleteByUserId(user.getId()); + userSettingsRepository.deleteByUserId(user.getId()); + refreshTokenRepository.deleteByUserId(user.getId()); + } +} diff --git a/src/main/java/com/example/paycheck/domain/workplace/repository/WorkplaceRepository.java b/src/main/java/com/example/paycheck/domain/workplace/repository/WorkplaceRepository.java index 71e3fdf3..d68e20fe 100644 --- a/src/main/java/com/example/paycheck/domain/workplace/repository/WorkplaceRepository.java +++ b/src/main/java/com/example/paycheck/domain/workplace/repository/WorkplaceRepository.java @@ -2,6 +2,9 @@ import com.example.paycheck.domain.workplace.entity.Workplace; 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 org.springframework.stereotype.Repository; import java.util.List; @@ -10,4 +13,11 @@ public interface WorkplaceRepository extends JpaRepository { List findByEmployerId(Long employerId); List findByEmployerIdAndIsActive(Long employerId, Boolean isActive); + + /** + * 영구 삭제용: 특정 고용주의 모든 사업장 일괄 삭제 + */ + @Modifying(clearAutomatically = true) + @Query("DELETE FROM Workplace w WHERE w.employer.id = :employerId") + void deleteAllByEmployerId(@Param("employerId") Long employerId); } diff --git a/src/main/java/com/example/paycheck/domain/workrecord/repository/WorkRecordRepository.java b/src/main/java/com/example/paycheck/domain/workrecord/repository/WorkRecordRepository.java index 558bc2a2..e0694d14 100644 --- a/src/main/java/com/example/paycheck/domain/workrecord/repository/WorkRecordRepository.java +++ b/src/main/java/com/example/paycheck/domain/workrecord/repository/WorkRecordRepository.java @@ -159,6 +159,11 @@ int bulkUpdateStatusByContractIdAndStatus( @Param("currentStatus") WorkRecordStatus currentStatus, @Param("newStatus") WorkRecordStatus newStatus); + // 영구 삭제용: 여러 계약의 모든 WorkRecord 일괄 삭제 + @Modifying(clearAutomatically = true) + @Query("DELETE FROM WorkRecord wr WHERE wr.contract.id IN :contractIds") + void deleteAllByContractIdIn(@Param("contractIds") List contractIds); + // 현재 시점 기준으로 종료된 SCHEDULED 근무 기록 ID 조회 (자동 완료 배치용) // ID만 조회하여 메모리 효율 확보, 각 레코드는 completeWorkRecord(Long)에서 개별 트랜잭션으로 처리 @Query("SELECT wr.id FROM WorkRecord wr " + diff --git a/src/test/java/com/example/paycheck/domain/user/service/UserHardDeleteServiceTest.java b/src/test/java/com/example/paycheck/domain/user/service/UserHardDeleteServiceTest.java new file mode 100644 index 00000000..212897cb --- /dev/null +++ b/src/test/java/com/example/paycheck/domain/user/service/UserHardDeleteServiceTest.java @@ -0,0 +1,227 @@ +package com.example.paycheck.domain.user.service; + +import com.example.paycheck.common.exception.NotFoundException; +import com.example.paycheck.domain.allowance.repository.WeeklyAllowanceRepository; +import com.example.paycheck.domain.auth.repository.RefreshTokenRepository; +import com.example.paycheck.domain.contract.repository.WorkerContractRepository; +import com.example.paycheck.domain.correction.repository.CorrectionRequestRepository; +import com.example.paycheck.domain.employer.entity.Employer; +import com.example.paycheck.domain.employer.repository.EmployerRepository; +import com.example.paycheck.domain.fcm.repository.FcmTokenRepository; +import com.example.paycheck.domain.notice.repository.NoticeRepository; +import com.example.paycheck.domain.notification.repository.NotificationRepository; +import com.example.paycheck.domain.payment.repository.PaymentRepository; +import com.example.paycheck.domain.salary.repository.SalaryRepository; +import com.example.paycheck.domain.settings.repository.UserSettingsRepository; +import com.example.paycheck.domain.user.entity.User; +import com.example.paycheck.domain.user.enums.UserType; +import com.example.paycheck.domain.user.repository.UserRepository; +import com.example.paycheck.domain.worker.entity.Worker; +import com.example.paycheck.domain.worker.repository.WorkerRepository; +import com.example.paycheck.domain.workplace.entity.Workplace; +import com.example.paycheck.domain.workplace.repository.WorkplaceRepository; +import com.example.paycheck.domain.workrecord.repository.WorkRecordRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("UserHardDeleteService 테스트") +class UserHardDeleteServiceTest { + + @Mock private UserRepository userRepository; + @Mock private EmployerRepository employerRepository; + @Mock private WorkerRepository workerRepository; + @Mock private WorkplaceRepository workplaceRepository; + @Mock private WorkerContractRepository workerContractRepository; + @Mock private WorkRecordRepository workRecordRepository; + @Mock private WeeklyAllowanceRepository weeklyAllowanceRepository; + @Mock private SalaryRepository salaryRepository; + @Mock private PaymentRepository paymentRepository; + @Mock private CorrectionRequestRepository correctionRequestRepository; + @Mock private NoticeRepository noticeRepository; + @Mock private NotificationRepository notificationRepository; + @Mock private FcmTokenRepository fcmTokenRepository; + @Mock private UserSettingsRepository userSettingsRepository; + @Mock private RefreshTokenRepository refreshTokenRepository; + + @InjectMocks + private UserHardDeleteService userHardDeleteService; + + private User employerUser; + private User workerUser; + + @BeforeEach + void setUp() { + employerUser = User.builder() + .id(1L) + .kakaoId("kakao_employer") + .name("고용주") + .userType(UserType.EMPLOYER) + .build(); + employerUser.withdraw(); + + workerUser = User.builder() + .id(2L) + .kakaoId("kakao_worker") + .name("근로자") + .userType(UserType.WORKER) + .build(); + workerUser.withdraw(); + } + + @Test + @DisplayName("고용주 영구 삭제 - 사업장/계약/하위 모든 데이터 삭제 + FK 순서 보장") + void hardDeleteEmployer_success() { + // given + Employer employer = Employer.builder().id(10L).user(employerUser).phone("010-1111-2222").build(); + Workplace workplace = Workplace.builder().id(100L).employer(employer).name("사업장A").build(); + + when(userRepository.findById(employerUser.getId())).thenReturn(Optional.of(employerUser)); + when(employerRepository.findByUserId(employerUser.getId())).thenReturn(Optional.of(employer)); + when(workplaceRepository.findByEmployerId(employer.getId())).thenReturn(List.of(workplace)); + when(workerContractRepository.findIdsByWorkplaceIdIn(List.of(100L))).thenReturn(List.of(1000L, 1001L)); + when(salaryRepository.findIdsByContractIdIn(List.of(1000L, 1001L))).thenReturn(List.of(5000L)); + + // when + userHardDeleteService.hardDeleteUser(employerUser.getId()); + + // then - FK 순서 검증 + InOrder inOrder = inOrder( + correctionRequestRepository, paymentRepository, salaryRepository, + workRecordRepository, weeklyAllowanceRepository, workerContractRepository, + noticeRepository, workplaceRepository, employerRepository, userRepository); + + inOrder.verify(correctionRequestRepository).deleteAllByRequesterId(employerUser.getId()); + inOrder.verify(correctionRequestRepository).deleteAllByContractIdIn(List.of(1000L, 1001L)); + inOrder.verify(paymentRepository).deleteAllBySalaryIdIn(List.of(5000L)); + inOrder.verify(salaryRepository).deleteAllByContractIdIn(List.of(1000L, 1001L)); + inOrder.verify(workRecordRepository).deleteAllByContractIdIn(List.of(1000L, 1001L)); + inOrder.verify(weeklyAllowanceRepository).deleteAllByContractIdIn(List.of(1000L, 1001L)); + inOrder.verify(workerContractRepository).deleteAllByWorkplaceIdIn(List.of(100L)); + inOrder.verify(noticeRepository).deleteAllByWorkplaceIdIn(List.of(100L)); + inOrder.verify(noticeRepository).deleteAllByAuthorId(employerUser.getId()); + inOrder.verify(workplaceRepository).deleteAllByEmployerId(employer.getId()); + inOrder.verify(employerRepository).delete(employer); + inOrder.verify(userRepository).delete(employerUser); + } + + @Test + @DisplayName("근로자 영구 삭제 - 본인 계약/하위 데이터만 삭제") + void hardDeleteWorker_success() { + // given + Worker worker = Worker.builder().id(20L).user(workerUser).workerCode("ABC123").build(); + + when(userRepository.findById(workerUser.getId())).thenReturn(Optional.of(workerUser)); + when(workerRepository.findByUserId(workerUser.getId())).thenReturn(Optional.of(worker)); + when(workerContractRepository.findIdsByWorkerId(worker.getId())).thenReturn(List.of(2000L)); + when(salaryRepository.findIdsByContractIdIn(List.of(2000L))).thenReturn(List.of()); + + // when + userHardDeleteService.hardDeleteUser(workerUser.getId()); + + // then + InOrder inOrder = inOrder( + correctionRequestRepository, salaryRepository, workRecordRepository, + weeklyAllowanceRepository, workerContractRepository, noticeRepository, + workerRepository, userRepository); + + inOrder.verify(correctionRequestRepository).deleteAllByRequesterId(workerUser.getId()); + inOrder.verify(correctionRequestRepository).deleteAllByContractIdIn(List.of(2000L)); + inOrder.verify(salaryRepository).deleteAllByContractIdIn(List.of(2000L)); + inOrder.verify(workRecordRepository).deleteAllByContractIdIn(List.of(2000L)); + inOrder.verify(weeklyAllowanceRepository).deleteAllByContractIdIn(List.of(2000L)); + inOrder.verify(workerContractRepository).deleteAllByWorkerId(worker.getId()); + inOrder.verify(noticeRepository).deleteAllByAuthorId(workerUser.getId()); + inOrder.verify(workerRepository).delete(worker); + inOrder.verify(userRepository).delete(workerUser); + + // salary IDs가 비어있으므로 Payment 삭제는 호출되지 않음 + verify(paymentRepository, never()).deleteAllBySalaryIdIn(any()); + } + + @Test + @DisplayName("공통 데이터 정리 - Notification, FcmToken, UserSettings, RefreshToken 삭제") + void hardDeleteUser_cleanupsCommonData() { + // given + when(userRepository.findById(workerUser.getId())).thenReturn(Optional.of(workerUser)); + when(workerRepository.findByUserId(workerUser.getId())).thenReturn(Optional.empty()); + + // when + userHardDeleteService.hardDeleteUser(workerUser.getId()); + + // then + verify(notificationRepository).deleteAllByUser(workerUser); + verify(fcmTokenRepository).deleteByUserId(workerUser.getId()); + verify(userSettingsRepository).deleteByUserId(workerUser.getId()); + verify(refreshTokenRepository).deleteByUserId(workerUser.getId()); + verify(userRepository).delete(workerUser); + } + + @Test + @DisplayName("Employer 프로필이 없어도 NPE 없이 정상 정리") + void hardDeleteEmployer_noProfile_noException() { + // given + when(userRepository.findById(employerUser.getId())).thenReturn(Optional.of(employerUser)); + when(employerRepository.findByUserId(employerUser.getId())).thenReturn(Optional.empty()); + + // when + userHardDeleteService.hardDeleteUser(employerUser.getId()); + + // then + verify(workplaceRepository, never()).findByEmployerId(any()); + verify(workerContractRepository, never()).findIdsByWorkplaceIdIn(any()); + verify(userRepository).delete(employerUser); + } + + @Test + @DisplayName("계약/사업장이 없는 고용주는 빈 IN절 쿼리를 호출하지 않음") + void hardDeleteEmployer_noWorkplaces_skipsBulkQueries() { + // given + Employer employer = Employer.builder().id(10L).user(employerUser).build(); + when(userRepository.findById(employerUser.getId())).thenReturn(Optional.of(employerUser)); + when(employerRepository.findByUserId(employerUser.getId())).thenReturn(Optional.of(employer)); + when(workplaceRepository.findByEmployerId(employer.getId())).thenReturn(List.of()); + + // when + userHardDeleteService.hardDeleteUser(employerUser.getId()); + + // then - 빈 workplaceIds는 in절 쿼리 미호출 + verify(workerContractRepository, never()).findIdsByWorkplaceIdIn(any()); + verify(workerContractRepository, never()).deleteAllByWorkplaceIdIn(any()); + verify(noticeRepository, never()).deleteAllByWorkplaceIdIn(any()); + verify(workplaceRepository, never()).deleteAllByEmployerId(any()); + // 본인 작성 공지는 여전히 정리 + verify(noticeRepository).deleteAllByAuthorId(employerUser.getId()); + // requester 정정요청은 항상 정리 + verify(correctionRequestRepository).deleteAllByRequesterId(employerUser.getId()); + // contract IDs 비어있으므로 contract 기반 정정요청 삭제는 미호출 + verify(correctionRequestRepository, never()).deleteAllByContractIdIn(any()); + verify(employerRepository).delete(employer); + verify(userRepository).delete(employerUser); + } + + @Test + @DisplayName("사용자가 존재하지 않으면 NotFoundException") + void hardDeleteUser_notFound_throwsException() { + // given + when(userRepository.findById(99L)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> userHardDeleteService.hardDeleteUser(99L)) + .isInstanceOf(NotFoundException.class) + .hasMessageContaining("사용자를 찾을 수 없습니다"); + } +}