Skip to content

Commit 8c8b5b2

Browse files
authored
Merge pull request #176 from SEMOSAN/refactor/#171-constraint-violation-utils
[Refactor] ConstraintViolationException 파싱 로직 유틸로 통합
2 parents f4cb4ee + 1bcd51a commit 8c8b5b2

6 files changed

Lines changed: 93 additions & 72 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package com.semosan.api.common.exception;
2+
3+
import org.hibernate.exception.ConstraintViolationException;
4+
5+
import java.util.regex.Pattern;
6+
7+
public final class ConstraintViolationUtils {
8+
9+
private ConstraintViolationUtils() {}
10+
11+
public static boolean isViolation(Throwable exception, String constraintName) {
12+
Pattern constraintNamePattern = Pattern.compile(
13+
"(?<![A-Za-z0-9_])" + Pattern.quote(constraintName) + "(?![A-Za-z0-9_])"
14+
);
15+
Throwable current = exception;
16+
while (current != null) {
17+
if (current instanceof ConstraintViolationException constraintViolation
18+
&& constraintName.equals(constraintViolation.getConstraintName())) {
19+
return true;
20+
}
21+
String message = current.getMessage();
22+
// 드라이버마다 다른 예외 래핑에 대응하되, 다른 제약명 일부와 겹치는 오탐은 막는다.
23+
if (message != null && constraintNamePattern.matcher(message).find()) {
24+
return true;
25+
}
26+
current = current.getCause();
27+
}
28+
return false;
29+
}
30+
}

src/main/java/com/semosan/api/domain/community/post/service/FreePostReportService.java

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.semosan.api.common.alert.DiscordAlertClient;
44
import com.semosan.api.common.alert.dto.DiscordEmbed;
55
import com.semosan.api.common.alert.dto.DiscordMessage;
6+
import com.semosan.api.common.exception.ConstraintViolationUtils;
67
import com.semosan.api.common.exception.GeneralException;
78
import com.semosan.api.common.status.ErrorStatus;
89
import com.semosan.api.domain.community.post.entity.FreePost;
@@ -13,7 +14,6 @@
1314
import com.semosan.api.domain.user.entity.User;
1415
import com.semosan.api.domain.user.repository.UserRepository;
1516
import lombok.RequiredArgsConstructor;
16-
import org.hibernate.exception.ConstraintViolationException;
1717
import org.springframework.dao.DataIntegrityViolationException;
1818
import org.springframework.stereotype.Service;
1919
import org.springframework.transaction.annotation.Transactional;
@@ -61,7 +61,7 @@ public void afterCommit() {
6161
});
6262
return saved;
6363
} catch (DataIntegrityViolationException e) {
64-
if (isUniqueViolation(e)) {
64+
if (ConstraintViolationUtils.isViolation(e, REPORT_UNIQUE_CONSTRAINT)) {
6565
throw new GeneralException(ErrorStatus.FREE_POST_REPORT_ALREADY_EXISTS);
6666
}
6767
throw e;
@@ -93,22 +93,6 @@ private DiscordMessage buildDiscordMessage(FreePostReport report) {
9393
);
9494
}
9595

96-
private boolean isUniqueViolation(Throwable exception) {
97-
Throwable current = exception;
98-
while (current != null) {
99-
if (current instanceof ConstraintViolationException constraintViolation
100-
&& REPORT_UNIQUE_CONSTRAINT.equals(constraintViolation.getConstraintName())) {
101-
return true;
102-
}
103-
String message = current.getMessage();
104-
if (message != null && message.contains(REPORT_UNIQUE_CONSTRAINT)) {
105-
return true;
106-
}
107-
current = current.getCause();
108-
}
109-
return false;
110-
}
111-
11296
private User findReporterOrThrow(Long reporterId) {
11397
return userRepository.findByIdAndDeletedFalse(reporterId)
11498
.orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND));

