diff --git a/src/main/java/org/cotato/csquiz/common/config/WebConfig.java b/src/main/java/org/cotato/csquiz/common/config/WebConfig.java new file mode 100644 index 00000000..cc51c2e9 --- /dev/null +++ b/src/main/java/org/cotato/csquiz/common/config/WebConfig.java @@ -0,0 +1,22 @@ +package org.cotato.csquiz.common.config; + +import lombok.RequiredArgsConstructor; +import org.cotato.csquiz.common.idempotency.IdempotencyInterceptor; +import org.cotato.csquiz.common.idempotency.IdempotencyRedisRepository; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + + private final IdempotencyRedisRepository idempotencyRedisRepository; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(new IdempotencyInterceptor(idempotencyRedisRepository)) + .addPathPatterns("/v1/api/record/reply") + .order(1); + } +} diff --git a/src/main/java/org/cotato/csquiz/common/error/ErrorCode.java b/src/main/java/org/cotato/csquiz/common/error/ErrorCode.java index a7b78c51..c1258a05 100644 --- a/src/main/java/org/cotato/csquiz/common/error/ErrorCode.java +++ b/src/main/java/org/cotato/csquiz/common/error/ErrorCode.java @@ -52,10 +52,12 @@ public enum ErrorCode { CONTENT_IS_ALREADY_ANSWER(HttpStatus.BAD_REQUEST, "Q-303", "이미 정답인 답을 추가했습니다"), QUIZ_ACCESS_DENIED(HttpStatus.BAD_REQUEST, "Q-401", "해당 퀴즈는 아직 접근할 수 없습니다."), QUIZ_TYPE_NOT_MATCH(HttpStatus.BAD_REQUEST, "Q-402", "주관식 정답만 추가 가능합니다."), - + + KING_MEMBER_EXIST(HttpStatus.CONFLICT, "K-301", "이미 킹킹 멤버가 존재합니다"), + SUBJECT_INVALID(HttpStatus.BAD_REQUEST, "E-000", "교육 주제는 NULL이거나 비어있을 수 없습니다."), - KING_MEMBER_EXIST(HttpStatus.CONFLICT, "K-301", "이미 킹킹 멤버가 존재합니다"), + PROCESSING(HttpStatus.CONFLICT, "D-999", "해당 키의 요청은 아직 처리 중 입니다."), ALREADY_REPLY_CORRECT(HttpStatus.BAD_REQUEST, "R-301", "해당 사용자는 이미 정답 처리되었습니다."), diff --git a/src/main/java/org/cotato/csquiz/common/idempotency/CustomServletWrappingFilter.java b/src/main/java/org/cotato/csquiz/common/idempotency/CustomServletWrappingFilter.java new file mode 100644 index 00000000..6f285d8f --- /dev/null +++ b/src/main/java/org/cotato/csquiz/common/idempotency/CustomServletWrappingFilter.java @@ -0,0 +1,23 @@ +package org.cotato.csquiz.common.idempotency; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.ContentCachingResponseWrapper; + +@Component +public class CustomServletWrappingFilter extends OncePerRequestFilter { + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + final ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper(response); + filterChain.doFilter(request, responseWrapper); + + responseWrapper.copyBodyToResponse(); + } +} diff --git a/src/main/java/org/cotato/csquiz/common/idempotency/IdempotencyInterceptor.java b/src/main/java/org/cotato/csquiz/common/idempotency/IdempotencyInterceptor.java new file mode 100644 index 00000000..becb807d --- /dev/null +++ b/src/main/java/org/cotato/csquiz/common/idempotency/IdempotencyInterceptor.java @@ -0,0 +1,71 @@ +package org.cotato.csquiz.common.idempotency; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.cotato.csquiz.common.error.ErrorCode; +import org.cotato.csquiz.common.error.response.ErrorResponse; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.ModelAndView; +import org.springframework.web.util.ContentCachingResponseWrapper; + +@Slf4j +@Component +@RequiredArgsConstructor +public class IdempotencyInterceptor implements HandlerInterceptor { + + private static final String IDEMPOTENCY_HEADER = "Idempotency-Key"; + private final IdempotencyRedisRepository idempotencyRedisRepository; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) + throws Exception { + String idempotencyKey = request.getHeader(IDEMPOTENCY_HEADER); + if (idempotencyKey == null) { + return true; + } + ObjectMapper objectMapper = new ObjectMapper(); + + if (idempotencyRedisRepository.hasSucceedResult(idempotencyKey)) { + response.getWriter() + .write(objectMapper.writeValueAsString( + idempotencyRedisRepository.getSucceedResponse(idempotencyKey))); + log.info("[멱등성 DB에 데이터 존재]"); + return false; + } + + if (idempotencyRedisRepository.isProcessing(idempotencyKey)) { + response.setStatus(HttpStatus.CONFLICT.value()); + response.setContentType("application/json; charset=UTF-8"); + response.getWriter() + .write(objectMapper.writeValueAsString(ErrorResponse.of(ErrorCode.PROCESSING, request))); + log.warn("[요청은 왔지만 아직 처리 중]"); + return false; + } + + // 캐시에 결과가 존재하지 않으면 -> 처리중이란 값을 넣고 컨트롤러를 실행함 + idempotencyRedisRepository.saveStatusProcessing(idempotencyKey); + return true; + } + + @Override + public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, + ModelAndView modelAndView) throws Exception { + String idempotencyKey = request.getHeader(IDEMPOTENCY_HEADER); + if (idempotencyKey == null) { + return; + } + ObjectMapper objectMapper = new ObjectMapper(); + + final ContentCachingResponseWrapper responseWrapper = (ContentCachingResponseWrapper) response; + + idempotencyRedisRepository.saveSucceedResult(idempotencyKey, + objectMapper.readTree(responseWrapper.getContentAsByteArray())); + + responseWrapper.copyBodyToResponse(); + } +} diff --git a/src/main/java/org/cotato/csquiz/common/idempotency/IdempotencyRedisRepository.java b/src/main/java/org/cotato/csquiz/common/idempotency/IdempotencyRedisRepository.java new file mode 100644 index 00000000..916f30ce --- /dev/null +++ b/src/main/java/org/cotato/csquiz/common/idempotency/IdempotencyRedisRepository.java @@ -0,0 +1,75 @@ +package org.cotato.csquiz.common.idempotency; + +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +@Slf4j +@Repository +@RequiredArgsConstructor +public class IdempotencyRedisRepository { + + private static final String KEY_PREFIX = "$Idempotency "; + private static final int RESPONSE_EXPIRATION = 30; + + private final RedisTemplate redisTemplate; + + + public boolean hasSucceedResult(String idempotencyKey) { + String key = KEY_PREFIX + idempotencyKey; + IdempotencyResponse response = (IdempotencyResponse) redisTemplate.opsForValue().get(key); + + if (response == null) { + return false; + } + return response.isSucceed(); + } + + public void saveStatusProcessing(String idempotencyKey) { + String key = KEY_PREFIX + idempotencyKey; + IdempotencyResponse response = IdempotencyResponse.builder() + .processStatus(ProcessStatus.PROCESSING) + .build(); + + redisTemplate.opsForValue().set( + key, + response, + RESPONSE_EXPIRATION, + TimeUnit.MINUTES + ); + } + + public boolean isProcessing(String idempotencyKey) { + String key = KEY_PREFIX + idempotencyKey; + IdempotencyResponse response = (IdempotencyResponse) redisTemplate.opsForValue().get(key); + log.info("[처리 중]"); + if (response == null) { + return false; + } + return response.isProcessing(); + } + + public Object getSucceedResponse(String idempotencyKey) { + String key = KEY_PREFIX + idempotencyKey; + IdempotencyResponse savedData = (IdempotencyResponse) redisTemplate.opsForValue().get(key); + return savedData.getResult(); + } + + public void saveSucceedResult(String idempotencyKey, Object result) { + final String key = KEY_PREFIX + idempotencyKey; + + IdempotencyResponse response = IdempotencyResponse.builder() + .processStatus(ProcessStatus.SUCCESS) + .result(result) + .build(); + + redisTemplate.opsForValue().set( + key, + response, + RESPONSE_EXPIRATION, + TimeUnit.MINUTES + ); + } +} diff --git a/src/main/java/org/cotato/csquiz/common/idempotency/IdempotencyResponse.java b/src/main/java/org/cotato/csquiz/common/idempotency/IdempotencyResponse.java new file mode 100644 index 00000000..08cb812f --- /dev/null +++ b/src/main/java/org/cotato/csquiz/common/idempotency/IdempotencyResponse.java @@ -0,0 +1,23 @@ +package org.cotato.csquiz.common.idempotency; + +import java.io.Serializable; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; + +@Builder +@AllArgsConstructor +@Data +public class IdempotencyResponse implements Serializable { + + private ProcessStatus processStatus; + private Object result; + + public boolean isProcessing() { + return this.processStatus == ProcessStatus.PROCESSING; + } + + public boolean isSucceed() { + return this.processStatus == ProcessStatus.SUCCESS; + } +} diff --git a/src/main/java/org/cotato/csquiz/common/idempotency/ProcessStatus.java b/src/main/java/org/cotato/csquiz/common/idempotency/ProcessStatus.java new file mode 100644 index 00000000..2d4caf4f --- /dev/null +++ b/src/main/java/org/cotato/csquiz/common/idempotency/ProcessStatus.java @@ -0,0 +1,16 @@ +package org.cotato.csquiz.common.idempotency; + +import lombok.AllArgsConstructor; +import org.cotato.csquiz.common.error.ErrorCode; + +@AllArgsConstructor +public enum ProcessStatus { + + + PROCESSING("현재 해당 요청 처리 중"), + SUCCESS("요청 완료") + + ; + + private final String description; +}