diff --git a/backend/src/main/java/org/sejongisc/backend/betting/entity/UserBet.java b/backend/src/main/java/org/sejongisc/backend/betting/entity/UserBet.java index ebc0bc2c..8cd0dab9 100644 --- a/backend/src/main/java/org/sejongisc/backend/betting/entity/UserBet.java +++ b/backend/src/main/java/org/sejongisc/backend/betting/entity/UserBet.java @@ -54,6 +54,10 @@ public class UserBet extends BasePostgresEntity { private boolean isCorrect; + //[추가] 낙관적 락을 위한 버전 필드 + @Version + private Long version; + public void win(int reward) { this.payoutPoints = reward; this.isCorrect = true; @@ -66,4 +70,9 @@ public void lose() { this.betStatus = BetStatus.CLOSED; } + // [추가] 취소 상태 변경 메서드 + public void cancel() { + this.betStatus = BetStatus.DELETED; + } + } diff --git a/backend/src/main/java/org/sejongisc/backend/betting/repository/UserBetRepository.java b/backend/src/main/java/org/sejongisc/backend/betting/repository/UserBetRepository.java index 41e3dc42..50152b9f 100644 --- a/backend/src/main/java/org/sejongisc/backend/betting/repository/UserBetRepository.java +++ b/backend/src/main/java/org/sejongisc/backend/betting/repository/UserBetRepository.java @@ -21,8 +21,5 @@ public interface UserBetRepository extends JpaRepository { List findAllByRound(BetRound round); - // JPQL을 사용하여 원자적 업데이트 수행 - @Modifying(clearAutomatically = true) - @Query("UPDATE UserBet u SET u.betStatus = :newStatus WHERE u.userBetId = :id AND u.userId = :userId AND u.betStatus = :oldStatus") - int updateStatusToCanceled(@Param("id") UUID id, @Param("userId") UUID userId, @Param("oldStatus") BetStatus oldStatus, @Param("newStatus") BetStatus newStatus); + } diff --git a/backend/src/main/java/org/sejongisc/backend/betting/service/BettingService.java b/backend/src/main/java/org/sejongisc/backend/betting/service/BettingService.java index 4d0cd973..f9b90a51 100644 --- a/backend/src/main/java/org/sejongisc/backend/betting/service/BettingService.java +++ b/backend/src/main/java/org/sejongisc/backend/betting/service/BettingService.java @@ -18,6 +18,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.sejongisc.backend.betting.dto.UserBetResponse; +import org.springframework.orm.ObjectOptimisticLockingFailureException; // import 확인 import java.math.BigDecimal; import java.time.LocalDate; @@ -208,49 +209,56 @@ public Optional getActiveRoundResponse(Scope type) { */ @Transactional public void cancelUserBet(UUID userId, UUID userBetId) { - // 1. 먼저 베팅 정보를 조회 (검증용) - UserBet userBet = userBetRepository.findByUserBetIdAndUserId(userBetId, userId) - .orElseThrow(() -> new CustomException(ErrorCode.BET_NOT_FOUND)); - - // 2. [핵심] 상태를 ACTIVE -> CANCELED로 변경 시도 - // 이 쿼리는 동시에 여러 요청이 와도 단 하나만 1을 반환합니다. (나머지는 0) - int updatedCount = userBetRepository.updateStatusToCanceled( - userBetId, - userId, - BetStatus.ACTIVE, - BetStatus.CANCELED // Enum에 CANCELED 추가 - ); - - if (updatedCount == 0) { - // 이미 취소되었거나 처리된 베팅임 -> 중복 처리 방지 - throw new CustomException(ErrorCode.BET_ALREADY_PROCESSED); - } + try { + // 1. 엔티티 조회 (UserBet) + UserBet userBet = userBetRepository.findByUserBetIdAndUserId(userBetId, userId) + .orElseThrow(() -> new CustomException(ErrorCode.BET_NOT_FOUND)); - // 3. 상태 변경에 성공한 딱 1개의 요청만 아래 환불/통계 로직 수행 - BetRound betRound = userBet.getRound(); - betRound.validate(); + // 2. 이미 처리된 상태인지 검증 (중복 방지 1차) + if (userBet.getBetStatus() != BetStatus.ACTIVE) { + throw new CustomException(ErrorCode.BET_ALREADY_PROCESSED); + } - // 포인트 환불 - if (!userBet.isFree() && userBet.getStakePoints() > 0) { - pointHistoryService.createPointHistory( - userId, - userBet.getStakePoints(), - PointReason.BETTING, - PointOrigin.BETTING, - betRound.getBetRoundID() // 밑에서 설명할 targetId 이슈 확인 필요 - ); - } + // 3. BetRound 조회 및 검증 + // (Lazy Loading 문제 방지를 위해 ID로 다시 조회하는 기존 로직 유지 권장) + UUID roundId = userBet.getRound().getBetRoundID(); + BetRound betRound = betRoundRepository.findById(roundId) + .orElseThrow(() -> new CustomException(ErrorCode.BET_ROUND_NOT_FOUND)); + + betRound.validate(); // 마감 시간 등 체크 + + // 4. 상태 변경 (ACTIVE -> CANCELED) + // 여기서 @Version 필드 덕분에 커밋 시점에 버전 충돌 여부를 체크함 + userBet.cancel(); + userBetRepository.saveAndFlush(userBet); // 명시적 flush로 버전 충돌 즉시 감지 + + // 5. 포인트 환불 + if (!userBet.isFree() && userBet.getStakePoints() > 0) { + pointHistoryService.createPointHistory( + userId, + userBet.getStakePoints(), + PointReason.BETTING, + PointOrigin.BETTING, + betRound.getBetRoundID() // targetId 통일 (리뷰 반영) + ); + } - // 통계 차감 - int stake = userBet.getStakePoints(); - if (userBet.getOption() == BetOption.RISE) { - betRoundRepository.decrementUpStats(betRound.getBetRoundID(), stake); - } else { - betRoundRepository.decrementDownStats(betRound.getBetRoundID(), stake); + // 6. 통계 차감 + int stake = userBet.getStakePoints(); + if (userBet.getOption() == BetOption.RISE) { + betRoundRepository.decrementUpStats(betRound.getBetRoundID(), stake); + } else { + betRoundRepository.decrementDownStats(betRound.getBetRoundID(), stake); + } + + // userBetRepository.save(userBet); // Transactional이라 자동 저장되지만 명시해도 됨 + + } catch (ObjectOptimisticLockingFailureException e) { + // 동시에 취소 요청이 들어온 경우 하나만 성공하고 나머지는 여기서 걸러짐 + throw new CustomException(ErrorCode.BET_ALREADY_PROCESSED); } + } // 삭제(delete)는 하지 않음 (이력 관리를 위해) - // 삭제(delete)는 하지 않음 (이력 관리를 위해) - } /** * 베팅 결과 정산 diff --git a/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java b/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java index e6490462..2a832846 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java +++ b/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java @@ -81,7 +81,7 @@ public enum ErrorCode { BET_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 베팅을 찾을 수 없습니다."), BET_POINT_TOO_LOW(HttpStatus.CONFLICT, "베팅 포인트는 10 이상이어야 합니다."), BET_ROUND_NOT_CLOSED(HttpStatus.CONFLICT, "닫히지 않은 배팅입니다."), - BET_ALREADY_PROCESSED(HttpStatus.CONFLICT, "이미 취소 되거나 처리된 베팅입니다."), + BET_ALREADY_PROCESSED(HttpStatus.CONFLICT, "이미 취소되었거나 처리된 베팅입니다."), // BOARD