src/main/java/com/semosan/api/domain/hiking/service/HikingRecordService.java

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,10 @@
1919
import com.semosan.api.domain.tracking.repository.TrackingPhotoRepository;
2020
import com.semosan.api.domain.tracking.repository.TrackingPointRepository;
2121
import com.semosan.api.domain.tracking.repository.projection.TrackingPathProjection;
22+
import com.semosan.api.common.exception.ConstraintViolationUtils;
2223
import com.semosan.api.domain.user.entity.User;
2324
import com.semosan.api.domain.user.service.UserReader;
2425
import lombok.RequiredArgsConstructor;
25-
import org.hibernate.exception.ConstraintViolationException;
2626
import org.springframework.dao.DataIntegrityViolationException;
2727
import org.springframework.data.domain.Page;
2828
import org.springframework.data.domain.Pageable;
@@ -147,26 +147,11 @@ public CourseDifficultyFeedbackResponse createCourseDifficultyFeedback(
147147
try {
148148
return CourseDifficultyFeedbackResponse.from(courseDifficultyFeedbackRepository.saveAndFlush(feedback));
149149
} catch (DataIntegrityViolationException e) {
150-
if (isDifficultyFeedbackHikingRecordUniqueViolation(e)) {
150+
// 동시 요청으로 유니크 제약 위반 시 이미 피드백이 존재하는 에러로 변환
151+
if (ConstraintViolationUtils.isViolation(e, DIFFICULTY_FEEDBACK_HIKING_RECORD_UNIQUE_CONSTRAINT)) {
151152
throw new GeneralException(ErrorStatus.COURSE_DIFFICULTY_FEEDBACK_ALREADY_EXISTS);
152153
}
153154
throw e;
154155
}
155156
}
156-
157-
private boolean isDifficultyFeedbackHikingRecordUniqueViolation(Throwable exception) {
158-
Throwable current = exception;
159-
while (current != null) {
160-
if (current instanceof ConstraintViolationException constraintViolation
161-
&& DIFFICULTY_FEEDBACK_HIKING_RECORD_UNIQUE_CONSTRAINT.equals(constraintViolation.getConstraintName())) {
162-
return true;
163-
}
164-
String message = current.getMessage();
165-
if (message != null && message.contains(DIFFICULTY_FEEDBACK_HIKING_RECORD_UNIQUE_CONSTRAINT)) {
166-
return true;
167-
}
168-
current = current.getCause();
169-
}
170-
return false;
171-
}
172157
}

src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,11 @@
1616
import com.semosan.api.domain.tracking.enums.TrackingSessionStatus;
1717
import com.semosan.api.domain.tracking.event.TrackingSessionTerminatedEvent;
1818
import com.semosan.api.domain.tracking.repository.TrackingSessionRepository;
19+
import com.semosan.api.common.exception.ConstraintViolationUtils;
1920
import com.semosan.api.domain.user.entity.User;
2021
import com.semosan.api.domain.user.service.UserReader;
2122
import lombok.RequiredArgsConstructor;
2223
import lombok.extern.slf4j.Slf4j;
23-
import org.hibernate.exception.ConstraintViolationException;
2424
import org.springframework.context.ApplicationEventPublisher;
2525
import org.springframework.dao.DataIntegrityViolationException;
2626
import org.springframework.stereotype.Service;
@@ -178,26 +178,11 @@ private TrackingSession saveSession(TrackingSession session) {
178178
try {
179179
return trackingSessionRepository.save(session);
180180
} catch (DataIntegrityViolationException e) {
181-
if (isActiveSessionUniqueViolation(e)) {
181+
// 동시 요청으로 유니크 제약 위반 시 이미 진행 중인 세션 존재 에러로 변환
182+
if (ConstraintViolationUtils.isViolation(e, ACTIVE_SESSION_UNIQUE_INDEX)) {
182183
throw new GeneralException(ErrorStatus.TRACKING_SESSION_ALREADY_IN_PROGRESS);
183184
}
184185
throw e;
185186
}
186187
}
187-
188-
private boolean isActiveSessionUniqueViolation(Throwable exception) {
189-
Throwable current = exception;
190-
while (current != null) {
191-
if (current instanceof ConstraintViolationException constraintViolation
192-
&& ACTIVE_SESSION_UNIQUE_INDEX.equals(constraintViolation.getConstraintName())) {
193-
return true;
194-
}
195-
String message = current.getMessage();
196-
if (message != null && message.contains(ACTIVE_SESSION_UNIQUE_INDEX)) {
197-
return true;
198-
}
199-
current = current.getCause();
200-
}
201-
return false;
202-
}
203188
}

src/main/java/com/semosan/api/domain/user/service/UserBlockService.java

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.semosan.api.domain.user.service;
22

