diff --git a/backend/src/main/java/org/sejongisc/backend/betting/controller/BettingController.java b/backend/src/main/java/org/sejongisc/backend/betting/controller/BettingController.java index 4ff2c154..17de4597 100644 --- a/backend/src/main/java/org/sejongisc/backend/betting/controller/BettingController.java +++ b/backend/src/main/java/org/sejongisc/backend/betting/controller/BettingController.java @@ -18,6 +18,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +import org.sejongisc.backend.betting.dto.UserBetResponse; import java.util.List; import java.util.Optional; @@ -91,12 +92,12 @@ public ResponseEntity> getAllBetRounds() { } ) @PostMapping("/user-bets") - public ResponseEntity postUserBet( + public ResponseEntity postUserBet( @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails principal, @Valid @RequestBody UserBetRequest userBetRequest) { - UserBet userBet = bettingService.postUserBet(principal.getUserId(), userBetRequest); + UserBetResponse userBet = bettingService.postUserBet(principal.getUserId(), userBetRequest); return ResponseEntity.ok(userBet); } @@ -135,11 +136,11 @@ public ResponseEntity cancelUserBet( } ) @GetMapping("/user-bets/history") - public ResponseEntity> getAllUserBets( + public ResponseEntity> getAllUserBets( @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails principal) { - List userBets = bettingService.getAllMyBets(principal.getUserId()); + List userBets = bettingService.getAllMyBets(principal.getUserId()); return ResponseEntity.ok(userBets); } } diff --git a/backend/src/main/java/org/sejongisc/backend/betting/dto/UserBetResponse.java b/backend/src/main/java/org/sejongisc/backend/betting/dto/UserBetResponse.java new file mode 100644 index 00000000..59f29870 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/betting/dto/UserBetResponse.java @@ -0,0 +1,41 @@ +package org.sejongisc.backend.betting.dto; + +import lombok.Builder; +import lombok.Getter; +import org.sejongisc.backend.betting.entity.BetOption; +import org.sejongisc.backend.betting.entity.BetStatus; +import org.sejongisc.backend.betting.entity.UserBet; + +import java.util.UUID; + +@Getter +@Builder +public class UserBetResponse { + private UUID userBetId; + private UUID betRoundId; + private String roundTitle; // BetRound의 제목 + private String symbol; // BetRound의 종목명 + private BetOption option; + private boolean isFree; + private Integer stakePoints; + private BetStatus betStatus; + private Boolean isCorrect; // 결과 (성공 여부) + private Integer earnedPoints; + + // Entity -> DTO 변환 메서드 + public static UserBetResponse from(UserBet bet) { + return UserBetResponse.builder() + .userBetId(bet.getUserBetId()) + // 여기서 bet.getRound()를 호출할 때 영속성 컨텍스트가 살아있어야 함 (Service 내부) + .betRoundId(bet.getRound().getBetRoundID()) + .roundTitle(bet.getRound().getTitle()) + .symbol(bet.getRound().getSymbol()) + .option(bet.getOption()) + .isFree(bet.isFree()) + .stakePoints(bet.getStakePoints()) + .betStatus(bet.getBetStatus()) + .isCorrect(bet.isCollect()) // boolean 타입의 Getter는 isCorrect() + .earnedPoints(bet.getPayoutPoints()) // 엔티티 필드명이 payoutPoints임 + .build(); + } +} \ No newline at end of file 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 bae72b2e..05a04e26 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 @@ -47,6 +47,7 @@ public class BetRound extends BasePostgresEntity { @Column(nullable = false) @Schema(description = "라운드 진행 상태", defaultValue = "false") + @Builder.Default private boolean status = false; // Todo : Enum 클래스로 변경 고려 @Schema(description = "베팅이 열리는 시각 (유저 참여 시작 시점)") @@ -78,16 +79,20 @@ public class BetRound extends BasePostgresEntity { // [추가] 상승(UP) 베팅 통계 @Column(nullable = false, columnDefinition = "integer default 0") + @Builder.Default private int upBetCount = 0; @Column(nullable = false) + @Builder.Default private long upTotalPoints = 0; // [추가] 하락(DOWN) 베팅 통계 @Column(nullable = false, columnDefinition = "integer default 0") + @Builder.Default private int downBetCount = 0; @Column(nullable = false) + @Builder.Default private long downTotalPoints = 0; // 라운드가 현재 진행 중인지 여부 반환 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 cea5bf70..e8c35ab4 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 @@ -17,6 +17,7 @@ import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.sejongisc.backend.betting.dto.UserBetResponse; import java.math.BigDecimal; import java.time.LocalDate; @@ -85,10 +86,16 @@ public boolean setAllowFree() { } /** - * 사용자의 전체 베팅 내역 조회 + * 사용자의 전체 베팅 내역 조회 (수정됨) */ - public List getAllMyBets(UUID userId) { - return userBetRepository.findAllByUserIdOrderByRound_SettleAtDesc(userId); + @Transactional(readOnly = true) // 트랜잭션 유지 필수 + public List getAllMyBets(UUID userId) { + List userBets = userBetRepository.findAllByUserIdOrderByRound_SettleAtDesc(userId); + + // Entity List -> DTO List 변환 + return userBets.stream() + .map(UserBetResponse::from) + .toList(); } /** @@ -128,30 +135,34 @@ public void closeBetRound() { /** * 사용자 베팅 생성 + * - 반환 타입을 UserBetResponse(DTO)로 변경하여 LazyInitializationException 방지 + * - 통계 업데이트 시 Repository의 @Modifying 쿼리를 사용하여 동시성 문제(Lost Update) 해결 */ @Transactional - public UserBet postUserBet(UUID userId, UserBetRequest userBetRequest) { + 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)) { throw new CustomException(ErrorCode.BET_DUPLICATE); } + // 3. 라운드 상태 검증 (마감 시간 등) betRound.validate(); - // [수정] 유료 베팅인 경우 베팅 포인트 설정 + // 4. 베팅 포인트(stake) 결정 int stake = userBetRequest.isFree() ? 0 : userBetRequest.getStakePoints(); - // [삭제] 기존 엔티티 메서드 호출 방식 (동시성 문제 발생) - //betRound.addBetStats(userBetRequest.getOption(), stake); - + // 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); @@ -159,14 +170,14 @@ public UserBet postUserBet(UUID userId, UserBetRequest userBetRequest) { pointHistoryService.createPointHistory( userId, - -userBetRequest.getStakePoints(), + -stake, // 포인트 차감 PointReason.BETTING, PointOrigin.BETTING, userBetRequest.getRoundId() ); - stake = userBetRequest.getStakePoints(); } + // 7. UserBet 엔티티 생성 UserBet userBet = UserBet.builder() .round(betRound) .userId(userId) @@ -176,8 +187,11 @@ public UserBet postUserBet(UUID userId, UserBetRequest userBetRequest) { .betStatus(BetStatus.ACTIVE) .build(); + // 8. 저장 및 DTO 변환 반환 try { - return userBetRepository.save(userBet); + UserBet savedBet = userBetRepository.save(userBet); + // 여기서 DTO로 변환해야 트랜잭션 내에서 betRound 정보를 안전하게 가져올 수 있음 + return UserBetResponse.from(savedBet); } catch (DataIntegrityViolationException e) { throw new CustomException(ErrorCode.BET_DUPLICATE); } diff --git a/backend/src/main/java/org/sejongisc/backend/common/config/PrimaryDataSourceConfig.java b/backend/src/main/java/org/sejongisc/backend/common/config/PrimaryDataSourceConfig.java index 4f85b0c7..e4326b52 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/config/PrimaryDataSourceConfig.java +++ b/backend/src/main/java/org/sejongisc/backend/common/config/PrimaryDataSourceConfig.java @@ -87,6 +87,7 @@ public LocalContainerEntityManagerFactoryBean primaryEntityManagerFactory( "org.sejongisc.backend.point.entity", "org.sejongisc.backend.stock.entity", "org.sejongisc.backend.template.entity", + "org.sejongisc.backend.betting.entity", "org.sejongisc.backend.user.entity" ) .persistenceUnit("primary")