Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import jakarta.persistence.*;
import lombok.*;
import org.sejongisc.backend.common.entity.postgres.BasePostgresEntity;
import org.sejongisc.backend.common.exception.CustomException;
import org.sejongisc.backend.common.exception.ErrorCode;

import java.math.BigDecimal;
import java.time.LocalDateTime;
Expand All @@ -11,10 +13,12 @@
@Entity
@Builder
@Getter
@NoArgsConstructor @AllArgsConstructor
@NoArgsConstructor
@AllArgsConstructor
public class BetRound extends BasePostgresEntity {

@Id @GeneratedValue(strategy = GenerationType.UUID)
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(columnDefinition = "uuid")
private UUID betRoundID;

Expand All @@ -35,7 +39,7 @@ public class BetRound extends BasePostgresEntity {
private BigDecimal baseMultiplier;

@Column(nullable = false)
private boolean status; // enum 고려할 것
private boolean status = false; // Todo : Enum 클래스로 수정

private LocalDateTime openAt;

Expand All @@ -56,4 +60,49 @@ public class BetRound extends BasePostgresEntity {

@Column(precision = 15, scale = 2)
private BigDecimal settleClosePrice;

public boolean isOpen() {
return this.status;
}

public boolean isClosed() {
return !this.status;
}

public void open() {
this.status = true;
}

public void close() {
this.status = false;
}

public void validate() {
if (isClosed() || (lockAt != null && LocalDateTime.now().isAfter(lockAt))) {
throw new CustomException(ErrorCode.BET_ROUND_CLOSED);
}
}
Comment on lines +80 to +84
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

lockAt와 동일 시각 베팅 허용되는 경계 오류

LocalDateTime.now().isAfter(lockAt)만 검사하면 현재 시각이 lockAt과 정확히 같을 때는 베팅이 허용됩니다. 잠금 시간을 초 단위로 맞춰 두었다면 동일 시각에도 베팅이 들어올 수 있어 의도와 달리 마감 후 베팅이 수락되는 문제가 발생합니다. isEqual까지 포함하거나 !now.isBefore(lockAt)로 비교하여 lockAt에 도달한 순간부터는 차단되도록 보완해 주세요.

-        if (isClosed() || (lockAt != null && LocalDateTime.now().isAfter(lockAt))) {
+        if (isClosed() || (lockAt != null && !LocalDateTime.now().isBefore(lockAt))) {
             throw new CustomException(ErrorCode.BET_ROUND_CLOSED);
         }
🤖 Prompt for AI Agents
In backend/src/main/java/org/sejongisc/backend/betting/entity/BetRound.java
around lines 80 to 84, the current validation uses
LocalDateTime.now().isAfter(lockAt) which still allows bets when now equals
lockAt; change the condition to block betting at lockAt as well by comparing
using isEqual or !now.isBefore(lockAt). Update the if to compute LocalDateTime
now = LocalDateTime.now() and then check if (isClosed() || (lockAt != null &&
!now.isBefore(lockAt))) and throw the same CustomException to ensure bets are
rejected at and after lockAt.


public void settle(BigDecimal finalPrice) {
if (isOpen()) {
throw new CustomException(ErrorCode.BET_ROUND_NOT_CLOSED);
}
if (this.settleAt != null) {
return;
}
if (finalPrice == null) {
throw new IllegalArgumentException("finalPrice must not be null");
}
this.settleClosePrice = finalPrice;
this.resultOption = determineResult(finalPrice);
this.settleAt = LocalDateTime.now();
}

private BetOption determineResult(BigDecimal finalPrice) {
int compare = finalPrice.compareTo(previousClosePrice);

if (compare >= 0) return BetOption.RISE;
return BetOption.FALL;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@

public enum BetStatus {
ACTIVE,
DELETED
DELETED, // 삭제
CLOSED // 정산 완료
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public class Stock {
@Column(nullable = false, length = 100)
private String name;

@Column(nullable = false, length = 50)
@Column(nullable = false, length = 50, unique = true)
private String symbol;

@Enumerated(EnumType.STRING)
Expand All @@ -30,4 +30,7 @@ public class Stock {

@Column(precision = 15, scale = 2, nullable = false)
private BigDecimal previousClosePrice;

@Column(precision = 15, scale = 2)
private BigDecimal settleClosePrice;
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,19 @@ public class UserBet extends BasePostgresEntity {
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private BetStatus betStatus;

private boolean isCollect;

public void win(int reward) {
this.payoutPoints = reward;
this.isCollect = true;
this.betStatus = BetStatus.CLOSED;
}

public void lose() {
this.payoutPoints = 0;
this.isCollect = false;
this.betStatus = BetStatus.CLOSED;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,6 @@ public interface BetRoundRepository extends JpaRepository<BetRound, UUID> {
List<BetRound> findAllByOrderBySettleAtDesc();

List<BetRound> findByStatusTrueAndLockAtLessThanEqual(LocalDateTime now);

List<BetRound> findByStatusFalseAndSettleAtIsNullAndLockAtLessThanEqual(LocalDateTime now);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,8 @@
import org.sejongisc.backend.betting.entity.Stock;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface StockRepository extends JpaRepository<Stock, Long> {
Optional<Stock> findBySymbol(String symbol);
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@

public interface UserBetRepository extends JpaRepository<UserBet, UUID> {
boolean existsByRoundAndUserId(BetRound round, UUID userId);
Optional<UserBet> findByUserBetIdAndUserId(UUID userBetId, UUID userId);

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

List<UserBet> findAllByUserIdOrderByRound_SettleAtDesc(UUID userId);

List<UserBet> findAllByRound(BetRound round);
}
Original file line number Diff line number Diff line change
@@ -1,33 +1,49 @@
package org.sejongisc.backend.betting.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.sejongisc.backend.betting.entity.Scope;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;

@Component
@Slf4j
@RequiredArgsConstructor
public class BettingScheduler {

private final BettingService bettingService;

@Scheduled(cron = "0 0 9 * * MON-FRI")
@Scheduled(cron = "0 0 9 * * MON-FRI", zone = "Asia/Seoul")
public void dailyOpenScheduler() {
bettingService.createBetRound(Scope.DAILY);
// log.info("✅ 스케줄러 정상 작동 중: {}", LocalDateTime.now());
}

@Scheduled(cron = "0 0 9 * * MON")
@Scheduled(cron = "0 0 9 * * MON", zone = "Asia/Seoul")
public void weeklyOpenScheduler() {
bettingService.createBetRound(Scope.WEEKLY);
}

@Scheduled(cron = "0 0 22 * * MON-FRI")
@Scheduled(cron = "0 0 22 * * MON-FRI", zone = "Asia/Seoul")
public void dailyCloseScheduler() {
bettingService.closeBetRound();
}

@Scheduled(cron = "0 0 22 * * FRI")
@Scheduled(cron = "0 0 22 * * FRI", zone = "Asia/Seoul")
public void weeklyCloseScheduler() {
bettingService.closeBetRound();
}

@Scheduled(cron = "0 5 22 * * MON-FRI", zone = "Asia/Seoul")
public void dailySettleScheduler() {
bettingService.settleUserBets();
}

@Scheduled(cron = "0 5 22 * * FRI", zone = "Asia/Seoul")
public void weeklySettleScheduler() {
bettingService.settleUserBets();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
Expand All @@ -32,7 +33,7 @@ public class BettingService {

private final Random random = new Random();

public Optional<BetRound> getActiveRound(Scope type){
public Optional<BetRound> getActiveRound(Scope type) {
return betRoundRepository.findByStatusTrueAndScope(type);
}

Expand All @@ -42,7 +43,7 @@ public List<BetRound> getAllBetRounds() {
return betRoundRepository.findAllByOrderBySettleAtDesc();
}

public Stock getStock(){
public Stock getStock() {
List<Stock> stocks = stockRepository.findAll();
if (stocks.isEmpty()) {
throw new CustomException(ErrorCode.STOCK_NOT_FOUND);
Expand All @@ -51,7 +52,7 @@ public Stock getStock(){
return stocks.get(random.nextInt(stocks.size()));
}

public boolean setAllowFree(){
public boolean setAllowFree() {
return random.nextDouble() < 0.2;
}

Expand All @@ -61,28 +62,36 @@ public List<UserBet> getAllMyBets(UUID userId) {
}

public void createBetRound(Scope scope) {
Stock stock = getStock();

LocalDateTime now = LocalDateTime.now();

Stock stock = getStock();

BetRound betRound = BetRound.builder()
.scope(scope)
.title(now.toLocalDate() + " " + stock.getName() + " " + scope.name() + " 라운드")
.symbol(stock.getSymbol())
.allowFree(setAllowFree())
.status(true)
.openAt(scope.getOpenAt(now))
.lockAt(scope.getLockAt(now))
.market(stock.getMarket())
.previousClosePrice(stock.getPreviousClosePrice())
.build();

betRound.open();

betRoundRepository.save(betRound);
}

public void closeBetRound(){
// TODO : status를 false로 바꿔야함, 정산 로직 구현하면서 같이 할 것
public void closeBetRound() {
LocalDateTime now = LocalDateTime.now();
List<BetRound> toClose = betRoundRepository.findByStatusTrueAndLockAtLessThanEqual(now);
if (toClose.isEmpty()) return;
toClose.forEach(BetRound::close);
betRoundRepository.saveAll(toClose);
}


@Transactional
public UserBet postUserBet(UUID userId, UserBetRequest userBetRequest) {
BetRound betRound = betRoundRepository.findById(userBetRequest.getRoundId())
Expand All @@ -92,12 +101,7 @@ public UserBet postUserBet(UUID userId, UserBetRequest userBetRequest) {
throw new CustomException(ErrorCode.BET_DUPLICATE);
}

LocalDateTime now = LocalDateTime.now();

// 허용 구간: [openAt, lockAt)
if (now.isBefore(betRound.getOpenAt()) || !now.isBefore(betRound.getLockAt())) {
throw new CustomException(ErrorCode.BET_TIME_INVALID);
}
betRound.validate();

int stake = 0;

Expand Down Expand Up @@ -139,9 +143,7 @@ public void cancelUserBet(UUID userId, UUID userBetId) {

BetRound betRound = userBet.getRound();

if (!LocalDateTime.now().isBefore(betRound.getLockAt())){
throw new CustomException(ErrorCode.BET_ROUND_CLOSED);
}
betRound.validate();

if (!userBet.isFree() && userBet.getStakePoints() > 0) {
pointHistoryService.createPointHistory(
Expand All @@ -155,4 +157,49 @@ public void cancelUserBet(UUID userId, UUID userBetId) {

userBetRepository.delete(userBet);
}

@Transactional
public void settleUserBets() {
LocalDateTime now = LocalDateTime.now();

List<BetRound> activeRounds =
betRoundRepository.findByStatusFalseAndSettleAtIsNullAndLockAtLessThanEqual(now);

for (BetRound round : activeRounds) {
Stock stock = stockRepository.findBySymbol(round.getSymbol())
.orElseThrow(() -> new CustomException(ErrorCode.STOCK_NOT_FOUND));

BigDecimal finalPrice = stock.getSettleClosePrice();
if (finalPrice == null) {
continue;
}
round.settle(finalPrice);
betRoundRepository.save(round);

List<UserBet> userBets = userBetRepository.findAllByRound(round);

for (UserBet bet : userBets) {
if (bet.getBetStatus() != BetStatus.ACTIVE) continue;
if (bet.getOption() == round.getResultOption()) {
int reward = calculateReward(bet);
bet.win(reward);
pointHistoryService.createPointHistory(
bet.getUserId(),
reward,
PointReason.BETTING_WIN,
PointOrigin.BETTING,
round.getBetRoundID()
);
} else {
bet.lose();
}
}
userBetRepository.saveAll(userBets);
}
}

// TODO : 비율을 바탕으로한 reward 계산 로직
private int calculateReward(UserBet bet) {
return 2;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,10 @@ public enum ErrorCode {
STOCK_NOT_FOUND(HttpStatus.NOT_FOUND, "주식 종목이 존재하지 않습니다."),
BET_ROUND_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 라운드입니다."),
BET_DUPLICATE(HttpStatus.CONFLICT, "이미 이 라운드에 베팅했습니다."),
BET_TIME_INVALID(HttpStatus.CONFLICT, "베팅 가능 시간이 아닙니다."),
BET_ROUND_CLOSED(HttpStatus.CONFLICT, "베팅 가능 시간이 아닙니다."),
BET_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 베팅을 찾을 수 없습니다."),
BET_ROUND_CLOSED(HttpStatus.CONFLICT, "이미 마감된 라운드입니다."),
BET_POINT_TOO_LOW(HttpStatus.CONFLICT, "베팅 포인트는 10 이상이어야 합니다.");
BET_POINT_TOO_LOW(HttpStatus.CONFLICT, "베팅 포인트는 10 이상이어야 합니다."),
BET_ROUND_NOT_CLOSED(HttpStatus.CONFLICT, "닫히지 않은 배팅입니다.");
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

용어 일관성 문제가 있습니다.

에러 코드명은 BET_ROUND_NOT_CLOSED로 "라운드(round)"를 언급하지만, 에러 메시지는 "닫히지 않은 배팅입니다."로 "배팅(bet)"을 언급합니다.

정산 작업은 개별 배팅이 아닌 라운드 단위로 수행되므로, 메시지를 "닫히지 않은 라운드입니다." 또는 "배팅 라운드가 아직 종료되지 않았습니다."로 수정하는 것이 더 명확합니다.

다음 diff를 적용하여 용어를 일관되게 수정하세요:

-  BET_ROUND_NOT_CLOSED(HttpStatus.CONFLICT, "닫히지 않은 배팅입니다.");
+  BET_ROUND_NOT_CLOSED(HttpStatus.CONFLICT, "닫히지 않은 라운드입니다.");
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
BET_ROUND_NOT_CLOSED(HttpStatus.CONFLICT, "닫히지 않은 배팅입니다.");
BET_ROUND_NOT_CLOSED(HttpStatus.CONFLICT, "닫히지 않은 라운드입니다.");
🤖 Prompt for AI Agents
In backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java
around line 72, the enum constant BET_ROUND_NOT_CLOSED uses an inconsistent
message referencing "배팅" while the code name refers to a "라운드"; update the
message to match the enum name and domain language — e.g., change the message to
"닫히지 않은 라운드입니다." (or "배팅 라운드가 아직 종료되지 않았습니다.") so the error text consistently
refers to a round rather than a bet.


private final HttpStatus status;
private final String message;
Expand Down
Loading