Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,4 @@ public enum BetStatus {
ACTIVE,
DELETED, // 삭제
CLOSED, // 정산 완료
CANCELED // 취소
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

}
Original file line number Diff line number Diff line change
@@ -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<UserBet, UUID> {
boolean existsByRoundAndUserId(BetRound round, UUID userId);
Optional<UserBet> findByRoundAndUserId(BetRound round, UUID userId);

Optional<UserBet> findByUserBetIdAndUserId(UUID userBetId, UUID userId);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;

@Component
@Slf4j
@RequiredArgsConstructor
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -178,21 +182,25 @@ 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())
.isFree(userBetRequest.isFree())
.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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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에서 발생한 마지막 예외
Expand Down