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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,11 @@ public class Challenge extends BaseEntity {
@OneToMany(mappedBy = "challenge", cascade = CascadeType.ALL, orphanRemoval = false)
private List<RecommendationResult> recommendationResults = new ArrayList<>();

// 참가자 수 변경
public void updateCurrentParticipants(int currentParticipants) {
this.currentParticipants = currentParticipants;
}

// 참가자 수 증가 편의 메서드
public void increaseCurrentParticipants() {
this.currentParticipants++;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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")
Expand All @@ -29,6 +31,21 @@ public class ReportController {

private final ReportService reportService;

@PostMapping("/verification/weak")
@Operation(summary = "부실 인증 신고", description = "부실 인증을 신고합니다. \n중복 신고할 수 없으며, 해당 챌린지에 참가 중인 챌린저만 신고 가능합니다.")
public ApiResponse<Void> 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<Void> reportVerificationPost(
Expand All @@ -54,4 +71,5 @@ public ApiResponse<Void> reportUser(

return ApiResponse.onSuccess(SuccessCode.OK, null);
}

}
Original file line number Diff line number Diff line change
@@ -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; // 페널티 대상자의 라운드 기록

}
Original file line number Diff line number Diff line change
@@ -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<WeakVerificationReport, Long> {

// 특정 라운드의 특정 챌린저의 부실인증 개수 조회
long countByRoundRecordId(Long roundRecordId);

// 중복 체크 - 특정 사용자가 특정 인증을 신고한 내역이 있는지 조회
boolean existsByReporterAndVerification(User reporter, Verification targetVerification);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@

public interface ReportService {

// 부실 인증 신고
void reportWeakVerification(User reporter, Long verificationId);

// 인증 게시글 신고
void reportVerificationPost(User reporter, ReportRequestDto request);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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());
}
Comment on lines +46 to +85
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

이미 퇴출(KICKED)된 사용자의 인증에 대한 부실 인증 신고 처리가 누락되어 있습니다.

현재 로직은 인증 글이 BLOCKED인지만 확인하지만, 해당 인증의 소유자가 이미 챌린지에서 퇴출된 상태인지는 검증하지 않습니다. 퇴출된 사용자의 인증에 대해 신고가 접수되면 불필요한 경고가 누적되고, synchronizeWarnCount가 이미 KICKED 상태인 레코드를 다시 처리하게 됩니다.

targetRecord 또는 관련 UserChallenge의 상태를 확인하여 이미 퇴출된 경우 조기 반환하는 것을 고려해 주세요.

🤖 Prompt for AI Agents
In `@src/main/java/com/hrr/backend/domain/report/service/ReportServiceImpl.java`
around lines 46 - 85, reportWeakVerification must also bail out when the owner
of the verification is already kicked; after loading targetRecord (or after
fetching targetVerification), check the kicked status on targetRecord and/or
targetVerification.getUserChallenge() (e.g., compare to a KICKED enum on
RoundRecord or UserChallenge) and return early by throwing an appropriate
GlobalException (reuse ErrorCode.ACCESS_DENIED_REPORTED_POST or add a new error
code) instead of continuing to save a report and calling
roundRecordService.synchronizeWarnCount.


/**
* 신고자와 피신고자가 같은 챌린지에 참여하고 있는지를 검증
* @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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.*;
Expand Down Expand Up @@ -36,6 +37,8 @@
}
)
public class RoundRecord extends BaseEntity {
@Version
private Long version; // for 낙관적 락

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
Expand Down Expand Up @@ -84,4 +87,14 @@ public void updateFinalRank(Integer rank) {
public void updateNextRoundIntent(NextRoundIntent intent) {
this.nextRoundIntent = intent;
}
}

// 데이터 정합성을 고려하여 경고 횟수를 업데이트 하고 퇴출 조건을 확인
public void synchronizeWarnCount(int newWarnCount) {
this.warnCount = newWarnCount; //

// 경고가 3회 이상이면 해당 유저의 챌린지 참여 상태를 KICKED로 변경
if (this.warnCount >= 3) {
this.userChallenge.updateStatus(ChallengeJoinStatus.KICKED);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<RoundRecord, Long> {

// 비관적 락 조회
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT rr FROM RoundRecord rr WHERE rr.id = :id")
Optional<RoundRecord> findByIdWithPessimisticLock(@Param("id") Long id);

/**
* UserChallenge와 Round ID로 RoundRecord 조회
* 인증 생성 시 사용자의 해당 라운드 기록 조회
Expand Down Expand Up @@ -88,4 +98,23 @@ Optional<RoundRecord> 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<RoundRecord> findAbsentees(
@Param("yesterdayChallengeDay") ChallengeDays yesterdayChallengeDay,
@Param("yesterdayDate") LocalDate yesterdayDate
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.hrr.backend.domain.round.service;

public interface RoundRecordService {

// 경고 횟수의 데이터 정합성을 보장하기 위한 계산 확인 메서드
void synchronizeWarnCount(Long roundRecordId);

}
Original file line number Diff line number Diff line change
@@ -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);

// --필요 시 추가 작업 진행(알림 발송 등)
}
}
Loading