11package org .sejongisc .backend .betting .service ;
22
33import lombok .RequiredArgsConstructor ;
4+ import lombok .extern .slf4j .Slf4j ;
45import org .sejongisc .backend .betting .dto .BetRoundResponse ;
56import org .sejongisc .backend .betting .dto .PriceResponse ;
67import org .sejongisc .backend .betting .dto .UserBetRequest ;
1011import org .sejongisc .backend .common .exception .CustomException ;
1112import org .sejongisc .backend .common .exception .ErrorCode ;
1213import 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 ;
1620import org .sejongisc .backend .stock .entity .PriceData ;
1721import org .sejongisc .backend .stock .repository .PriceDataRepository ;
1822import org .springframework .dao .DataIntegrityViolationException ;
2529import java .util .*;
2630import java .util .stream .Collectors ;
2731
32+ @ Slf4j
2833@ Service
2934@ RequiredArgsConstructor
3035public 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}
0 commit comments