Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
c1f8dac
Merge pull request #4 from TaskSprints/develop
na0th Dec 23, 2024
a6a4ec9
fix : Build 에러 수정 및 log 레벨 수정
na0th Dec 23, 2024
801059c
delete : viewCount 삭제
na0th Dec 23, 2024
54dd042
feat : SearchCondition Resolver에 유효 값 검증 추가
na0th Dec 24, 2024
7950838
refactor : Wallet ID 타입 변경(String -> Long)
na0th Dec 24, 2024
d3ffce0
refactor : 쿼리 최적화 (N+1 문제) 해결
na0th Dec 24, 2024
8f5f738
refactor : 결제 임시 값 저장 Session -> Redis 리팩터링
na0th Dec 27, 2024
4d8b566
feat : Auction 시작, 종료 시각이 되면 경매 상태가 변경된다.
na0th Dec 30, 2024
19d910f
refactor : 결제 시 JWT 토큰에서 userId 추출
na0th Dec 30, 2024
88a956a
fix : PaymentController 인증/인가 테스트 오류 수정
na0th Dec 31, 2024
d1c204f
refactor : 최고 입찰 시, Auction의 highestBid를 갱신한다
na0th Dec 31, 2024
109d19c
feat : 입찰 시 지갑 잔액 검증 추가
na0th Dec 31, 2024
3732f0f
refactor : 서비스에서 지갑 잔액 체크 로직을 도메인 모델로 이동
na0th Jan 1, 2025
8cbcf03
feat : Auction 종료 시 closed_at 생성
na0th Jan 1, 2025
e61fba6
feat : 낙찰 시 지갑 잔액 차감 비동기 이벤트 처리
na0th Jan 1, 2025
92f85b3
Merge pull request #5 from na0th/낙찰
na0th Jan 1, 2025
b10e418
fix : 입찰 시 Auction 수정 후 save() 누락해서 추가
na0th Jan 1, 2025
b6c92c5
refactor : 메서드 가독성을 위한 포맷팅
na0th Jan 2, 2025
4ba03c0
fix : 메서드 리팩터링 시 테스트 오류 해결
na0th Jan 2, 2025
4a04ef6
delete : highestBid, highestBidderId 삭제
na0th Jan 3, 2025
db378fb
refactor : initializer, auctionJob 수정
na0th Jan 3, 2025
842fc9f
fix : user 생성 시 wallet 생성 안되던 오류 해결
na0th Jan 3, 2025
6d88c12
Merge branch 'develop' into auction_product
na0th Jan 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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,
Expand All @@ -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.");
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Long, UUID> startJobMap = new ConcurrentHashMap<>();
private final Map<Long, UUID> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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);
}

Expand Down Expand Up @@ -100,8 +104,6 @@ public List<AuctionResponse> 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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
Expand All @@ -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)
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -16,6 +18,7 @@
@NoArgsConstructor
@AllArgsConstructor
@Getter
@SQLRestriction("closed_at is null")
@ToString
@Entity(name = "auction")
public class Auction extends BaseEntity {
Expand Down Expand Up @@ -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<Bid> 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()
Expand All @@ -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.");
}
}


}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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());
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.tasksprints.auction.auction.exception;

public class InvalidAuctionStateException extends RuntimeException {
public InvalidAuctionStateException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,8 @@ public interface AuctionRepository extends JpaRepository<Auction, Long>, Auction
@Query("SELECT a FROM auction a WHERE a.id = :auctionId")
Optional<Auction> findAuctionById(@Param("auctionId") Long auctionId);

@Query("SELECT a FROM auction a JOIN FETCH a.seller u WHERE a.id = :auctionId")
Optional<Auction> findAuctionByIdV2(@Param("auctionId") Long auctionId);

}

Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;

Expand All @@ -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"));
Expand All @@ -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);
Expand Down
Loading
Loading