Skip to content

Commit

Permalink
Merge pull request #47 from f-lab-edu/feature/46
Browse files Browse the repository at this point in the history
[#46] 경매 상품 결제(포인트) 기능 추가
  • Loading branch information
f-lab-moony authored Dec 1, 2024
2 parents 4085cf4 + 772d18d commit 4163bea
Show file tree
Hide file tree
Showing 19 changed files with 345 additions and 12 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import com.example.moduleapi.exception.file.CreateDirectoryFailException;
import com.example.moduleapi.exception.file.DeleteImageFailException;
import com.example.moduleapi.exception.file.ImageFileUploadFailException;
import com.example.moduleapi.exception.point.PointDeductionFailedException;
import com.example.moduleapi.exception.product.NotFoundProductException;
import com.example.moduleapi.exception.product.ProductNotPendingException;
import com.example.moduleapi.exception.product.UnauthorizedEnrollException;
Expand Down Expand Up @@ -105,4 +106,10 @@ public ErrorResponse redisLockAcquisitionException(RedisLockAcquisitionException
public ErrorResponse redisLockOperationException(RedisLockInterruptedException e) {
return new ErrorResponse(e.getMessage());
}

@ExceptionHandler(PointDeductionFailedException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse pointDeductionFailedException(PointDeductionFailedException e) {
return new ErrorResponse(e.getMessage());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.example.moduleapi.controller.point;

import com.example.moduleapi.controller.request.point.PointAmount;
import com.example.moduleapi.controller.response.point.PointResponse;
import com.example.moduleapi.service.point.PointService;
import com.example.moduledomain.domain.user.CustomUserDetails;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/v1/point")
public class PointController {

private final PointService pointService;

public PointController(PointService pointService) {
this.pointService = pointService;
}

// 포인트 충전
@PostMapping("/charge")
public PointResponse chargePoint(
@AuthenticationPrincipal CustomUserDetails customUserDetails,
@RequestBody PointAmount pointAmount) {
int totalPoint = pointService.chargePoint(customUserDetails, pointAmount);
PointResponse pointResponse = PointResponse.builder()
.currentPoint(totalPoint)
.message("포인트가 성공적으로 충전되었습니다.")
.build();
return pointResponse;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.example.moduleapi.controller.request.point;

import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
public class PointAmount {
private int amount;

public PointAmount(int amount) {
this.amount = amount;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.example.moduleapi.controller.response.point;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
public class PointResponse {
private int currentPoint;
private String message;

@Builder
public PointResponse(int currentPoint, String message) {
this.currentPoint = currentPoint;
this.message = message;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.example.moduleapi.exception.point;

public class PointDeductionFailedException extends RuntimeException {
public PointDeductionFailedException(Long userId) {
super(userId + ": 포인트가 부족합니다.");
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package com.example.moduleapi.service.auction;

import com.example.moduleapi.controller.request.auction.BidRequest;
import com.example.moduleapi.controller.request.point.PointAmount;
import com.example.moduleapi.controller.response.auction.BidResponse;
import com.example.moduleapi.controller.response.product.ProductFindResponse;
import com.example.moduleapi.exception.auction.BiddingFailException;
import com.example.moduleapi.exception.auction.RedisLockAcquisitionException;
import com.example.moduleapi.exception.auction.RedisLockInterruptedException;
import com.example.moduleapi.service.point.PointService;
import com.example.moduleapi.service.product.ProductFacade;
import com.example.moduledomain.domain.user.CustomUserDetails;
import org.redisson.api.RLock;
Expand All @@ -25,12 +27,14 @@ public class AuctionService {
private final HighestBidSseNotificationService bidSseNotificationService;
private final RedissonClient redissonClient;
private final KafkaProducerService kafkaProducerService;
private final PointService pointService;

public AuctionService(ProductFacade productFacade, HighestBidSseNotificationService bidSseNotificationService, RedissonClient redissonClient, KafkaProducerService kafkaProducerService) {
public AuctionService(ProductFacade productFacade, HighestBidSseNotificationService bidSseNotificationService, RedissonClient redissonClient, KafkaProducerService kafkaProducerService, PointService pointService) {
this.productFacade = productFacade;
this.bidSseNotificationService = bidSseNotificationService;
this.redissonClient = redissonClient;
this.kafkaProducerService = kafkaProducerService;
this.pointService = pointService;
}

@Transactional
Expand Down Expand Up @@ -70,10 +74,11 @@ private Long processBid(CustomUserDetails user, BidRequest bidRequest, Long prod
return updateRedisBidData(user, highestBidMap, bidRequest, productId);
}
if (bidRequest.getBiddingPrice() <= userIdAndCurrentPrice.getSecond()) {
pointService.rollbackPoint(user.getUser().getId(), bidRequest.getBiddingPrice());
throw new BiddingFailException(user.getUser().getUserId(), bidRequest.getBiddingPrice(), productId);
}

//updateRedisBidData(user, highestBidMap, bidRequest, productId);
pointService.rollbackPoint(userIdAndCurrentPrice.getFirst(), userIdAndCurrentPrice.getSecond().intValue());
return updateRedisBidData(user, highestBidMap, bidRequest, productId);
}

Expand All @@ -83,6 +88,9 @@ private void isBiddingAvailable(CustomUserDetails user, BidRequest bidRequest, L
if (biddingRequestTime.isAfter(product.getCloseDate())) {
throw new BiddingFailException(user.getUser().getUserId(), bidRequest.getBiddingPrice(), productId);
}

PointAmount pointAmount = new PointAmount(bidRequest.getBiddingPrice());
pointService.deductPoint(user, pointAmount);
}

private Long updateRedisBidData(CustomUserDetails user, RMap<Long, Pair<Long, Long>> bidMap, BidRequest bidRequest,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.example.moduleapi.service.point;

import com.example.moduleapi.controller.request.point.PointAmount;
import com.example.moduleapi.exception.point.PointDeductionFailedException;
import com.example.moduledomain.domain.point.Point;
import com.example.moduledomain.domain.user.CustomUserDetails;
import com.example.moduledomain.domain.user.User;
import com.example.moduledomain.repository.user.PointRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class PointService {
private final PointRepository pointRepository;

public PointService(PointRepository pointRepository) {
this.pointRepository = pointRepository;
}

// 포인트 충전
@Transactional
public int chargePoint(CustomUserDetails userDetails, PointAmount pointAmount) {
User user = userDetails.getUser();
Point point = pointRepository.findByUserId(user.getId());
point.plus(pointAmount.getAmount());
return point.getAmount();
}

// 포인트 차감
@Transactional
public int deductPoint(CustomUserDetails userDetails, PointAmount pointAmount) {
User user = userDetails.getUser();
Point point = pointRepository.findByUserId(user.getId());

validateDeductPoint(pointAmount, user, point);

point.minus(pointAmount.getAmount());
return point.getAmount();
}

// 포인트 롤백
@Transactional
public void rollbackPoint(Long userId, int plusAmount) {
Point point = pointRepository.findByUserId(userId);
point.plus(plusAmount);
}

private static void validateDeductPoint(PointAmount pointAmount, User user, Point point) {
if (point.getAmount() < pointAmount.getAmount()) {
throw new PointDeductionFailedException(user.getId());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
import com.example.moduleapi.exception.user.DuplicatedUserIdException;
import com.example.moduleapi.exception.user.NotFoundUserException;
import com.example.moduleapi.exception.user.UnauthorizedUserException;
import com.example.moduledomain.domain.point.Point;
import com.example.moduledomain.domain.user.User;
import com.example.moduledomain.repository.user.PointRepository;
import com.example.moduledomain.repository.user.UserRepository;
import com.google.common.base.Preconditions;
import org.springframework.security.core.userdetails.UserDetails;
Expand All @@ -20,10 +22,12 @@
public class UserService {

private final UserRepository userRepository;
private final PointRepository pointRepository;
private final BCryptPasswordEncoder bCryptPasswordEncoder;

public UserService(UserRepository userRepository, BCryptPasswordEncoder bCryptPasswordEncoder) {
public UserService(UserRepository userRepository, PointRepository pointRepository, BCryptPasswordEncoder bCryptPasswordEncoder) {
this.userRepository = userRepository;
this.pointRepository = pointRepository;
this.bCryptPasswordEncoder = bCryptPasswordEncoder;
}

Expand All @@ -37,6 +41,11 @@ public UserResponse join(UserSaveRequest userSaveRequest) {
user.updateEncodedPassword(encodedPassword);

User savedUser = userRepository.save(user);
Point point = Point.builder()
.userId(user.getId())
.amount(0)
.build();
pointRepository.save(point);
return UserResponse.from(savedUser);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.example.moduleapi.fixture.point

import com.example.moduledomain.domain.point.Point

class PointFixtures {
static Point createPoint(Map map = [:]) {
return Point.builder()
.userId(map.getOrDefault("userId", 1L) as Long)
.amount(map.getOrDefault("amount", 0) as int)
.build()
}

}

Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.example.moduleapi.fixture.UserFixtures
package com.example.moduleapi.fixture.user

import com.example.moduledomain.domain.user.User

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import com.example.moduleapi.controller.request.auction.BidRequest
import com.example.moduleapi.controller.response.product.ProductFindResponse
import com.example.moduleapi.exception.auction.BiddingFailException
import com.example.moduleapi.exception.auction.RedisLockAcquisitionException
import com.example.moduleapi.fixture.UserFixtures.UserFixtures
import com.example.moduleapi.exception.point.PointDeductionFailedException
import com.example.moduleapi.fixture.product.ProductFixtures
import com.example.moduleapi.fixture.user.UserFixtures
import com.example.moduleapi.service.point.PointService
import com.example.moduleapi.service.product.ProductFacade
import com.example.moduledomain.domain.user.CustomUserDetails
import com.example.moduledomain.domain.user.User
Expand All @@ -23,7 +25,9 @@ class AuctionServiceTest extends Specification {

ProductFacade productFacade = Mock()
HighestBidSseNotificationService bidSseNotificationService = Mock()
AuctionService auctionService = new AuctionService(productFacade, bidSseNotificationService, redissonClient)
KafkaProducerService kafkaProducerService = Mock()
PointService pointService = Mock()
AuctionService auctionService = new AuctionService(productFacade, bidSseNotificationService, redissonClient, kafkaProducerService, pointService)

def "경매 입찰 성공"() {
given:
Expand Down Expand Up @@ -143,4 +147,47 @@ class AuctionServiceTest extends Specification {
def e = thrown(BiddingFailException.class)
e.message == String.format("입찰자: %s, 입찰가: %d, 입찰 상품: %d - 입찰 실패.", user.getUserId(), bidRequest.getBiddingPrice(), 1L)
}

def "포인트 부족으로 인한 입찰 실패"() {
given:
// Redisson Lock 관련
RLock lock = Mock()
redissonClient.getLock(_) >> lock
lock.tryLock(5, 10, TimeUnit.SECONDS) >> true
lock.isHeldByCurrentThread() >> true

// 입찰 제시
BidRequest bidRequest = new BidRequest(5000)

// User 관련
User user = UserFixtures.createUser()
user.id = 1L
CustomUserDetails customUserDetails = Mock()
customUserDetails.getUser() >> user

// Redisson Map 관련
RMap<Long, Pair<Long, Long>> highestBidMap = Mock()
Pair<Long, Long> userIdAndCurrentPrice = new Pair<>(1L, 7000L)
highestBidMap.get(1L) >> userIdAndCurrentPrice
userIdAndCurrentPrice.getSecond() >> 7000L
redissonClient.getMap(_) >> highestBidMap

// Product 관련
ProductFindResponse productFindResponse = ProductFixtures.createProductFindResponse()
productFacade.findById(1L) >> productFindResponse

// Point
pointService.deductPoint(customUserDetails, _) >> { throw new PointDeductionFailedException(1L) }

when:
auctionService.biddingPrice(customUserDetails, bidRequest, 1L)

then:
def e = thrown(PointDeductionFailedException.class)
e.message == 1L + ": 포인트가 부족합니다."
0 * kafkaProducerService.publishAuctionPriceChangeNotification(_, _)
0 * bidSseNotificationService.sendToAllUsers(_, _, _)
}


}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.example.moduleapi.service.file

import com.example.moduleapi.fixture.UserFixtures.UserFixtures
import com.example.moduleapi.fixture.product.ProductFixtures
import com.example.moduleapi.fixture.user.UserFixtures
import com.example.moduledomain.domain.product.Product
import com.example.moduledomain.domain.user.User
import spock.lang.Specification
Expand Down
Loading

0 comments on commit 4163bea

Please sign in to comment.