diff --git a/tictoc-api/src/main/java/tictoc/bid/application/BidCommandService.java b/tictoc-api/src/main/java/tictoc/bid/application/BidCommandService.java index 8b6586d..27389fe 100644 --- a/tictoc-api/src/main/java/tictoc/bid/application/BidCommandService.java +++ b/tictoc-api/src/main/java/tictoc/bid/application/BidCommandService.java @@ -4,6 +4,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import tictoc.annotation.DistributedLock; +import tictoc.auction.model.Auction; import tictoc.auction.port.AuctionRepositoryPort; import tictoc.bid.dto.request.BidUseCaseReqDTO; import tictoc.constants.RedisConstants; @@ -11,12 +12,15 @@ import tictoc.bid.model.Bid; import tictoc.bid.port.BidCommandUseCase; import tictoc.bid.port.BidRepositoryPort; +import tictoc.profile.port.ProfileRepositoryPort; import static tictoc.error.ErrorCode.BID_FAIL; +import static tictoc.error.ErrorCode.INVALID_PROFILE_MONEY; @Service @Transactional @RequiredArgsConstructor public class BidCommandService implements BidCommandUseCase { + private final ProfileRepositoryPort profileRepositoryPort; private final AuctionRepositoryPort auctionRepositoryPort; private final BidRepositoryPort bidRepositoryPort; @@ -24,11 +28,17 @@ public class BidCommandService implements BidCommandUseCase { @DistributedLock(key = "#requestDTO.auctionId", delayTime = RedisConstants.LOCK_LEASE_TIME) public void bid(final Long userId, BidUseCaseReqDTO.Bid requestDTO) { var findAuction = auctionRepositoryPort.findAuctionById(requestDTO.auctionId()); - bidRepositoryPort.checkBeforeBid(findAuction); - findAuction.startBid(userId); - Integer beforePrice = findAuction.getCurrentPrice(); + validateBid(userId, requestDTO.price(), findAuction); executeAtomicBidUpdate(requestDTO); - bidRepositoryPort.saveBid(Bid.of(userId, requestDTO, beforePrice)); + bidRepositoryPort.saveBid(Bid.of(userId, requestDTO, findAuction.getCurrentPrice())); + } + + private void validateBid(Long userId, Integer price, Auction auction) { + if (!profileRepositoryPort.checkMoney(userId, price)) { + throw new BidException(INVALID_PROFILE_MONEY); + } + bidRepositoryPort.checkBeforeBid(auction); + auction.validateBeforeBid(userId); } private void executeAtomicBidUpdate(BidUseCaseReqDTO.Bid requestDTO) { diff --git a/tictoc-api/src/test/java/tictoc/bid/application/BidCommandServiceTest.java b/tictoc-api/src/test/java/tictoc/bid/application/BidCommandServiceTest.java index f7cbc16..2e65435 100644 --- a/tictoc-api/src/test/java/tictoc/bid/application/BidCommandServiceTest.java +++ b/tictoc-api/src/test/java/tictoc/bid/application/BidCommandServiceTest.java @@ -6,6 +6,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.junit.jupiter.SpringExtension; import tictoc.TicTocApiApplication; @@ -18,6 +19,8 @@ import tictoc.bid.port.BidCommandUseCase; import tictoc.bid.port.BidRepositoryPort; import tictoc.error.ErrorCode; +import tictoc.profile.port.ProfileRepositoryPort; + import java.time.LocalDateTime; import java.util.Collections; import java.util.concurrent.CountDownLatch; @@ -26,6 +29,9 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.when; @ExtendWith(SpringExtension.class) @SpringBootTest(classes = TicTocApiApplication.class) @@ -38,6 +44,9 @@ public class BidCommandServiceTest { @Autowired private BidRepositoryPort bidRepositoryPort; + @MockBean + private ProfileRepositoryPort profileRepositoryPort; + private static final Integer BID_PRICE = 1500; private static final int NUM_USERS = 5000; private Auction auction; @@ -48,6 +57,7 @@ public void setup() { LocalDateTime sellStartTime = now.plusMinutes(10); LocalDateTime sellEndTime = now.plusHours(2); LocalDateTime auctionCloseTime = now.plusHours(3); + when(profileRepositoryPort.checkMoney(anyLong(), anyInt())).thenReturn(true); AuctionUseCaseReqDTO.Register requestDTO = new AuctionUseCaseReqDTO.Register( "Test Auction", diff --git a/tictoc-common/src/main/java/tictoc/error/ErrorCode.java b/tictoc-common/src/main/java/tictoc/error/ErrorCode.java index c942f87..331ddc1 100644 --- a/tictoc-common/src/main/java/tictoc/error/ErrorCode.java +++ b/tictoc-common/src/main/java/tictoc/error/ErrorCode.java @@ -19,11 +19,14 @@ public enum ErrorCode { CONFLICT_AUCTION_DELETE(HttpStatus.BAD_REQUEST,"경매 삭제가 충돌나서 할 수 없습니다."), AUCTION_TIME_OVER(HttpStatus.BAD_REQUEST,"경매 시간이 종료되었습니다."), INVALID_AUCTION_TIME_RANGE(HttpStatus.BAD_REQUEST,"sellStartTime은 sellEndTime보다 이전이어야 합니다."), + CLOSE_AUCTION_ERROR_FROM_BIDDER(HttpStatus.BAD_REQUEST,"경매 종료 실패입니다. (찾을 수 없는 사용거나 보유 금액이 적습니다.)"), + CLOSE_AUCTION_ERROR_FROM_AUCTIONEER(HttpStatus.BAD_REQUEST,"경매 종료 실패입니다. (찾을 수 없는 사용거나 보유 금액이 적습니다.)"), // Bid AUCTION_ALREADY_FINISHED(HttpStatus.BAD_REQUEST, "이미 경매가 종료되었습니다."), BID_NO_ACCESS(HttpStatus.FORBIDDEN,"입찰에 대한 접근 권한이 없습니다."), INVALID_BID_PRICE(HttpStatus.BAD_REQUEST,"현재 경매가보다 낮은 입찰가를 입력했습니다."), + INVALID_PROFILE_MONEY(HttpStatus.BAD_REQUEST,"현재 경매가보다 낮은 금액을 소유하고 있습니다."), BID_NOT_FOUND(HttpStatus.BAD_REQUEST,"찾을 수 없는 입찰입니다."), BID_FAIL(HttpStatus.BAD_REQUEST,"이미 입찰되었습니다."), diff --git a/tictoc-domain/src/main/java/tictoc/auction/event/CloseAuctionEventListener.java b/tictoc-domain/src/main/java/tictoc/auction/event/CloseAuctionEventListener.java index 9894aca..ea289d4 100644 --- a/tictoc-domain/src/main/java/tictoc/auction/event/CloseAuctionEventListener.java +++ b/tictoc-domain/src/main/java/tictoc/auction/event/CloseAuctionEventListener.java @@ -12,6 +12,7 @@ import tictoc.bid.port.BidRepositoryPort; import tictoc.bid.port.WinningBidRepositoryPort; import tictoc.constants.RedisConstants; +import tictoc.profile.port.ProfileRepositoryPort; import tictoc.redis.auction.port.out.CloseAuctionUseCase; import tictoc.user.model.UserSchedule; import tictoc.user.port.UserScheduleRepositoryPort; @@ -20,6 +21,7 @@ @Event("Listener") @RequiredArgsConstructor public class CloseAuctionEventListener implements MessageListener { + private final ProfileRepositoryPort profileRepositoryPort; private final AuctionRepositoryPort auctionRepositoryPort; private final BidRepositoryPort bidRepositoryPort; private final WinningBidRepositoryPort winningBidRepositoryPort; @@ -30,29 +32,35 @@ public class CloseAuctionEventListener implements MessageListener { public void onMessage(Message message, byte[] pattern) { String expiredKey = new String(message.getBody(), StandardCharsets.UTF_8); if (expiredKey.startsWith(RedisConstants.AUCTION_CLOSE_KEY_PREFIX)) { - Long auctionId = Long.parseLong(expiredKey.replace(RedisConstants.AUCTION_CLOSE_KEY_PREFIX, "")); - Auction findAuction = auctionRepositoryPort.findAuctionById(auctionId); - close(findAuction); + Long auctionId = parseAuctionId(expiredKey); + Auction auction = auctionRepositoryPort.findAuctionById(auctionId); + closeAuction(auction); closeAuctionUseCase.delete(auctionId); } } - private void close(Auction findAuction) { - if (findAuction.getProgress().equals(AuctionProgress.NOT_STARTED)) { - findAuction.notBid(); + private Long parseAuctionId(String expiredKey) { + return Long.parseLong(expiredKey.replace(RedisConstants.AUCTION_CLOSE_KEY_PREFIX, "")); + } + + private void closeAuction(Auction auction) { + if (auction.getProgress() == AuctionProgress.NOT_STARTED) { + auction.notBid(); } else { - findAuction.bid(); - Bid findBid = bidRepositoryPort.findBidByAuctionId(findAuction.getId()); - processWinningBid(findAuction, findBid); - processUserSchedule(findAuction, findBid); + auction.bid(); + Bid bid = bidRepositoryPort.findBidByAuctionId(auction.getId()); + processWinningBid(auction, bid); + processUserSchedule(auction, bid); } - auctionRepositoryPort.saveAuction(findAuction); + auctionRepositoryPort.saveAuction(auction); } private void processWinningBid(Auction auction, Bid bid) { bid.win(); bidRepositoryPort.saveBid(bid); winningBidRepositoryPort.saveWinningBid(WinningBid.of(auction, bid)); + profileRepositoryPort.subtractMoney(bid.getBidderId(), bid.getBidPrice()); + profileRepositoryPort.addMoney(auction.getAuctioneerId(), bid.getBidPrice()); } private void processUserSchedule(Auction auction, Bid bid) { diff --git a/tictoc-domain/src/main/java/tictoc/auction/exception/CloseAuctionException.java b/tictoc-domain/src/main/java/tictoc/auction/exception/CloseAuctionException.java new file mode 100644 index 0000000..1d2d282 --- /dev/null +++ b/tictoc-domain/src/main/java/tictoc/auction/exception/CloseAuctionException.java @@ -0,0 +1,10 @@ +package tictoc.auction.exception; + +import tictoc.error.ErrorCode; +import tictoc.error.exception.TicTocException; + +public class CloseAuctionException extends TicTocException { + public CloseAuctionException(final ErrorCode errorCode) { + super(errorCode); + } +} \ No newline at end of file diff --git a/tictoc-domain/src/main/java/tictoc/auction/model/Auction.java b/tictoc-domain/src/main/java/tictoc/auction/model/Auction.java index 840a851..b424582 100644 --- a/tictoc-domain/src/main/java/tictoc/auction/model/Auction.java +++ b/tictoc-domain/src/main/java/tictoc/auction/model/Auction.java @@ -99,7 +99,7 @@ private void validateAuctionAlreadyStarted() { } } - public void startBid(final Long userId) { + public void validateBeforeBid(final Long userId) { this.validateAuctionTime(); this.validateBidAccess(userId); this.validateAuctionProgress(); diff --git a/tictoc-domain/src/main/java/tictoc/profile/adapter/ProfileRepositoryAdapter.java b/tictoc-domain/src/main/java/tictoc/profile/adapter/ProfileRepositoryAdapter.java index a577c3d..1bbb530 100644 --- a/tictoc-domain/src/main/java/tictoc/profile/adapter/ProfileRepositoryAdapter.java +++ b/tictoc-domain/src/main/java/tictoc/profile/adapter/ProfileRepositoryAdapter.java @@ -2,6 +2,8 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import tictoc.auction.exception.CloseAuctionException; import tictoc.profile.model.Profile; import tictoc.profile.model.ProfileImage; import tictoc.profile.port.ProfileRepositoryPort; @@ -9,6 +11,8 @@ import tictoc.profile.repository.ProfileImageRepository; import tictoc.profile.repository.ProfileRepository; +import static tictoc.error.ErrorCode.*; + @Component @RequiredArgsConstructor public class ProfileRepositoryAdapter implements ProfileRepositoryPort { @@ -25,4 +29,27 @@ public Profile saveProfile(Profile profile) { public ProfileImage saveProfileImage(ProfileImage profileImage) { return profileImageRepository.save(profileImage); } -} + + @Override + public boolean checkMoney(Long userId, Integer price) { + return profileRepository.existsByUserIdAndMoneyGreaterThanEqual(userId, price); + } + + @Override + @Transactional + public void subtractMoney(Long userId, Integer price) { + int updatedCount = profileRepository.subtractMoney(userId, price); + if (updatedCount == 0) { + throw new CloseAuctionException(CLOSE_AUCTION_ERROR_FROM_BIDDER); + } + } + + @Override + @Transactional + public void addMoney(Long userId, Integer price) { + int updatedCount = profileRepository.addMoney(userId, price); + if (updatedCount == 0) { + throw new CloseAuctionException(CLOSE_AUCTION_ERROR_FROM_AUCTIONEER); + } + } +} \ No newline at end of file diff --git a/tictoc-domain/src/main/java/tictoc/profile/model/MoneyHistory.java b/tictoc-domain/src/main/java/tictoc/profile/model/MoneyHistory.java index 6982f88..1c4a06d 100644 --- a/tictoc-domain/src/main/java/tictoc/profile/model/MoneyHistory.java +++ b/tictoc-domain/src/main/java/tictoc/profile/model/MoneyHistory.java @@ -23,4 +23,4 @@ public class MoneyHistory extends BaseTimeEntity { private Integer finalMoney; @Enumerated(EnumType.STRING) private MoneyHistoryStatus status; -} +} \ No newline at end of file diff --git a/tictoc-domain/src/main/java/tictoc/profile/port/ProfileRepositoryPort.java b/tictoc-domain/src/main/java/tictoc/profile/port/ProfileRepositoryPort.java index f636dd2..ef0f8d8 100644 --- a/tictoc-domain/src/main/java/tictoc/profile/port/ProfileRepositoryPort.java +++ b/tictoc-domain/src/main/java/tictoc/profile/port/ProfileRepositoryPort.java @@ -6,4 +6,7 @@ public interface ProfileRepositoryPort { Profile saveProfile(Profile profile); ProfileImage saveProfileImage(ProfileImage profileImage); + boolean checkMoney(Long userId, Integer price); + void subtractMoney(Long userId, Integer price); + void addMoney(Long auctioneerId, Integer bidPrice); } diff --git a/tictoc-domain/src/main/java/tictoc/profile/repository/ProfileRepository.java b/tictoc-domain/src/main/java/tictoc/profile/repository/ProfileRepository.java index fd8522a..137cc4e 100644 --- a/tictoc-domain/src/main/java/tictoc/profile/repository/ProfileRepository.java +++ b/tictoc-domain/src/main/java/tictoc/profile/repository/ProfileRepository.java @@ -1,7 +1,19 @@ package tictoc.profile.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import tictoc.profile.model.Profile; public interface ProfileRepository extends JpaRepository { + boolean existsByUserIdAndMoneyGreaterThanEqual(Long userId, Integer price); + + @Modifying(clearAutomatically = true) + @Query("UPDATE Profile p SET p.money = p.money - :price WHERE p.userId = :userId AND p.money >= :price") + int subtractMoney(@Param("userId") Long userId, @Param("price") Integer price); + + @Modifying(clearAutomatically = true) + @Query("UPDATE Profile p SET p.money = p.money + :price WHERE p.userId = :userId") + int addMoney(@Param("userId") Long userId, @Param("price") Integer price); }