diff --git a/build.gradle b/build.gradle index 49842485..644db963 100644 --- a/build.gradle +++ b/build.gradle @@ -74,6 +74,12 @@ dependencies { //jjwt implementation 'io.jsonwebtoken:jjwt:0.9.1' implementation 'javax.xml.bind:jaxb-api:2.3.1' + //Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.apache.commons:commons-pool2' + + //Job Runr + implementation 'org.jobrunr:jobrunr-spring-boot-3-starter:7.3.2' } tasks.named('test') { useJUnitPlatform() diff --git a/src/main/java/com/tasksprints/auction/auction/application/resolver/SearchConditionResolver.java b/src/main/java/com/tasksprints/auction/auction/application/resolver/SearchConditionResolver.java index 6ca52788..a4bb2629 100644 --- a/src/main/java/com/tasksprints/auction/auction/application/resolver/SearchConditionResolver.java +++ b/src/main/java/com/tasksprints/auction/auction/application/resolver/SearchConditionResolver.java @@ -26,7 +26,6 @@ public boolean supportsParameter(MethodParameter parameter) { @Override public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { - // QueryString에서 값을 추출 String auctionCategory = webRequest.getParameter("auctionCategory"); String productCategory = webRequest.getParameter("productCategory"); @@ -45,6 +44,11 @@ public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer m BigDecimal parsedMaxPrice = maxPrice != null ? new BigDecimal(maxPrice) : null; AuctionStatus parsedAuctionStatus = auctionStatus != null ? AuctionStatus.fromDisplayName(auctionStatus) : null; + //값 검증(비즈니스 로직이 아닌 값 유효성 검증은 resolver가 담당) + validateBothTimesProvided(parsedStartTime, parsedEndTime); + validateIsStartBeforeEnd(parsedStartTime, parsedEndTime); + validateMinLessThanMax(parsedMinPrice, parsedMaxPrice); + // SearchCondition 객체 생성 및 반환 return new AuctionRequest.SearchCondition( parsedAuctionCategory, @@ -57,4 +61,22 @@ public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer m sortBy ); } + + private void validateMinLessThanMax(BigDecimal parsedMinPrice, BigDecimal parsedMaxPrice) { + if (parsedMinPrice != null && parsedMaxPrice != null && parsedMinPrice.compareTo(parsedMaxPrice) > 0) { + throw new IllegalArgumentException("minPrice cannot be greater than maxPrice."); + } + } + + private void validateIsStartBeforeEnd(LocalDateTime parsedStartTime, LocalDateTime parsedEndTime) { + if (parsedStartTime != null && parsedEndTime != null && parsedStartTime.isAfter(parsedEndTime)) { + throw new IllegalArgumentException("startTime cannot be after endTime."); + } + } + + private void validateBothTimesProvided(LocalDateTime parsedStartTime, LocalDateTime parsedEndTime) { + if ((parsedStartTime == null && parsedEndTime != null) || (parsedStartTime != null && parsedEndTime == null)) { + throw new IllegalArgumentException("Both startTime and endTime must be provided together."); + } + } } diff --git a/src/main/java/com/tasksprints/auction/auction/application/service/AuctionScheduleService.java b/src/main/java/com/tasksprints/auction/auction/application/service/AuctionScheduleService.java new file mode 100644 index 00000000..f0ce398e --- /dev/null +++ b/src/main/java/com/tasksprints/auction/auction/application/service/AuctionScheduleService.java @@ -0,0 +1,55 @@ +package com.tasksprints.auction.auction.application.service; + +import com.tasksprints.auction.common.jobrunr.AuctionJob; +import lombok.RequiredArgsConstructor; +import org.jobrunr.scheduling.JobScheduler; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +@Service +@RequiredArgsConstructor +public class AuctionScheduleService { + private final JobScheduler jobScheduler; + private final AuctionJob auctionJob; + + // 작업 ID 관리 (수정/삭제 지원) + private final Map startJobMap = new ConcurrentHashMap<>(); + private final Map endJobMap = new ConcurrentHashMap<>(); + + // 시작 시각 작업 스케줄 + public void scheduleStart(Long auctionId, LocalDateTime startTime) { + if (startJobMap.containsKey(auctionId)) { + UUID jobId = startJobMap.get(auctionId); + jobScheduler.delete(jobId); // 기존 작업 삭제 + } + UUID jobId = jobScheduler.schedule( + Instant.from(startTime.atZone(ZoneId.systemDefault())), + () -> auctionJob.startAuction(auctionId) + ).asUUID(); + + startJobMap.put(auctionId, jobId); + System.out.println("경매 시작 작업 등록 완료 - ID: " + auctionId + ", 시간: " + startTime); + } + + // 종료 시각 작업 스케줄 + public void scheduleEnd(Long auctionId, LocalDateTime endTime) { + + if (endJobMap.containsKey(auctionId)) { + UUID jobId = endJobMap.get(auctionId); + jobScheduler.delete(jobId); // 기존 작업 삭제 + } + UUID jobId = jobScheduler.schedule( + Instant.from(endTime.atZone(ZoneId.systemDefault())), + () -> auctionJob.endAuction(auctionId) + ).asUUID(); + + endJobMap.put(auctionId, jobId); + System.out.println("경매 종료 작업 등록 완료 - ID: " + auctionId + ", 시간: " + endTime); + } +} diff --git a/src/main/java/com/tasksprints/auction/auction/application/service/AuctionServiceImpl.java b/src/main/java/com/tasksprints/auction/auction/application/service/AuctionServiceImpl.java index 7f059082..380d6a5a 100644 --- a/src/main/java/com/tasksprints/auction/auction/application/service/AuctionServiceImpl.java +++ b/src/main/java/com/tasksprints/auction/auction/application/service/AuctionServiceImpl.java @@ -26,7 +26,7 @@ public class AuctionServiceImpl implements AuctionService { private final UserRepository userRepository; private final AuctionRepository auctionRepository; - + private final AuctionScheduleService schedulerService; @Override public AuctionResponse createAuction(Long userId, AuctionRequest.Create auctionRequest) { User seller = userRepository.findById(userId) @@ -53,6 +53,10 @@ public AuctionResponse createAuction(Long userId, AuctionRequest.Create auctionR * STEP 2 * - 각각의 기능을 완전 분리 */ + // 시작 및 종료 시간 스케줄 등록 + schedulerService.scheduleStart(savedAuction.getId(), savedAuction.getStartTime()); + schedulerService.scheduleEnd(savedAuction.getId(), savedAuction.getEndTime()); + return AuctionResponse.of(savedAuction); } @@ -100,8 +104,6 @@ public List getAllAuctions() { public AuctionResponse getAuctionById(Long auctionId) { Auction foundAuction = auctionRepository.findAuctionById(auctionId) .orElseThrow(() -> new AuctionNotFoundException("Auction not found")); - foundAuction.incrementViewCount(); - auctionRepository.save(foundAuction); return AuctionResponse.of(foundAuction); } diff --git a/src/main/java/com/tasksprints/auction/auction/domain/dto/response/AuctionResponse.java b/src/main/java/com/tasksprints/auction/auction/domain/dto/response/AuctionResponse.java index 25254c70..ab08f864 100644 --- a/src/main/java/com/tasksprints/auction/auction/domain/dto/response/AuctionResponse.java +++ b/src/main/java/com/tasksprints/auction/auction/domain/dto/response/AuctionResponse.java @@ -22,7 +22,6 @@ public class AuctionResponse { private String category; private String status; private BigDecimal startingBid; - private Long viewCount; private Long sellerId; private String sellerNickName; @@ -37,7 +36,6 @@ public static class Details { private String category; private String status; private BigDecimal startingBid; - private Long viewCount; private Long sellerId; private String sellerNickName; private Long productId; @@ -53,7 +51,6 @@ public static AuctionResponse.Details of(Auction auction) { .category(auction.getAuctionCategory().name()) .status(auction.getAuctionStatus().name()) .startingBid(auction.getStartingBid()) - .viewCount(auction.getViewCount()) .sellerId(auction.getSeller() != null ? auction.getSeller().getId() : null) .sellerNickName(auction.getSeller() != null ? auction.getSeller().getNickName() : null) .productId(auction.getProduct() != null ? auction.getProduct().getId() : null) @@ -74,7 +71,6 @@ public static AuctionResponse of(Auction auction) { .category(auction.getAuctionCategory().name()) .status(auction.getAuctionStatus().name()) .startingBid(auction.getStartingBid()) - .viewCount(auction.getViewCount()) .sellerId(auction.getSeller().getId()) .sellerNickName(auction.getSeller().getNickName()) .build(); diff --git a/src/main/java/com/tasksprints/auction/auction/domain/entity/Auction.java b/src/main/java/com/tasksprints/auction/auction/domain/entity/Auction.java index fc0c3991..56cb38e0 100644 --- a/src/main/java/com/tasksprints/auction/auction/domain/entity/Auction.java +++ b/src/main/java/com/tasksprints/auction/auction/domain/entity/Auction.java @@ -1,11 +1,13 @@ package com.tasksprints.auction.auction.domain.entity; +import com.tasksprints.auction.auction.exception.InvalidAuctionStateException; import com.tasksprints.auction.bid.domain.entity.Bid; import com.tasksprints.auction.common.entity.BaseEntity; import com.tasksprints.auction.product.domain.entity.Product; import com.tasksprints.auction.user.domain.entity.User; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.SQLRestriction; import java.math.BigDecimal; import java.time.LocalDateTime; @@ -16,6 +18,7 @@ @NoArgsConstructor @AllArgsConstructor @Getter +@SQLRestriction("closed_at is null") @ToString @Entity(name = "auction") public class Auction extends BaseEntity { @@ -46,17 +49,16 @@ public class Auction extends BaseEntity { @ToString.Exclude private User seller; - @OneToOne + @OneToOne(fetch = FetchType.LAZY) @Builder.Default private Product product = null; - @OneToMany + @OneToMany(mappedBy = "auction", fetch = FetchType.LAZY) @Builder.Default private List bids = new ArrayList<>(); - @Builder.Default //초기화 값 적용 위함 -> @Builder만 사용할 경우 초기화 값이 무시될 가능성 존재 - @Column(nullable = false) - private long viewCount = 0L; + @Column(nullable = true, name= "closed_at") + private LocalDateTime closedAt; public static Auction create(LocalDateTime startTime, LocalDateTime endTime, BigDecimal startingBid, AuctionCategory auctionCategory, AuctionStatus auctionStatus, User seller) { Auction newAuction = Auction.builder() @@ -80,7 +82,28 @@ public void addUser(User seller) { this.seller = seller; } - public void incrementViewCount() { - this.viewCount += 1; + public void activate() { + canActivate(); + this.auctionStatus = AuctionStatus.ACTIVE; + } + + public void close() { + canClose(); + this.auctionStatus = AuctionStatus.CLOSED; + this.closedAt = LocalDateTime.now(); + } + + private void canActivate() { + if (!auctionStatus.equals(AuctionStatus.PENDING)) { + throw new InvalidAuctionStateException("Auction state change error: Can only start an auction from PENDING state."); + } } + + private void canClose() { + if (!auctionStatus.equals(AuctionStatus.ACTIVE)) { + throw new InvalidAuctionStateException("Auction state change error: Can only close an auction from ACTIVE state."); + } + } + + } diff --git a/src/main/java/com/tasksprints/auction/auction/domain/event/AuctionClosedEvent.java b/src/main/java/com/tasksprints/auction/auction/domain/event/AuctionClosedEvent.java new file mode 100644 index 00000000..e048d983 --- /dev/null +++ b/src/main/java/com/tasksprints/auction/auction/domain/event/AuctionClosedEvent.java @@ -0,0 +1,14 @@ +package com.tasksprints.auction.auction.domain.event; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.math.BigDecimal; + +@Getter +@RequiredArgsConstructor +public class AuctionClosedEvent { + private final Long auctionId; + private final Long highestBidderId; + private final BigDecimal highestBidAmount; +} diff --git a/src/main/java/com/tasksprints/auction/auction/domain/event/AuctionClosedEventListener.java b/src/main/java/com/tasksprints/auction/auction/domain/event/AuctionClosedEventListener.java new file mode 100644 index 00000000..ce6f837c --- /dev/null +++ b/src/main/java/com/tasksprints/auction/auction/domain/event/AuctionClosedEventListener.java @@ -0,0 +1,38 @@ +package com.tasksprints.auction.auction.domain.event; + +import com.tasksprints.auction.wallet.domain.entity.Wallet; +import com.tasksprints.auction.wallet.infrastructure.WalletRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AuctionClosedEventListener { + private final WalletRepository walletRepository; + + /** + * 비동기로 지갑 잔액 차감 처리 + * 동기로 처리하게 되면 이벤트 처리 할 때까지 경매의 close 상태를 업데이트 커밋하지 않음(트랜잭션 커밋) + * 경매를 닫고(트랜잭션 끝내고), 비동기로 잔액 차감을 처리해도 문제 없다.. + */ + @Async("asyncExecutor") + @EventListener + @Transactional + public void handleAuctionClosed(AuctionClosedEvent event) { + log.info("경매 종료 이벤트 처리: auctionId={}", event.getAuctionId()); + try { + Wallet winnerWallet = walletRepository.getWalletByUserId(event.getHighestBidderId()); + winnerWallet.deductBalance(event.getHighestBidAmount()); + walletRepository.save(winnerWallet); + log.info("지갑 잔액 차감 성공: auctionId={}", event.getAuctionId()); + } catch (Exception e) { + log.error("지갑 잔액 차감 실패: auctionId={}, error={}", event.getAuctionId(), e.getMessage()); + } + } + +} diff --git a/src/main/java/com/tasksprints/auction/auction/exception/InvalidAuctionStateException.java b/src/main/java/com/tasksprints/auction/auction/exception/InvalidAuctionStateException.java new file mode 100644 index 00000000..7c57f9fc --- /dev/null +++ b/src/main/java/com/tasksprints/auction/auction/exception/InvalidAuctionStateException.java @@ -0,0 +1,7 @@ +package com.tasksprints.auction.auction.exception; + +public class InvalidAuctionStateException extends RuntimeException { + public InvalidAuctionStateException(String message) { + super(message); + } +} diff --git a/src/main/java/com/tasksprints/auction/auction/infrastructure/AuctionRepository.java b/src/main/java/com/tasksprints/auction/auction/infrastructure/AuctionRepository.java index 76f82e81..489f466a 100644 --- a/src/main/java/com/tasksprints/auction/auction/infrastructure/AuctionRepository.java +++ b/src/main/java/com/tasksprints/auction/auction/infrastructure/AuctionRepository.java @@ -18,5 +18,8 @@ public interface AuctionRepository extends JpaRepository, Auction @Query("SELECT a FROM auction a WHERE a.id = :auctionId") Optional findAuctionById(@Param("auctionId") Long auctionId); + @Query("SELECT a FROM auction a JOIN FETCH a.seller u WHERE a.id = :auctionId") + Optional findAuctionByIdV2(@Param("auctionId") Long auctionId); + } diff --git a/src/main/java/com/tasksprints/auction/auction/infrastructure/support/AuctionCriteriaRepositoryImpl.java b/src/main/java/com/tasksprints/auction/auction/infrastructure/support/AuctionCriteriaRepositoryImpl.java index 83128f64..a72da439 100644 --- a/src/main/java/com/tasksprints/auction/auction/infrastructure/support/AuctionCriteriaRepositoryImpl.java +++ b/src/main/java/com/tasksprints/auction/auction/infrastructure/support/AuctionCriteriaRepositoryImpl.java @@ -110,7 +110,6 @@ private OrderSpecifier getSortOrder(AuctionRequest.SearchCondition condition) case "bidsDesc" -> auction.bids.size().desc(); case "endTimeASC" -> auction.endTime.asc(); case "startTimeASC" -> auction.startTime.asc(); - case "viewCountDESC" -> auction.viewCount.desc(); default -> null; }; diff --git a/src/main/java/com/tasksprints/auction/auth/application/resolver/AuthenticationResolver.java b/src/main/java/com/tasksprints/auction/auth/application/resolver/AuthenticationResolver.java index efe5a249..6a5dcddb 100644 --- a/src/main/java/com/tasksprints/auction/auth/application/resolver/AuthenticationResolver.java +++ b/src/main/java/com/tasksprints/auction/auth/application/resolver/AuthenticationResolver.java @@ -45,7 +45,7 @@ public Object resolveArgument(MethodParameter parameter, jwtProvider.validateToken(accessToken); jwtProvider.validateToken(refreshToken); - Long userId = Long.valueOf(jwtProvider.getSubject(refreshToken)); + Long userId = Long.valueOf(jwtProvider.getSubject(accessToken)); return Accessor.user(userId); } catch (RefreshTokenException e) { return Accessor.guest(); diff --git a/src/main/java/com/tasksprints/auction/bid/application/service/BidServiceImpl.java b/src/main/java/com/tasksprints/auction/bid/application/service/BidServiceImpl.java index ace333ac..e28238d0 100644 --- a/src/main/java/com/tasksprints/auction/bid/application/service/BidServiceImpl.java +++ b/src/main/java/com/tasksprints/auction/bid/application/service/BidServiceImpl.java @@ -1,5 +1,7 @@ package com.tasksprints.auction.bid.application.service; +import com.tasksprints.auction.auction.domain.entity.AuctionStatus; +import com.tasksprints.auction.auction.exception.InvalidAuctionStateException; import com.tasksprints.auction.bid.application.service.BidService; import com.tasksprints.auction.bid.exception.BidNotFoundException; import com.tasksprints.auction.bid.exception.InvalidBidAmountException; @@ -13,6 +15,8 @@ import com.tasksprints.auction.user.exception.UserNotFoundException; import com.tasksprints.auction.user.domain.entity.User; import com.tasksprints.auction.user.infrastructure.UserRepository; +import com.tasksprints.auction.wallet.domain.entity.Wallet; +import com.tasksprints.auction.wallet.exception.InSufficientBalanceException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -34,7 +38,7 @@ public class BidServiceImpl implements BidService { @Override public BidResponse submitBid(Long userId, Long auctionId, BigDecimal amount) { // 입찰 시 유효성 검사 - User foundUser = userRepository.findById(userId) + User foundUser = userRepository.findByIdWithWallet(userId) .orElseThrow(() -> new UserNotFoundException("User not found")); Auction foundAuction = auctionRepository.findById(auctionId) .orElseThrow(() -> new AuctionNotFoundException("Auction not found")); @@ -43,12 +47,21 @@ public BidResponse submitBid(Long userId, Long auctionId, BigDecimal amount) { if (foundAuction.getEndTime().isBefore(LocalDateTime.now())) { throw new AuctionEndedException("This auction has already ended."); } + // 경매가 종료되었는지 확인 + if (!foundAuction.getAuctionStatus().equals(AuctionStatus.ACTIVE)) { + throw new InvalidAuctionStateException("Bids can only be placed if the auction is active."); + } // 최소 입찰 금액 충족 여부 확인 if (amount.compareTo(foundAuction.getStartingBid()) < 0) { throw new InvalidBidAmountException("Bid amount is less than the minimum required bid amount."); } + // 지갑 잔액 검증 + if (foundUser.getWallet().getBalance().compareTo(amount) < 0) { + throw new InSufficientBalanceException("Insufficient Wallet balance"); + } + // 입찰 생성 및 저장 Bid createdBid = Bid.create(amount, foundUser, foundAuction); Bid savedBid = bidRepository.save(createdBid); diff --git a/src/main/java/com/tasksprints/auction/common/config/AsyncConfig.java b/src/main/java/com/tasksprints/auction/common/config/AsyncConfig.java new file mode 100644 index 00000000..5269e0ff --- /dev/null +++ b/src/main/java/com/tasksprints/auction/common/config/AsyncConfig.java @@ -0,0 +1,23 @@ +package com.tasksprints.auction.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +@Configuration +@EnableAsync +public class AsyncConfig { + @Bean(name = "asyncExecutor") + public Executor asyncExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(5); + executor.setMaxPoolSize(10); + executor.setQueueCapacity(30); + executor.setThreadNamePrefix("Async-"); + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/com/tasksprints/auction/common/config/JwtConfig.java b/src/main/java/com/tasksprints/auction/common/config/JwtConfig.java index 349a1f0c..441869f7 100644 --- a/src/main/java/com/tasksprints/auction/common/config/JwtConfig.java +++ b/src/main/java/com/tasksprints/auction/common/config/JwtConfig.java @@ -1,6 +1,9 @@ package com.tasksprints.auction.common.config; import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; import lombok.Setter; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Configuration; @@ -10,12 +13,8 @@ @Configuration @ConfigurationProperties(prefix = "jwt") public class JwtConfig { - private Long accessExpireMs; - private Long refreshExpireMs; - private String issuer; - private String secretKey; } diff --git a/src/main/java/com/tasksprints/auction/common/handler/GlobalExceptionHandler.java b/src/main/java/com/tasksprints/auction/common/handler/GlobalExceptionHandler.java index e569c4aa..b4469305 100644 --- a/src/main/java/com/tasksprints/auction/common/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/tasksprints/auction/common/handler/GlobalExceptionHandler.java @@ -11,6 +11,7 @@ import com.tasksprints.auction.auth.exception.AuthException; import com.tasksprints.auction.payment.exception.InvalidSessionException; import com.tasksprints.auction.payment.exception.PaymentDataMismatchException; +import com.tasksprints.auction.payment.exception.RedisKeyNotFoundException; import com.tasksprints.auction.product.exception.ProductNotFoundException; import com.tasksprints.auction.user.exception.UserNotFoundException; import org.springframework.http.HttpStatus; @@ -70,6 +71,11 @@ public ResponseEntity> handleInvalidSessionException(InvalidSe return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResult.failure(message)); } + @ExceptionHandler(RedisKeyNotFoundException.class) + public ResponseEntity> handleRedisKeyNotFoundException(RedisKeyNotFoundException ex) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResult.failure(ex.getMessage())); + } + @ExceptionHandler(PaymentDataMismatchException.class) public ResponseEntity> PaymentDataMismatchException(PaymentDataMismatchException ex) { String message = "Session Data Mismatch Error. "; diff --git a/src/main/java/com/tasksprints/auction/common/initializer/AuctionInitializer.java b/src/main/java/com/tasksprints/auction/common/initializer/AuctionInitializer.java index 770eb9c1..40fe09c3 100644 --- a/src/main/java/com/tasksprints/auction/common/initializer/AuctionInitializer.java +++ b/src/main/java/com/tasksprints/auction/common/initializer/AuctionInitializer.java @@ -1,23 +1,30 @@ package com.tasksprints.auction.common.initializer; +import com.tasksprints.auction.auction.application.service.AuctionScheduleService; import com.tasksprints.auction.auction.domain.entity.Auction; import com.tasksprints.auction.auction.domain.entity.AuctionCategory; import com.tasksprints.auction.auction.domain.entity.AuctionStatus; import com.tasksprints.auction.auction.infrastructure.AuctionRepository; +import com.tasksprints.auction.bid.domain.entity.Bid; +import com.tasksprints.auction.bid.infrastructure.BidRepository; import com.tasksprints.auction.product.domain.entity.Product; import com.tasksprints.auction.product.domain.entity.ProductImage; import com.tasksprints.auction.product.infrastructure.ProductImageRepository; import com.tasksprints.auction.product.infrastructure.ProductRepository; import com.tasksprints.auction.user.domain.entity.User; import com.tasksprints.auction.user.infrastructure.UserRepository; +import com.tasksprints.auction.wallet.domain.entity.Wallet; import jakarta.transaction.Transactional; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; import org.springframework.stereotype.Component; import java.math.BigDecimal; +import java.time.Duration; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; +import java.util.UUID; @Component public class AuctionInitializer implements ApplicationRunner { @@ -27,12 +34,16 @@ public class AuctionInitializer implements ApplicationRunner { private final ProductRepository productRepository; private final ProductImageRepository productImageRepository; + private final AuctionScheduleService auctionScheduleService; // 스케줄 서비스 추가 + private final BidRepository bidRepository; - public AuctionInitializer(UserRepository userRepository, AuctionRepository auctionRepository, ProductRepository productRepository, ProductImageRepository productImageRepository) { + public AuctionInitializer(UserRepository userRepository, AuctionRepository auctionRepository, ProductRepository productRepository, ProductImageRepository productImageRepository, AuctionScheduleService auctionScheduleService, BidRepository bidRepository) { this.userRepository = userRepository; this.auctionRepository = auctionRepository; this.productRepository = productRepository; this.productImageRepository = productImageRepository; + this.auctionScheduleService = auctionScheduleService; + this.bidRepository = bidRepository; } private void createDummyUser() { @@ -40,8 +51,16 @@ private void createDummyUser() { userRepository.save(user1); } - private Auction createDummyAuction(User user) { - Auction auction = Auction.create(LocalDateTime.now(), LocalDateTime.now().plusHours(2), BigDecimal.TEN, AuctionCategory.PRIVATE_FREE, AuctionStatus.ACTIVE, user); + private Auction createDummyAuction(User user, LocalDateTime startTime, LocalDateTime endTime) { + Auction auction = Auction.builder() + .startTime(startTime) + .endTime(endTime) + .startingBid(BigDecimal.TEN) + .auctionCategory(AuctionCategory.PUBLIC_PAID) + .auctionStatus(AuctionStatus.PENDING) + .build(); + auction.addUser(user); + return auctionRepository.save(auction); } @@ -54,15 +73,50 @@ private void createDummyProduct(User user, Auction auction) { productRepository.save(product); } + private void createDummyBid(Auction auction) { + Bid bid = Bid.builder() + .uuid(UUID.randomUUID().toString()) + .amount(BigDecimal.valueOf(100)) + .auction(auction) + .build(); + bidRepository.save(bid); + } + @Override @Transactional public void run(ApplicationArguments args) throws Exception { - User user = userRepository.save(User.createWithWallet("name", "email@email.com", "password", "NickName")); // 각 제품에 대해 새로운 경매를 생성 - for (int i = 0; i < 100; i++) { - Auction auction = createDummyAuction(user); + for (int i = 0; i < 50; i++) { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime startTime = now.plus(Duration.ofMillis(15000 + i * 500)); // 시작 시간: 15초 텀을 두고 0.5 초 간격 + LocalDateTime endTime = startTime.plusSeconds(15); // 종료 시간: 시작 후 15초 + + User user = createUserWithWallet(i); + Auction auction = createDummyAuction(user, startTime, endTime); createDummyProduct(user, auction); + createDummyBid(auction); + + auctionScheduleService.scheduleStart(auction.getId(), startTime); + auctionScheduleService.scheduleEnd(auction.getId(), endTime); } } + + private User createUserWithWallet(int i) { + User user = User.builder() + .name("name" + i) + .email("email" + i + "@email.com") + .password("password") + .nickName("NickName" + i) + .build(); + + Wallet wallet = Wallet.builder() + .user(user) + .userName("name" + i) + .balance(BigDecimal.valueOf(100000.0)) + .build(); + + user.addWallet(wallet); + return userRepository.save(user); + } } diff --git a/src/main/java/com/tasksprints/auction/common/jobrunr/AuctionJob.java b/src/main/java/com/tasksprints/auction/common/jobrunr/AuctionJob.java new file mode 100644 index 00000000..33da7b02 --- /dev/null +++ b/src/main/java/com/tasksprints/auction/common/jobrunr/AuctionJob.java @@ -0,0 +1,40 @@ +package com.tasksprints.auction.common.jobrunr; + +import com.tasksprints.auction.auction.domain.entity.Auction; +import com.tasksprints.auction.auction.domain.event.AuctionClosedEvent; +import com.tasksprints.auction.auction.exception.AuctionNotFoundException; +import com.tasksprints.auction.auction.infrastructure.AuctionRepository; +import com.tasksprints.auction.bid.domain.entity.Bid; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; + +@Component +@RequiredArgsConstructor +public class AuctionJob { + private final AuctionRepository auctionRepository; + private final ApplicationEventPublisher applicationEventPublisher; + + // 경매 시작 상태 변경 + @Transactional + public void startAuction(Long auctionId) { + Auction auction = auctionRepository.findById(auctionId).orElseThrow(() -> new AuctionNotFoundException("Not Found Auction ID: " + auctionId)); + auction.activate(); + System.out.println("경매 시작 - ID: " + auctionId); + } + + // 경매 종료 상태 변경 + @Transactional + public void endAuction(Long auctionId) { + Auction auction = auctionRepository.findById(auctionId).orElseThrow(() -> new AuctionNotFoundException("Not Found Auction ID: " + auctionId)); + auction.close(); + //잔액 차감 이벤트 요청 + Bid bid = auction.getBids().getFirst(); + AuctionClosedEvent event = new AuctionClosedEvent(auctionId, bid.getId(), bid.getAmount()); + applicationEventPublisher.publishEvent(event); + System.out.println("경매 종료 - ID: " + auctionId); + } +} diff --git a/src/main/java/com/tasksprints/auction/payment/application/service/PaymentService.java b/src/main/java/com/tasksprints/auction/payment/application/service/PaymentService.java index fe2c6e70..90810eb4 100644 --- a/src/main/java/com/tasksprints/auction/payment/application/service/PaymentService.java +++ b/src/main/java/com/tasksprints/auction/payment/application/service/PaymentService.java @@ -9,7 +9,6 @@ public interface PaymentService { - public void prepare(HttpSession session, PaymentRequest.Prepare prepareRequest); public Response sendPaymentRequest(PaymentRequest.Confirm confirmRequest) throws IOException, InterruptedException; public Response handleTossPaymentResponse(Long userId, PaymentRequest.Confirm confirmRequest, Response response) throws IOException, InterruptedException ; } diff --git a/src/main/java/com/tasksprints/auction/payment/application/service/PaymentServiceImpl.java b/src/main/java/com/tasksprints/auction/payment/application/service/PaymentServiceImpl.java index 1d92715f..cbfc4009 100644 --- a/src/main/java/com/tasksprints/auction/payment/application/service/PaymentServiceImpl.java +++ b/src/main/java/com/tasksprints/auction/payment/application/service/PaymentServiceImpl.java @@ -25,11 +25,6 @@ public class PaymentServiceImpl implements PaymentService { private final WalletService walletService; private final PaymentApiSerializer paymentApiSerializer; - @Override - public void prepare(HttpSession session, PaymentRequest.Prepare prepareRequest) { - session.setAttribute("orderId", prepareRequest.getOrderId()); - session.setAttribute("amount", prepareRequest.getAmount()); - } @Override public Response sendPaymentRequest(PaymentRequest.Confirm confirmRequest) throws IOException, InterruptedException{ return paymentApiSerializer.sendPaymentRequest(confirmRequest); diff --git a/src/main/java/com/tasksprints/auction/payment/exception/RedisKeyNotFoundException.java b/src/main/java/com/tasksprints/auction/payment/exception/RedisKeyNotFoundException.java new file mode 100644 index 00000000..2141e2c2 --- /dev/null +++ b/src/main/java/com/tasksprints/auction/payment/exception/RedisKeyNotFoundException.java @@ -0,0 +1,7 @@ +package com.tasksprints.auction.payment.exception; + +public class RedisKeyNotFoundException extends RuntimeException { + public RedisKeyNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/com/tasksprints/auction/payment/infrastructure/redis/RedisService.java b/src/main/java/com/tasksprints/auction/payment/infrastructure/redis/RedisService.java new file mode 100644 index 00000000..bb58036b --- /dev/null +++ b/src/main/java/com/tasksprints/auction/payment/infrastructure/redis/RedisService.java @@ -0,0 +1,26 @@ +package com.tasksprints.auction.payment.infrastructure.redis; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; + +@Service +@RequiredArgsConstructor +public class RedisService { + + private final StringRedisTemplate redisTemplate; + + public void saveValue(String key, String value) { + redisTemplate.opsForValue().set(key, value); + } + + public void saveDataWithExpiration(String key, String value, long timeoutSeconds) { + redisTemplate.opsForValue().set(key, value, Duration.ofSeconds(timeoutSeconds)); + } + + public String getValue(String key) { + return redisTemplate.opsForValue().get(key); + } +} diff --git a/src/main/java/com/tasksprints/auction/payment/presentation/PaymentController.java b/src/main/java/com/tasksprints/auction/payment/presentation/PaymentController.java index 35abe18a..688eaaa6 100644 --- a/src/main/java/com/tasksprints/auction/payment/presentation/PaymentController.java +++ b/src/main/java/com/tasksprints/auction/payment/presentation/PaymentController.java @@ -1,6 +1,9 @@ package com.tasksprints.auction.payment.presentation; +import com.tasksprints.auction.auth.domain.model.Accessor; import com.tasksprints.auction.common.constant.ApiResponseMessages; +import com.tasksprints.auction.common.jwt.Auth; +import com.tasksprints.auction.common.jwt.UserOnly; import com.tasksprints.auction.common.response.ApiResult; import com.tasksprints.auction.payment.api.Response; import com.tasksprints.auction.payment.domain.dto.request.PaymentRequest; @@ -8,6 +11,8 @@ import com.tasksprints.auction.payment.exception.InvalidSessionException; import com.tasksprints.auction.payment.exception.PaymentDataMismatchException; import com.tasksprints.auction.payment.application.service.PaymentService; +import com.tasksprints.auction.payment.exception.RedisKeyNotFoundException; +import com.tasksprints.auction.payment.infrastructure.redis.RedisService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import jakarta.servlet.http.HttpSession; @@ -18,26 +23,35 @@ import java.io.IOException; import java.math.BigDecimal; +import java.util.NoSuchElementException; +import java.util.Optional; @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/payment") public class PaymentController { private final PaymentService paymentService; + private final RedisService redisService; @PostMapping("/prepare") @Operation(summary = "Temporarily stores the payment element", description = "Save orderID and amount in session") @ApiResponse(responseCode = "200", description = "Payment prepared successfully") - public ResponseEntity> preparePayment(HttpSession session, @RequestBody PaymentRequest.Prepare prepareRequest) { - paymentService.prepare(session, prepareRequest); + public ResponseEntity> preparePayment(@RequestBody PaymentRequest.Prepare prepareRequest) { + redisService.saveDataWithExpiration( + prepareRequest.getOrderId(), // key + prepareRequest.getAmount().toString(), // value + 60 * 5 // 5분 TTL + ); return ResponseEntity.ok(ApiResult.success(ApiResponseMessages.PAYMENT_PREPARED_SUCCESS)); } @PostMapping("/confirm") - public ResponseEntity confirmPayment(HttpSession session, @RequestBody PaymentRequest.Confirm confirmRequest, @RequestParam Long userId) throws IOException, InterruptedException { - validateSession(session); - validatePaymentConfirmRequest(confirmRequest, session); + @UserOnly + public ResponseEntity confirmPayment(@RequestBody PaymentRequest.Confirm confirmRequest, @Auth Accessor accessor) throws IOException, InterruptedException { + Long userId = accessor.userId(); + System.out.println("유저:" + userId); + validatePaymentConfirmRequestV2(confirmRequest); Response response = paymentService.sendPaymentRequest(confirmRequest); //토스페이먼츠로 보낸 결제 승인 요청에 대한 response 리턴 Response objectResponse = paymentService.handleTossPaymentResponse(userId, confirmRequest, response); @@ -49,28 +63,19 @@ public ResponseEntity confirmPayment(HttpSession session, @RequestBody Paymen return ResponseEntity.status(response.getStatusCode()).body(response.getBody()); } + private void validatePaymentConfirmRequestV2(PaymentRequest.Confirm confirmRequest) { + Optional amountStr = getAmountStr(confirmRequest.getOrderId()); - private void validatePaymentConfirmRequest(PaymentRequest.Confirm confirmRequest, HttpSession session) { - String savedOrderId = (String) session.getAttribute("orderId"); - BigDecimal savedAmount = (BigDecimal) session.getAttribute("amount"); + BigDecimal savedAmount = amountStr + .map(BigDecimal::new) + .orElseThrow(() -> new RedisKeyNotFoundException("Redis key 'orderId' not found.")); - if (!confirmRequest.getOrderId().equals(savedOrderId) || !confirmRequest.getAmount().equals(savedAmount)) { - throw new PaymentDataMismatchException("Payment data mismatch"); + if (!confirmRequest.getAmount().equals(savedAmount)) { + throw new PaymentDataMismatchException("Payment data mismatch : Amount does not match the previous value"); } } - private void validateSession(HttpSession session) { - if (session == null) { - throw new InvalidSessionException("Invalid session"); - } - - String savedOrderId = (String) session.getAttribute("orderId"); - BigDecimal savedAmount = (BigDecimal) session.getAttribute("amount"); - - if (savedOrderId == null || savedAmount == null) { - throw new InvalidSessionException("Invalid session"); - } + private Optional getAmountStr(String orderId) { + return Optional.ofNullable(redisService.getValue(orderId)); } - - } diff --git a/src/main/java/com/tasksprints/auction/product/domain/entity/Product.java b/src/main/java/com/tasksprints/auction/product/domain/entity/Product.java index bb195282..3f2e9818 100644 --- a/src/main/java/com/tasksprints/auction/product/domain/entity/Product.java +++ b/src/main/java/com/tasksprints/auction/product/domain/entity/Product.java @@ -31,16 +31,16 @@ public class Product extends BaseEntity { @Enumerated(EnumType.STRING) private ProductCategory category; //제품의 Category - @ManyToOne + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "owner_id") private User owner; - @OneToOne + @OneToOne(fetch = FetchType.LAZY) //mappedby @JoinColumn(name = "auction_id") private Auction auction; - @OneToMany + @OneToMany(mappedBy = "product", fetch = FetchType.LAZY, cascade = CascadeType.ALL) @Builder.Default private List productImageList = new ArrayList<>(); @@ -62,7 +62,9 @@ public void addAuction(Auction auction) { } public void initProductImageList(List productImageList) { + if (productImageList == null) return; this.productImageList = productImageList; + productImageList.forEach(image -> image.addProduct(this)); } public void addOwnerAndAuction(User owner, Auction auction) { diff --git a/src/main/java/com/tasksprints/auction/product/domain/entity/ProductImage.java b/src/main/java/com/tasksprints/auction/product/domain/entity/ProductImage.java index 4ebac704..78adcdea 100644 --- a/src/main/java/com/tasksprints/auction/product/domain/entity/ProductImage.java +++ b/src/main/java/com/tasksprints/auction/product/domain/entity/ProductImage.java @@ -19,7 +19,13 @@ public class ProductImage { @Getter private String imageUrl; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_id") + private Product product; + public void addProduct(Product product) { + this.product = product; + } // @ColumnDefault("false") // private Boolean isPrime; diff --git a/src/main/java/com/tasksprints/auction/product/infrastructure/ProductRepository.java b/src/main/java/com/tasksprints/auction/product/infrastructure/ProductRepository.java index 16597e97..c92ca2b8 100644 --- a/src/main/java/com/tasksprints/auction/product/infrastructure/ProductRepository.java +++ b/src/main/java/com/tasksprints/auction/product/infrastructure/ProductRepository.java @@ -2,13 +2,17 @@ import com.tasksprints.auction.product.domain.entity.Product; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; public interface ProductRepository extends JpaRepository { Optional findByAuctionId(Long auctionId); - + //컬렉션까지 Fetch Join 하거나, 컬렉션은 지연 로딩 초기화로 하는 방식 트레이드 오프 고려하기 + @Query("SELECT DISTINCT p FROM products p JOIN FETCH p.owner o LEFT JOIN FETCH p.productImageList pi WHERE p.auction.id = :auctionId") + Optional findByAuctionIdV2(@Param("auctionId")Long auctionId); // 쿼리를 메서드 이름으로 표현 List findByOwnerId(Long ownerId); } diff --git a/src/main/java/com/tasksprints/auction/user/application/service/UserServiceImpl.java b/src/main/java/com/tasksprints/auction/user/application/service/UserServiceImpl.java index 5e56ff26..5acde192 100644 --- a/src/main/java/com/tasksprints/auction/user/application/service/UserServiceImpl.java +++ b/src/main/java/com/tasksprints/auction/user/application/service/UserServiceImpl.java @@ -20,7 +20,7 @@ public class UserServiceImpl implements UserService { @Override public UserDetailResponse createUser(UserRequest.Register request) { - User user = User.create(request.getName(), request.getEmail(), request.getPassword(), request.getNickname()); + User user = User.createWithWallet(request.getName(), request.getEmail(), request.getPassword(), request.getNickname()); User newUser = userRepository.save(user); return UserDetailResponse.of(newUser); diff --git a/src/main/java/com/tasksprints/auction/user/domain/dto/response/UserDetailResponse.java b/src/main/java/com/tasksprints/auction/user/domain/dto/response/UserDetailResponse.java index 3e0004c1..3c3585fa 100644 --- a/src/main/java/com/tasksprints/auction/user/domain/dto/response/UserDetailResponse.java +++ b/src/main/java/com/tasksprints/auction/user/domain/dto/response/UserDetailResponse.java @@ -8,19 +8,19 @@ @Data public class UserDetailResponse { private Long id; + private Long walletId; private String name; private String email; private String password; private String nickName; - private String walletId; private UserDetailResponse(User user) { this.id = user.getId(); + this.walletId = user.getWallet().getId(); this.name = user.getName(); this.email = user.getEmail(); this.password = user.getPassword(); this.nickName = user.getNickName(); - this.walletId = String.valueOf(user.getWallet().getId()); } public static UserDetailResponse of(User user) { diff --git a/src/main/java/com/tasksprints/auction/user/infrastructure/UserRepository.java b/src/main/java/com/tasksprints/auction/user/infrastructure/UserRepository.java index 392813b3..78781537 100644 --- a/src/main/java/com/tasksprints/auction/user/infrastructure/UserRepository.java +++ b/src/main/java/com/tasksprints/auction/user/infrastructure/UserRepository.java @@ -2,9 +2,13 @@ import com.tasksprints.auction.user.domain.entity.User; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.Optional; public interface UserRepository extends JpaRepository { Optional findByEmail(String email); + @Query("SELECT u FROM users u JOIN FETCH u.wallet w WHERE u.id = :id") + Optional findByIdWithWallet(@Param("id") Long id); } diff --git a/src/main/java/com/tasksprints/auction/wallet/application/service/WalletService.java b/src/main/java/com/tasksprints/auction/wallet/application/service/WalletService.java index d38364cc..f5ffad1d 100644 --- a/src/main/java/com/tasksprints/auction/wallet/application/service/WalletService.java +++ b/src/main/java/com/tasksprints/auction/wallet/application/service/WalletService.java @@ -7,8 +7,6 @@ public interface WalletService { void chargeMoney(Wallet wallet, BigDecimal amount); - boolean isSufficientMoney(); - Wallet getWalletByUserId(Long userId); } diff --git a/src/main/java/com/tasksprints/auction/wallet/application/service/WalletServiceImpl.java b/src/main/java/com/tasksprints/auction/wallet/application/service/WalletServiceImpl.java index 7a4de7b2..8aea7fb1 100644 --- a/src/main/java/com/tasksprints/auction/wallet/application/service/WalletServiceImpl.java +++ b/src/main/java/com/tasksprints/auction/wallet/application/service/WalletServiceImpl.java @@ -18,11 +18,6 @@ public void chargeMoney(Wallet wallet, BigDecimal amount) { walletRepository.save(wallet); } - @Override - public boolean isSufficientMoney() { - return false; - } - @Override public Wallet getWalletByUserId(Long userId) { return walletRepository.getWalletByUserId(userId); diff --git a/src/main/java/com/tasksprints/auction/wallet/domain/entity/Wallet.java b/src/main/java/com/tasksprints/auction/wallet/domain/entity/Wallet.java index 08a7fc2e..02cb6adf 100644 --- a/src/main/java/com/tasksprints/auction/wallet/domain/entity/Wallet.java +++ b/src/main/java/com/tasksprints/auction/wallet/domain/entity/Wallet.java @@ -3,6 +3,7 @@ import com.tasksprints.auction.common.entity.BaseEntityWithUpdate; import com.tasksprints.auction.payment.domain.entity.Payment; import com.tasksprints.auction.user.domain.entity.User; +import com.tasksprints.auction.wallet.exception.InSufficientBalanceException; import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Builder; @@ -57,6 +58,16 @@ public void addUser(User user) { } public void chargeBalance(BigDecimal amount) { - this.balance = this.balance.add(amount); + balance = balance.add(amount); + } + + public void deductBalance(BigDecimal amount) { + isSufficientBalance(amount); + balance = balance.subtract(amount); + } + private void isSufficientBalance(BigDecimal amount) { + if (balance.compareTo(amount) < 0) { + throw new InSufficientBalanceException("Insufficient Wallet balance"); + } } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index aa8ede58..5b008161 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -23,7 +23,7 @@ spring: properties: hibernate: format_sql: true -# default_batch_fetch_size: 100 + default_batch_fetch_size: 100 database-platform: org.hibernate.dialect.H2Dialect thymeleaf: @@ -37,6 +37,12 @@ spring: include: - jwt - payment + data: + redis: + host: localhost + port: 6379 + password: + timeout: 5000 springdoc: api-docs: @@ -49,6 +55,8 @@ springdoc: packages-to-scan: com.tasksprints.auction.api logging: level: + org.springframework.scheduling.annotation.AsyncAnnotationBeanPostProcessor: DEBUG + org.springframework.context.event: DEBUG org.hibernate.SQL: DEBUG org.hibernate.type.descriptor.sql.BasicBinder: INFO root: INFO @@ -56,3 +64,17 @@ server: port: 8080 +org : + jobrunr: + job-scheduler: + enabled: true # 작업 스케줄러 활성화 + background-job-server: + enabled: true # 백그라운드 서버 활성화 +# worker-count: 4 # 스레드 풀 수 + dashboard: + enabled: true # 대시보드 활성화 + port: 8000 # 대시보드 포트 (http://localhost:8000) + database: + table-prefix: JOBRUNR_ # JobRunr 테이블 접두사 + + diff --git a/src/test/java/com/tasksprints/auction/auction/application/AuctionServiceImplTest.java b/src/test/java/com/tasksprints/auction/auction/application/AuctionServiceImplTest.java index 4c190814..0a1b80ea 100644 --- a/src/test/java/com/tasksprints/auction/auction/application/AuctionServiceImplTest.java +++ b/src/test/java/com/tasksprints/auction/auction/application/AuctionServiceImplTest.java @@ -1,5 +1,6 @@ package com.tasksprints.auction.domain.auction.service; +import com.tasksprints.auction.auction.application.service.AuctionScheduleService; import com.tasksprints.auction.auction.domain.dto.request.AuctionRequest; import com.tasksprints.auction.auction.domain.dto.response.AuctionResponse; import com.tasksprints.auction.auction.exception.AuctionAlreadyClosedException; @@ -48,6 +49,8 @@ class AuctionServiceImplTest { @InjectMocks private AuctionServiceImpl auctionService; + @Mock + private AuctionScheduleService scheduleService; private User seller; @BeforeEach diff --git a/src/test/java/com/tasksprints/auction/auction/infrastructure/AuctionRepositoryTest.java b/src/test/java/com/tasksprints/auction/auction/infrastructure/AuctionRepositoryTest.java index ea29e9d5..86b75275 100644 --- a/src/test/java/com/tasksprints/auction/auction/infrastructure/AuctionRepositoryTest.java +++ b/src/test/java/com/tasksprints/auction/auction/infrastructure/AuctionRepositoryTest.java @@ -1,6 +1,5 @@ -package com.tasksprints.auction.domain.auction.repository; +package com.tasksprints.auction.auction.infrastructure; -import com.tasksprints.auction.auction.infrastructure.AuctionRepository; import com.tasksprints.auction.common.config.QueryDslConfig; import com.tasksprints.auction.auction.domain.dto.request.AuctionRequest; import com.tasksprints.auction.auction.domain.entity.Auction; @@ -24,6 +23,7 @@ import java.math.BigDecimal; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -86,7 +86,7 @@ private Product createProduct(User user, Auction auction, String productCategory user, auction, productCategory, - null + new ArrayList<>() ); } diff --git a/src/test/java/com/tasksprints/auction/payment/application/PaymentServiceImplTest.java b/src/test/java/com/tasksprints/auction/payment/application/PaymentServiceImplTest.java index 7151f42f..5ca540ae 100644 --- a/src/test/java/com/tasksprints/auction/payment/application/PaymentServiceImplTest.java +++ b/src/test/java/com/tasksprints/auction/payment/application/PaymentServiceImplTest.java @@ -43,15 +43,11 @@ public class PaymentServiceImplTest { @Mock private PaymentRepository paymentRepository; - private MockHttpSession session; - private User user; private Wallet wallet; @BeforeEach void setUp() { - session = new MockHttpSession(); - wallet = Wallet.builder() .id(1L) .balance(BigDecimal.ZERO) @@ -70,25 +66,6 @@ void setUp() { } - @Nested - @DisplayName("결제 전 세션 임시 저장 테스트") - class 임시_저장_테스트 { - @Test - void 결제_요청을_받았을_때_세션에_값이_저장되면_성공한다() { - //given - String orderId = "testOrderId"; - BigDecimal amount = BigDecimal.valueOf(1000.00); - PaymentRequest.Prepare prepareRequest = new PaymentRequest.Prepare(orderId, amount); - - //when - paymentService.prepare(session, prepareRequest); - //then - assertThat(session.getAttribute("orderId")).isEqualTo(orderId); - assertThat(session.getAttribute("amount")).isEqualTo(amount); - } - - } - @Nested @DisplayName("토스_페이_응답_처리") class handleTossPayResponse { diff --git a/src/test/java/com/tasksprints/auction/payment/infrastructure/redis/RedisServiceTest.java b/src/test/java/com/tasksprints/auction/payment/infrastructure/redis/RedisServiceTest.java new file mode 100644 index 00000000..18493b42 --- /dev/null +++ b/src/test/java/com/tasksprints/auction/payment/infrastructure/redis/RedisServiceTest.java @@ -0,0 +1,23 @@ +package com.tasksprints.auction.payment.infrastructure.redis; + +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +@ExtendWith(MockitoExtension.class) +class RedisServiceTest { + @InjectMocks + private RedisService redisService; + @Mock + private StringRedisTemplate redisTemplate; + @Mock + private ValueOperations valueOperations; + + /** + * Redis 자체를 테스트하는 것은 무의미한 것 같다.. + */ + +} diff --git a/src/test/java/com/tasksprints/auction/payment/presentation/PaymentControllerTest.java b/src/test/java/com/tasksprints/auction/payment/presentation/PaymentControllerTest.java index 961fc6e2..07edfd1c 100644 --- a/src/test/java/com/tasksprints/auction/payment/presentation/PaymentControllerTest.java +++ b/src/test/java/com/tasksprints/auction/payment/presentation/PaymentControllerTest.java @@ -1,14 +1,20 @@ package com.tasksprints.auction.payment.presentation; import com.tasksprints.auction.BaseControllerTest; + +import com.tasksprints.auction.auth.application.resolver.AuthenticationResolver; +import com.tasksprints.auction.auth.domain.model.Accessor; + import com.tasksprints.auction.common.config.TestMockResolverConfig; import com.tasksprints.auction.common.constant.ApiResponseMessages; + import com.tasksprints.auction.payment.api.Response; +import com.tasksprints.auction.payment.application.service.PaymentService; import com.tasksprints.auction.payment.domain.dto.response.PaymentErrorResponse; import com.tasksprints.auction.payment.domain.dto.response.PaymentResponse; -import com.tasksprints.auction.payment.exception.InvalidSessionException; import com.tasksprints.auction.payment.exception.PaymentDataMismatchException; -import com.tasksprints.auction.payment.application.service.PaymentService; +import com.tasksprints.auction.payment.exception.RedisKeyNotFoundException; +import com.tasksprints.auction.payment.infrastructure.redis.RedisService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -19,14 +25,14 @@ import org.springframework.context.annotation.Import; import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; import org.springframework.http.MediaType; -import org.springframework.mock.web.MockHttpSession; import org.springframework.test.web.servlet.MockMvc; import java.math.BigDecimal; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; +import static com.tasksprints.auction.common.constant.ApiResponseMessages.PAYMENT_PREPARED_SUCCESS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -38,16 +44,20 @@ public class PaymentControllerTest extends BaseControllerTest { @Autowired private MockMvc mockMvc; - @MockBean private PaymentService paymentService; + @MockBean + private RedisService redisService; @MockBean - MockHttpSession session; + private AuthenticationResolver authResolver; + private Accessor accessor; @BeforeEach - void setup() { - session = new MockHttpSession(); + void setup() throws Exception { + accessor = Accessor.user(1L); + when(authResolver.supportsParameter(any())).thenReturn(true); + when(authResolver.resolveArgument(any(),any(),any(),any())).thenReturn(accessor); } @Test @@ -55,75 +65,69 @@ void setup() { public void 결제_전_임시_값_저장() throws Exception { String jsonRequest = """ { - "orderId": "test1", + "orderId": "orderId", "amount": 1000.00 } """; mockMvc.perform(post("/api/v1/payment/prepare") - .session(session) .contentType(MediaType.APPLICATION_JSON) .content(jsonRequest)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value(ApiResponseMessages.PAYMENT_PREPARED_SUCCESS)); + .andExpect(jsonPath("$.message").value(PAYMENT_PREPARED_SUCCESS)); + verify(redisService).saveDataWithExpiration( + eq("orderId"), + eq("1000.00"), + eq(300L) + ); } @Nested - class sessionTest { - - + class RedisTest { @Test - void 결제_전_세션_값이_null인_경우_예외가_발생한다() throws Exception { + void 결제_전_Redis_key_value값이_null인_경우_예외가_발생한다() throws Exception { // Given String jsonRequest = """ { - "orderId": "12345", + "orderId": "orderId", "amount": 10000 } """; + when(redisService.getValue(any(String.class))).thenReturn(null); // When & Then mockMvc.perform(post("/api/v1/payment/confirm") - .session(session) - .param("userId", "1") .contentType(MediaType.APPLICATION_JSON) .content(jsonRequest)) .andExpect(status().isBadRequest()) - .andExpect(result -> { - Exception resolvedException = result.getResolvedException(); - assertNotNull(resolvedException); - assertInstanceOf(InvalidSessionException.class, resolvedException); - }); + .andExpect(result -> assertThat(result.getResolvedException()) + .isInstanceOf(RedisKeyNotFoundException.class) + .hasMessage("Redis key 'orderId' not found.")); + } @Test - void 결제_전_세션_OrderId와_Request의_OrderId가_다른_경우_예외가_발생한다() throws Exception { + @DisplayName("결제 전 Redis에 저장된 amount와 결제 요청 전 request의 amount가 다르면 예외가 발생한다") + void 결제_amount가_결제_과정중_변경되면_예외가_발생한다() throws Exception { // Given String jsonRequest = """ { - "orderId": "12345", + "orderId": "orderId", "amount": 10000 } """; - MockHttpSession session = new MockHttpSession(); - session.setAttribute("orderId", "changed-OrderId"); - session.setAttribute("amount", BigDecimal.valueOf(10000)); // + when(redisService.getValue(any(String.class))).thenReturn("99999"); //changed-amount // When & Then mockMvc.perform(post("/api/v1/payment/confirm") - .session(session) - .param("userId", "1") .contentType(MediaType.APPLICATION_JSON) .content(jsonRequest)) .andExpect(status().isBadRequest()) - .andExpect(result -> { - Exception resolvedException = result.getResolvedException(); - assertNotNull(resolvedException); - assertInstanceOf(PaymentDataMismatchException.class, resolvedException); - assertEquals("Payment data mismatch", resolvedException.getMessage()); + .andExpect(result -> assertThat(result.getResolvedException()) + .isInstanceOf(PaymentDataMismatchException.class) + .hasMessage("Payment data mismatch : Amount does not match the previous value")); - }); } } @@ -133,50 +137,41 @@ class sessionTest { // Given String jsonRequest = """ { - "orderId": "12345", + "orderId": "orderId", "amount": 10000 } """; - MockHttpSession session = new MockHttpSession(); - session.setAttribute("orderId", "12345"); - session.setAttribute("amount", BigDecimal.valueOf(10000)); - - PaymentResponse successPaymentResponse = new PaymentResponse("CARD", "paymentKey", BigDecimal.valueOf(10000), "Test Order", "12345", "DONE"); + PaymentResponse successPaymentResponse = new PaymentResponse("CARD", "paymentKey", BigDecimal.valueOf(10000), "Test Order", "orderId", "DONE"); Response mockResponse = Response.success(200, successPaymentResponse); + when(redisService.getValue("orderId")).thenReturn("10000"); when(paymentService.sendPaymentRequest(any())).thenReturn(mockResponse); when(paymentService.handleTossPaymentResponse(anyLong(), any(), any())) .thenReturn(mockResponse); // When / Then mockMvc.perform(post("/api/v1/payment/confirm") - .session(session) - .param("userId", "1") .contentType(MediaType.APPLICATION_JSON) .content(jsonRequest)) .andExpect(status().isOk()) .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.message").value("결제가 성공적으로 처리되었습니다.")) - .andExpect(jsonPath("$.data.orderId").value("12345")) + .andExpect(jsonPath("$.data.orderId").value("orderId")) .andExpect(jsonPath("$.data.totalAmount").value(10000)); } @Test - @DisplayName("결제 승인 성공 시 HTTP 400 응답을 반환한다") + @DisplayName("결제 승인 실패 시 HTTP 400 응답을 반환한다") void 결제_승인_실패_시_응답() throws Exception { // Given String jsonRequest = """ { - "orderId": "12345", + "orderId": "orderId", "amount": 10000 } """; - MockHttpSession session = new MockHttpSession(); - session.setAttribute("orderId", "12345"); - session.setAttribute("amount", BigDecimal.valueOf(10000)); - PaymentErrorResponse failurePaymentResponse = PaymentErrorResponse.builder() .version("2022-11-16") .traceId("{traceId}") @@ -185,14 +180,13 @@ class sessionTest { .build(); Response mockResponse = Response.failure(400, failurePaymentResponse); + when(redisService.getValue("orderId")).thenReturn("10000"); when(paymentService.sendPaymentRequest(any())).thenReturn(mockResponse); when(paymentService.handleTossPaymentResponse(anyLong(), any(), any())) .thenReturn(mockResponse); // When / Then mockMvc.perform(post("/api/v1/payment/confirm") - .session(session) - .param("userId", "1") .contentType(MediaType.APPLICATION_JSON) .content(jsonRequest)) .andExpect(status().isBadRequest()) diff --git a/src/test/java/com/tasksprints/auction/user/presentation/UserControllerTest.java b/src/test/java/com/tasksprints/auction/user/presentation/UserControllerTest.java index cacbc308..dd982aff 100644 --- a/src/test/java/com/tasksprints/auction/user/presentation/UserControllerTest.java +++ b/src/test/java/com/tasksprints/auction/user/presentation/UserControllerTest.java @@ -51,7 +51,7 @@ class SuccessfulTests { @Test @DisplayName("POST /api/v1/user - 성공") void registerUser() throws Exception { - UserDetailResponse userDetailResponse = new UserDetailResponse(1L, "John", "john@example.com", "password", "john123", "1L"); + UserDetailResponse userDetailResponse = new UserDetailResponse(1L, 1L,"John", "john@example.com", "password", "john123"); Mockito.when(userService.createUser(any(UserRequest.Register.class))).thenReturn(userDetailResponse); @@ -70,7 +70,7 @@ void registerUser() throws Exception { @Test @DisplayName("GET /api/v1/user/{id} - 성공") void getUserById() throws Exception { - UserDetailResponse userDetailResponse = new UserDetailResponse(1L, "John", "john@example.com", "password", "john123", "1L"); + UserDetailResponse userDetailResponse = new UserDetailResponse(1L, 1L,"John", "john@example.com", "password", "john123" ); Mockito.when(userService.getUserDetailsById(anyLong())).thenReturn(userDetailResponse); @@ -100,7 +100,7 @@ void getAllUsers() throws Exception { @Test @DisplayName("PUT /api/v1/user - 성공") void updateUser() throws Exception { - UserDetailResponse userDetailResponse = new UserDetailResponse(1L, "John Updated", "john@example.com", "newpassword", "john123updated", "1L"); + UserDetailResponse userDetailResponse = new UserDetailResponse(1L, 1L,"John Updated", "john@example.com", "newpassword", "john123updated" ); Mockito.when(userService.updateUser(anyLong(), any(UserRequest.Update.class))).thenReturn(userDetailResponse);