diff --git a/src/main/java/com/hrr/backend/domain/challenge/entity/Challenge.java b/src/main/java/com/hrr/backend/domain/challenge/entity/Challenge.java index 935cdc01..08464850 100644 --- a/src/main/java/com/hrr/backend/domain/challenge/entity/Challenge.java +++ b/src/main/java/com/hrr/backend/domain/challenge/entity/Challenge.java @@ -106,6 +106,11 @@ public class Challenge extends BaseEntity { @OneToMany(mappedBy = "challenge", cascade = CascadeType.ALL, orphanRemoval = false) private List recommendationResults = new ArrayList<>(); + // 참가자 수 변경 + public void updateCurrentParticipants(int currentParticipants) { + this.currentParticipants = currentParticipants; + } + // 참가자 수 증가 편의 메서드 public void increaseCurrentParticipants() { this.currentParticipants++; diff --git a/src/main/java/com/hrr/backend/domain/challenge/service/ChallengeServiceImpl.java b/src/main/java/com/hrr/backend/domain/challenge/service/ChallengeServiceImpl.java index 45f745bd..f2f8d995 100644 --- a/src/main/java/com/hrr/backend/domain/challenge/service/ChallengeServiceImpl.java +++ b/src/main/java/com/hrr/backend/domain/challenge/service/ChallengeServiceImpl.java @@ -524,7 +524,7 @@ public void registerChallengeWait(User user, Long challengeId) { Challenge challenge = findChallenge(challengeId); // 챌린지 참여 여부 확인 - if (userChallengeRepository.existsByUserAndChallenge(user, challenge)) { + if (userChallengeRepository.existsByUserAndChallengeAndStatus(user, challenge, ChallengeJoinStatus.JOINED)) { throw new GlobalException(ErrorCode.CHALLENGE_ALREADY_JOINED); } diff --git a/src/main/java/com/hrr/backend/domain/report/controller/ReportController.java b/src/main/java/com/hrr/backend/domain/report/controller/ReportController.java index c6c533d6..910f89fd 100644 --- a/src/main/java/com/hrr/backend/domain/report/controller/ReportController.java +++ b/src/main/java/com/hrr/backend/domain/report/controller/ReportController.java @@ -2,10 +2,10 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.GetMapping; 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.RequestParam; import org.springframework.web.bind.annotation.RestController; import com.hrr.backend.domain.report.dto.ReportRequestDto; @@ -16,8 +16,10 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; import lombok.RequiredArgsConstructor; @Tag(name = "Report", description = "신고 관련 API") @@ -29,6 +31,21 @@ public class ReportController { private final ReportService reportService; + @PostMapping("/verification/weak") + @Operation(summary = "부실 인증 신고", description = "부실 인증을 신고합니다. \n중복 신고할 수 없으며, 해당 챌린지에 참가 중인 챌린저만 신고 가능합니다.") + public ApiResponse reportWeakVerification( + @Schema(description = "신고하려는 인증 ID; verificationID", example = "1") + @NotNull(message = "신고 대상 ID는 필수입니다.") + @RequestParam Long targetId, + + @Parameter(hidden = true) + @AuthenticationPrincipal CustomUserDetails userDetails + ){ + reportService.reportWeakVerification(userDetails.getUser(), targetId); + + return ApiResponse.onSuccess(SuccessCode.OK, null); + } + @PostMapping("/verification/post") @Operation(summary = "인증 게시글 신고", description = "인증 게시글을 신고합니다. 신고 누적 5회 시 해당 게시글에 접근할 수 없습니다.") public ApiResponse reportVerificationPost( @@ -54,4 +71,5 @@ public ApiResponse reportUser( return ApiResponse.onSuccess(SuccessCode.OK, null); } + } diff --git a/src/main/java/com/hrr/backend/domain/report/entity/WeakVerificationReport.java b/src/main/java/com/hrr/backend/domain/report/entity/WeakVerificationReport.java new file mode 100644 index 00000000..b1d929e5 --- /dev/null +++ b/src/main/java/com/hrr/backend/domain/report/entity/WeakVerificationReport.java @@ -0,0 +1,47 @@ +package com.hrr.backend.domain.report.entity; + +import com.hrr.backend.domain.round.entity.RoundRecord; +import com.hrr.backend.domain.user.entity.User; +import com.hrr.backend.domain.verification.entity.Verification; +import com.hrr.backend.global.common.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Builder +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "weak_verification_report", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_reporter_verification", + columnNames = {"reporter_id", "verification_id"} + ) + } +) +// 부실인증 신고를 위한 엔티티 +public class WeakVerificationReport extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reporter_id", nullable = false) + private User reporter; // 신고자 + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "verification_id", nullable = false) + private Verification verification; // 신고 대상 인증 + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "round_record_id", nullable = false) + private RoundRecord roundRecord; // 페널티 대상자의 라운드 기록 + +} diff --git a/src/main/java/com/hrr/backend/domain/report/repository/WeakVerificationReportRepository.java b/src/main/java/com/hrr/backend/domain/report/repository/WeakVerificationReportRepository.java new file mode 100644 index 00000000..54b00465 --- /dev/null +++ b/src/main/java/com/hrr/backend/domain/report/repository/WeakVerificationReportRepository.java @@ -0,0 +1,16 @@ +package com.hrr.backend.domain.report.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.hrr.backend.domain.report.entity.WeakVerificationReport; +import com.hrr.backend.domain.user.entity.User; +import com.hrr.backend.domain.verification.entity.Verification; + +public interface WeakVerificationReportRepository extends JpaRepository { + + // 특정 라운드의 특정 챌린저의 부실인증 개수 조회 + long countByRoundRecordId(Long roundRecordId); + + // 중복 체크 - 특정 사용자가 특정 인증을 신고한 내역이 있는지 조회 + boolean existsByReporterAndVerification(User reporter, Verification targetVerification); +} diff --git a/src/main/java/com/hrr/backend/domain/report/service/ReportService.java b/src/main/java/com/hrr/backend/domain/report/service/ReportService.java index 105c8de7..b488f314 100644 --- a/src/main/java/com/hrr/backend/domain/report/service/ReportService.java +++ b/src/main/java/com/hrr/backend/domain/report/service/ReportService.java @@ -5,6 +5,9 @@ public interface ReportService { + // 부실 인증 신고 + void reportWeakVerification(User reporter, Long verificationId); + // 인증 게시글 신고 void reportVerificationPost(User reporter, ReportRequestDto request); diff --git a/src/main/java/com/hrr/backend/domain/report/service/ReportServiceImpl.java b/src/main/java/com/hrr/backend/domain/report/service/ReportServiceImpl.java index 3037a3b9..4eebdbca 100644 --- a/src/main/java/com/hrr/backend/domain/report/service/ReportServiceImpl.java +++ b/src/main/java/com/hrr/backend/domain/report/service/ReportServiceImpl.java @@ -3,12 +3,19 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.hrr.backend.domain.challenge.entity.Challenge; import com.hrr.backend.domain.report.dto.ReportRequestDto; import com.hrr.backend.domain.report.entity.UserReport; import com.hrr.backend.domain.report.entity.VerificationPostReport; +import com.hrr.backend.domain.report.entity.WeakVerificationReport; import com.hrr.backend.domain.report.repository.UserReportRepository; import com.hrr.backend.domain.report.repository.VerificationPostReportRepository; +import com.hrr.backend.domain.report.repository.WeakVerificationReportRepository; +import com.hrr.backend.domain.round.entity.RoundRecord; +import com.hrr.backend.domain.round.service.RoundRecordService; import com.hrr.backend.domain.user.entity.User; +import com.hrr.backend.domain.user.entity.enums.ChallengeJoinStatus; +import com.hrr.backend.domain.user.repository.UserChallengeRepository; import com.hrr.backend.domain.user.repository.UserRepository; import com.hrr.backend.domain.verification.entity.Verification; import com.hrr.backend.domain.verification.entity.enums.VerificationStatus; @@ -30,6 +37,64 @@ public class ReportServiceImpl implements ReportService { private final UserRepository userRepository; private final UserReportRepository userReportRepository; + private final UserChallengeRepository userChallengeRepository; + + private final WeakVerificationReportRepository weakVerificationReportRepository; + + private final RoundRecordService roundRecordService; + + @Override + @Transactional + public void reportWeakVerification(User reporter,Long verificationId) { + // 신고 대상 조회(Verification) + Verification targetVerification = verificationRepository.findByIdWithPessimisticLock(verificationId) + .orElseThrow(() -> new GlobalException(ErrorCode.VERIFICATION_NOT_FOUND)); + + // 차단 확인 (가장 먼저!) + // 이미 게시글 신고 5회 누적으로 차단된 글이라면 다른 검증을 할 필요도 없이 바로 예외를 던짐 + if (VerificationStatus.BLOCKED.equals(targetVerification.getStatus())) { + throw new GlobalException(ErrorCode.ACCESS_DENIED_REPORTED_POST); + } + + // 피신고자 정보 조회(RoundRecord) + RoundRecord targetRecord = targetVerification.getRoundRecord(); + + // 자기 신고 방지 + if (targetVerification.getUserChallenge().getUser().getId().equals(reporter.getId())) { + throw new GlobalException(ErrorCode.CANNOT_REPORT_OWN_POST); + } + + // 중복 신고 방지 + if (weakVerificationReportRepository.existsByReporterAndVerification(reporter, targetVerification)) { + throw new GlobalException(ErrorCode.ALREADY_REPORTED); + } + + // 권한 검증: 신고자와 피신고자가 동일 챌린지에 참여 중인지 확인 - 검증 완료 시 다음 단계로 이동 + validateChallengeParticipation(reporter, targetVerification.getUserChallenge().getChallenge()); + + // 신고 내역 저장 + WeakVerificationReport report = WeakVerificationReport.builder() + .reporter(reporter) + .verification(targetVerification) + .roundRecord(targetRecord) + .build(); + weakVerificationReportRepository.save(report); + + // 경고 횟수를 업데이트 후 퇴출 여부를 결정하는 메소드 호출 + roundRecordService.synchronizeWarnCount(targetRecord.getId()); + } + + /** + * 신고자와 피신고자가 같은 챌린지에 참여하고 있는지를 검증 + * @param reporter 신고자 User 객체 + * @param challenge 확인하려는 챌린지 + */ + private void validateChallengeParticipation(User reporter, Challenge challenge) { + if (!userChallengeRepository.existsByUserAndChallengeAndStatus(reporter, challenge, ChallengeJoinStatus.JOINED)) { + throw new GlobalException(ErrorCode.CANNOT_REPORT_OTHER_CHALLENGE_VERIFICATION); + } + } + @Override @Transactional public void reportVerificationPost(User reporter, ReportRequestDto request) { diff --git a/src/main/java/com/hrr/backend/domain/round/entity/RoundRecord.java b/src/main/java/com/hrr/backend/domain/round/entity/RoundRecord.java index 75a62ec9..05263920 100644 --- a/src/main/java/com/hrr/backend/domain/round/entity/RoundRecord.java +++ b/src/main/java/com/hrr/backend/domain/round/entity/RoundRecord.java @@ -2,6 +2,7 @@ import com.hrr.backend.domain.round.entity.enums.NextRoundIntent; import com.hrr.backend.domain.user.entity.UserChallenge; +import com.hrr.backend.domain.user.entity.enums.ChallengeJoinStatus; import com.hrr.backend.domain.verification.entity.Verification; import com.hrr.backend.global.common.BaseEntity; import jakarta.persistence.*; @@ -36,6 +37,8 @@ } ) public class RoundRecord extends BaseEntity { + @Version + private Long version; // for 낙관적 락 @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -84,4 +87,14 @@ public void updateFinalRank(Integer rank) { public void updateNextRoundIntent(NextRoundIntent intent) { this.nextRoundIntent = intent; } -} \ No newline at end of file + + // 데이터 정합성을 고려하여 경고 횟수를 업데이트 하고 퇴출 조건을 확인 + public void synchronizeWarnCount(int newWarnCount) { + this.warnCount = newWarnCount; // + + // 경고가 3회 이상이면 해당 유저의 챌린지 참여 상태를 KICKED로 변경 + if (this.warnCount >= 3) { + this.userChallenge.updateStatus(ChallengeJoinStatus.KICKED); + } + } +} diff --git a/src/main/java/com/hrr/backend/domain/round/repository/RoundRecordRepository.java b/src/main/java/com/hrr/backend/domain/round/repository/RoundRecordRepository.java index 241a81c9..d582dab2 100644 --- a/src/main/java/com/hrr/backend/domain/round/repository/RoundRecordRepository.java +++ b/src/main/java/com/hrr/backend/domain/round/repository/RoundRecordRepository.java @@ -6,16 +6,26 @@ import com.hrr.backend.domain.user.entity.UserChallenge; import com.hrr.backend.domain.user.entity.enums.ChallengeJoinStatus; import com.hrr.backend.domain.user.entity.enums.UserStatus; +import com.hrr.backend.global.common.enums.ChallengeDays; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.LocalDate; import java.util.List; import java.util.Optional; +import jakarta.persistence.LockModeType; + public interface RoundRecordRepository extends JpaRepository { + // 비관적 락 조회 + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT rr FROM RoundRecord rr WHERE rr.id = :id") + Optional findByIdWithPessimisticLock(@Param("id") Long id); + /** * UserChallenge와 Round ID로 RoundRecord 조회 * 인증 생성 시 사용자의 해당 라운드 기록 조회 @@ -88,4 +98,23 @@ Optional findByUserAndRoundId( "WHERE rr.round.id = :roundId " + "AND rr.userChallenge.user.userStatus = :status") int countParticipantsByRoundAndUserStatus(@Param("roundId") Long roundId, @Param("status") UserStatus status); + + // 라운드가 진행 중인 챌린지에 참여 중인 유저가 어제 인증요일이었는데 미인증 한 사실이 있는지 조회 + @Query("SELECT rr FROM RoundRecord rr " + + "JOIN rr.userChallenge uc " + + "JOIN uc.challenge c " + + "JOIN c.challengeDays cd " + + "WHERE c.status = com.hrr.backend.global.common.enums.ChallengeStatus.ONGOING " + // 진행 중인 챌린지 + "AND uc.status = com.hrr.backend.domain.user.entity.enums.ChallengeJoinStatus.JOINED " + // 참여 중인 유저 + "AND rr.round = c.currentRound " + // 현재 라운드만 확인 + "AND cd.dayOfWeek = :yesterdayChallengeDay " + // 어제 요일이 인증 요일인지 확인 + "AND NOT EXISTS ( " + + " SELECT v FROM Verification v " + + " WHERE v.roundRecord = rr " + + " AND CAST(v.createdAt AS LocalDate) = :yesterdayDate " + // 어제 날짜의 인증 기록이 없는지 확인 - CAST를 통해 LocalDate로의 캐스팅 효과 + ")") + List findAbsentees( + @Param("yesterdayChallengeDay") ChallengeDays yesterdayChallengeDay, + @Param("yesterdayDate") LocalDate yesterdayDate + ); } diff --git a/src/main/java/com/hrr/backend/domain/round/service/RoundRecordService.java b/src/main/java/com/hrr/backend/domain/round/service/RoundRecordService.java new file mode 100644 index 00000000..109d0d77 --- /dev/null +++ b/src/main/java/com/hrr/backend/domain/round/service/RoundRecordService.java @@ -0,0 +1,8 @@ +package com.hrr.backend.domain.round.service; + +public interface RoundRecordService { + + // 경고 횟수의 데이터 정합성을 보장하기 위한 계산 확인 메서드 + void synchronizeWarnCount(Long roundRecordId); + +} diff --git a/src/main/java/com/hrr/backend/domain/round/service/RoundRecordServiceImpl.java b/src/main/java/com/hrr/backend/domain/round/service/RoundRecordServiceImpl.java new file mode 100644 index 00000000..d84ebdf1 --- /dev/null +++ b/src/main/java/com/hrr/backend/domain/round/service/RoundRecordServiceImpl.java @@ -0,0 +1,73 @@ +package com.hrr.backend.domain.round.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.hrr.backend.domain.challenge.entity.Challenge; +import com.hrr.backend.domain.challenge.repository.ChallengeRepository; +import com.hrr.backend.domain.report.repository.WeakVerificationReportRepository; +import com.hrr.backend.domain.round.entity.RoundRecord; +import com.hrr.backend.domain.round.repository.RoundRecordRepository; +import com.hrr.backend.domain.user.entity.enums.ChallengeJoinStatus; +import com.hrr.backend.domain.user.repository.UserChallengeRepository; +import com.hrr.backend.domain.verification.repository.VerificationAbsenceLogRepository; +import com.hrr.backend.global.exception.GlobalException; +import com.hrr.backend.global.response.ErrorCode; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class RoundRecordServiceImpl implements RoundRecordService { + + private final RoundRecordRepository roundRecordRepository; + + private final WeakVerificationReportRepository weakVerificationReportRepository; + + private final VerificationAbsenceLogRepository verificationAbsenceLogRepository; + + private final UserChallengeRepository userChallengeRepository; + + private final ChallengeRepository challengeRepository; + + @Override + @Transactional + public void synchronizeWarnCount(Long roundRecordId) { + // 라운드 기록 조회 + RoundRecord roundRecord = roundRecordRepository.findByIdWithPessimisticLock(roundRecordId) + .orElseThrow(() -> new GlobalException(ErrorCode.ROUND_RECORD_NOT_FOUND)); + + // 부실 인증 신고 수와 미인증 로그 수 조회 + long weakReportCount = weakVerificationReportRepository.countByRoundRecordId(roundRecordId); + long absenceCount = verificationAbsenceLogRepository.countByRoundRecordId(roundRecordId); + + // 경고 횟수 계산: (부실 신고 / 3) + 미인증 횟수 + int calculatedWarnCount = (int) (weakReportCount / 3) + (int) absenceCount; + + // 경고 횟수 동기화 및 챌린지 퇴출 여부 판단 - warnCound==3이면 KICKED로 변경되며 퇴출 + roundRecord.synchronizeWarnCount(calculatedWarnCount); + + // 필요 시, 추가적인 퇴출 처리 진행 + if (roundRecord.getUserChallenge().getStatus() == ChallengeJoinStatus.KICKED) { + processKickOutSideEffects(roundRecord.getUserChallenge().getChallenge().getId()); + } + } + + // 퇴출 처리 진행 + private void processKickOutSideEffects(Long challengeId) { + Challenge targetChallenge = challengeRepository.findById(challengeId) + .orElseThrow(() -> new GlobalException(ErrorCode.CHALLENGE_NOT_FOUND)); + + // -- 챌린지 인원 재계산 + // 실제 JOINED 상태인 인원만 다시 카운트 + int actualCount = (int) userChallengeRepository.countByChallengeIdAndStatus( + targetChallenge.getId(), ChallengeJoinStatus.JOINED); + + // 챌린지 엔티티의 인원수 필드를 실제 값으로 덮어쓰기 + targetChallenge.updateCurrentParticipants(actualCount); + + // --필요 시 추가 작업 진행(알림 발송 등) + } +} diff --git a/src/main/java/com/hrr/backend/domain/user/repository/UserChallengeRepository.java b/src/main/java/com/hrr/backend/domain/user/repository/UserChallengeRepository.java index 980c7e57..a67e58a6 100644 --- a/src/main/java/com/hrr/backend/domain/user/repository/UserChallengeRepository.java +++ b/src/main/java/com/hrr/backend/domain/user/repository/UserChallengeRepository.java @@ -15,10 +15,10 @@ public interface UserChallengeRepository extends JpaRepository, UserChallengeRepositoryCustom { - // 유저가 특정 챌린지에 이미 참여 중인지 확인 - boolean existsByUserAndChallenge(User user, Challenge challenge); + // 유저가 특정 챌린지에 특정 상태로 참여한 데이터가 있는지 확인 + boolean existsByUserAndChallengeAndStatus(User user, Challenge challenge, ChallengeJoinStatus status); - // 유저와 챌린지 객체로 내 참여 정보(엔티티) 조회 (이미 객체가 있을 때 사용) + // 유저와 챌린지 객체로 내 참여 정보(엔티티) 조회 (이미 객체가 있을 때 사용) Optional findByUserAndChallenge(User user, Challenge challenge); // 인증 생성할 때 유저-챌린지 매핑 조회용 (ID만 알 때 사용) @@ -96,4 +96,6 @@ List findChallengeIdsWhereOwnerIsBlockedByUser( @Param("role") UserChallengeRole role ); + // 특정 챌린지에 특정 상태로 참여 중인 유저 수 집계 + long countByChallengeIdAndStatus(Long challengeId, ChallengeJoinStatus status); } diff --git a/src/main/java/com/hrr/backend/domain/verification/entity/VerificationAbsenceLog.java b/src/main/java/com/hrr/backend/domain/verification/entity/VerificationAbsenceLog.java new file mode 100644 index 00000000..50277ccb --- /dev/null +++ b/src/main/java/com/hrr/backend/domain/verification/entity/VerificationAbsenceLog.java @@ -0,0 +1,51 @@ +package com.hrr.backend.domain.verification.entity; + +import java.time.LocalDate; + + +import com.hrr.backend.domain.round.entity.RoundRecord; +import com.hrr.backend.global.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Table( + name = "verification_absence_log", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_round_record_date", + columnNames = {"round_record_id", "absence_date"} + ) + } +) +// 미인증 기록을 저장하기 위한 엔티티 +public class VerificationAbsenceLog extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "round_record_id", nullable = false) + private RoundRecord roundRecord; + + @Column(name = "absence_date", nullable = false) + private LocalDate absenceDate; // 미인증이 발생한 날짜 +} diff --git a/src/main/java/com/hrr/backend/domain/verification/repository/VerificationAbsenceLogRepository.java b/src/main/java/com/hrr/backend/domain/verification/repository/VerificationAbsenceLogRepository.java new file mode 100644 index 00000000..6df370f0 --- /dev/null +++ b/src/main/java/com/hrr/backend/domain/verification/repository/VerificationAbsenceLogRepository.java @@ -0,0 +1,11 @@ +package com.hrr.backend.domain.verification.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.hrr.backend.domain.verification.entity.VerificationAbsenceLog; + +public interface VerificationAbsenceLogRepository extends JpaRepository { + + // 특정 라운드의 특정 챌린저의 미인증 횟수 조회 + long countByRoundRecordId(Long roundRecordId); +} diff --git a/src/main/java/com/hrr/backend/domain/verification/service/VerificationAbsenceService.java b/src/main/java/com/hrr/backend/domain/verification/service/VerificationAbsenceService.java new file mode 100644 index 00000000..b930385e --- /dev/null +++ b/src/main/java/com/hrr/backend/domain/verification/service/VerificationAbsenceService.java @@ -0,0 +1,33 @@ +package com.hrr.backend.domain.verification.service; + +import java.time.LocalDate; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import com.hrr.backend.domain.round.entity.RoundRecord; +import com.hrr.backend.domain.round.service.RoundRecordService; +import com.hrr.backend.domain.verification.entity.VerificationAbsenceLog; +import com.hrr.backend.domain.verification.repository.VerificationAbsenceLogRepository; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class VerificationAbsenceService { + private final VerificationAbsenceLogRepository absenceLogRepository; + private final RoundRecordService roundRecordService; + + @Transactional(propagation = Propagation.REQUIRES_NEW) // 각 건마다 독립적인 트랜잭션 보장 + public void processAbsentee(RoundRecord record, LocalDate date) { + // 미인증 로그 저장 + absenceLogRepository.save(VerificationAbsenceLog.builder() + .roundRecord(record) + .absenceDate(date) + .build()); + + // 경고 횟수를 업데이트 후 퇴출 여부를 결정하는 메소드 호출 + roundRecordService.synchronizeWarnCount(record.getId()); + } +} diff --git a/src/main/java/com/hrr/backend/global/response/ErrorCode.java b/src/main/java/com/hrr/backend/global/response/ErrorCode.java index ab62cf7e..12cb8de2 100644 --- a/src/main/java/com/hrr/backend/global/response/ErrorCode.java +++ b/src/main/java/com/hrr/backend/global/response/ErrorCode.java @@ -136,6 +136,8 @@ public enum ErrorCode implements BaseCode{ VERIFICATION_INVALID_IMAGE_KEY(HttpStatus.BAD_REQUEST, "VERIFICATION40021", "이미지 키는 null 또는 공백이 아닌 값이어야 합니다."), VERIFICATION_DAY_INVALID(HttpStatus.BAD_REQUEST, "VERIFICATION40022", "인증 가능한 요일이 아닙니다."), VERIFICATION_TIME_INVALID(HttpStatus.BAD_REQUEST, "VERIFICATION40023", "인증 가능한 시간대가 아닙니다."), + CANNOT_REPORT_OTHER_CHALLENGE_VERIFICATION(HttpStatus.CONFLICT, "VERIFICATION409110", "참가 중인 챌린지에서만 부실 인증 신고가 가능합니다."), + // file upload FILE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FILE5001", "파일 업로드에 실패했습니다."), diff --git a/src/main/java/com/hrr/backend/global/scheduler/VerificationScheduler.java b/src/main/java/com/hrr/backend/global/scheduler/VerificationScheduler.java new file mode 100644 index 00000000..f5c67320 --- /dev/null +++ b/src/main/java/com/hrr/backend/global/scheduler/VerificationScheduler.java @@ -0,0 +1,57 @@ +package com.hrr.backend.global.scheduler; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.hrr.backend.domain.round.entity.RoundRecord; +import com.hrr.backend.domain.round.repository.RoundRecordRepository; +import com.hrr.backend.domain.round.service.RoundRecordService; +import com.hrr.backend.domain.verification.repository.VerificationAbsenceLogRepository; +import com.hrr.backend.domain.verification.service.VerificationAbsenceService; +import com.hrr.backend.global.common.enums.ChallengeDays; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@RequiredArgsConstructor +@Slf4j +public class VerificationScheduler { + + private final RoundRecordRepository roundRecordRepository; + + private final VerificationAbsenceService verificationAbsenceService; + + + @Scheduled(cron = "0 5 0 * * *") // 매일 00 : 05 실행 + @Transactional + // 어제가 인증 요일이었지만 인증 기록이 없는, 즉 미인증 기록을 로그로 남기기 위한 스케줄러 + // 효율성을 위해 전체 데이터 확인이 아닌 어제 하루만의 미인증을 매일 기록 + public void checkAbsence() { + // 어제 날짜 + LocalDate yesterdayDate = LocalDate.now().minusDays(1); + + // Java 요일을 챌린지 요일 Enum으로 변환 + ChallengeDays yesterdayChallengeDay = ChallengeDays.from(yesterdayDate.getDayOfWeek()); + + // 미인증 대상자 조회 + List absentees = roundRecordRepository.findAbsentees(yesterdayChallengeDay, yesterdayDate); + + log.info("미인증 대상자 {}건 처리 시작", absentees.size()); + int failCount = 0; + + for (RoundRecord record : absentees) { + try { + verificationAbsenceService.processAbsentee(record, yesterdayDate); // 인증이 완료되지 않은 요일은 체크 대상인 어제이므로 어제를 미인증날짜로 기록 + } catch (Exception e) { + log.error("미인증 처리 실패 - roundRecordId: {}", record.getId(), e); + failCount++; + } + } + log.info("미인증 처리 완료 - 총: {}, 실패: {}", absentees.size(), failCount); + } +} diff --git a/src/main/resources/db/migration/V2.33__create_table_related_warncount.sql b/src/main/resources/db/migration/V2.33__create_table_related_warncount.sql new file mode 100644 index 00000000..ea50ceff --- /dev/null +++ b/src/main/resources/db/migration/V2.33__create_table_related_warncount.sql @@ -0,0 +1,44 @@ +DELIMITER // + +CREATE PROCEDURE migrate_hrr_penalty_system() +BEGIN + -- 1 . round_record 테이블에 version 컬럼 추가 (없을 경우에만) + IF NOT EXISTS ( + SELECT * FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'round_record' AND COLUMN_NAME = 'version' + ) THEN + ALTER TABLE `round_record` ADD COLUMN `version` BIGINT DEFAULT 0; + END IF; + + -- 2 . weak_verification_report (부실 인증 신고) 테이블 생성 + CREATE TABLE IF NOT EXISTS `weak_verification_report` ( + `id` BIGINT AUTO_INCREMENT PRIMARY KEY, + `reporter_id` BIGINT NOT NULL, + `verification_id` BIGINT NOT NULL, + `round_record_id` BIGINT NOT NULL, + `created_at` TIMESTAMP(6) NOT NULL, + `updated_at` TIMESTAMP(6) NULL, + CONSTRAINT `uk_reporter_verification` UNIQUE (`reporter_id`, `verification_id`), + CONSTRAINT `fk_wvr_reporter` FOREIGN KEY (`reporter_id`) REFERENCES `user` (`id`), + CONSTRAINT `fk_wvr_verification` FOREIGN KEY (`verification_id`) REFERENCES `verification` (`id`), + CONSTRAINT `fk_wvr_round_record` FOREIGN KEY (`round_record_id`) REFERENCES `round_record` (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + -- 3 . verification_absence_log (미인증 로그) 테이블 생성 + CREATE TABLE IF NOT EXISTS `verification_absence_log` ( + `id` BIGINT AUTO_INCREMENT PRIMARY KEY, + `round_record_id` BIGINT NOT NULL, + `absence_date` DATE NOT NULL, + `created_at` TIMESTAMP(6) NOT NULL, + `updated_at` TIMESTAMP(6) NULL, + CONSTRAINT `uk_round_record_date` UNIQUE (`round_record_id`, `absence_date`), + CONSTRAINT `fk_val_round_record` FOREIGN KEY (`round_record_id`) REFERENCES `round_record` (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +END // + +DELIMITER ; + +-- 실행 및 삭제 +CALL migrate_hrr_penalty_system(); +DROP PROCEDURE IF EXISTS migrate_hrr_penalty_system; diff --git a/src/test/java/com/hrr/backend/domain/round/RoundRecordServiceTest.java b/src/test/java/com/hrr/backend/domain/round/RoundRecordServiceTest.java new file mode 100644 index 00000000..71d16af0 --- /dev/null +++ b/src/test/java/com/hrr/backend/domain/round/RoundRecordServiceTest.java @@ -0,0 +1,94 @@ +package com.hrr.backend.domain.round; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.hrr.backend.domain.challenge.entity.Challenge; +import com.hrr.backend.domain.challenge.repository.ChallengeRepository; +import com.hrr.backend.domain.report.repository.WeakVerificationReportRepository; +import com.hrr.backend.domain.round.entity.RoundRecord; +import com.hrr.backend.domain.round.repository.RoundRecordRepository; +import com.hrr.backend.domain.round.service.RoundRecordServiceImpl; +import com.hrr.backend.domain.user.entity.UserChallenge; +import com.hrr.backend.domain.user.entity.enums.ChallengeJoinStatus; +import com.hrr.backend.domain.user.repository.UserChallengeRepository; +import com.hrr.backend.domain.verification.repository.VerificationAbsenceLogRepository; + +@ExtendWith(MockitoExtension.class) +class RoundRecordServiceTest { + + @InjectMocks + private RoundRecordServiceImpl roundRecordService; + + @Mock + private RoundRecordRepository roundRecordRepository; + @Mock + private WeakVerificationReportRepository weakReportRepository; + @Mock + private VerificationAbsenceLogRepository absenceLogRepository; + @Mock + private UserChallengeRepository userChallengeRepository; + @Mock + private ChallengeRepository challengeRepository; + + @Test + @DisplayName("신고와 미인증 로그를 합산하여 경고 점수가 정확히 계산되는지 확인한다") + void shouldCalculateWarnCountCorrectly() { + // given + Long recordId = 1L; + Long challengeId = 100L; + + Challenge challenge = Challenge.builder().id(100L).currentParticipants(10).build(); + UserChallenge userChallenge = UserChallenge.builder().challenge(challenge).status(ChallengeJoinStatus.JOINED).build(); + RoundRecord roundRecord = RoundRecord.builder().id(recordId).userChallenge(userChallenge).warnCount(0).build(); + + given(roundRecordRepository.findById(recordId)).willReturn(Optional.of(roundRecord)); + given(weakReportRepository.countByRoundRecordId(recordId)).willReturn(7L); // 7 / 3 = 2회 경고 + given(absenceLogRepository.countByRoundRecordId(recordId)).willReturn(1L); // 1회 경고 + + // 퇴출 로직 수행 시 필요한 Mock 설정 + given(challengeRepository.findById(challengeId)).willReturn(Optional.of(challenge)); + given(userChallengeRepository.countByChallengeIdAndStatus(challengeId, ChallengeJoinStatus.JOINED)).willReturn(9L); + + // when + roundRecordService.synchronizeWarnCount(recordId); + + // then: (7 / 3) + 1 = 3 + assertEquals(3, roundRecord.getWarnCount()); + assertEquals(ChallengeJoinStatus.KICKED, userChallenge.getStatus()); + } + + @Test + @DisplayName("퇴출 발생 시 해당 챌린지의 참여 인원 재계산 로직이 호출된다") + void shouldRecalculateParticipantsOnKickOut() { + // given + Long recordId = 1L; + Long challengeId = 100L; + Challenge challenge = Challenge.builder().id(challengeId).currentParticipants(10).build(); + UserChallenge userChallenge = UserChallenge.builder().challenge(challenge).status(ChallengeJoinStatus.JOINED).build(); + RoundRecord roundRecord = RoundRecord.builder().id(recordId).userChallenge(userChallenge).warnCount(0).build(); + + given(roundRecordRepository.findById(recordId)).willReturn(Optional.of(roundRecord)); + given(weakReportRepository.countByRoundRecordId(recordId)).willReturn(0L); + given(absenceLogRepository.countByRoundRecordId(recordId)).willReturn(3L); // 바로 퇴출 조건 + + given(challengeRepository.findById(challengeId)).willReturn(Optional.of(challenge)); + given(userChallengeRepository.countByChallengeIdAndStatus(challengeId, ChallengeJoinStatus.JOINED)).willReturn(5L); + + // when + roundRecordService.synchronizeWarnCount(recordId); + + // then + assertEquals(5, challenge.getCurrentParticipants()); // 인원이 5명으로 재설정되었는지 확인 + verify(userChallengeRepository, times(1)).countByChallengeIdAndStatus(any(), any()); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 7230a5c8..3db97622 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -6,6 +6,9 @@ spring: database-platform: org.hibernate.dialect.H2Dialect hibernate: ddl-auto: create-drop # 테스트 후 삭제 + properties: + hibernate: + globally_quoted_identifiers: true config: activate: on-profile: "test"