diff --git a/backend/src/main/java/org/sejongisc/backend/betting/entity/BetRound.java b/backend/src/main/java/org/sejongisc/backend/betting/entity/BetRound.java index 05a04e26..b0c9e510 100644 --- a/backend/src/main/java/org/sejongisc/backend/betting/entity/BetRound.java +++ b/backend/src/main/java/org/sejongisc/backend/betting/entity/BetRound.java @@ -126,7 +126,7 @@ public BigDecimal getEstimatedRewardMultiplier(BetOption option) { return BigDecimal.valueOf((double) totalPool / optionPool); } - // "베팅 가능한 상태인지 검증 + // 베팅 가능한 상태인지 검증 public void validate() { if (isClosed() || (lockAt != null && LocalDateTime.now().isAfter(lockAt))) { throw new CustomException(ErrorCode.BET_ROUND_CLOSED); diff --git a/backend/src/main/java/org/sejongisc/backend/betting/entity/BetStatus.java b/backend/src/main/java/org/sejongisc/backend/betting/entity/BetStatus.java index 17347582..6694220a 100644 --- a/backend/src/main/java/org/sejongisc/backend/betting/entity/BetStatus.java +++ b/backend/src/main/java/org/sejongisc/backend/betting/entity/BetStatus.java @@ -4,5 +4,4 @@ public enum BetStatus { ACTIVE, DELETED, // 삭제 CLOSED, // 정산 완료 - CANCELED // 취소 } \ No newline at end of file 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 8cd0dab9..6f1810c6 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 @@ -70,9 +70,19 @@ public void lose() { this.betStatus = BetStatus.CLOSED; } - // [추가] 취소 상태 변경 메서드 + // 취소 상태 변경 메서드 public void cancel() { this.betStatus = BetStatus.DELETED; } + // 재베팅 처리 + public void updateBet(BetOption option, Integer stakePoints, boolean isFree) { + this.option = option; + this.stakePoints = stakePoints; + this.isFree = isFree; + this.betStatus = BetStatus.ACTIVE; + this.isCorrect = false; + this.payoutPoints = null; + } + } 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 50152b9f..30330a55 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 @@ -1,19 +1,15 @@ package org.sejongisc.backend.betting.repository; import org.sejongisc.backend.betting.entity.BetRound; -import org.sejongisc.backend.betting.entity.BetStatus; import org.sejongisc.backend.betting.entity.UserBet; 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 java.util.List; import java.util.Optional; import java.util.UUID; public interface UserBetRepository extends JpaRepository { - boolean existsByRoundAndUserId(BetRound round, UUID userId); + Optional findByRoundAndUserId(BetRound round, UUID userId); Optional findByUserBetIdAndUserId(UUID userBetId, UUID userId); diff --git a/backend/src/main/java/org/sejongisc/backend/betting/service/BettingScheduler.java b/backend/src/main/java/org/sejongisc/backend/betting/service/BettingScheduler.java index 4d9d4260..2adb673a 100644 --- a/backend/src/main/java/org/sejongisc/backend/betting/service/BettingScheduler.java +++ b/backend/src/main/java/org/sejongisc/backend/betting/service/BettingScheduler.java @@ -6,8 +6,6 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; -import java.time.LocalDateTime; - @Component @Slf4j @RequiredArgsConstructor 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 f9b90a51..c8a8405c 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 @@ -138,32 +138,36 @@ public void closeBetRound() { * 사용자 베팅 생성 * - 반환 타입을 UserBetResponse(DTO)로 변경하여 LazyInitializationException 방지 * - 통계 업데이트 시 Repository의 @Modifying 쿼리를 사용하여 동시성 문제(Lost Update) 해결 + * - 취소 이력이 있는 베팅도 베팅 가능한 상태에 한하여 재배팅 가능 */ @Transactional public UserBetResponse postUserBet(UUID userId, UserBetRequest userBetRequest) { - // 1. 라운드 조회 + // 라운드 조회 BetRound betRound = betRoundRepository.findById(userBetRequest.getRoundId()) .orElseThrow(() -> new CustomException(ErrorCode.BET_ROUND_NOT_FOUND)); - // 2. 중복 베팅 검증 - if (userBetRepository.existsByRoundAndUserId(betRound, userId)) { + UserBet existingBet = userBetRepository.findByRoundAndUserId(betRound, userId) + .orElse(null); + + // 중복 베팅 존재 여부 검증 + if (existingBet != null && existingBet.getBetStatus() != BetStatus.DELETED) { throw new CustomException(ErrorCode.BET_DUPLICATE); } - // 3. 라운드 상태 검증 (마감 시간 등) + // 베팅 가능한 라운드 상태인지 검증 betRound.validate(); - // 4. 베팅 포인트(stake) 결정 + // 베팅 포인트 결정 int stake = userBetRequest.isFree() ? 0 : userBetRequest.getStakePoints(); - // 5. 라운드 통계 업데이트 (동시성 해결: DB 직접 업데이트) + // 라운드 통계 업데이트 if (userBetRequest.getOption() == BetOption.RISE) { betRoundRepository.incrementUpStats(betRound.getBetRoundID(), stake); } else { betRoundRepository.incrementDownStats(betRound.getBetRoundID(), stake); } - // 6. 포인트 차감 및 이력 생성 (유료 베팅인 경우) + // 포인트 차감 및 이력 생성 (유료 베팅인 경우) if (!userBetRequest.isFree()) { if (!userBetRequest.isStakePointsValid()) { throw new CustomException(ErrorCode.BET_POINT_TOO_LOW); @@ -178,8 +182,14 @@ public UserBetResponse postUserBet(UUID userId, UserBetRequest userBetRequest) { ); } - // 7. UserBet 엔티티 생성 - UserBet userBet = UserBet.builder() + // 기존 베팅 존재 시 재베팅, 없으면 생성 + UserBet userBet; + if (existingBet != null) { + userBet = existingBet; + userBet.updateBet(userBetRequest.getOption(), stake, userBetRequest.isFree()); + } + else { + userBet = UserBet.builder() .round(betRound) .userId(userId) .option(userBetRequest.getOption()) @@ -187,12 +197,10 @@ public UserBetResponse postUserBet(UUID userId, UserBetRequest userBetRequest) { .stakePoints(stake) .betStatus(BetStatus.ACTIVE) .build(); + } - // 8. 저장 및 DTO 변환 반환 try { - UserBet savedBet = userBetRepository.save(userBet); - // 여기서 DTO로 변환해야 트랜잭션 내에서 betRound 정보를 안전하게 가져올 수 있음 - return UserBetResponse.from(savedBet); + return UserBetResponse.from(userBetRepository.save(userBet)); } catch (DataIntegrityViolationException e) { throw new CustomException(ErrorCode.BET_DUPLICATE); } diff --git a/backend/src/main/java/org/sejongisc/backend/common/auth/config/SecurityConfig.java b/backend/src/main/java/org/sejongisc/backend/common/auth/config/SecurityConfig.java index fb311ce7..0f573b7a 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/auth/config/SecurityConfig.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/config/SecurityConfig.java @@ -106,7 +106,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/oauth2/**" ).permitAll(); - auth.requestMatchers("/api/user/**").authenticated(); + auth.requestMatchers( + "/api/user/**", + "/api/user-bets/**" + ).authenticated(); auth.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() // .anyRequest().authenticated(); diff --git a/backend/src/main/java/org/sejongisc/backend/point/service/PointHistoryService.java b/backend/src/main/java/org/sejongisc/backend/point/service/PointHistoryService.java index af992fae..d48dadc9 100644 --- a/backend/src/main/java/org/sejongisc/backend/point/service/PointHistoryService.java +++ b/backend/src/main/java/org/sejongisc/backend/point/service/PointHistoryService.java @@ -83,6 +83,16 @@ public PointHistory createPointHistory(UUID userId, int amount, PointReason reas return pointHistoryRepository.save(history); } + /** + * OptimisticLockingFailureException가 아닌 CustomException이 발생했을 때 재시도 없이 예외를 던집니다. + * @param e @Retryable에서 발생한 예외 + */ + @Recover + public PointHistory recover(CustomException e) { + log.warn("포인트 업데이트 중 비즈니스 로직 예외 발생: {}", e.getMessage()); + throw e; + } + /** * @Retryable에서 모든 재시도를 실패했을 때 호출될 메서드입니다. * @param e @Retryable에서 발생한 마지막 예외