fix(review): scope concurrent write conflicts

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-09-01 19:00:47 +08:00
parent 5e380e50da
commit 74c389ee1e
5 changed files with 50 additions and 19 deletions

View file

@ -13,7 +13,6 @@ import jakarta.servlet.http.HttpServletRequest;
import java.util.Set;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
@ -70,10 +69,7 @@ public class GlobalExceptionHandler {
return renderLocalizedError(ex, HttpStatus.valueOf(ex.statusCode()), request);
}
@ExceptionHandler({
ObjectOptimisticLockingFailureException.class,
DataIntegrityViolationException.class
})
@ExceptionHandler(ObjectOptimisticLockingFailureException.class)
public ResponseEntity<ApiResponse<Void>> handlePersistenceConflict(
RuntimeException ex,
HttpServletRequest request) {

View file

@ -30,7 +30,6 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.slf4j.LoggerFactory;
import org.springframework.context.support.StaticMessageSource;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
@ -154,19 +153,15 @@ class GlobalExceptionHandlerTest {
attachAppender();
prepareClientErrorRequest("PUT", "/api/v1/skills/10/reviews/me");
for (RuntimeException exception : List.of(
new ObjectOptimisticLockingFailureException(SkillRating.class, 7L),
new DataIntegrityViolationException("duplicate key value violates constraint user-secret"))) {
ResponseEntity<ApiResponse<Void>> response = handler.handlePersistenceConflict(exception, request);
ResponseEntity<ApiResponse<Void>> response = handler.handlePersistenceConflict(
new ObjectOptimisticLockingFailureException(SkillRating.class, 7L), request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().code()).isEqualTo(409);
assertThat(response.getBody().msg()).isEqualTo("Refresh and try again");
}
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().code()).isEqualTo(409);
assertThat(response.getBody().msg()).isEqualTo("Refresh and try again");
assertThat(loggedMessages()).allSatisfy(message -> assertThat(message)
.doesNotContain("user-secret")
.doesNotContain("duplicate key"));
.doesNotContain("user-secret"));
}
@Test

View file

@ -0,0 +1,16 @@
package com.iflytek.skillhub.domain.shared.exception;
/**
* Domain exception used when a concurrent request prevents a safe state change.
*/
public class DomainConflictException extends LocalizedDomainException {
public DomainConflictException(String messageCode, Object... messageArgs) {
super(messageCode, messageArgs);
}
@Override
public int statusCode() {
return 409;
}
}

View file

@ -1,10 +1,12 @@
package com.iflytek.skillhub.domain.social;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainConflictException;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.social.event.SkillRatedEvent;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -50,8 +52,13 @@ public class SkillRatingService {
SkillRating rating = ratingRepository.findBySkillIdAndUserId(skillId, userId)
.orElseGet(() -> new SkillRating(skillId, userId, score));
rating.updateReview(score, reviewText);
SkillRating saved = ratingRepository.save(rating);
ratingRepository.flush();
SkillRating saved;
try {
saved = ratingRepository.save(rating);
ratingRepository.flush();
} catch (DataIntegrityViolationException exception) {
throw new DomainConflictException("error.request.conflict");
}
eventPublisher.publishEvent(new SkillRatedEvent(skillId, userId, score));
return saved;
}

View file

@ -1,6 +1,7 @@
package com.iflytek.skillhub.domain.social;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainConflictException;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
@ -11,6 +12,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.*;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.dao.DataIntegrityViolationException;
import java.util.Optional;
@ -118,6 +120,21 @@ class SkillRatingServiceTest {
.isInstanceOf(DomainBadRequestException.class);
}
@Test
void upsertReview_mapsConcurrentFirstInsertToConflict() {
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill()));
when(ratingRepository.findBySkillIdAndUserId(1L, "user-1")).thenReturn(Optional.empty());
when(ratingRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
doThrow(new DataIntegrityViolationException("duplicate rating"))
.when(ratingRepository).flush();
assertThatThrownBy(() -> service.upsertReview(1L, "user-1", (short) 4, "Useful"))
.isInstanceOf(DomainConflictException.class)
.hasMessage("error.request.conflict");
verify(eventPublisher, never()).publishEvent(any(SkillRatedEvent.class));
}
@Test
void clearReview_keeps_rating_row_and_score() {
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill()));