Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
3f6ad35
[BE] [FIX] 라운드 통계 업데이트 벌크 연산 시 영속성 컨텍스트 초기화 옵션 제거
Yooonjeong Jan 22, 2026
d5d0efc
[BE] [FEAT] 포인트 복식부기 원장 DB 스키마 정의
Yooonjeong Jan 22, 2026
2eb1c27
[BE] [FEAT] 차/대변 분개 항목 정의 record 추가
Yooonjeong Jan 22, 2026
0752d71
[BE] [FEAT] Account, Transaction, LedgerEntry 레포지토리 정의
Yooonjeong Jan 22, 2026
e98479e
[BE] [FEAT] 복식부기 관련 ErrorCode 정의
Yooonjeong Jan 22, 2026
6fc997a
[BE] [FEAT] 계정 조회 및 생성 로직 구현
Yooonjeong Jan 22, 2026
f3c9040
[BE] [FEAT] 포인트 거래 및 분개 생성 메서드 정의
Yooonjeong Jan 22, 2026
f454695
[BE] [FEAT] 마이그레이션을 위한 계정 생성 전 사용자 조회 쿼리 작성
Yooonjeong Jan 22, 2026
fbe2489
[BE] [FEAT] 계정 생성 전 사용자 조회 메서드 구현
Yooonjeong Jan 22, 2026
3a9880a
[BE] [FEAT] 계정 생성 및 포인트 마이그레이션 로직 구현
Yooonjeong Jan 22, 2026
68bde32
[BE] [FEAT] 회원가입 시 계정 생성 및 기본 포인트 제공 로직 구현
Yooonjeong Jan 22, 2026
aac0bc9
[BE] [FEAT] 베팅 생성 시 포인트 차감 복식부기 포인트 서비스 로직으로 변경
Yooonjeong Jan 22, 2026
970d17e
[BE] [FEAT] 베팅 정산 로직 복식부기 서비스로 변경 및 보상 잔여금 트랜잭션 구현
Yooonjeong Jan 22, 2026
dcdc83b
[BE] [FEAT] 베팅 취소 복식부기 도입 및 불필요한 saveAndFlush 제거
Yooonjeong Jan 22, 2026
4dc1d64
[BE] [FEAT] LedgerEntry에도 BaseEntity 상속 추가
Yooonjeong Jan 22, 2026
7667260
[BE] [FEAT] 분개 레코드에 EntryType(차변/대변) 포함
Yooonjeong Jan 22, 2026
e358650
[BE] [FEAT] 분개 생성 시 EntryType 필드 추가
Yooonjeong Jan 22, 2026
f2e067d
[BE] [FEAT] 포인트 내역 조회용 내부 DTO 정의
Yooonjeong Jan 26, 2026
86c2b38
[BE] [FEAT] PointTransaction을 Fetch Join으로 함께 조회하는 전체 포인트 내역 조회 쿼리 구현
Yooonjeong Jan 26, 2026
38c661e
[BE] [FEAT] 리더보드 제외한 포인트 내역 조회 dto 정의
Yooonjeong Jan 26, 2026
9ad8f8b
[BE] [FEAT] 기존 서비스 삭제 및 복식부기 기반 포인트 기록 조회 메서드 추가
Yooonjeong Jan 26, 2026
4541d57
[BE] [FEAT] 리더보드 조회 api 삭제 및 포인트 내역 조회 api 수정
Yooonjeong Jan 26, 2026
4ed8f5b
[BE] [REFACTOR] 복식부기 시스템 도입에 따른 기존 포인트 테스트 코드 제거
Yooonjeong Jan 26, 2026
7ff5561
[BE] [REFACTOR] 베팅, 계정, 포인트 서비스 로직 로깅 추가
Yooonjeong Jan 26, 2026
39df16c
[BE] [FEAT] 회원가입 메서드에 @OptimisticRetry 어노테이션 추가
Yooonjeong Jan 26, 2026
93aa12b
[BE] [FEAT] 통계 직접 업데이트 시 flushAutomatically = true 옵션 추가 및 주석 업데이트
Yooonjeong Jan 26, 2026
47ac3d1
[BE] [FIX] 베팅 보상 트랜잭션에서 사용자 계정 debit으로 변경
Yooonjeong Jan 26, 2026
7e68cf2
[BE] [FIX] 복식부기 관련 Entity nullable false 속성 추가
Yooonjeong Jan 26, 2026
74e3624
[BE] [REFACTOR] balance 컬럼 primitive 타입으로 변경 및 null 체크 추가
Yooonjeong Jan 26, 2026
835a3b1
[BE] [FEAT] OAUTH 회원가입 시에도 계정 생성 및 포인트 지급 처리 추가
Yooonjeong Jan 28, 2026
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 @@ -21,23 +21,46 @@ public interface BetRoundRepository extends JpaRepository<BetRound, UUID> {

List<BetRound> findByStatusFalseAndSettleAtIsNullAndLockAtLessThanEqual(LocalDateTime now);

// [추가] 상승(UP) 통계 원자적 업데이트
@Modifying(clearAutomatically = true) // 쿼리 실행 후 영속성 컨텍스트 초기화 (데이터 동기화)
@Query("UPDATE BetRound b SET b.upBetCount = b.upBetCount + 1, b.upTotalPoints = b.upTotalPoints + :points WHERE b.betRoundID = :id")
/**
* 상승(UP) 통계 원자적 업데이트
* - flushAutomatically: 해당 메서드 호출 전 변경사항을 DB에 flush -> 데이터 유실 방지
* - clearAutomatically: 업데이트 후 1차 캐시를 비움 -> 조회 시 데이터 정합성 문제 방지
* -> 이후 필요하다면 DB의 최신값 조회 필요
*/
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query(
"UPDATE BetRound b " +
"SET b.upBetCount = b.upBetCount + 1, b.upTotalPoints = b.upTotalPoints + :points " +
"WHERE b.betRoundID = :id")
void incrementUpStats(@Param("id") UUID id, @Param("points") long points);

// [추가] 하락(DOWN) 통계 원자적 업데이트
@Modifying(clearAutomatically = true)
@Query("UPDATE BetRound b SET b.downBetCount = b.downBetCount + 1, b.downTotalPoints = b.downTotalPoints + :points WHERE b.betRoundID = :id")
/**
* 하락(DOWN) 통계 원자적 업데이트
*/
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query(
"UPDATE BetRound b " +
"SET b.downBetCount = b.downBetCount + 1, b.downTotalPoints = b.downTotalPoints + :points " +
"WHERE b.betRoundID = :id")
void incrementDownStats(@Param("id") UUID id, @Param("points") long points);

// [추가] 상승(UP) 통계 감소 (취소 시)
@Modifying(clearAutomatically = true)
@Query("UPDATE BetRound b SET b.upBetCount = b.upBetCount - 1, b.upTotalPoints = b.upTotalPoints - :points WHERE b.betRoundID = :id")
/**
* 상승(UP) 통계 감소 (취소 시)
*/
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query(
"UPDATE BetRound b " +
"SET b.upBetCount = b.upBetCount - 1, b.upTotalPoints = b.upTotalPoints - :points " +
"WHERE b.betRoundID = :id")
void decrementUpStats(@Param("id") UUID id, @Param("points") long points);

// [추가] 하락(DOWN) 통계 감소 (취소 시)
@Modifying(clearAutomatically = true)
@Query("UPDATE BetRound b SET b.downBetCount = b.downBetCount - 1, b.downTotalPoints = b.downTotalPoints - :points WHERE b.betRoundID = :id")
/**
* 하락(DOWN) 통계 감소 (취소 시)
*/
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query(
"UPDATE BetRound b " +
"SET b.downBetCount = b.downBetCount - 1, b.downTotalPoints = b.downTotalPoints - :points " +
"WHERE b.betRoundID = :id")
void decrementDownStats(@Param("id") UUID id, @Param("points") long points);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.sejongisc.backend.betting.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.sejongisc.backend.betting.dto.BetRoundResponse;
import org.sejongisc.backend.betting.dto.PriceResponse;
import org.sejongisc.backend.betting.dto.UserBetRequest;
Expand All @@ -10,9 +11,12 @@
import org.sejongisc.backend.common.exception.CustomException;
import org.sejongisc.backend.common.exception.ErrorCode;
import org.sejongisc.backend.common.annotation.OptimisticRetry;
import org.sejongisc.backend.point.entity.PointOrigin;
import org.sejongisc.backend.point.entity.PointReason;
import org.sejongisc.backend.point.service.PointHistoryService;
import org.sejongisc.backend.point.dto.AccountEntry;
import org.sejongisc.backend.point.entity.Account;
import org.sejongisc.backend.point.entity.AccountName;
import org.sejongisc.backend.point.entity.TransactionReason;
import org.sejongisc.backend.point.service.AccountService;
import org.sejongisc.backend.point.service.PointLedgerService;
import org.sejongisc.backend.stock.entity.PriceData;
import org.sejongisc.backend.stock.repository.PriceDataRepository;
import org.springframework.dao.DataIntegrityViolationException;
Expand All @@ -25,13 +29,15 @@
import java.util.*;
import java.util.stream.Collectors;

@Slf4j
@Service
@RequiredArgsConstructor
public class BettingService {

private final BetRoundRepository betRoundRepository;
private final UserBetRepository userBetRepository;
private final PointHistoryService pointHistoryService;
private final AccountService accountService;
private final PointLedgerService pointLedgerService;
private final PriceDataRepository priceDataRepository;

private final Random random = new Random();
Expand All @@ -52,6 +58,7 @@ public List<BetRound> getAllBetRounds() {
public PriceResponse getPriceData() {
List<PriceData> allData = priceDataRepository.findAll();
if (allData.isEmpty()) {
log.error("시세 데이터 조회 실패: DB에 저장된 시세 데이터가 없습니다.");
throw new CustomException(ErrorCode.STOCK_NOT_FOUND);
}

Expand Down Expand Up @@ -116,6 +123,7 @@ public void createBetRound(Scope scope) {

betRound.open();
betRoundRepository.save(betRound);
log.info("베팅 라운드 생성 완료: roundId={}, symbol={}", betRound.getBetRoundID(), betRound.getSymbol());
}

/**
Expand Down Expand Up @@ -156,6 +164,7 @@ public UserBetResponse postUserBet(UUID userId, UserBetRequest userBetRequest) {

// 중복 베팅 존재 여부 검증
if (existingBet != null && existingBet.getBetStatus() != BetStatus.DELETED) {
log.warn("베팅 등록 실패: 이미 베팅에 참여한 사용자입니다. userId={}, roundId={}", userId, userBetRequest.getRoundId());
throw new CustomException(ErrorCode.BET_DUPLICATE);
}

Expand All @@ -169,14 +178,13 @@ public UserBetResponse postUserBet(UUID userId, UserBetRequest userBetRequest) {
betRoundRepository.incrementDownStats(betRound.getBetRoundID(), stake);
}

// 포인트 차감 및 이력 생성 (유료 베팅인 경우)
if (!userBetRequest.isFree()) {
pointHistoryService.createPointHistory(
userId,
-stake, // 포인트 차감
PointReason.BETTING,
PointOrigin.BETTING,
userBetRequest.getRoundId()
// 사용자 포인트 차감 및 이력 생성 (유료 베팅인 경우)
if (!userBetRequest.isFree() && stake > 0) {
pointLedgerService.processTransaction(
TransactionReason.BETTING_STAKE,
userBetRequest.getRoundId(),
AccountEntry.credit(accountService.getUserAccount(userId), (long) stake),
AccountEntry.debit(accountService.getAccountByName(AccountName.BETTING_POOL), (long) stake)
);
}

Expand All @@ -198,8 +206,11 @@ public UserBetResponse postUserBet(UUID userId, UserBetRequest userBetRequest) {
}

try {
return UserBetResponse.from(userBetRepository.save(userBet));
UserBet savedBet = userBetRepository.save(userBet);
log.info("사용자 베팅 완료: userId={}, roundId={}, stake={}", userId, userBetRequest.getRoundId(), stake);
return UserBetResponse.from(savedBet);
} catch (DataIntegrityViolationException e) {
log.error("베팅 등록 실패: 이미 등록된 베팅 정보와 충돌이 발생했습니다. userId={}, roundId={}", userId, userBetRequest.getRoundId());
throw new CustomException(ErrorCode.BET_DUPLICATE);
}
}
Expand All @@ -222,38 +233,40 @@ public void cancelUserBet(UUID userId, UUID userBetId) {
// fetch join으로 UserBet 및 BetRound 조회
UserBet userBet = userBetRepository.findByUserBetIdAndUserIdWithRound(userBetId, userId)
.orElseThrow(() -> new CustomException(ErrorCode.BET_NOT_FOUND));
BetRound betRound = userBet.getRound();

// 이미 처리된 상태인지 검증
if (userBet.getBetStatus() != BetStatus.ACTIVE) {
log.warn("베팅 취소 실패: 이미 처리되었거나 취소된 베팅입니다. userBetId={}", userBetId);
throw new CustomException(ErrorCode.BET_ALREADY_PROCESSED);
}

BetRound betRound = userBet.getRound();
// 베팅 가능한 라운드 상태인지 검증
betRound.validate();

int stake = userBet.getStakePoints();
UUID roundId = betRound.getBetRoundID();

// 상태 변경 (ACTIVE -> DELETED)
userBet.cancel();
userBetRepository.saveAndFlush(userBet);

// 포인트 환불
if (!userBet.isFree() && userBet.getStakePoints() > 0) {
pointHistoryService.createPointHistory(
userId,
userBet.getStakePoints(),
PointReason.BETTING,
PointOrigin.BETTING,
betRound.getBetRoundID()

// 사용자 포인트 환불
if (!userBet.isFree() && stake > 0) {
pointLedgerService.processTransaction(
TransactionReason.BETTING_CANCEL,
roundId,
AccountEntry.credit(accountService.getAccountByName(AccountName.BETTING_POOL), (long) stake),
AccountEntry.debit(accountService.getUserAccount(userId), (long) stake)
);
}

// 통계 차감
int stake = userBet.getStakePoints();
if (userBet.getOption() == BetOption.RISE) {
betRoundRepository.decrementUpStats(betRound.getBetRoundID(), stake);
betRoundRepository.decrementUpStats(roundId, stake);
} else {
betRoundRepository.decrementDownStats(betRound.getBetRoundID(), stake);
betRoundRepository.decrementDownStats(roundId, stake);
}
log.info("사용자 베팅 취소 완료: userId={}, userBetId={}", userId, userBetId);
}


Expand All @@ -264,6 +277,8 @@ public void cancelUserBet(UUID userId, UUID userBetId) {
@OptimisticRetry
public void settleUserBets() {
LocalDateTime now = LocalDateTime.now();
Account poolAccount = accountService.getAccountByName(AccountName.BETTING_POOL);
Account systemAccount = accountService.getAccountByName(AccountName.SYSTEM_ISSUANCE);

// 정산 대상 활성 라운드 조회
List<BetRound> activeRounds =
Expand All @@ -279,7 +294,10 @@ public void settleUserBets() {
for (BetRound round : activeRounds) {
// PriceData를 이용해 시세 조회
Optional<PriceData> priceOpt = priceDataRepository.findTopByTickerOrderByDateDesc(round.getSymbol());
if (priceOpt.isEmpty()) continue;
if (priceOpt.isEmpty()) {
log.warn("베팅 라운드 정산 실패: 시세 정보 누락으로 정산이 불가능합니다. symbol={}, roundId={}", round.getSymbol(), round.getBetRoundID());
continue;
}

PriceData price = priceOpt.get();
BigDecimal finalPrice = price.getAdjustedClose();
Expand All @@ -292,38 +310,67 @@ public void settleUserBets() {
// 현재 라운드의 베팅 리스트
List<UserBet> userBets = betMap.getOrDefault(round.getBetRoundID(), Collections.emptyList());
BetOption resultOption = round.getResultOption();
// 해당 라운드에서 나갈 포인트 합
long totalStake = 0;

for (UserBet bet : userBets) {
if (bet.getBetStatus() != BetStatus.ACTIVE) continue;

Account userAccount = accountService.getUserAccount(bet.getUserId());

if (round.isDraw()) {
// 가격 변동이 없을 시 참여자 전원 원금 환불
if (!bet.isFree() && bet.getStakePoints() > 0) {
pointHistoryService.createPointHistory(
bet.getUserId(),
bet.getStakePoints(),
PointReason.BETTING,
PointOrigin.BETTING,
round.getBetRoundID()
pointLedgerService.processTransaction(
TransactionReason.BETTING_REFUND,
round.getBetRoundID(),
AccountEntry.credit(poolAccount, (long) bet.getStakePoints()),
AccountEntry.debit(userAccount, (long) bet.getStakePoints())
);
totalStake += bet.getStakePoints();
}
bet.draw();
} else if (bet.getOption() == resultOption) {
// 예측 성공 시 보상 포인트 지급
int reward = calculateReward(bet);
bet.win(reward);
pointHistoryService.createPointHistory(
bet.getUserId(),
reward,
PointReason.BETTING_WIN,
PointOrigin.BETTING,
round.getBetRoundID()
);

if (bet.isFree()) {
// 무료 베팅: 시스템 -> 사용자 보상 지급
pointLedgerService.processTransaction(
TransactionReason.BETTING_REWARD,
round.getBetRoundID(),
AccountEntry.credit(systemAccount, (long) reward),
AccountEntry.debit(userAccount, (long) reward)
);
}
else {
pointLedgerService.processTransaction(
TransactionReason.BETTING_REWARD,
round.getBetRoundID(),
AccountEntry.credit(poolAccount, (long) reward),
AccountEntry.debit(userAccount, (long) reward)
);
totalStake += reward;
}
} else {
// 예측 실패 시 포인트 소멸
bet.lose();
}
}

// 보상 소수점 처리 후 잔여금: 베팅 풀 -> 시스템 게정으로 이동
long residual = round.getUpTotalPoints() + round.getDownTotalPoints() - totalStake;

if (residual > 0) {
pointLedgerService.processTransaction(
TransactionReason.BETTING_RESIDUAL,
round.getBetRoundID(),
AccountEntry.credit(accountService.getAccountByName(AccountName.BETTING_POOL), residual),
AccountEntry.debit(accountService.getAccountByName(AccountName.SYSTEM_ISSUANCE), residual)
);
}
log.info("베팅 라운드 정산 완료: roundId={}, residual={}", round.getBetRoundID(), residual);
}
}

Expand Down Expand Up @@ -354,7 +401,7 @@ private int calculateReward(UserBet bet) {
// 배당률 계산: 내 베팅액 * (전체 포인트 / 정답 측 포인트 합)
double multiplier = (double) total / winning;

// 소수점 floor -> TODO: 복식부기 도입 시 남는 포인트는 시스템으로 이동시키기
// 소수점 floor -> 남는 포인트는 시스템으로 이동됨
return (int) Math.floor(bet.getStakePoints() * multiplier);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ public enum ErrorCode {

CONCURRENT_UPDATE(HttpStatus.CONFLICT, "동시성 업데이트에 실패했습니다. 다시 시도해주세요."),

ACCOUNT_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 포인트 계정을 찾을 수 없습니다."),

ACCOUNT_REQUIRED(HttpStatus.BAD_REQUEST, "계정 정보는 필수입니다."),

POINT_TRANSACTION_TOTAL_MISMATCH(HttpStatus.BAD_REQUEST, "포인트 거래 내역의 합계가 0이 아닙니다."),

// AUTH

UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "인증에 실패했습니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,10 @@ public class PointHistoryController {
)
public ResponseEntity<PointHistoryResponse> getPointHistory(@RequestParam int pageNumber, @RequestParam int pageSize,
@AuthenticationPrincipal CustomUserDetails customUserDetails) {
return ResponseEntity.ok(pointHistoryService.getPointHistoryListByUserId(
return ResponseEntity.ok(pointHistoryService.getPointHistory(
customUserDetails.getUserId(), PageRequest.of(pageNumber, pageSize))
);
}

@GetMapping("/leaderboard")
@Operation(
summary = "포인트 리더보드 조회",
description = "지정된 기간 동안의 포인트 리더보드를 조회합니다. 기간은 일간, 주간, 월간 단위의 요청이 가능합니다. ex) /leaderboard?period=1 or 7 or 30"
)
public ResponseEntity<PointHistoryResponse> getPointLeaderboard(@RequestParam int period) {
return ResponseEntity.ok(pointHistoryService.getPointLeaderboard(period));
}
// TODO: 리더보드 조회 API 추가
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package org.sejongisc.backend.point.dto;

import org.sejongisc.backend.common.exception.CustomException;
import org.sejongisc.backend.common.exception.ErrorCode;
import org.sejongisc.backend.point.entity.Account;
import org.sejongisc.backend.point.entity.EntryType;

public record AccountEntry(
Account account,
Long amount,
EntryType entryType
) {
public AccountEntry {
if (account == null) {
throw new CustomException(ErrorCode.ACCOUNT_REQUIRED);
}
if (amount == null || amount == 0) {
throw new CustomException(ErrorCode.INVALID_POINT_AMOUNT);
}
}

/**
* 차변 항목 생성
* 해당 계정에 잔액이 증가할 때 사용
*/
public static AccountEntry debit(Account account, Long amount) {
return new AccountEntry(account, Math.abs(amount), EntryType.DEBIT);
}

/**
* 대변 항목 생성
* 해당 계정에 잔액이 감소할 때 사용
*/
public static AccountEntry credit(Account account, Long amount) {
return new AccountEntry(account, -Math.abs(amount), EntryType.CREDIT);
}
}
Loading