3+
import com.semosan.api.common.exception.ConstraintViolationUtils;
34
import com.semosan.api.common.exception.GeneralException;
45
import com.semosan.api.common.status.ErrorStatus;
56
import com.semosan.api.domain.community.post.entity.FreePost;
@@ -9,7 +10,6 @@
910
import com.semosan.api.domain.user.repository.UserBlockRepository;
1011
import com.semosan.api.domain.user.repository.UserRepository;
1112
import lombok.RequiredArgsConstructor;
12-
import org.hibernate.exception.ConstraintViolationException;
1313
import org.springframework.dao.DataIntegrityViolationException;
1414
import org.springframework.stereotype.Service;
1515
import org.springframework.transaction.annotation.Transactional;
@@ -40,7 +40,8 @@ public void block(Long blockerId, Long blockedUserId) {
4040
try {
4141
userBlockRepository.saveAndFlush(UserBlock.create(blocker, blockedUser));
4242
} catch (DataIntegrityViolationException e) {
43-
if (!isUniqueViolation(e)) {
43+
// 동시 요청으로 유니크 제약 위반 시 멱등 처리 (이미 차단된 상태로 간주)
44+
if (!ConstraintViolationUtils.isViolation(e, BLOCK_UNIQUE_CONSTRAINT)) {
4445
throw e;
4546
}
4647
}
@@ -53,22 +54,6 @@ public void blockByPost(Long blockerId, Long postId) {
5354
block(blockerId, post.getAuthor().getId());
5455
}
5556

56-
private boolean isUniqueViolation(Throwable exception) {
57-
Throwable current = exception;
58-
while (current != null) {
59-
if (current instanceof ConstraintViolationException constraintViolation
60-
&& BLOCK_UNIQUE_CONSTRAINT.equals(constraintViolation.getConstraintName())) {
61-
return true;
62-
}
63-
String message = current.getMessage();
64-
if (message != null && message.contains(BLOCK_UNIQUE_CONSTRAINT)) {
65-
return true;
66-
}
67-
current = current.getCause();
68-
}
69-
return false;
70-
}
71-
7257
private User findActiveUserOrThrow(Long userId) {
7358
return userRepository.findByIdAndDeletedFalse(userId)
7459
.orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND));
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package com.semosan.api.common.exception;
2+
3+
import org.hibernate.exception.ConstraintViolationException;
4+
import org.junit.jupiter.api.Test;
5+
import org.springframework.dao.DataIntegrityViolationException;
6+
7+
import java.sql.SQLException;
8+
9+
import static org.assertj.core.api.Assertions.assertThat;
10+
11+
class ConstraintViolationUtilsTest {
12+
13+
private static final String CONSTRAINT_NAME = "uk_user_blocks_blocker_blocked";
14+
15+
@Test
16+
void isViolationReturnsTrueWhenHibernateConstraintNameMatches() {
17+
ConstraintViolationException cause = new ConstraintViolationException(
18+
"duplicate",
19+
new SQLException("unique violation"),
20+
CONSTRAINT_NAME
21+
);
22+
23+
boolean result = ConstraintViolationUtils.isViolation(
24+
new DataIntegrityViolationException("duplicate", cause),
25+
CONSTRAINT_NAME
26+
);
27+
28+
assertThat(result).isTrue();
29+
}
30+
31+
@Test
32+
void isViolationReturnsTrueWhenMessageContainsExactConstraintToken() {
33+
DataIntegrityViolationException exception = new DataIntegrityViolationException(
34+
"ERROR: duplicate key value violates unique constraint \"" + CONSTRAINT_NAME + "\""
35+
);
36+
37+
boolean result = ConstraintViolationUtils.isViolation(exception, CONSTRAINT_NAME);
38+
39+
assertThat(result).isTrue();
40+
}
41+
42+
@Test
43+
void isViolationReturnsFalseWhenMessageContainsOnlyConstraintNamePrefix() {
44+
DataIntegrityViolationException exception = new DataIntegrityViolationException(
45+
"ERROR: duplicate key value violates unique constraint \"" + CONSTRAINT_NAME + "_idx\""
46+
);
47+
48+
boolean result = ConstraintViolationUtils.isViolation(exception, CONSTRAINT_NAME);
49+
50+
assertThat(result).isFalse();
51+
}
52+
}

0 commit comments

Comments
 (0)