Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[#46] 경매 상품 결제(포인트) 기능 추가 #47

Merged
merged 8 commits into from
Dec 1, 2024
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());
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

redis 최고가 정보 변경 전에 포인트를 롤백해주도록 추가했습니다.
이러면 실제 결제 시 포인트 부족 문제를 해결할 수 있을 것 같습니다!
혹시 제가 놓치고 있는 부분이 있을까요?!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

지금은 괜찮아보여요!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵 감사합니다~!

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);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

경매 입찰이 가능한지 검증하는 메서드쪽에 포인트 차감하는 코드를 추가했습니다

}

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) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

트랜잭션이 안 걸린 것 같아요 ~

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

앗 자꾸... ㅠㅠ
추가하도록 하겠습니다!

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) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

배치에서 차감을 한다고 할 때, 이 사람이 다른 경매도 참여해서 실제 결제시에는 포인트가 부족할 수도 있겠네요!
그거에 대한 생각도 해보면 좋을 것 같아요!

Copy link
Collaborator Author

@JIWON27 JIWON27 Nov 25, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

경매 입찰 시도할때 가격만큼 포인트에서 차감을 시킨 다음에 낙찰받지 못하면 다시 포인트 돌려주는 방식으로 포인트 부족 문제를 해결할 수 있을꺼같은데 이 방식은 어떤지 궁금합니다!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요건 저번 멘토링때 얘기 나눴던 것 같네요! 전 좋은 방법 같습니다!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵 감사합니다!

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