diff --git a/src/main/java/com/semosan/api/common/exception/ConstraintViolationUtils.java b/src/main/java/com/semosan/api/common/exception/ConstraintViolationUtils.java new file mode 100644 index 00000000..8a9599d1 --- /dev/null +++ b/src/main/java/com/semosan/api/common/exception/ConstraintViolationUtils.java @@ -0,0 +1,30 @@ +package com.semosan.api.common.exception; + +import org.hibernate.exception.ConstraintViolationException; + +import java.util.regex.Pattern; + +public final class ConstraintViolationUtils { + + private ConstraintViolationUtils() {} + + public static boolean isViolation(Throwable exception, String constraintName) { + Pattern constraintNamePattern = Pattern.compile( + "(? new GeneralException(ErrorStatus.USER_NOT_FOUND)); diff --git a/src/main/java/com/semosan/api/domain/hiking/service/HikingRecordService.java b/src/main/java/com/semosan/api/domain/hiking/service/HikingRecordService.java index ac948419..9a8200e5 100644 --- a/src/main/java/com/semosan/api/domain/hiking/service/HikingRecordService.java +++ b/src/main/java/com/semosan/api/domain/hiking/service/HikingRecordService.java @@ -19,10 +19,10 @@ import com.semosan.api.domain.tracking.repository.TrackingPhotoRepository; import com.semosan.api.domain.tracking.repository.TrackingPointRepository; import com.semosan.api.domain.tracking.repository.projection.TrackingPathProjection; +import com.semosan.api.common.exception.ConstraintViolationUtils; import com.semosan.api.domain.user.entity.User; import com.semosan.api.domain.user.service.UserReader; import lombok.RequiredArgsConstructor; -import org.hibernate.exception.ConstraintViolationException; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -147,26 +147,11 @@ public CourseDifficultyFeedbackResponse createCourseDifficultyFeedback( try { return CourseDifficultyFeedbackResponse.from(courseDifficultyFeedbackRepository.saveAndFlush(feedback)); } catch (DataIntegrityViolationException e) { - if (isDifficultyFeedbackHikingRecordUniqueViolation(e)) { + // 동시 요청으로 유니크 제약 위반 시 이미 피드백이 존재하는 에러로 변환 + if (ConstraintViolationUtils.isViolation(e, DIFFICULTY_FEEDBACK_HIKING_RECORD_UNIQUE_CONSTRAINT)) { throw new GeneralException(ErrorStatus.COURSE_DIFFICULTY_FEEDBACK_ALREADY_EXISTS); } throw e; } } - - private boolean isDifficultyFeedbackHikingRecordUniqueViolation(Throwable exception) { - Throwable current = exception; - while (current != null) { - if (current instanceof ConstraintViolationException constraintViolation - && DIFFICULTY_FEEDBACK_HIKING_RECORD_UNIQUE_CONSTRAINT.equals(constraintViolation.getConstraintName())) { - return true; - } - String message = current.getMessage(); - if (message != null && message.contains(DIFFICULTY_FEEDBACK_HIKING_RECORD_UNIQUE_CONSTRAINT)) { - return true; - } - current = current.getCause(); - } - return false; - } } diff --git a/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java b/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java index d088ed2e..b86b62d0 100644 --- a/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java +++ b/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java @@ -16,11 +16,11 @@ import com.semosan.api.domain.tracking.enums.TrackingSessionStatus; import com.semosan.api.domain.tracking.event.TrackingSessionTerminatedEvent; import com.semosan.api.domain.tracking.repository.TrackingSessionRepository; +import com.semosan.api.common.exception.ConstraintViolationUtils; import com.semosan.api.domain.user.entity.User; import com.semosan.api.domain.user.service.UserReader; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.hibernate.exception.ConstraintViolationException; import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; @@ -178,26 +178,11 @@ private TrackingSession saveSession(TrackingSession session) { try { return trackingSessionRepository.save(session); } catch (DataIntegrityViolationException e) { - if (isActiveSessionUniqueViolation(e)) { + // 동시 요청으로 유니크 제약 위반 시 이미 진행 중인 세션 존재 에러로 변환 + if (ConstraintViolationUtils.isViolation(e, ACTIVE_SESSION_UNIQUE_INDEX)) { throw new GeneralException(ErrorStatus.TRACKING_SESSION_ALREADY_IN_PROGRESS); } throw e; } } - - private boolean isActiveSessionUniqueViolation(Throwable exception) { - Throwable current = exception; - while (current != null) { - if (current instanceof ConstraintViolationException constraintViolation - && ACTIVE_SESSION_UNIQUE_INDEX.equals(constraintViolation.getConstraintName())) { - return true; - } - String message = current.getMessage(); - if (message != null && message.contains(ACTIVE_SESSION_UNIQUE_INDEX)) { - return true; - } - current = current.getCause(); - } - return false; - } } diff --git a/src/main/java/com/semosan/api/domain/user/service/UserBlockService.java b/src/main/java/com/semosan/api/domain/user/service/UserBlockService.java index a93ad651..caff515d 100644 --- a/src/main/java/com/semosan/api/domain/user/service/UserBlockService.java +++ b/src/main/java/com/semosan/api/domain/user/service/UserBlockService.java @@ -1,5 +1,6 @@ package com.semosan.api.domain.user.service; +import com.semosan.api.common.exception.ConstraintViolationUtils; import com.semosan.api.common.exception.GeneralException; import com.semosan.api.common.status.ErrorStatus; import com.semosan.api.domain.community.post.entity.FreePost; @@ -9,7 +10,6 @@ import com.semosan.api.domain.user.repository.UserBlockRepository; import com.semosan.api.domain.user.repository.UserRepository; import lombok.RequiredArgsConstructor; -import org.hibernate.exception.ConstraintViolationException; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -40,7 +40,8 @@ public void block(Long blockerId, Long blockedUserId) { try { userBlockRepository.saveAndFlush(UserBlock.create(blocker, blockedUser)); } catch (DataIntegrityViolationException e) { - if (!isUniqueViolation(e)) { + // 동시 요청으로 유니크 제약 위반 시 멱등 처리 (이미 차단된 상태로 간주) + if (!ConstraintViolationUtils.isViolation(e, BLOCK_UNIQUE_CONSTRAINT)) { throw e; } } @@ -53,22 +54,6 @@ public void blockByPost(Long blockerId, Long postId) { block(blockerId, post.getAuthor().getId()); } - private boolean isUniqueViolation(Throwable exception) { - Throwable current = exception; - while (current != null) { - if (current instanceof ConstraintViolationException constraintViolation - && BLOCK_UNIQUE_CONSTRAINT.equals(constraintViolation.getConstraintName())) { - return true; - } - String message = current.getMessage(); - if (message != null && message.contains(BLOCK_UNIQUE_CONSTRAINT)) { - return true; - } - current = current.getCause(); - } - return false; - } - private User findActiveUserOrThrow(Long userId) { return userRepository.findByIdAndDeletedFalse(userId) .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); diff --git a/src/test/java/com/semosan/api/common/exception/ConstraintViolationUtilsTest.java b/src/test/java/com/semosan/api/common/exception/ConstraintViolationUtilsTest.java new file mode 100644 index 00000000..ac0d76cb --- /dev/null +++ b/src/test/java/com/semosan/api/common/exception/ConstraintViolationUtilsTest.java @@ -0,0 +1,52 @@ +package com.semosan.api.common.exception; + +import org.hibernate.exception.ConstraintViolationException; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataIntegrityViolationException; + +import java.sql.SQLException; + +import static org.assertj.core.api.Assertions.assertThat; + +class ConstraintViolationUtilsTest { + + private static final String CONSTRAINT_NAME = "uk_user_blocks_blocker_blocked"; + + @Test + void isViolationReturnsTrueWhenHibernateConstraintNameMatches() { + ConstraintViolationException cause = new ConstraintViolationException( + "duplicate", + new SQLException("unique violation"), + CONSTRAINT_NAME + ); + + boolean result = ConstraintViolationUtils.isViolation( + new DataIntegrityViolationException("duplicate", cause), + CONSTRAINT_NAME + ); + + assertThat(result).isTrue(); + } + + @Test + void isViolationReturnsTrueWhenMessageContainsExactConstraintToken() { + DataIntegrityViolationException exception = new DataIntegrityViolationException( + "ERROR: duplicate key value violates unique constraint \"" + CONSTRAINT_NAME + "\"" + ); + + boolean result = ConstraintViolationUtils.isViolation(exception, CONSTRAINT_NAME); + + assertThat(result).isTrue(); + } + + @Test + void isViolationReturnsFalseWhenMessageContainsOnlyConstraintNamePrefix() { + DataIntegrityViolationException exception = new DataIntegrityViolationException( + "ERROR: duplicate key value violates unique constraint \"" + CONSTRAINT_NAME + "_idx\"" + ); + + boolean result = ConstraintViolationUtils.isViolation(exception, CONSTRAINT_NAME); + + assertThat(result).isFalse(); + } +}