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 @@ -107,7 +107,7 @@ public DataResponse<ReviewHelpfulResponse> toggleHelpful(
@PathVariable Long reviewId,
@AuthenticationPrincipal Long userId
) {
ReviewHelpfulResponse response = reviewQueryService.toggleHelpful(reviewId, userId);
ReviewHelpfulResponse response = reviewCommandService.toggleHelpful(reviewId, userId);
return DataResponse.from(response);
}

Expand Down
10 changes: 0 additions & 10 deletions src/main/java/com/ongil/backend/domain/review/entity/Review.java
Original file line number Diff line number Diff line change
Expand Up @@ -121,16 +121,6 @@ public class Review extends BaseEntity {
@JoinColumn(name = "product_id", nullable = false)
private Product product;

public void incrementHelpfulCount() {
this.helpfulCount++;
}

public void decrementHelpfulCount() {
if (this.helpfulCount > 0) {
this.helpfulCount--;
}
}

public void updateStep1(int rating, SizeAnswer sizeAnswer, ColorAnswer colorAnswer, MaterialAnswer materialAnswer) {
this.rating = rating;
this.sizeAnswer = sizeAnswer;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
import java.util.Optional;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import com.ongil.backend.domain.review.entity.ReviewHelpful;

Expand All @@ -13,6 +16,8 @@ public interface ReviewHelpfulRepository extends JpaRepository<ReviewHelpful, Lo
// 리뷰에 대해 특정 사용자가 도움돼요를 눌렀는지 여부 확인
boolean existsByReviewIdAndUserId(Long reviewId, Long userId);

// 리뷰 도움돼요 기록 삭제
void deleteByReviewIdAndUserId(Long reviewId, Long userId);
// 리뷰 도움돼요 기록 삭제 (삭제된 row 수 반환)
@Modifying
@Query("DELETE FROM ReviewHelpful rh WHERE rh.review.id = :reviewId AND rh.user.id = :userId")
int deleteByReviewIdAndUserId(@Param("reviewId") Long reviewId, @Param("userId") Long userId);
Comment on lines +19 to +22
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Find and inspect ReviewHelpful entity definition
echo "=== Searching for ReviewHelpful entity ==="
fd ReviewHelpful.java src/main/java --exec cat {} \;

echo -e "\n=== Searching for unique constraints/indexes on review_helpful ==="
# Check migration files and DDL
fd -t f "\.(sql|ddl)$" src/main/resources --exec grep -l "review_helpful\|ReviewHelpful" {} \;
fd -t f "\.(sql|ddl)$" src/main/resources --exec cat {} \;

# Check if there are migration files with java/xml
fd -type f -name "*.xml" -o -name "*.java" | grep -i "migration\|flyway\|liquibase" | head -20

echo -e "\n=== Searching for ReviewCommandService or toggle method ==="
fd ReviewCommandService.java src/main/java --exec cat {} \;

echo -e "\n=== Searching for exception handling patterns ==="
rg -i "DataIntegrityViolationException|ConstraintViolationException|DuplicateKeyException" src/main/java -C 3 || true

Repository: IT-Cotato/12th-OnGil-BE

Length of output: 1389


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Searching for ReviewCommandService or toggle-related services ==="
fd -type f -name "*CommandService.java" src/main/java | xargs cat 2>/dev/null || true

echo -e "\n=== Searching for toggle or helpful-related business logic ==="
fd -type f -name "*.java" src/main/java | xargs rg -l "toggleHelpful|deleteByReviewIdAndUserId" 2>/dev/null || true

echo -e "\n=== Searching for exception handling of DataIntegrityViolationException ==="
rg "DataIntegrityViolationException|ConstraintViolationException" src/main/java -B 3 -A 5 2>/dev/null || true

echo -e "\n=== Searching for SQL migration/DDL files ==="
find src/main/resources -name "*.sql" -o -name "*.ddl" 2>/dev/null | head -20

# Look for Flyway or Liquibase migration files
find src/main/resources -type d -name db -o -name migration -o -name flyway 2>/dev/null || true

Repository: IT-Cotato/12th-OnGil-BE

Length of output: 6413


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Full ReviewCommandService content ==="
cat src/main/java/com/ongil/backend/domain/review/service/ReviewCommandService.java

echo -e "\n=== Searching for toggleHelpful or addHelpful method ==="
rg "toggleHelpful|addHelpful|deleteByReviewIdAndUserId" src/main/java/com/ongil/backend/domain/review -B 2 -A 15

Repository: IT-Cotato/12th-OnGil-BE

Length of output: 15071


동시성 환경에서 도움돼요 추가 시 미처리 예외 발생 위험

삭제 여부 판단 후 save()로 신규 레코드 추가 시, DataIntegrityViolationException에 대한 예외 처리가 없어 동시 요청 시 미처리 예외가 발생할 수 있습니다. 유니크 제약(uk_review_helpful_review_user)은 엔티티 레벨에 이미 정의되어 있으나, 경합 상황에서 insert 실패 시 예외를 명시적으로 처리하거나 다른 동시성 전략(낙관적 잠금, 멀티스레드 안전 로직 등)이 필요합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/com/ongil/backend/domain/review/repository/ReviewHelpfulRepository.java`
around lines 19 - 22, When inserting a new ReviewHelpful after calling
deleteByReviewIdAndUserId you must handle race conditions that can cause a
DataIntegrityViolationException due to the uk_review_helpful_review_user unique
constraint; update the code that calls save() (the service method that performs
deleteByReviewIdAndUserId + repository.save(...)) to wrap the save() in a
try/catch for DataIntegrityViolationException and handle it by either
re-querying the existing ReviewHelpful (returning the existing record/flag) or
swallowing/logging and returning a safe failure indicator, and ensure the
operation runs in a transactional context so deleteByReviewIdAndUserId and the
insert behave atomically; reference the repository method
deleteByReviewIdAndUserId and the unique constraint
uk_review_helpful_review_user when applying this change.

}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
Expand All @@ -17,8 +16,6 @@
import com.ongil.backend.domain.review.enums.ReviewStatus;
import com.ongil.backend.domain.review.enums.ReviewType;

import jakarta.persistence.LockModeType;

public interface ReviewRepository extends JpaRepository<Review, Long> {

// 상품별 리뷰 목록 조회 (필터 없음)
Expand Down Expand Up @@ -189,9 +186,16 @@ List<Long> findReviewedOrderItemIds(
@Param("reviewType") ReviewType reviewType
);

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT r FROM Review r WHERE r.id = :reviewId")
Optional<Review> findByIdWithLock(@Param("reviewId") Long reviewId);
@Modifying(clearAutomatically = true)
@Query("UPDATE Review r SET r.helpfulCount = r.helpfulCount + 1 WHERE r.id = :reviewId")
int incrementHelpfulCount(@Param("reviewId") Long reviewId);

@Modifying(clearAutomatically = true)
@Query("UPDATE Review r SET r.helpfulCount = r.helpfulCount - 1 WHERE r.id = :reviewId AND r.helpfulCount > 0")
int decrementHelpfulCount(@Param("reviewId") Long reviewId);

@Query("SELECT r.helpfulCount FROM Review r WHERE r.id = :reviewId")
Optional<Integer> findHelpfulCountById(@Param("reviewId") Long reviewId);

@Modifying
@Query("DELETE FROM Review r WHERE r.reviewStatus = 'DRAFT' AND r.createdAt < :threshold")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,16 @@
import com.ongil.backend.domain.review.dto.request.ReviewStep2MaterialRequest;
import com.ongil.backend.domain.review.dto.request.ReviewStep2SizeRequest;
import com.ongil.backend.domain.review.dto.response.AiReviewResponse;
import com.ongil.backend.domain.review.dto.response.ReviewHelpfulResponse;
import com.ongil.backend.domain.review.dto.response.ReviewStep1Response;
import com.ongil.backend.domain.review.entity.Review;
import com.ongil.backend.domain.review.entity.ReviewHelpful;
import com.ongil.backend.domain.review.enums.ClothingCategory;
import com.ongil.backend.domain.review.enums.MaterialAnswer;
import com.ongil.backend.domain.review.enums.MaterialFeatureType;
import com.ongil.backend.domain.review.enums.ReviewStatus;
import com.ongil.backend.domain.review.enums.ReviewType;
import com.ongil.backend.domain.review.repository.ReviewHelpfulRepository;
import com.ongil.backend.domain.review.repository.ReviewRepository;
import com.ongil.backend.domain.review.validator.ReviewValidator;
import com.ongil.backend.domain.user.entity.User;
Expand All @@ -42,6 +45,7 @@ public class ReviewCommandService {
private static final int REVIEW_REWARD_POINTS = 500;

private final ReviewRepository reviewRepository;
private final ReviewHelpfulRepository reviewHelpfulRepository;
private final UserRepository userRepository;
private final OrderItemRepository orderItemRepository;
private final ProductRepository productRepository;
Expand Down Expand Up @@ -177,6 +181,42 @@ public void submitReview(Long userId, Long reviewId, ReviewFinalSubmitRequest re
productRepository.updateReviewStats(review.getProduct().getId());
}

public ReviewHelpfulResponse toggleHelpful(Long reviewId, Long userId) {
if (!reviewRepository.existsById(reviewId)) {
throw new EntityNotFoundException(ErrorCode.REVIEW_NOT_FOUND);
}

// delete 결과로 이전 상태 판단 (TOCTOU 제거)
int deleted = reviewHelpfulRepository.deleteByReviewIdAndUserId(reviewId, userId);
boolean isHelpful;

if (deleted > 0) {
// 도움돼요 취소
reviewRepository.decrementHelpfulCount(reviewId);
isHelpful = false;
} else {
// 도움돼요 추가
User user = getUserOrThrow(userId);
Review reviewRef = reviewRepository.getReferenceById(reviewId);
ReviewHelpful helpful = ReviewHelpful.builder()
.review(reviewRef)
.user(user)
.build();
reviewHelpfulRepository.save(helpful);
reviewRepository.incrementHelpfulCount(reviewId);
isHelpful = true;
}

// DB에서 최신 count 조회
int updatedCount = reviewRepository.findHelpfulCountById(reviewId).orElse(0);

return ReviewHelpfulResponse.builder()
.reviewId(reviewId)
.isHelpful(isHelpful)
.helpfulCount(updatedCount)
.build();
}

private Review getReviewOrThrow(Long reviewId) {
return reviewRepository.findById(reviewId)
.orElseThrow(() -> new EntityNotFoundException(ErrorCode.REVIEW_NOT_FOUND));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
import com.ongil.backend.domain.review.dto.request.ReviewListRequest;
import com.ongil.backend.domain.review.dto.response.*;
import com.ongil.backend.domain.review.entity.Review;
import com.ongil.backend.domain.review.entity.ReviewHelpful;
import com.ongil.backend.domain.review.enums.ColorAnswer;
import com.ongil.backend.domain.review.enums.MaterialAnswer;
import com.ongil.backend.domain.review.enums.ReviewSortType;
Expand Down Expand Up @@ -215,36 +214,6 @@ public int getPendingReviewCount(Long userId) {
return count;
}

// 6. 리뷰 도움돼요 토글
@Transactional
public ReviewHelpfulResponse toggleHelpful(Long reviewId, Long userId) {
// 리뷰에 대한 PESSIMISTIC WRITE 락 획득
Review review = reviewRepository.findByIdWithLock(reviewId)
.orElseThrow(() -> new EntityNotFoundException(ErrorCode.REVIEW_NOT_FOUND));

User user = getUserOrThrow(userId);

boolean exists = reviewHelpfulRepository.existsByReviewIdAndUserId(reviewId, userId);

if (exists) {
reviewHelpfulRepository.deleteByReviewIdAndUserId(reviewId, userId);
review.decrementHelpfulCount();
} else {
ReviewHelpful helpful = ReviewHelpful.builder()
.review(review)
.user(user)
.build();
reviewHelpfulRepository.save(helpful);
review.incrementHelpfulCount();
}

return ReviewHelpfulResponse.builder()
.reviewId(reviewId)
.isHelpful(!exists)
.helpfulCount(review.getHelpfulCount())
.build();
}

// 정렬 기준에 따른 Pageable 생성
private Pageable createPageable(int page, int size, ReviewSortType sortType) {
if (size <= 0) size = 10;
Expand Down