Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -64,4 +65,11 @@ List<WeeklyAllowance> 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<Long> contractIds);
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,30 @@ public interface WorkerContractRepository extends JpaRepository<WorkerContract,
"JOIN FETCH c.workplace wp " +
"WHERE c.isActive = true AND c.paymentDay >= :tomorrowDay")
List<WorkerContract> findActiveContractsByPaymentDayOnLastDay(@Param("tomorrowDay") int tomorrowDay);

/**
* 영구 삭제용: 여러 사업장에 속한 모든 WorkerContract ID 조회
*/
@Query("SELECT c.id FROM WorkerContract c WHERE c.workplace.id IN :workplaceIds")
List<Long> findIdsByWorkplaceIdIn(@Param("workplaceIds") List<Long> 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<Long> workplaceIds);

/**
* 영구 삭제용: 특정 근로자의 모든 WorkerContract ID 조회
*/
@Query("SELECT c.id FROM WorkerContract c WHERE c.worker.id = :workerId")
List<Long> 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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Long> contractIds);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -30,4 +31,18 @@ List<Notice> findActiveNoticesByWorkplaceId(
"WHERE n.id = :noticeId " +
"AND n.isDeleted = false")
Optional<Notice> 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<Long> workplaceIds);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -98,4 +99,11 @@ List<Payment> 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<Long> salaryIds);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -81,4 +82,17 @@ Optional<Salary> 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<Long> findIdsByContractIdIn(@Param("contractIds") List<Long> contractIds);

/**
* 영구 삭제용: 여러 계약의 모든 Salary 일괄 삭제
*/
@Modifying(clearAutomatically = true)
@Query("DELETE FROM Salary s WHERE s.contract.id IN :contractIds")
void deleteAllByContractIdIn(@Param("contractIds") List<Long> contractIds);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -15,4 +17,7 @@ public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> 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<User> findAllByDeletedAtBefore(@Param("threshold") LocalDateTime threshold);
}
Original file line number Diff line number Diff line change
@@ -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<User> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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<Workplace> workplaces = workplaceRepository.findByEmployerId(employer.getId());
List<Long> workplaceIds = workplaces.stream().map(Workplace::getId).toList();

List<Long> 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<Long> 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를 참조하므로 가장 먼저 정리)
*/
Comment on lines +133 to +137
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

주석의 삭제 순서 설명을 실제 코드와 맞춰 주세요.

현재 주석은 Payment → Salary → WorkRecord → WeeklyAllowance → CorrectionRequest로 적혀 있지만, 실제 구현은 CorrectionRequest를 가장 먼저 삭제합니다. 다음 유지보수자가 FK 순서를 오해하지 않도록 주석을 코드와 동일하게 정리하는 게 좋습니다.

수정 예시
 /**
  * 계약 산하 데이터를 FK 의존성 역순으로 삭제한다.
- *  Payment → Salary → WorkRecord → WeeklyAllowance → CorrectionRequest
- *  (CorrectionRequest는 WorkRecord/Contract를 참조하므로 가장 먼저 정리)
+ *  CorrectionRequest → Payment → Salary → WorkRecord → WeeklyAllowance
  */
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* 계약 산하 데이터를 FK 의존성 역순으로 삭제한다.
* PaymentSalaryWorkRecordWeeklyAllowanceCorrectionRequest
* (CorrectionRequest는 WorkRecord/Contract를 참조하므로 가장 먼저 정리)
*/
/**
* 계약 산하 데이터를 FK 의존성 역순으로 삭제한다.
* CorrectionRequestPaymentSalaryWorkRecordWeeklyAllowance
*/
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/com/example/paycheck/domain/user/service/UserHardDeleteService.java`
around lines 133 - 137, Update the Javadoc in UserHardDeleteService (the method
that deletes contract-related data) so the listed FK deletion order matches the
actual implementation: move CorrectionRequest to the front and list the sequence
as CorrectionRequest → WeeklyAllowance → WorkRecord → Salary → Payment (or the
exact order used by the delete methods in UserHardDeleteService). Reference the
method/class name (UserHardDeleteService) when editing the comment to keep it
accurate for future maintainers.

private void deleteContractDescendants(Long userId, List<Long> contractIds) {
// 사용자가 요청한 모든 정정요청 (다른 사업장에서의 요청 포함) 정리
correctionRequestRepository.deleteAllByRequesterId(userId);

if (contractIds.isEmpty()) {
return;
}

// 계약 직접 참조 + WorkRecord 경유 정정요청 정리
correctionRequestRepository.deleteAllByContractIdIn(contractIds);

// Payment는 Salary FK를 가지므로 먼저 삭제
List<Long> 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());
}
}
Loading
Loading