From 1e57fb52fd6e16691be089ef6dff8596e5d394c4 Mon Sep 17 00:00:00 2001 From: yc3697 Date: Sat, 7 Feb 2026 21:38:45 +0900 Subject: [PATCH 01/14] =?UTF-8?q?feat:=20=EB=B6=80=EC=8B=A4=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=8B=A0=EA=B3=A0=20API=20=EC=83=9D=EC=84=B1=20#29?= =?UTF-8?q?9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../report/controller/ReportController.java | 20 +++++++- .../report/entity/WeakVerificationReport.java | 46 +++++++++++++++++++ .../WeakVerificationReportRepository.java | 8 ++++ .../domain/report/service/ReportService.java | 3 ++ .../report/service/ReportServiceImpl.java | 44 ++++++++++++++++++ .../backend/global/response/ErrorCode.java | 2 + 6 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/hrr/backend/domain/report/entity/WeakVerificationReport.java create mode 100644 src/main/java/com/hrr/backend/domain/report/repository/WeakVerificationReportRepository.java 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..2217eec0 --- /dev/null +++ b/src/main/java/com/hrr/backend/domain/report/entity/WeakVerificationReport.java @@ -0,0 +1,46 @@ +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..e437d0a1 --- /dev/null +++ b/src/main/java/com/hrr/backend/domain/report/repository/WeakVerificationReportRepository.java @@ -0,0 +1,8 @@ +package com.hrr.backend.domain.report.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.hrr.backend.domain.report.entity.WeakVerificationReport; + +public interface WeakVerificationReportRepository extends JpaRepository { +} 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..6c0ca62e 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,17 @@ 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.user.entity.User; +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 +35,45 @@ public class ReportServiceImpl implements ReportService { private final UserRepository userRepository; private final UserReportRepository userReportRepository; + private final UserChallengeRepository userChallengeRepository; + + private final WeakVerificationReportRepository weakVerificationReportRepository; + + @Override + @Transactional + public void reportWeakVerification(User reporter,Long verificationId) { + // 신고 대상 조회(Verification) + Verification targetVerification = verificationRepository.findByIdWithPessimisticLock(verificationId) + .orElseThrow(() -> new GlobalException(ErrorCode.VERIFICATION_NOT_FOUND)); + + // 피신고자 정보 조회(RoundRecord) + RoundRecord targetRecord = targetVerification.getRoundRecord(); + + // 권한 검증: 신고자와 피신고자가 동일 챌린지에 참여 중인지 확인 - 검증 완료 시 다음 단계로 이동 + validateChallengeParticipation(reporter, targetVerification.getUserChallenge().getChallenge()); + + // 신고 내역 저장 + WeakVerificationReport report = WeakVerificationReport.builder() + .reporter(reporter) + .verification(targetVerification) + .roundRecord(targetRecord) + .build(); + weakVerificationReportRepository.save(report); + + // Todo: 경고 횟수를 업데이트 후 퇴출 여부를 결정하는 메소드 호출 + } + + /** + * 신고자와 피신고자가 같은 챌린지에 참여하고 있는지를 검증 + * @param reporter 신고자 User 객체 + * @param challenge 확인하려는 챌린지 + */ + private void validateChallengeParticipation(User reporter, Challenge challenge) { + if (!userChallengeRepository.existsByUserAndChallenge(reporter, challenge)) { + 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/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", "파일 업로드에 실패했습니다."), From 75e4d976a2b88a2a2a73127cf4e0b0e361b1bd39 Mon Sep 17 00:00:00 2001 From: yc3697 Date: Sat, 7 Feb 2026 22:40:16 +0900 Subject: [PATCH 02/14] =?UTF-8?q?feat:=20=EB=AF=B8=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EC=B2=B4=ED=81=AC=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A1=9C=EA=B9=85=20=EA=B5=AC=ED=98=84=20#299?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../report/entity/WeakVerificationReport.java | 1 + .../repository/RoundRecordRepository.java | 20 ++++++++ .../round/service/RoundRecordService.java | 4 ++ .../round/service/RoundRecordServiceImpl.java | 7 +++ .../entity/VerificationAbsenceLog.java | 51 +++++++++++++++++++ .../VerificationAbsenceLogRepository.java | 8 +++ .../scheduler/VerificationScheduler.java | 49 ++++++++++++++++++ 7 files changed, 140 insertions(+) create mode 100644 src/main/java/com/hrr/backend/domain/round/service/RoundRecordService.java create mode 100644 src/main/java/com/hrr/backend/domain/round/service/RoundRecordServiceImpl.java create mode 100644 src/main/java/com/hrr/backend/domain/verification/entity/VerificationAbsenceLog.java create mode 100644 src/main/java/com/hrr/backend/domain/verification/repository/VerificationAbsenceLogRepository.java create mode 100644 src/main/java/com/hrr/backend/global/scheduler/VerificationScheduler.java 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 index 2217eec0..b1d929e5 100644 --- a/src/main/java/com/hrr/backend/domain/report/entity/WeakVerificationReport.java +++ b/src/main/java/com/hrr/backend/domain/report/entity/WeakVerificationReport.java @@ -25,6 +25,7 @@ ) } ) +// 부실인증 신고를 위한 엔티티 public class WeakVerificationReport extends BaseEntity { @Id 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..1ee9e9a3 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,11 +6,13 @@ 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.Query; import org.springframework.data.repository.query.Param; +import java.time.LocalDate; import java.util.List; import java.util.Optional; @@ -88,4 +90,22 @@ 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 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..6ce3c6a3 --- /dev/null +++ b/src/main/java/com/hrr/backend/domain/round/service/RoundRecordService.java @@ -0,0 +1,4 @@ +package com.hrr.backend.domain.round.service; + +public interface RoundRecordService { +} 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..8f1051f2 --- /dev/null +++ b/src/main/java/com/hrr/backend/domain/round/service/RoundRecordServiceImpl.java @@ -0,0 +1,7 @@ +package com.hrr.backend.domain.round.service; + +import org.springframework.stereotype.Service; + +@Service +public class RoundRecordServiceImpl implements RoundRecordService { +} 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..5e1fc5a0 --- /dev/null +++ b/src/main/java/com/hrr/backend/domain/verification/repository/VerificationAbsenceLogRepository.java @@ -0,0 +1,8 @@ +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 { +} 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..2803d36d --- /dev/null +++ b/src/main/java/com/hrr/backend/global/scheduler/VerificationScheduler.java @@ -0,0 +1,49 @@ +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.verification.entity.VerificationAbsenceLog; +import com.hrr.backend.domain.verification.repository.VerificationAbsenceLogRepository; +import com.hrr.backend.global.common.enums.ChallengeDays; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class VerificationScheduler { + + private final RoundRecordRepository roundRecordRepository; + private final VerificationAbsenceLogRepository verificationAbsenceLogRepository; + + @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); + + for (RoundRecord record : absentees) { + // 미인증 로그 저장 + verificationAbsenceLogRepository.save(VerificationAbsenceLog.builder() + .roundRecord(record) + .absenceDate(yesterdayDate) // 인증이 완료되지 않은 요일은 체크 대상인 어제이므로 어제를 미인증날짜로 기록 + .build()); + + // Todo: 경고 횟수를 업데이트 후 퇴출 여부를 결정하는 메소드 호출 + } + } +} From d8ddd83c55ef7cf158d5366e442773155497ec6d Mon Sep 17 00:00:00 2001 From: yc3697 Date: Sat, 7 Feb 2026 23:12:11 +0900 Subject: [PATCH 03/14] =?UTF-8?q?feat:=20=EA=B2=BD=EA=B3=A0=203=ED=9A=8C?= =?UTF-8?q?=20=EB=88=84=EC=A0=81=20=EC=8B=9C=20=EC=B1=8C=EB=A6=B0=EC=A7=80?= =?UTF-8?q?=20=ED=87=B4=EC=B6=9C=20=EA=B5=AC=ED=98=84=20#299?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/challenge/entity/Challenge.java | 5 ++ .../WeakVerificationReportRepository.java | 3 + .../report/service/ReportServiceImpl.java | 6 +- .../domain/round/entity/RoundRecord.java | 15 ++++- .../round/service/RoundRecordService.java | 4 ++ .../round/service/RoundRecordServiceImpl.java | 66 +++++++++++++++++++ .../repository/UserChallengeRepository.java | 2 + .../VerificationAbsenceLogRepository.java | 3 + .../scheduler/VerificationScheduler.java | 7 +- 9 files changed, 108 insertions(+), 3 deletions(-) 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/report/repository/WeakVerificationReportRepository.java b/src/main/java/com/hrr/backend/domain/report/repository/WeakVerificationReportRepository.java index e437d0a1..7ce0d397 100644 --- a/src/main/java/com/hrr/backend/domain/report/repository/WeakVerificationReportRepository.java +++ b/src/main/java/com/hrr/backend/domain/report/repository/WeakVerificationReportRepository.java @@ -5,4 +5,7 @@ import com.hrr.backend.domain.report.entity.WeakVerificationReport; public interface WeakVerificationReportRepository extends JpaRepository { + + // 특정 라운드의 특정 챌린저의 부실인증 개수 조회 + long countByRoundRecordId(Long roundRecordId); } 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 6c0ca62e..814d7662 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 @@ -12,6 +12,7 @@ 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.repository.UserChallengeRepository; import com.hrr.backend.domain.user.repository.UserRepository; @@ -39,6 +40,8 @@ public class ReportServiceImpl implements ReportService { private final WeakVerificationReportRepository weakVerificationReportRepository; + private final RoundRecordService roundRecordService; + @Override @Transactional public void reportWeakVerification(User reporter,Long verificationId) { @@ -60,7 +63,8 @@ public void reportWeakVerification(User reporter,Long verificationId) { .build(); weakVerificationReportRepository.save(report); - // Todo: 경고 횟수를 업데이트 후 퇴출 여부를 결정하는 메소드 호출 + // 경고 횟수를 업데이트 후 퇴출 여부를 결정하는 메소드 호출 + roundRecordService.synchronizeWarnCount(targetRecord.getId()); } /** 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/service/RoundRecordService.java b/src/main/java/com/hrr/backend/domain/round/service/RoundRecordService.java index 6ce3c6a3..109d0d77 100644 --- a/src/main/java/com/hrr/backend/domain/round/service/RoundRecordService.java +++ b/src/main/java/com/hrr/backend/domain/round/service/RoundRecordService.java @@ -1,4 +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 index 8f1051f2..379ed84f 100644 --- a/src/main/java/com/hrr/backend/domain/round/service/RoundRecordServiceImpl.java +++ b/src/main/java/com/hrr/backend/domain/round/service/RoundRecordServiceImpl.java @@ -1,7 +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.findById(roundRecordId) + .orElseThrow(() -> new RuntimeException("라운드 기록을 찾을 수 없습니다.")); + + // 부실 인증 신고 수와 미인증 로그 수 조회 + 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..82a0814b 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 @@ -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/repository/VerificationAbsenceLogRepository.java b/src/main/java/com/hrr/backend/domain/verification/repository/VerificationAbsenceLogRepository.java index 5e1fc5a0..6df370f0 100644 --- a/src/main/java/com/hrr/backend/domain/verification/repository/VerificationAbsenceLogRepository.java +++ b/src/main/java/com/hrr/backend/domain/verification/repository/VerificationAbsenceLogRepository.java @@ -5,4 +5,7 @@ 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/global/scheduler/VerificationScheduler.java b/src/main/java/com/hrr/backend/global/scheduler/VerificationScheduler.java index 2803d36d..39dab95a 100644 --- a/src/main/java/com/hrr/backend/global/scheduler/VerificationScheduler.java +++ b/src/main/java/com/hrr/backend/global/scheduler/VerificationScheduler.java @@ -9,6 +9,7 @@ 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.entity.VerificationAbsenceLog; import com.hrr.backend.domain.verification.repository.VerificationAbsenceLogRepository; import com.hrr.backend.global.common.enums.ChallengeDays; @@ -20,8 +21,11 @@ public class VerificationScheduler { private final RoundRecordRepository roundRecordRepository; + private final VerificationAbsenceLogRepository verificationAbsenceLogRepository; + private final RoundRecordService roundRecordService; + @Scheduled(cron = "0 5 0 * * *") // 매일 00 : 05 실행 @Transactional // 어제가 인증 요일이었지만 인증 기록이 없는, 즉 미인증 기록을 로그로 남기기 위한 스케줄러 @@ -43,7 +47,8 @@ public void checkAbsence() { .absenceDate(yesterdayDate) // 인증이 완료되지 않은 요일은 체크 대상인 어제이므로 어제를 미인증날짜로 기록 .build()); - // Todo: 경고 횟수를 업데이트 후 퇴출 여부를 결정하는 메소드 호출 + // 경고 횟수를 업데이트 후 퇴출 여부를 결정하는 메소드 호출 + roundRecordService.synchronizeWarnCount(record.getId()); } } } From 0225aa7a0683496533446619f851b5cb759228f6 Mon Sep 17 00:00:00 2001 From: yc3697 Date: Sat, 7 Feb 2026 23:53:33 +0900 Subject: [PATCH 04/14] =?UTF-8?q?feat:=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=8A=A4=ED=81=AC=EB=A6=BD?= =?UTF-8?q?=ED=8A=B8=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=9E=91=EC=84=B1=20#299?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../V2.33__create_table_related_warncount.sql | 44 +++++++++ .../domain/round/RoundRecordServiceTest.java | 94 +++++++++++++++++++ src/test/resources/application-test.yml | 1 + 3 files changed, 139 insertions(+) create mode 100644 src/main/resources/db/migration/V2.33__create_table_related_warncount.sql create mode 100644 src/test/java/com/hrr/backend/domain/round/RoundRecordServiceTest.java 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..5bcbcd71 --- /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_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..05b85e3d 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -6,6 +6,7 @@ spring: database-platform: org.hibernate.dialect.H2Dialect hibernate: ddl-auto: create-drop # 테스트 후 삭제 + globally_quoted_identifiers: true config: activate: on-profile: "test" From c565af19bc6e18ef1a447f786d5be3ca13bc0756 Mon Sep 17 00:00:00 2001 From: yc3697 Date: Sun, 8 Feb 2026 00:04:58 +0900 Subject: [PATCH 05/14] =?UTF-8?q?chore:=20=EC=97=90=EB=9F=AC=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EC=88=98=EC=A0=95=20#299?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/round/service/RoundRecordServiceImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 379ed84f..ac59cb8e 100644 --- a/src/main/java/com/hrr/backend/domain/round/service/RoundRecordServiceImpl.java +++ b/src/main/java/com/hrr/backend/domain/round/service/RoundRecordServiceImpl.java @@ -37,7 +37,7 @@ public class RoundRecordServiceImpl implements RoundRecordService { public void synchronizeWarnCount(Long roundRecordId) { // 라운드 기록 조회 RoundRecord roundRecord = roundRecordRepository.findById(roundRecordId) - .orElseThrow(() -> new RuntimeException("라운드 기록을 찾을 수 없습니다.")); + .orElseThrow(() -> new GlobalException(ErrorCode.ROUND_RECORD_NOT_FOUND)); // 부실 인증 신고 수와 미인증 로그 수 조회 long weakReportCount = weakVerificationReportRepository.countByRoundRecordId(roundRecordId); From bafa384d9017931ef2830c6758374b0716521fe1 Mon Sep 17 00:00:00 2001 From: yc3697 Date: Sun, 8 Feb 2026 00:06:10 +0900 Subject: [PATCH 06/14] =?UTF-8?q?chore:=20=EB=AF=B8=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EC=B2=B4=ED=81=AC=20=EC=8B=9C=20=ED=98=84=EC=9E=AC=20=EB=9D=BC?= =?UTF-8?q?=EC=9A=B4=EB=93=9C=EB=A7=8C=20=ED=99=95=EC=9D=B8=EB=90=98?= =?UTF-8?q?=EA=B2=8C=20=EC=88=98=EC=A0=95=20#299?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/round/repository/RoundRecordRepository.java | 1 + 1 file changed, 1 insertion(+) 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 1ee9e9a3..97c7d9d2 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 @@ -98,6 +98,7 @@ Optional findByUserAndRoundId( "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 " + From 5d0f7cee19076cd7d3aa42a8f5e380aa27bc2c7f Mon Sep 17 00:00:00 2001 From: yc3697 Date: Sun, 8 Feb 2026 00:13:01 +0900 Subject: [PATCH 07/14] =?UTF-8?q?chore:=20Race=20Condition=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=95=B4=EA=B2=B0=20#299?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/round/repository/RoundRecordRepository.java | 8 ++++++++ .../domain/round/service/RoundRecordServiceImpl.java | 2 +- .../migration/V2.33__create_table_related_warncount.sql | 2 +- src/test/resources/application-test.yml | 4 +++- 4 files changed, 13 insertions(+), 3 deletions(-) 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 97c7d9d2..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 @@ -9,6 +9,7 @@ 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; @@ -16,8 +17,15 @@ 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 조회 * 인증 생성 시 사용자의 해당 라운드 기록 조회 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 index ac59cb8e..d84ebdf1 100644 --- a/src/main/java/com/hrr/backend/domain/round/service/RoundRecordServiceImpl.java +++ b/src/main/java/com/hrr/backend/domain/round/service/RoundRecordServiceImpl.java @@ -36,7 +36,7 @@ public class RoundRecordServiceImpl implements RoundRecordService { @Transactional public void synchronizeWarnCount(Long roundRecordId) { // 라운드 기록 조회 - RoundRecord roundRecord = roundRecordRepository.findById(roundRecordId) + RoundRecord roundRecord = roundRecordRepository.findByIdWithPessimisticLock(roundRecordId) .orElseThrow(() -> new GlobalException(ErrorCode.ROUND_RECORD_NOT_FOUND)); // 부실 인증 신고 수와 미인증 로그 수 조회 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 index 5bcbcd71..ea50ceff 100644 --- 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 @@ -5,7 +5,7 @@ BEGIN -- 1 . round_record 테이블에 version 컬럼 추가 (없을 경우에만) IF NOT EXISTS ( SELECT * FROM information_schema.COLUMNS - WHERE TABLE_NAME = 'round_record' AND COLUMN_NAME = 'version' + 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; diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 05b85e3d..3db97622 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -6,7 +6,9 @@ spring: database-platform: org.hibernate.dialect.H2Dialect hibernate: ddl-auto: create-drop # 테스트 후 삭제 - globally_quoted_identifiers: true + properties: + hibernate: + globally_quoted_identifiers: true config: activate: on-profile: "test" From c8571056851949898b78bf8ba09bd325cadaf043 Mon Sep 17 00:00:00 2001 From: yc3697 Date: Sun, 8 Feb 2026 00:16:12 +0900 Subject: [PATCH 08/14] =?UTF-8?q?chore:=20=EC=85=80=ED=94=84=20=EC=8B=A0?= =?UTF-8?q?=EA=B3=A0=20=EB=B0=8F=20=EC=A4=91=EB=B3=B5=20=EC=8B=A0=EA=B3=A0?= =?UTF-8?q?=20=EB=B0=A9=EC=A7=80=20#299?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/WeakVerificationReportRepository.java | 5 +++++ .../domain/report/service/ReportServiceImpl.java | 10 ++++++++++ 2 files changed, 15 insertions(+) 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 index 7ce0d397..54b00465 100644 --- a/src/main/java/com/hrr/backend/domain/report/repository/WeakVerificationReportRepository.java +++ b/src/main/java/com/hrr/backend/domain/report/repository/WeakVerificationReportRepository.java @@ -3,9 +3,14 @@ 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/ReportServiceImpl.java b/src/main/java/com/hrr/backend/domain/report/service/ReportServiceImpl.java index 814d7662..abbf65ab 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 @@ -52,6 +52,16 @@ public void reportWeakVerification(User reporter,Long verificationId) { // 피신고자 정보 조회(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()); From 6cd102a8a5753284a4a8f46d9ac019506f767dfe Mon Sep 17 00:00:00 2001 From: yc3697 Date: Sun, 8 Feb 2026 00:39:32 +0900 Subject: [PATCH 09/14] =?UTF-8?q?chore:=20=EC=B0=A8=EB=8B=A8=EB=90=9C=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=EC=97=90=20=EB=8C=80=ED=95=9C=20=EB=B0=A9?= =?UTF-8?q?=EC=96=B4=20=EC=B6=94=EA=B0=80=20#299?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/report/service/ReportServiceImpl.java | 6 ++++++ 1 file changed, 6 insertions(+) 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 abbf65ab..327c9239 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 @@ -49,6 +49,12 @@ public void reportWeakVerification(User reporter,Long verificationId) { 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(); From a58add1efb039a6d10760dbbec8f6bf3cedd744b Mon Sep 17 00:00:00 2001 From: yc3697 Date: Sun, 8 Feb 2026 00:43:14 +0900 Subject: [PATCH 10/14] =?UTF-8?q?chore:=20=EC=B0=B8=EA=B0=80=20=EC=A4=91?= =?UTF-8?q?=EC=9D=B8=20=EC=B1=8C=EB=A6=B0=EC=A0=80=EB=A7=8C=20=EC=8B=A0?= =?UTF-8?q?=EA=B3=A0=ED=95=A0=20=EC=88=98=20=EC=9E=88=EA=B2=8C=20=EC=A0=9C?= =?UTF-8?q?=ED=95=9C=20#299?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/report/service/ReportServiceImpl.java | 3 ++- .../domain/user/repository/UserChallengeRepository.java | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) 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 327c9239..027fabcb 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 @@ -14,6 +14,7 @@ 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; @@ -89,7 +90,7 @@ public void reportWeakVerification(User reporter,Long verificationId) { * @param challenge 확인하려는 챌린지 */ private void validateChallengeParticipation(User reporter, Challenge challenge) { - if (!userChallengeRepository.existsByUserAndChallenge(reporter, challenge)) { + if (!userChallengeRepository.existsByUserAndChallengeAndStatus(reporter, challenge, ChallengeJoinStatus.JOINED)) { throw new GlobalException(ErrorCode.CANNOT_REPORT_OTHER_CHALLENGE_VERIFICATION); } } 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 82a0814b..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만 알 때 사용) From d25d6b732716eeb51a5483ae38a2948445029f19 Mon Sep 17 00:00:00 2001 From: yc3697 Date: Sun, 8 Feb 2026 00:43:42 +0900 Subject: [PATCH 11/14] =?UTF-8?q?chore:=20=EB=93=A4=EC=97=AC=EC=93=B0?= =?UTF-8?q?=EA=B8=B0=20#299?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hrr/backend/domain/report/service/ReportServiceImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 027fabcb..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 @@ -66,7 +66,7 @@ public void reportWeakVerification(User reporter,Long verificationId) { // 중복 신고 방지 if (weakVerificationReportRepository.existsByReporterAndVerification(reporter, targetVerification)) { - throw new GlobalException(ErrorCode.ALREADY_REPORTED); + throw new GlobalException(ErrorCode.ALREADY_REPORTED); } // 권한 검증: 신고자와 피신고자가 동일 챌린지에 참여 중인지 확인 - 검증 완료 시 다음 단계로 이동 From 60dc1bc919fdc826f882b1d96cd0f9d4cace1940 Mon Sep 17 00:00:00 2001 From: yc3697 Date: Sun, 8 Feb 2026 00:52:12 +0900 Subject: [PATCH 12/14] =?UTF-8?q?chore:=20=EC=8A=A4=EC=BC=80=EC=A4=84?= =?UTF-8?q?=EB=9F=AC=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20#299?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/VerificationAbsenceService.java | 33 +++++++++++++++++++ .../scheduler/VerificationScheduler.java | 24 +++++++++----- 2 files changed, 48 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/hrr/backend/domain/verification/service/VerificationAbsenceService.java 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/scheduler/VerificationScheduler.java b/src/main/java/com/hrr/backend/global/scheduler/VerificationScheduler.java index 39dab95a..aaa9561d 100644 --- a/src/main/java/com/hrr/backend/global/scheduler/VerificationScheduler.java +++ b/src/main/java/com/hrr/backend/global/scheduler/VerificationScheduler.java @@ -10,22 +10,26 @@ 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.entity.VerificationAbsenceLog; 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 VerificationAbsenceLogRepository verificationAbsenceLogRepository; + private final VerificationAbsenceService verificationAbsenceService; private final RoundRecordService roundRecordService; + @Scheduled(cron = "0 5 0 * * *") // 매일 00 : 05 실행 @Transactional // 어제가 인증 요일이었지만 인증 기록이 없는, 즉 미인증 기록을 로그로 남기기 위한 스케줄러 @@ -40,15 +44,17 @@ public void checkAbsence() { // 미인증 대상자 조회 List absentees = roundRecordRepository.findAbsentees(yesterdayChallengeDay, yesterdayDate); + log.info("미인증 대상자 {}건 처리 시작", absentees.size()); + int failCount = 0; + for (RoundRecord record : absentees) { - // 미인증 로그 저장 - verificationAbsenceLogRepository.save(VerificationAbsenceLog.builder() - .roundRecord(record) - .absenceDate(yesterdayDate) // 인증이 완료되지 않은 요일은 체크 대상인 어제이므로 어제를 미인증날짜로 기록 - .build()); - - // 경고 횟수를 업데이트 후 퇴출 여부를 결정하는 메소드 호출 - roundRecordService.synchronizeWarnCount(record.getId()); + try { + verificationAbsenceService.processAbsentee(record, yesterdayDate); // 인증이 완료되지 않은 요일은 체크 대상인 어제이므로 어제를 미인증날짜로 기록 + } catch (Exception e) { + log.error("미인증 처리 실패 - roundRecordId: {}", record.getId(), e); + failCount++; + } } + log.info("미인증 처리 완료 - 총: {}, 실패: {}", absentees.size(), failCount); } } From 77c396bddc0c80897968368ff9979890fc6fd95d Mon Sep 17 00:00:00 2001 From: yc3697 Date: Sun, 8 Feb 2026 00:58:55 +0900 Subject: [PATCH 13/14] =?UTF-8?q?chore:=20=EC=88=98=EC=A0=95=EB=90=9C=20?= =?UTF-8?q?=EB=A9=94=EC=86=8C=EB=93=9C=20=EB=B0=98=EC=98=81=20#299?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/challenge/service/ChallengeServiceImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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); } From fb4ea71f121881f41976abf8804058537d498150 Mon Sep 17 00:00:00 2001 From: yc3697 Date: Sun, 8 Feb 2026 00:59:27 +0900 Subject: [PATCH 14/14] =?UTF-8?q?chore:=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EC=9D=98=EC=A1=B4=EC=84=B1=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20#299?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hrr/backend/global/scheduler/VerificationScheduler.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/main/java/com/hrr/backend/global/scheduler/VerificationScheduler.java b/src/main/java/com/hrr/backend/global/scheduler/VerificationScheduler.java index aaa9561d..f5c67320 100644 --- a/src/main/java/com/hrr/backend/global/scheduler/VerificationScheduler.java +++ b/src/main/java/com/hrr/backend/global/scheduler/VerificationScheduler.java @@ -24,11 +24,8 @@ public class VerificationScheduler { private final RoundRecordRepository roundRecordRepository; - private final VerificationAbsenceLogRepository verificationAbsenceLogRepository; private final VerificationAbsenceService verificationAbsenceService; - private final RoundRecordService roundRecordService; - @Scheduled(cron = "0 5 0 * * *") // 매일 00 : 05 실행 @Transactional