Skip to content

Commit 6ce9ba5

Browse files
authored
[BE] [FEAT] 복식부기 기반 포인트 원장 시스템 도입 (#200)
* [BE] [FIX] 라운드 통계 업데이트 벌크 연산 시 영속성 컨텍스트 초기화 옵션 제거 * [BE] [FEAT] 포인트 복식부기 원장 DB 스키마 정의 * [BE] [FEAT] 차/대변 분개 항목 정의 record 추가 * [BE] [FEAT] Account, Transaction, LedgerEntry 레포지토리 정의 * [BE] [FEAT] 복식부기 관련 ErrorCode 정의 * [BE] [FEAT] 계정 조회 및 생성 로직 구현 * [BE] [FEAT] 포인트 거래 및 분개 생성 메서드 정의 * [BE] [FEAT] 마이그레이션을 위한 계정 생성 전 사용자 조회 쿼리 작성 * [BE] [FEAT] 계정 생성 전 사용자 조회 메서드 구현 * [BE] [FEAT] 계정 생성 및 포인트 마이그레이션 로직 구현 * [BE] [FEAT] 회원가입 시 계정 생성 및 기본 포인트 제공 로직 구현 * [BE] [FEAT] 베팅 생성 시 포인트 차감 복식부기 포인트 서비스 로직으로 변경 * [BE] [FEAT] 베팅 정산 로직 복식부기 서비스로 변경 및 보상 잔여금 트랜잭션 구현 * [BE] [FEAT] 베팅 취소 복식부기 도입 및 불필요한 saveAndFlush 제거 * [BE] [FEAT] LedgerEntry에도 BaseEntity 상속 추가 * [BE] [FEAT] 분개 레코드에 EntryType(차변/대변) 포함 * [BE] [FEAT] 분개 생성 시 EntryType 필드 추가 * [BE] [FEAT] 포인트 내역 조회용 내부 DTO 정의 * [BE] [FEAT] PointTransaction을 Fetch Join으로 함께 조회하는 전체 포인트 내역 조회 쿼리 구현 * [BE] [FEAT] 리더보드 제외한 포인트 내역 조회 dto 정의 * [BE] [FEAT] 기존 서비스 삭제 및 복식부기 기반 포인트 기록 조회 메서드 추가 * [BE] [FEAT] 리더보드 조회 api 삭제 및 포인트 내역 조회 api 수정 * [BE] [REFACTOR] 복식부기 시스템 도입에 따른 기존 포인트 테스트 코드 제거 * [BE] [REFACTOR] 베팅, 계정, 포인트 서비스 로직 로깅 추가 * [BE] [FEAT] 회원가입 메서드에 @OptimisticRetry 어노테이션 추가 * [BE] [FEAT] 통계 직접 업데이트 시 flushAutomatically = true 옵션 추가 및 주석 업데이트 * [BE] [FIX] 베팅 보상 트랜잭션에서 사용자 계정 debit으로 변경 * [BE] [FIX] 복식부기 관련 Entity nullable false 속성 추가 * [BE] [REFACTOR] balance 컬럼 primitive 타입으로 변경 및 null 체크 추가 * [BE] [FEAT] OAUTH 회원가입 시에도 계정 생성 및 포인트 지급 처리 추가
1 parent c28e1b6 commit 6ce9ba5

26 files changed

Lines changed: 938 additions & 411 deletions

backend/src/main/java/org/sejongisc/backend/betting/repository/BetRoundRepository.java

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,23 +21,46 @@ public interface BetRoundRepository extends JpaRepository<BetRound, UUID> {
2121

2222
List<BetRound> findByStatusFalseAndSettleAtIsNullAndLockAtLessThanEqual(LocalDateTime now);
2323

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

29-
// [추가] 하락(DOWN) 통계 원자적 업데이트
30-
@Modifying(clearAutomatically = true)
31-
@Query("UPDATE BetRound b SET b.downBetCount = b.downBetCount + 1, b.downTotalPoints = b.downTotalPoints + :points WHERE b.betRoundID = :id")
37+
/**
38+
* 하락(DOWN) 통계 원자적 업데이트
39+
*/
40+
@Modifying(flushAutomatically = true, clearAutomatically = true)
41+
@Query(
42+
"UPDATE BetRound b " +
43+
"SET b.downBetCount = b.downBetCount + 1, b.downTotalPoints = b.downTotalPoints + :points " +
44+
"WHERE b.betRoundID = :id")
3245
void incrementDownStats(@Param("id") UUID id, @Param("points") long points);
3346

34-
// [추가] 상승(UP) 통계 감소 (취소 시)
35-
@Modifying(clearAutomatically = true)
36-
@Query("UPDATE BetRound b SET b.upBetCount = b.upBetCount - 1, b.upTotalPoints = b.upTotalPoints - :points WHERE b.betRoundID = :id")
47+
/**
48+
* 상승(UP) 통계 감소 (취소 시)
49+
*/
50+
@Modifying(flushAutomatically = true, clearAutomatically = true)
51+
@Query(
52+
"UPDATE BetRound b " +
53+
"SET b.upBetCount = b.upBetCount - 1, b.upTotalPoints = b.upTotalPoints - :points " +
54+
"WHERE b.betRoundID = :id")
3755
void decrementUpStats(@Param("id") UUID id, @Param("points") long points);
3856

39-
// [추가] 하락(DOWN) 통계 감소 (취소 시)
40-
@Modifying(clearAutomatically = true)
41-
@Query("UPDATE BetRound b SET b.downBetCount = b.downBetCount - 1, b.downTotalPoints = b.downTotalPoints - :points WHERE b.betRoundID = :id")
57+
/**
58+
* 하락(DOWN) 통계 감소 (취소 시)
59+
*/
60+
@Modifying(flushAutomatically = true, clearAutomatically = true)
61+
@Query(
62+
"UPDATE BetRound b " +
63+
"SET b.downBetCount = b.downBetCount - 1, b.downTotalPoints = b.downTotalPoints - :points " +
64+
"WHERE b.betRoundID = :id")
4265
void decrementDownStats(@Param("id") UUID id, @Param("points") long points);
4366
}

backend/src/main/java/org/sejongisc/backend/betting/service/BettingService.java

Lines changed: 89 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package org.sejongisc.backend.betting.service;
22

33
import lombok.RequiredArgsConstructor;
4+
import lombok.extern.slf4j.Slf4j;
45
import org.sejongisc.backend.betting.dto.BetRoundResponse;
56
import org.sejongisc.backend.betting.dto.PriceResponse;
67
import org.sejongisc.backend.betting.dto.UserBetRequest;
@@ -10,9 +11,12 @@
1011
import org.sejongisc.backend.common.exception.CustomException;
1112
import org.sejongisc.backend.common.exception.ErrorCode;
1213
import org.sejongisc.backend.common.annotation.OptimisticRetry;
13-
import org.sejongisc.backend.point.entity.PointOrigin;
14-
import org.sejongisc.backend.point.entity.PointReason;
15-
import org.sejongisc.backend.point.service.PointHistoryService;
14+
import org.sejongisc.backend.point.dto.AccountEntry;
15+
import org.sejongisc.backend.point.entity.Account;
16+
import org.sejongisc.backend.point.entity.AccountName;
17+
import org.sejongisc.backend.point.entity.TransactionReason;
18+
import org.sejongisc.backend.point.service.AccountService;
19+
import org.sejongisc.backend.point.service.PointLedgerService;
1620
import org.sejongisc.backend.stock.entity.PriceData;
1721
import org.sejongisc.backend.stock.repository.PriceDataRepository;
1822
import org.springframework.dao.DataIntegrityViolationException;
@@ -25,13 +29,15 @@
2529
import java.util.*;
2630
import java.util.stream.Collectors;
2731

32+
@Slf4j
2833
@Service
2934
@RequiredArgsConstructor
3035
public class BettingService {
3136

3237
private final BetRoundRepository betRoundRepository;
3338
private final UserBetRepository userBetRepository;
34-
private final PointHistoryService pointHistoryService;
39+
private final AccountService accountService;
40+
private final PointLedgerService pointLedgerService;
3541
private final PriceDataRepository priceDataRepository;
3642

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

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

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

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

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

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

172-
// 포인트 차감 및 이력 생성 (유료 베팅인 경우)
173-
if (!userBetRequest.isFree()) {
174-
pointHistoryService.createPointHistory(
175-
userId,
176-
-stake, // 포인트 차감
177-
PointReason.BETTING,
178-
PointOrigin.BETTING,
179-
userBetRequest.getRoundId()
181+
// 사용자 포인트 차감 및 이력 생성 (유료 베팅인 경우)
182+
if (!userBetRequest.isFree() && stake > 0) {
183+
pointLedgerService.processTransaction(
184+
TransactionReason.BETTING_STAKE,
185+
userBetRequest.getRoundId(),
186+
AccountEntry.credit(accountService.getUserAccount(userId), (long) stake),
187+
AccountEntry.debit(accountService.getAccountByName(AccountName.BETTING_POOL), (long) stake)
180188
);
181189
}
182190

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

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

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

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

247+
int stake = userBet.getStakePoints();
248+
UUID roundId = betRound.getBetRoundID();
249+
235250
// 상태 변경 (ACTIVE -> DELETED)
236251
userBet.cancel();
237-
userBetRepository.saveAndFlush(userBet);
238-
239-
// 포인트 환불
240-
if (!userBet.isFree() && userBet.getStakePoints() > 0) {
241-
pointHistoryService.createPointHistory(
242-
userId,
243-
userBet.getStakePoints(),
244-
PointReason.BETTING,
245-
PointOrigin.BETTING,
246-
betRound.getBetRoundID()
252+
253+
// 사용자 포인트 환불
254+
if (!userBet.isFree() && stake > 0) {
255+
pointLedgerService.processTransaction(
256+
TransactionReason.BETTING_CANCEL,
257+
roundId,
258+
AccountEntry.credit(accountService.getAccountByName(AccountName.BETTING_POOL), (long) stake),
259+
AccountEntry.debit(accountService.getUserAccount(userId), (long) stake)
247260
);
248261
}
249262

250263
// 통계 차감
251-
int stake = userBet.getStakePoints();
252264
if (userBet.getOption() == BetOption.RISE) {
253-
betRoundRepository.decrementUpStats(betRound.getBetRoundID(), stake);
265+
betRoundRepository.decrementUpStats(roundId, stake);
254266
} else {
255-
betRoundRepository.decrementDownStats(betRound.getBetRoundID(), stake);
267+
betRoundRepository.decrementDownStats(roundId, stake);
256268
}
269+
log.info("사용자 베팅 취소 완료: userId={}, userBetId={}", userId, userBetId);
257270
}
258271

259272

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

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

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

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

319+
Account userAccount = accountService.getUserAccount(bet.getUserId());
320+
299321
if (round.isDraw()) {
300322
// 가격 변동이 없을 시 참여자 전원 원금 환불
301323
if (!bet.isFree() && bet.getStakePoints() > 0) {
302-
pointHistoryService.createPointHistory(
303-
bet.getUserId(),
304-
bet.getStakePoints(),
305-
PointReason.BETTING,
306-
PointOrigin.BETTING,
307-
round.getBetRoundID()
324+
pointLedgerService.processTransaction(
325+
TransactionReason.BETTING_REFUND,
326+
round.getBetRoundID(),
327+
AccountEntry.credit(poolAccount, (long) bet.getStakePoints()),
328+
AccountEntry.debit(userAccount, (long) bet.getStakePoints())
308329
);
330+
totalStake += bet.getStakePoints();
309331
}
310332
bet.draw();
311333
} else if (bet.getOption() == resultOption) {
312334
// 예측 성공 시 보상 포인트 지급
313335
int reward = calculateReward(bet);
314336
bet.win(reward);
315-
pointHistoryService.createPointHistory(
316-
bet.getUserId(),
317-
reward,
318-
PointReason.BETTING_WIN,
319-
PointOrigin.BETTING,
320-
round.getBetRoundID()
321-
);
337+
338+
if (bet.isFree()) {
339+
// 무료 베팅: 시스템 -> 사용자 보상 지급
340+
pointLedgerService.processTransaction(
341+
TransactionReason.BETTING_REWARD,
342+
round.getBetRoundID(),
343+
AccountEntry.credit(systemAccount, (long) reward),
344+
AccountEntry.debit(userAccount, (long) reward)
345+
);
346+
}
347+
else {
348+
pointLedgerService.processTransaction(
349+
TransactionReason.BETTING_REWARD,
350+
round.getBetRoundID(),
351+
AccountEntry.credit(poolAccount, (long) reward),
352+
AccountEntry.debit(userAccount, (long) reward)
353+
);
354+
totalStake += reward;
355+
}
322356
} else {
323357
// 예측 실패 시 포인트 소멸
324358
bet.lose();
325359
}
326360
}
361+
362+
// 보상 소수점 처리 후 잔여금: 베팅 풀 -> 시스템 게정으로 이동
363+
long residual = round.getUpTotalPoints() + round.getDownTotalPoints() - totalStake;
364+
365+
if (residual > 0) {
366+
pointLedgerService.processTransaction(
367+
TransactionReason.BETTING_RESIDUAL,
368+
round.getBetRoundID(),
369+
AccountEntry.credit(accountService.getAccountByName(AccountName.BETTING_POOL), residual),
370+
AccountEntry.debit(accountService.getAccountByName(AccountName.SYSTEM_ISSUANCE), residual)
371+
);
372+
}
373+
log.info("베팅 라운드 정산 완료: roundId={}, residual={}", round.getBetRoundID(), residual);
327374
}
328375
}
329376

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

357-
// 소수점 floor -> TODO: 복식부기 도입 시 남는 포인트는 시스템으로 이동시키기
404+
// 소수점 floor -> 남는 포인트는 시스템으로 이동됨
358405
return (int) Math.floor(bet.getStakePoints() * multiplier);
359406
}
360407
}

backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ public enum ErrorCode {
4242

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

45+
ACCOUNT_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 포인트 계정을 찾을 수 없습니다."),
46+
47+
ACCOUNT_REQUIRED(HttpStatus.BAD_REQUEST, "계정 정보는 필수입니다."),
48+
49+
POINT_TRANSACTION_TOTAL_MISMATCH(HttpStatus.BAD_REQUEST, "포인트 거래 내역의 합계가 0이 아닙니다."),
50+
4551
// AUTH
4652

4753
UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "인증에 실패했습니다."),

backend/src/main/java/org/sejongisc/backend/point/controller/PointHistoryController.java

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,10 @@ public class PointHistoryController {
3333
)
3434
public ResponseEntity<PointHistoryResponse> getPointHistory(@RequestParam int pageNumber, @RequestParam int pageSize,
3535
@AuthenticationPrincipal CustomUserDetails customUserDetails) {
36-
return ResponseEntity.ok(pointHistoryService.getPointHistoryListByUserId(
36+
return ResponseEntity.ok(pointHistoryService.getPointHistory(
3737
customUserDetails.getUserId(), PageRequest.of(pageNumber, pageSize))
3838
);
3939
}
4040

41-
@GetMapping("/leaderboard")
42-
@Operation(
43-
summary = "포인트 리더보드 조회",
44-
description = "지정된 기간 동안의 포인트 리더보드를 조회합니다. 기간은 일간, 주간, 월간 단위의 요청이 가능합니다. ex) /leaderboard?period=1 or 7 or 30"
45-
)
46-
public ResponseEntity<PointHistoryResponse> getPointLeaderboard(@RequestParam int period) {
47-
return ResponseEntity.ok(pointHistoryService.getPointLeaderboard(period));
48-
}
41+
// TODO: 리더보드 조회 API 추가
4942
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package org.sejongisc.backend.point.dto;
2+
3+
import org.sejongisc.backend.common.exception.CustomException;
4+
import org.sejongisc.backend.common.exception.ErrorCode;
5+
import org.sejongisc.backend.point.entity.Account;
6+
import org.sejongisc.backend.point.entity.EntryType;
7+
8+
public record AccountEntry(
9+
Account account,
10+
Long amount,
11+
EntryType entryType
12+
) {
13+
public AccountEntry {
14+
if (account == null) {
15+
throw new CustomException(ErrorCode.ACCOUNT_REQUIRED);
16+
}
17+
if (amount == null || amount == 0) {
18+
throw new CustomException(ErrorCode.INVALID_POINT_AMOUNT);
19+
}
20+
}
21+
22+
/**
23+
* 차변 항목 생성
24+
* 해당 계정에 잔액이 증가할 때 사용
25+
*/
26+
public static AccountEntry debit(Account account, Long amount) {
27+
return new AccountEntry(account, Math.abs(amount), EntryType.DEBIT);
28+
}
29+
30+
/**
31+
* 대변 항목 생성
32+
* 해당 계정에 잔액이 감소할 때 사용
33+
*/
34+
public static AccountEntry credit(Account account, Long amount) {
35+
return new AccountEntry(account, -Math.abs(amount), EntryType.CREDIT);
36+
}
37+
}

0 commit comments

Comments
 (0)