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

[feat] 멱등성키 적용을 통한 중복결제 방지 #155

Merged
merged 9 commits into from
Aug 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 16 additions & 0 deletions src/main/java/camp/woowak/lab/infra/aop/idempotent/Idempotent.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package camp.woowak.lab.infra.aop.idempotent;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
boolean throwError() default false;

Class<? extends RuntimeException> throwable() default RuntimeException.class;

String exceptionMessage() default "";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package camp.woowak.lab.infra.aop.idempotent;

import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.time.Duration;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import camp.woowak.lab.infra.aop.idempotent.exception.IdempotencyKeyNotExistsException;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class IdempotentAop {
private static final String REDIS_IDEMPOTENT_KEY = "IDEMPOTENT_KEY: ";
private final RedisTemplate<String, Object> redisTemplate;

@Around("@annotation(camp.woowak.lab.infra.aop.idempotent.Idempotent)")
public Object idempotentOperation(ProceedingJoinPoint joinPoint) throws Throwable {
HttpServletRequest request = ((ServletRequestAttributes)RequestContextHolder.currentRequestAttributes()).getRequest();
String idempotencyKey = request.getHeader("Idempotency-Key");
MethodSignature methodSignature = (MethodSignature)joinPoint.getSignature();
Method method = methodSignature.getMethod();
Idempotent idempotent = method.getAnnotation(Idempotent.class);

if (idempotencyKey == null || idempotencyKey.isEmpty()) {
throw new IdempotencyKeyNotExistsException("Idempotency-Key is required");
}

if (Boolean.TRUE.equals(redisTemplate.hasKey(REDIS_IDEMPOTENT_KEY + idempotencyKey))) {
if (idempotent.throwError()) {
Class<? extends RuntimeException> throwable = idempotent.throwable();
String exceptionMessage = idempotent.exceptionMessage();
Constructor<? extends RuntimeException> constructor = throwable.getConstructor(String.class);
RuntimeException exceptions = constructor.newInstance(exceptionMessage);

throw exceptions;
}
return redisTemplate.opsForValue().get(REDIS_IDEMPOTENT_KEY + idempotencyKey);
}

Object proceed = joinPoint.proceed();
redisTemplate.opsForValue()
.setIfAbsent(REDIS_IDEMPOTENT_KEY + idempotencyKey, proceed, Duration.ofSeconds(60L));
return proceed;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package camp.woowak.lab.infra.aop.idempotent.exception;

import org.springframework.http.HttpStatus;

import camp.woowak.lab.common.exception.ErrorCode;

public enum IdempotencyKeyErrorCode implements ErrorCode {
IDEMPOTENCY_KEY_ERROR_CODE(HttpStatus.UNAUTHORIZED, "idem1", "인증 키가 필요합니다.");

private final HttpStatus status;
private final String errorCode;
private final String message;

IdempotencyKeyErrorCode(HttpStatus status, String errorCode, String message) {
this.status = status;
this.errorCode = errorCode;
this.message = message;
}

@Override
public int getStatus() {
return status.value();
}

@Override
public String getErrorCode() {
return errorCode;
}

@Override
public String getMessage() {
return message;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package camp.woowak.lab.infra.aop.idempotent.exception;

import camp.woowak.lab.common.exception.UnauthorizedException;

public class IdempotencyKeyNotExistsException extends UnauthorizedException {
public IdempotencyKeyNotExistsException(String message) {
super(IdempotencyKeyErrorCode.IDEMPOTENCY_KEY_ERROR_CODE, message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package camp.woowak.lab.order.exception;

import camp.woowak.lab.common.exception.BadRequestException;

public class CompletedOrderException extends BadRequestException {
public CompletedOrderException(String message) {
super(OrderErrorCode.ALREADY_COMPLETED_ORDER, message);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ public enum OrderErrorCode implements ErrorCode {
NOT_ENOUGH_BALANCE(HttpStatus.BAD_REQUEST, "o_1_2", "잔고가 부족합니다."),
NOT_FOUND_MENU(HttpStatus.BAD_REQUEST, "o_1_4", "없는 메뉴입니다."),
MIN_ORDER_PRICE(HttpStatus.BAD_REQUEST, "o_1_5", "최소 주문금액 이상을 주문해야 합니다."),
DUPLICATED_ORDER(HttpStatus.BAD_REQUEST, "o_1_6", "중복 결제 요청입니다.");
DUPLICATED_ORDER(HttpStatus.BAD_REQUEST, "o_1_6", "중복 결제 요청입니다."),
ALREADY_COMPLETED_ORDER(HttpStatus.BAD_REQUEST,"o_1_7","이미 처리된 주문입니다.");

private final int status;
private final String errorCode;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

import camp.woowak.lab.infra.aop.idempotent.Idempotent;
import camp.woowak.lab.order.exception.CompletedOrderException;
import camp.woowak.lab.order.service.OrderCreationService;
import camp.woowak.lab.order.service.RetrieveOrderListService;
import camp.woowak.lab.order.service.command.OrderCreationCommand;
Expand Down Expand Up @@ -43,17 +45,17 @@ public Page<OrderDTO> retrieveOrderListByStore(@AuthenticationPrincipal LoginVen
@PathVariable(name = "storeId") Long storeId,
Pageable pageable) {
RetrieveOrderListCommand command =
new RetrieveOrderListCommand(storeId, loginVendor.getId(), null,null,pageable);
new RetrieveOrderListCommand(storeId, loginVendor.getId(), null, null, pageable);
return retrieveOrderListService.retrieveOrderListOfStore(command);
}

@PostMapping("/orders")
@ResponseStatus(HttpStatus.CREATED)
@Idempotent(throwError = true, throwable = CompletedOrderException.class, exceptionMessage = "already completed order")
public OrderCreationResponse order(@AuthenticationPrincipal LoginCustomer loginCustomer) {
OrderCreationCommand command = new OrderCreationCommand(loginCustomer.getId());
Long createdId = orderCreationService.create(command);
log.info("Created order for customer {} with id {}", loginCustomer.getId(), createdId);
return new OrderCreationResponse(createdId);

}
}
45 changes: 39 additions & 6 deletions src/main/resources/templates/store.html
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,22 @@ <h2>메뉴</h2>
<p>장바구니에 메뉴를 추가했습니다.</p>
</div>
</div>

<div id="orderingModal" class="modal">
<div class="modal-content">
<p>결제 진행중입니다...</p>
</div>
</div>
<div id="emptyCartModal" class="modal">
<div class="modal-content">
<p>장바구니가 비어있습니다...!</p>
</div>
</div>
<div id="completedOrderModal" class="modal">
<div class="modal-content">
<p>이미 완료된 주문입니다...</p>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/uuid/8.3.2/uuid.min.js"></script>
<script th:inline="javascript">
function addToCart(menuId) {
fetch('/cart', {
Expand All @@ -186,7 +201,7 @@ <h2>메뉴</h2>
.then(response => response.json())
.then(data => {
if (data.status === 200) {
showModal();
showModal('addToCartModal');
updateCartInfo();
} else {
if (data.status === 401) {
Expand Down Expand Up @@ -222,8 +237,8 @@ <h2>메뉴</h2>
});
}

function showModal() {
var modal = document.getElementById('addToCartModal');
function showModal(id) {
var modal = document.getElementById(id);
modal.style.display = "block";
setTimeout(function () {
modal.style.display = "none";
Expand All @@ -234,21 +249,39 @@ <h2>메뉴</h2>
document.addEventListener('DOMContentLoaded', function () {
updateCartInfo();
});

const idempotencyKey = uuid.v4();
console.log("idempotencyKey = "+idempotencyKey);
function placeOrder() {
fetch('/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key':idempotencyKey
}
})
.then(response => response.json())
.then(data => {
if (data.status === 201) {
showOrderAnimation();
updateCartInfo(); // 주문 후 장바구니 정보 갱신
} else {
}
else if(data.status === 400){
if(data.errorCode === 'o_1_0'){
showModal('emptyCartModal');
}
else if(data.errorCode === 'o_1_6'){
showModal('orderingModal');
}
else if(data.errorCode === 'o_1_7'){
showModal('completedOrderModal');
}
}
else {
if (data.status === 401) {
if(data.errorCode === 'idem1'){
alert('잘못된 요청입니다. 새로고침 해서 다시 주문해주세요');
return;
}
alert('로그인이 필요합니다.');
location.href = '/view/login';
return;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package camp.woowak.lab.web.api.order;

import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.BDDMockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

import java.util.UUID;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;

import camp.woowak.lab.order.exception.OrderErrorCode;
import camp.woowak.lab.order.service.OrderCreationService;
import camp.woowak.lab.order.service.RetrieveOrderListService;
import camp.woowak.lab.order.service.command.OrderCreationCommand;
import camp.woowak.lab.web.authentication.LoginCustomer;
import camp.woowak.lab.web.dto.response.order.OrderCreationResponse;
import camp.woowak.lab.web.resolver.session.SessionConst;

@SpringBootTest
@AutoConfigureMockMvc
public class OrderApiControllerAOPTest {
@InjectMocks
private OrderApiController orderApiController;
@MockBean
private OrderCreationService orderCreationService;
@MockBean
private RetrieveOrderListService retrieveOrderListService;

@Autowired
private RedisTemplate<String, Object> redisTemplate;

@Autowired
private MockMvc mvc;

@Test
@DisplayName("[성공] 요청을 처리하면 멱등성키-결과값을 redis에 저장함")
void idempotentSuccess() throws Exception {
//given
String idempotentKey = UUID.randomUUID().toString();
UUID customerId = UUID.randomUUID();
MockHttpSession session = new MockHttpSession();
session.setAttribute(SessionConst.SESSION_CUSTOMER_KEY, new LoginCustomer(customerId));

given(orderCreationService.create(any(OrderCreationCommand.class)))
.willReturn(1L);

//when
ResultActions actions = mvc.perform(post("/orders")
.session(session)
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.header("Idempotency-Key", idempotentKey));

//then
actions.andExpect(status().isCreated());
// assertThat();
OrderCreationResponse response = (OrderCreationResponse)redisTemplate.opsForValue()
.get("IDEMPOTENT_KEY: " + idempotentKey);
assertThat(response.orderId()).isEqualTo(1L);
}

@Test
@DisplayName("[성공] 이전에 보낸 요청이 결과값을 저장했다면 서비스에 들어가지 않고 결과값을 반환")
void idempotentSuccessWithBeforeResult() throws Exception {
//given
String idempotentKey = UUID.randomUUID().toString();
UUID customerId = UUID.randomUUID();
MockHttpSession session = new MockHttpSession();
session.setAttribute(SessionConst.SESSION_CUSTOMER_KEY, new LoginCustomer(customerId));

given(orderCreationService.create(any(OrderCreationCommand.class)))
.willReturn(1L);

//when
ResultActions action1 = mvc.perform(post("/orders").session(session)
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.header("Idempotency-Key", idempotentKey));
ResultActions action2 = mvc.perform(post("/orders").session(session)
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.header("Idempotency-Key", idempotentKey));
ResultActions action3 = mvc.perform(post("/orders").session(session)
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.header("Idempotency-Key", idempotentKey));
ResultActions action4 = mvc.perform(post("/orders").session(session)
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.header("Idempotency-Key", idempotentKey));
ResultActions action5 = mvc.perform(post("/orders").session(session)
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.header("Idempotency-Key", idempotentKey));

//then
verify(orderCreationService, times(1)).create(any(OrderCreationCommand.class));
action1.andExpect(status().isCreated())
.andExpect(jsonPath("$.data.orderId").value(1L));
action2.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errorCode").value(OrderErrorCode.ALREADY_COMPLETED_ORDER.getErrorCode()));
action3.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errorCode").value(OrderErrorCode.ALREADY_COMPLETED_ORDER.getErrorCode()));
action4.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errorCode").value(OrderErrorCode.ALREADY_COMPLETED_ORDER.getErrorCode()));
action5.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errorCode").value(OrderErrorCode.ALREADY_COMPLETED_ORDER.getErrorCode()));
}

@Test
@DisplayName("[실패] 멱등성키를 가져오지 않으면 오류 출력")
void notExistsIdempotentFailure() throws Exception {
//given
UUID customerId = UUID.randomUUID();
MockHttpSession session = new MockHttpSession();
session.setAttribute(SessionConst.SESSION_CUSTOMER_KEY, new LoginCustomer(customerId));

//when
ResultActions actions = mvc.perform(post("/orders").session(session));

//then
actions.andExpect(status().isUnauthorized());
}

}