diff --git a/.gitignore b/.gitignore index 377228ba..8a5c29d2 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ out/ application-private.yml application**local.yml +application**test.yml docker-compose/.env diff --git a/README.md b/README.md index e3b3c7b3..bbae63f6 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,25 @@
+### 대기열 화면 +
+

+ 대기열 화면 +

+
+ +### 예매 화면 +
+

+ 예매 화면 +

+
+ ## 🎯 프로젝트 목표 - **MSA**: 특정 서비스에 대한 부하가 증가할 때 해당 서비스만 독립적으로 스케일 아웃할 수 있는 MSA 적용 -- **느슨한 결합**: 메시지 큐를 활용한 비동기 처리로 각 서비스 간의 의존성을 최소화 +- **느슨한 결합**: 메시지 큐를 활용한 비동기 처리로 각 서비스 간의 의존성 최소화 - **고가용성**: 서버 장애 시에도 서비스가 지속적으로 운영될 수 있도록 고가용성 보장 @@ -135,23 +149,68 @@ CPU 사용량과 Load Average가 상대적으로 낮은 것을 확인할 수 있
-

결과 상세

- +

결과 상세

+ +- MVC + + mvc result + mvc cpu + mvc load + +- WebFlux + + wf result + wf cpu + wf load + +
+ +
+ +## 🥇 Jmeter 성능 비교를 통한 좌석 선점 방식 선정 + +Redis 데이터의 동시성 문제를 해결할 수 있는 Lua Script를 이용한 원자적 처리와, 분산락을 이용한 처리 두 가지 방법을 고민하였습니다. + +### 장단점 비교 + +  | 장점 | 단점 +-- | -- | -- +Lua Script | - 네트워크 호출을 최소화할 수 있음
- 락 해제 오류에 대한 위험성 없음 | - Redis 기본 함수를 알아야 함
- Spring 실행 시 디버깅이 어려움 +분산락 | - Spring 코드로 디버깅이 편리
- Redis 함수를 알지 못해도 쉽게 구현 가능 | - 분산락을 얻기 위한 네트워크 호출이 늘어남
- 분산락이 해제되지 않을 가능성이 존재 + +
+ +### Jmeter 성능 테스트 + +좌석 선점은 빠른 속도가 중요하다고 생각해 로컬 컴퓨터에서 `1,000`명의 동시 좌석 선점 속도를 비교해보았습니다. + +(CPU: AMD Ryzen 7 5700G, RAM: 32GB) + +image + +동일 환경에서 테스트한 결과 Lua Script에서 응답 속도가 2배 빠르고, 처리량도 더 높은 것을 확인할 수 있었습니다. + +
+

결과 상세

+ +- Lua Script + + ![LuaScript](https://github.com/user-attachments/assets/1623bcf9-29fc-4b7c-b9ac-726b1a39436a) + +- 분산락 + + ![Distributed Lock](https://github.com/user-attachments/assets/157b9dc0-ffab-402f-89b8-061ef566cbab) +
+
+ +### 결론 + +두 방식의 장단점과 실제 성능 결과를 바탕으로 속도도 빠르고 더 안정성도 높은 Lua Script를 활용해 좌석 선점을 구현하였습니다. + +
+ ## 📃 다이어그램 ### 🧑 유저 플로우 @@ -180,7 +239,8 @@ CPU 사용량과 Load Average가 상대적으로 낮은 것을 확인할 수 있

🎫 예매 시퀀스 다이어그램


-![ticketping_sa-예매 시퀀스 drawio (6)](https://github.com/user-attachments/assets/339a32a1-1c52-45dc-b605-6125262b3891) +![ticketping_sa-예매 시퀀스 drawio](https://github.com/user-attachments/assets/1dca9e45-dfa9-40fe-9d9b-045a10d46495) + @@ -192,7 +252,8 @@ CPU 사용량과 Load Average가 상대적으로 낮은 것을 확인할 수 있

✏️ ERD


-![ticketping-erd](https://github.com/user-attachments/assets/4604f251-4e39-4856-8d67-853d29622a86) +![image](https://github.com/user-attachments/assets/b7e067c7-ea8a-4a93-bc49-9466b7b1219b) + @@ -216,15 +277,27 @@ CPU 사용량과 Load Average가 상대적으로 낮은 것을 확인할 수 있 - [🥶 Kafka Cluster 적용하기](https://github.com/TicketPing/TicketPing-Final/wiki/%F0%9F%A5%B6-Kafka-Cluster-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0) +- [✅ 인증 인가 구상하기](https://github.com/TicketPing/TicketPing-Final/wiki/%E2%9C%85-%EC%9D%B8%EC%A6%9D-%EC%9D%B8%EA%B0%80-%EA%B5%AC%EC%83%81%ED%95%98%EA%B8%B0) + +- [🎫 좌석 예매 흐름 구상하기](https://github.com/TicketPing/TicketPing-Final/wiki/%F0%9F%8E%AB-%EC%A2%8C%EC%84%9D-%EC%98%88%EB%A7%A4-%ED%9D%90%EB%A6%84-%EA%B5%AC%EC%83%81%ED%95%98%EA%B8%B0) + +- [💺 좌석 데이터 캐싱 구상하기](https://github.com/TicketPing/TicketPing-Final/wiki/%F0%9F%92%BA-%EC%A2%8C%EC%84%9D-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%BA%90%EC%8B%B1-%EA%B5%AC%EC%83%81%ED%95%98%EA%B8%B0) + +- [↩️ Redis Keyspace Notifications을 이용한 좌석 선점 만료 구현](https://github.com/TicketPing/TicketPing-Final/wiki/%E2%86%A9%EF%B8%8F-Redis-Keyspace-Notifications%EC%9D%84-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%A2%8C%EC%84%9D-%EC%84%A0%EC%A0%90-%EB%A7%8C%EB%A3%8C-%EA%B5%AC%ED%98%84) + +- [⏰ Grafana를 이용한 통합 모니터링 및 알람 구축](https://github.com/TicketPing/TicketPing-Final/wiki/%E2%8F%B0-Grafana%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%ED%86%B5%ED%95%A9-%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81-%EB%B0%8F-%EC%95%8C%EB%9E%8C-%EA%B5%AC%EC%B6%95) + +- [⚡ 게이트웨이에 서킷브레이커 구축](https://github.com/TicketPing/TicketPing-Final/wiki/%E2%9A%A1-%EA%B2%8C%EC%9D%B4%ED%8A%B8%EC%9B%A8%EC%9D%B4%EC%97%90-%EC%84%9C%ED%82%B7%EB%B8%8C%EB%A0%88%EC%9D%B4%EC%BB%A4-%EA%B5%AC%EC%B6%95) +
## ⚽️ 트러블슈팅 - [🎁 Lua Script를 활용한 대기열 진입 동시성 문제 해결](https://github.com/TicketPing/TicketPing-Final/wiki/%F0%9F%8E%81-Lua-Script%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EB%8C%80%EA%B8%B0%EC%97%B4-%EC%A7%84%EC%9E%85-%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0) -- [🗣️ Redis Cluster 적용 이후 Lua Script 실행 오류 문제 해결](https://github.com/TicketPing/TicketPing-Final/wiki/%F0%9F%97%A3%EF%B8%8F-Redis-Cluster-%EC%A0%81%EC%9A%A9-%EC%9D%B4%ED%9B%84-Lua-Script-%EC%8B%A4%ED%96%89-%EC%98%A4%EB%A5%98-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0) +- [🥇 Lua Script를 이용한 좌석 선점 동시성 문제 해결](https://github.com/TicketPing/TicketPing-Final/wiki/%F0%9F%A5%87-Lua-Script%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%A2%8C%EC%84%9D-%EC%84%A0%EC%A0%90-%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0) -- [🖍️ Redis @class로 인해 다른 서버에서 캐시를 읽지 못하는 문제 해결](https://github.com/TicketPing/TicketPing-Final/wiki/%F0%9F%96%8D%EF%B8%8F-Redis-@class%EB%A1%9C-%EC%9D%B8%ED%95%B4-%EB%8B%A4%EB%A5%B8-%EC%84%9C%EB%B2%84%EC%97%90%EC%84%9C-%EC%BA%90%EC%8B%9C%EB%A5%BC-%EC%9D%BD%EC%A7%80-%EB%AA%BB%ED%95%98%EB%8A%94-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0) +- [🗣️ Redis Cluster 적용 이후 Lua Script 실행 오류 문제 해결](https://github.com/TicketPing/TicketPing-Final/wiki/%F0%9F%97%A3%EF%B8%8F-Redis-Cluster-%EC%A0%81%EC%9A%A9-%EC%9D%B4%ED%9B%84-Lua-Script-%EC%8B%A4%ED%96%89-%EC%98%A4%EB%A5%98-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0)
@@ -255,6 +328,7 @@ CPU 사용량과 Load Average가 상대적으로 낮은 것을 확인할 수 있 - 공연 서비스 개발
- 주문 서비스 개발
- 게이트웨이 JWT 인증 필터 개발
+ - 게이트웨이 서킷 브레이커 설정
- 모니터링 시스템 구축
GitHub diff --git a/common/circuit-breaker/build.gradle b/common/circuit-breaker/build.gradle new file mode 100644 index 00000000..9e2aad8d --- /dev/null +++ b/common/circuit-breaker/build.gradle @@ -0,0 +1,42 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.3.4' + id 'io.spring.dependency-management' version '1.1.6' +} + +group = 'com.ticketPing' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +bootJar { + enabled = false +} + +jar { + enabled = true +} + +dependencies { + api 'io.github.resilience4j:resilience4j-spring-boot3:2.2.0' + + +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/common/circuit-breaker/src/main/java/circuit/config/CircuitBreakerEventConfig.java b/common/circuit-breaker/src/main/java/circuit/config/CircuitBreakerEventConfig.java new file mode 100644 index 00000000..4ff27428 --- /dev/null +++ b/common/circuit-breaker/src/main/java/circuit/config/CircuitBreakerEventConfig.java @@ -0,0 +1,47 @@ +package circuit.config; + +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; +import io.github.resilience4j.circuitbreaker.event.CircuitBreakerOnErrorEvent; +import io.github.resilience4j.circuitbreaker.event.CircuitBreakerOnFailureRateExceededEvent; +import io.github.resilience4j.circuitbreaker.event.CircuitBreakerOnStateTransitionEvent; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Configuration; + +@Slf4j +@Configuration +@RequiredArgsConstructor +public class CircuitBreakerEventConfig { + + private final CircuitBreakerRegistry circuitBreakerRegistry; + + @PostConstruct + public void registerCircuitBreakerEventListeners() { + circuitBreakerRegistry.getAllCircuitBreakers().forEach(circuitBreaker -> { + circuitBreaker.getEventPublisher() + .onStateTransition(this::logStateTransition) + .onFailureRateExceeded(this::logFailureRateExceeded) + .onError(this::logErrorEvent); + }); + } + + private void logStateTransition(CircuitBreakerOnStateTransitionEvent event) { + log.info("CircuitBreaker '{}' state changed from {} to {}", + event.getCircuitBreakerName(), + event.getStateTransition().getFromState(), + event.getStateTransition().getToState()); + } + + private void logFailureRateExceeded(CircuitBreakerOnFailureRateExceededEvent event) { + log.warn("CircuitBreaker '{}' failure rate exceeded: {}%", + event.getCircuitBreakerName(), + event.getFailureRate()); + } + + private void logErrorEvent(CircuitBreakerOnErrorEvent event) { + log.error("CircuitBreaker '{}' recorded an error: {}", + event.getCircuitBreakerName(), + event.getThrowable().getMessage()); + } +} \ No newline at end of file diff --git a/common/circuit-breaker/src/main/resources/application-circuit-breaker.yml b/common/circuit-breaker/src/main/resources/application-circuit-breaker.yml new file mode 100644 index 00000000..484c1388 --- /dev/null +++ b/common/circuit-breaker/src/main/resources/application-circuit-breaker.yml @@ -0,0 +1,29 @@ +spring: + cloud: + openfeign: + circuitbreaker: enabled + +resilience4j: + circuitbreaker: + configs: + default: + registerHealthIndicator: true + slidingWindowType: COUNT_BASED + slidingWindowSize: 10 + minimumNumberOfCalls: 10 + failureRateThreshold: 50 + slowCallRateThreshold: 100 + slowCallDurationThreshold: 10s + waitDurationInOpenState: 10s + permittedNumberOfCallsInHalfOpenState: 3 + recordSlowCalls: true + record-exceptions: + - java.util.concurrent.TimeoutException + - java.net.SocketTimeoutException + - java.net.UnknownHostException + - java.net.ConnectException + - feign.RetryableException + - feign.FeignException.GatewayTimeout + - feign.FeignException.BadGateway + - feign.FeignException.TooManyRequests + - feign.FeignException.ServiceUnavailable \ No newline at end of file diff --git a/common/core/src/main/java/exception/ApplicationException.java b/common/core/src/main/java/exception/ApplicationException.java index f9d8a712..ebcfa37c 100644 --- a/common/core/src/main/java/exception/ApplicationException.java +++ b/common/core/src/main/java/exception/ApplicationException.java @@ -3,7 +3,7 @@ import lombok.Getter; @Getter -public class ApplicationException extends RuntimeException{ +public class ApplicationException extends RuntimeException { private final ErrorCase exceptionCase; diff --git a/common/core/src/main/java/exception/GlobalExceptionHandler.java b/common/core/src/main/java/exception/GlobalExceptionHandler.java index 9ed76266..cfa459ee 100644 --- a/common/core/src/main/java/exception/GlobalExceptionHandler.java +++ b/common/core/src/main/java/exception/GlobalExceptionHandler.java @@ -20,7 +20,7 @@ public class GlobalExceptionHandler { private final ObjectMapper objectMapper; @ExceptionHandler(ApplicationException.class) - public ResponseEntity handleDnaApplicationException(ApplicationException e) { + public ResponseEntity handleApplicationException(ApplicationException e) { CommonResponse response = CommonResponse.error(e.getExceptionCase()); return ResponseEntity .status(response.getStatus()) diff --git a/common/core/src/main/java/response/CommonResponse.java b/common/core/src/main/java/response/CommonResponse.java index b94c9c9a..ec135a69 100644 --- a/common/core/src/main/java/response/CommonResponse.java +++ b/common/core/src/main/java/response/CommonResponse.java @@ -25,6 +25,7 @@ public static CommonResponse success(T data) { public static CommonResponse success() { return CommonResponse.builder() + .message("success") .build(); } diff --git a/common/core/src/main/resources/application-eureka.yml b/common/core/src/main/resources/application-eureka.yml index f109d302..6b705140 100644 --- a/common/core/src/main/resources/application-eureka.yml +++ b/common/core/src/main/resources/application-eureka.yml @@ -1,4 +1,10 @@ eureka: client: service-url: - defaultZone: ${EUREKA_SERVER} \ No newline at end of file + defaultZone: ${EUREKA_SERVER} + +spring: + cloud: + loadbalancer: + ribbon: + enabled: true \ No newline at end of file diff --git a/common/dtos/src/main/java/order/OrderInfoForPaymentResponse.java b/common/dtos/src/main/java/order/OrderInfoForPaymentResponse.java deleted file mode 100644 index 58d10e1a..00000000 --- a/common/dtos/src/main/java/order/OrderInfoForPaymentResponse.java +++ /dev/null @@ -1,9 +0,0 @@ -package order; - -import java.util.UUID; - -public record OrderInfoForPaymentResponse( - UUID id, - Long amount -) { -} diff --git a/common/dtos/src/main/java/performance/OrderSeatResponse.java b/common/dtos/src/main/java/performance/OrderSeatResponse.java new file mode 100644 index 00000000..ca2ce96e --- /dev/null +++ b/common/dtos/src/main/java/performance/OrderSeatResponse.java @@ -0,0 +1,23 @@ +package performance; + +import lombok.AccessLevel; +import lombok.Builder; + +import java.time.LocalDate; +import java.util.UUID; + +@Builder(access = AccessLevel.PRIVATE) +public record OrderSeatResponse( + UUID performanceId, + String performanceName, + UUID scheduleId, + LocalDate startDate, + UUID performanceHallId, + String performanceHallName, + UUID companyId, + UUID seatId, + Integer row, + Integer col, + String seatGrade, + Integer cost +) { } diff --git a/common/dtos/src/main/java/performance/PaymentRequestDto.java b/common/dtos/src/main/java/performance/PaymentRequestDto.java deleted file mode 100644 index 91cbbb8c..00000000 --- a/common/dtos/src/main/java/performance/PaymentRequestDto.java +++ /dev/null @@ -1,24 +0,0 @@ -package performance; - -import lombok.*; - -import java.util.UUID; - -@Getter -@Builder(access = AccessLevel.PRIVATE) -@NoArgsConstructor -@AllArgsConstructor -public class PaymentRequestDto { - - private UUID scheduleId; - private UUID seatId; - private UUID userId; - - public static PaymentRequestDto field(UUID scheduleId, UUID seatId, UUID userId) { - return PaymentRequestDto.builder() - .scheduleId(scheduleId) - .seatId(seatId) - .userId(userId) - .build(); - } -} \ No newline at end of file diff --git a/common/dtos/src/main/java/performance/PaymentResponseDto.java b/common/dtos/src/main/java/performance/PaymentResponseDto.java deleted file mode 100644 index 021e7bb7..00000000 --- a/common/dtos/src/main/java/performance/PaymentResponseDto.java +++ /dev/null @@ -1,20 +0,0 @@ -package performance; - -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; - -import java.util.UUID; - -@Getter -@Setter -@AllArgsConstructor -@NoArgsConstructor -public class PaymentResponseDto { - private String performanceName; - private UUID performanceScheduleId; - private UUID seatId; - private Long amount; - private UUID userId; -} \ No newline at end of file diff --git a/common/dtos/src/main/java/user/UserResponse.java b/common/dtos/src/main/java/user/UserResponse.java index 774622ed..54a28410 100644 --- a/common/dtos/src/main/java/user/UserResponse.java +++ b/common/dtos/src/main/java/user/UserResponse.java @@ -2,14 +2,11 @@ import lombok.AccessLevel; import lombok.Builder; -import java.time.LocalDate; import java.util.UUID; @Builder(access = AccessLevel.PRIVATE) public record UserResponse( UUID userId, String email, - String nickname, - LocalDate birthday, - String gender + String nickname ) {} diff --git a/common/messaging/src/main/java/messaging/events/OrderCompletedEvent.java b/common/messaging/src/main/java/messaging/events/OrderCompletedEvent.java deleted file mode 100644 index 0522dcba..00000000 --- a/common/messaging/src/main/java/messaging/events/OrderCompletedEvent.java +++ /dev/null @@ -1,15 +0,0 @@ -package messaging.events; - -import java.util.UUID; - -public record OrderCompletedEvent( - String userId, - String performanceId -) { - public static OrderCompletedEvent create(UUID userId, UUID performanceId) { - return new OrderCompletedEvent( - userId.toString(), - performanceId.toString() - ); - } -} diff --git a/common/messaging/src/main/java/messaging/events/OrderCompletedForQueueTokenRemovalEvent.java b/common/messaging/src/main/java/messaging/events/OrderCompletedForQueueTokenRemovalEvent.java new file mode 100644 index 00000000..bdca67ee --- /dev/null +++ b/common/messaging/src/main/java/messaging/events/OrderCompletedForQueueTokenRemovalEvent.java @@ -0,0 +1,15 @@ +package messaging.events; + +import java.util.UUID; + +public record OrderCompletedForQueueTokenRemovalEvent( + String userId, + String performanceId +) { + public static OrderCompletedForQueueTokenRemovalEvent create(UUID userId, UUID performanceId) { + return new OrderCompletedForQueueTokenRemovalEvent( + userId.toString(), + performanceId.toString() + ); + } +} diff --git a/common/messaging/src/main/java/messaging/events/OrderCompletedForSeatReservationEvent.java b/common/messaging/src/main/java/messaging/events/OrderCompletedForSeatReservationEvent.java new file mode 100644 index 00000000..bf5a5c14 --- /dev/null +++ b/common/messaging/src/main/java/messaging/events/OrderCompletedForSeatReservationEvent.java @@ -0,0 +1,15 @@ +package messaging.events; + +import java.util.UUID; + +public record OrderCompletedForSeatReservationEvent( + String scheduleId, + String seatId +) { + public static OrderCompletedForSeatReservationEvent create(UUID scheduleId, UUID seatId) { + return new OrderCompletedForSeatReservationEvent( + scheduleId.toString(), + seatId.toString() + ); + } +} diff --git a/common/messaging/src/main/java/messaging/events/OrderFailedEvent.java b/common/messaging/src/main/java/messaging/events/OrderFailedEvent.java new file mode 100644 index 00000000..c505d177 --- /dev/null +++ b/common/messaging/src/main/java/messaging/events/OrderFailedEvent.java @@ -0,0 +1,15 @@ +package messaging.events; + +import java.util.UUID; + +public record OrderFailedEvent( + UUID scheduleId, + UUID seatId +) { + public static OrderFailedEvent create(UUID scheduleId, UUID seatId) { + return new OrderFailedEvent( + scheduleId, + seatId + ); + } +} diff --git a/common/messaging/src/main/java/messaging/events/PaymentCompletedEvent.java b/common/messaging/src/main/java/messaging/events/PaymentCompletedEvent.java index 7529bdf9..2a4c04f6 100644 --- a/common/messaging/src/main/java/messaging/events/PaymentCompletedEvent.java +++ b/common/messaging/src/main/java/messaging/events/PaymentCompletedEvent.java @@ -3,11 +3,13 @@ import java.util.UUID; public record PaymentCompletedEvent( - UUID orderId + UUID orderId, + UUID paymentId ) { - public static PaymentCompletedEvent create(UUID orderId) { + public static PaymentCompletedEvent create(UUID orderId, UUID paymentId) { return new PaymentCompletedEvent( - orderId + orderId, + paymentId ); } } diff --git a/common/messaging/src/main/java/messaging/events/PaymentCreatedEvent.java b/common/messaging/src/main/java/messaging/events/PaymentCreatedEvent.java deleted file mode 100644 index 4fa95f91..00000000 --- a/common/messaging/src/main/java/messaging/events/PaymentCreatedEvent.java +++ /dev/null @@ -1,15 +0,0 @@ -package messaging.events; - -import java.util.UUID; - -public record PaymentCreatedEvent( - UUID paymentId, - UUID orderId -) { - public static PaymentCreatedEvent create(UUID paymentId, UUID orderId) { - return new PaymentCreatedEvent( - paymentId, - orderId - ); - } -} diff --git a/common/messaging/src/main/java/messaging/events/SeatPreReserveExpiredEvent.java b/common/messaging/src/main/java/messaging/events/SeatPreReserveExpiredEvent.java new file mode 100644 index 00000000..20cea7a4 --- /dev/null +++ b/common/messaging/src/main/java/messaging/events/SeatPreReserveExpiredEvent.java @@ -0,0 +1,17 @@ +package messaging.events; + +import java.util.UUID; + +public record SeatPreReserveExpiredEvent( + UUID scheduleId, + UUID seatId +) { + + public static SeatPreReserveExpiredEvent create(UUID scheduleId, UUID seatId) { + return new SeatPreReserveExpiredEvent( + scheduleId, + seatId + ); + } + +} diff --git a/common/messaging/src/main/java/messaging/topics/OrderTopic.java b/common/messaging/src/main/java/messaging/topics/OrderTopic.java index df2c9106..5d257fe3 100644 --- a/common/messaging/src/main/java/messaging/topics/OrderTopic.java +++ b/common/messaging/src/main/java/messaging/topics/OrderTopic.java @@ -7,7 +7,9 @@ @RequiredArgsConstructor public enum OrderTopic { - COMPLETED("order-completed"); + COMPLETED_FOR_SEAT_RESERVATION("order-completed-for-seat-reservation"), + COMPLETED_FOR_QUEUE_TOKEN_REMOVAL("order-completed-for-queue-token-removal"), + FAILED("order-failed"); private final String topic; diff --git a/common/messaging/src/main/java/messaging/topics/PaymentTopic.java b/common/messaging/src/main/java/messaging/topics/PaymentTopic.java index 6f40f43a..99116e56 100644 --- a/common/messaging/src/main/java/messaging/topics/PaymentTopic.java +++ b/common/messaging/src/main/java/messaging/topics/PaymentTopic.java @@ -7,7 +7,6 @@ @RequiredArgsConstructor public enum PaymentTopic { - CREATED("payment-created"), COMPLETED("payment-completed"); private final String topic; diff --git a/common/messaging/src/main/java/messaging/topics/SeatTopic.java b/common/messaging/src/main/java/messaging/topics/SeatTopic.java new file mode 100644 index 00000000..9cfa6fb1 --- /dev/null +++ b/common/messaging/src/main/java/messaging/topics/SeatTopic.java @@ -0,0 +1,13 @@ +package messaging.topics; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SeatTopic { + + PRE_RESERVE_EXPIRED("pre-reserve-expired"); + + private final String topic; +} diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml index 05dc03cb..413e566e 100644 --- a/docker-compose/docker-compose.yml +++ b/docker-compose/docker-compose.yml @@ -27,6 +27,8 @@ services: - REDIS_NODE_5=${REDIS_NODE_5} - REDIS_NODE_6=${REDIS_NODE_6} - ZIPKIN=${ZIPKIN} + - FRONTEND_APP_URL=${FRONTEND_APP_URL} + - USER_TOKEN_SECRET_KEY=${USER_TOKEN_SECRET_KEY} depends_on: auth-server: condition: service_started @@ -91,6 +93,7 @@ services: - PERFORMANCE_POSTGRES_URL=${PERFORMANCE_POSTGRES_URL} - PERFORMANCE_POSTGRES_USERNAME=${PERFORMANCE_POSTGRES_USERNAME} - PERFORMANCE_POSTGRES_PASSWORD=${PERFORMANCE_POSTGRES_PASSWORD} + - DISCORD_WEBHOOK_URL=${GF_DISCORD_WEBHOOK_URL} - REDIS_NODE_1=${REDIS_NODE_1} - REDIS_NODE_2=${REDIS_NODE_2} - REDIS_NODE_3=${REDIS_NODE_3} @@ -144,6 +147,8 @@ services: - KAFKA_BROKER_2=${KAFKA_BROKER_2} - KAFKA_BROKER_3=${KAFKA_BROKER_3} - ZIPKIN=${ZIPKIN} + - TOSS_PAYMENT_WIDGET_SECRET_KEY=${TOSS_PAYMENT_WIDGET_SECRET_KEY} + - TOSS_PAYMENT_CONFIRM_URL=${TOSS_PAYMENT_CONFIRM_URL} depends_on: eureka-server: condition: service_started @@ -169,7 +174,8 @@ services: - ZIPKIN=${ZIPKIN} - USER_TOKEN_SECRET_KEY=${USER_TOKEN_SECRET_KEY} - WORKING_QUEUE_MAX_SIZE=${WORKING_QUEUE_MAX_SIZE} - - WORKING_QUEUE_TOKEN_TTL=${WORKING_QUEUE_TOKEN_TTL} + - INITIAL_WORKING_QUEUE_TOKEN_TTL=${INITIAL_WORKING_QUEUE_TOKEN_TTL} + - EXTENDED_WORKING_QUEUE_TOKEN_TTL=${EXTENDED_WORKING_QUEUE_TOKEN_TTL} depends_on: eureka-server: condition: service_started diff --git a/eureka-server/src/main/resources/application.yml b/eureka-server/src/main/resources/application.yml index 3395d4b3..1bf394c3 100644 --- a/eureka-server/src/main/resources/application.yml +++ b/eureka-server/src/main/resources/application.yml @@ -7,8 +7,8 @@ server: eureka: client: - register-with-eureka: false - fetch-registry: false + register-with-eureka: true + fetch-registry: true service-url: defaultZone: ${EUREKA_SERVER} instance: diff --git a/gateway/src/main/java/com/ticketPing/gateway/application/client/AuthClient.java b/gateway/src/main/java/com/ticketPing/gateway/application/client/AuthClient.java index 87c8d404..00b8598f 100644 --- a/gateway/src/main/java/com/ticketPing/gateway/application/client/AuthClient.java +++ b/gateway/src/main/java/com/ticketPing/gateway/application/client/AuthClient.java @@ -4,5 +4,5 @@ import reactor.core.publisher.Mono; public interface AuthClient { - public Mono validateToken(String token); + Mono validateToken(String token); } diff --git a/gateway/src/main/java/com/ticketPing/gateway/application/service/QueueCheckService.java b/gateway/src/main/java/com/ticketPing/gateway/application/service/QueueCheckService.java index e64ff22a..32cb60fa 100644 --- a/gateway/src/main/java/com/ticketPing/gateway/application/service/QueueCheckService.java +++ b/gateway/src/main/java/com/ticketPing/gateway/application/service/QueueCheckService.java @@ -5,7 +5,7 @@ import static com.ticketPing.gateway.common.exception.FilterErrorCase.PERFORMANCE_SOLD_OUT; import static com.ticketPing.gateway.common.exception.FilterErrorCase.TOO_MANY_WAITING_USERS; import static com.ticketPing.gateway.common.exception.FilterErrorCase.WORKING_QUEUE_TOKEN_NOT_FOUND; -import static com.ticketPing.gateway.common.utils.QueueTokenValueGenerator.generateTokenValue; +import static com.ticketPing.gateway.common.utils.TokenValueGenerator.generateTokenValue; import caching.repository.ReactiveRedisRepository; import exception.ApplicationException; diff --git a/gateway/src/main/java/com/ticketPing/gateway/common/exception/CircuitBreakerErrorCase.java b/gateway/src/main/java/com/ticketPing/gateway/common/exception/CircuitBreakerErrorCase.java index 618d23af..f5470444 100644 --- a/gateway/src/main/java/com/ticketPing/gateway/common/exception/CircuitBreakerErrorCase.java +++ b/gateway/src/main/java/com/ticketPing/gateway/common/exception/CircuitBreakerErrorCase.java @@ -8,9 +8,9 @@ @Getter @RequiredArgsConstructor public enum CircuitBreakerErrorCase implements ErrorCase { - SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "서비스가 연결 불가능합니다. 관리자에게 문의해주세요."), + SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "서비스가 연결 불가능합니다. 잠시 후 다시 시도해주세요."), CONNECTION_TIMEOUT(HttpStatus.GATEWAY_TIMEOUT, "서비스 요청 시간이 초과되었습니다. 잠시 후 다시 시도해주세요."), - SERVICE_IS_OPEN(HttpStatus.SERVICE_UNAVAILABLE, "서비스가 연결 불가능합니다. 잠시 후 다시 시도해주세요."); + SERVICE_IS_OPEN(HttpStatus.SERVICE_UNAVAILABLE, "서비스가 연결 불가능합니다. 관리자에게 문의해주세요."); private final HttpStatus httpStatus; private final String message; diff --git a/gateway/src/main/java/com/ticketPing/gateway/common/exception/SecurityErrorCase.java b/gateway/src/main/java/com/ticketPing/gateway/common/exception/SecurityErrorCase.java index 5b0b8edc..3759975d 100644 --- a/gateway/src/main/java/com/ticketPing/gateway/common/exception/SecurityErrorCase.java +++ b/gateway/src/main/java/com/ticketPing/gateway/common/exception/SecurityErrorCase.java @@ -9,7 +9,8 @@ @AllArgsConstructor public enum SecurityErrorCase implements ErrorCase { - USER_CACHE_IS_NULL(HttpStatus.INTERNAL_SERVER_ERROR, "내부 서버 오류"); + USER_CACHE_IS_NULL(HttpStatus.INTERNAL_SERVER_ERROR, "내부 서버 오류"), + EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "토큰이 만료되었습니다."); private final HttpStatus httpStatus; private final String message; diff --git a/gateway/src/main/java/com/ticketPing/gateway/common/utils/QueueTokenValueGenerator.java b/gateway/src/main/java/com/ticketPing/gateway/common/utils/TokenValueGenerator.java similarity index 95% rename from gateway/src/main/java/com/ticketPing/gateway/common/utils/QueueTokenValueGenerator.java rename to gateway/src/main/java/com/ticketPing/gateway/common/utils/TokenValueGenerator.java index a02c6b6f..7480f0c4 100644 --- a/gateway/src/main/java/com/ticketPing/gateway/common/utils/QueueTokenValueGenerator.java +++ b/gateway/src/main/java/com/ticketPing/gateway/common/utils/TokenValueGenerator.java @@ -8,7 +8,7 @@ import java.security.NoSuchAlgorithmException; import java.util.Base64; -public class QueueTokenValueGenerator { +public class TokenValueGenerator { public static String generateTokenValue(String userId, String performanceId) { try { diff --git a/gateway/src/main/java/com/ticketPing/gateway/infrastructure/client/AuthWebClient.java b/gateway/src/main/java/com/ticketPing/gateway/infrastructure/client/AuthWebClient.java index ce21f78f..26cd113f 100644 --- a/gateway/src/main/java/com/ticketPing/gateway/infrastructure/client/AuthWebClient.java +++ b/gateway/src/main/java/com/ticketPing/gateway/infrastructure/client/AuthWebClient.java @@ -2,23 +2,40 @@ import auth.UserCacheDto; import com.ticketPing.gateway.application.client.AuthClient; +import com.ticketPing.gateway.common.exception.CircuitBreakerErrorCase; import com.ticketPing.gateway.common.exception.SecurityErrorCase; import exception.ApplicationException; +import io.github.resilience4j.circuitbreaker.CallNotPermittedException; +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import io.netty.channel.ChannelOption; +import lombok.extern.slf4j.Slf4j; import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; import reactor.core.publisher.Mono; +import reactor.netty.http.client.HttpClient; import response.CommonResponse; +import java.time.Duration; + +@Slf4j @Component public class AuthWebClient implements AuthClient { private final WebClient webClient; public AuthWebClient(WebClient.Builder webClientBuilder) { - this.webClient = webClientBuilder.build(); + this.webClient = webClientBuilder + .clientConnector(new ReactorClientHttpConnector( + HttpClient.create() + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 1000) + .responseTimeout(Duration.ofSeconds(15)))) + .build(); } + @CircuitBreaker(name = "authServiceCircuitBreaker", fallbackMethod = "validateTokenFallback") public Mono validateToken(String token) { return webClient.post() .uri("http://auth/api/v1/auth/validate") @@ -33,4 +50,22 @@ public Mono validateToken(String token) { } }); } + + private Mono validateTokenFallback(String token, Throwable ex) { + if (ex instanceof CallNotPermittedException) { + return Mono.error(new ApplicationException(CircuitBreakerErrorCase.SERVICE_IS_OPEN)); + } else if ( + ex instanceof WebClientResponseException.BadGateway || + ex instanceof WebClientResponseException.GatewayTimeout || + ex instanceof WebClientResponseException.TooManyRequests || + ex instanceof WebClientResponseException.ServiceUnavailable + ) { + return Mono.error(new ApplicationException(CircuitBreakerErrorCase.SERVICE_UNAVAILABLE)); + } else if (ex instanceof WebClientResponseException) { + return Mono.error(ex); + } else { + return Mono.error(new ApplicationException(CircuitBreakerErrorCase.SERVICE_UNAVAILABLE)); + } + } + } diff --git a/gateway/src/main/java/com/ticketPing/gateway/infrastructure/config/CustomAuthenticationEntryPoint.java b/gateway/src/main/java/com/ticketPing/gateway/infrastructure/config/CustomAuthenticationEntryPoint.java new file mode 100644 index 00000000..104eb7e5 --- /dev/null +++ b/gateway/src/main/java/com/ticketPing/gateway/infrastructure/config/CustomAuthenticationEntryPoint.java @@ -0,0 +1,36 @@ +package com.ticketPing.gateway.infrastructure.config; + +import static com.ticketPing.gateway.common.exception.SecurityErrorCase.EXPIRED_TOKEN; + +import java.nio.charset.StandardCharsets; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.server.ServerAuthenticationEntryPoint; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +@Component +@RequiredArgsConstructor +public class CustomAuthenticationEntryPoint implements ServerAuthenticationEntryPoint { + + @Value("${client.url}") + private String clientUrl; + + @Override + public Mono commence(ServerWebExchange exchange, AuthenticationException ex) { + ServerHttpResponse response = exchange.getResponse(); + response.setStatusCode(EXPIRED_TOKEN.getHttpStatus()); + response.getHeaders().setContentType(MediaType.APPLICATION_JSON); + response.getHeaders().setAccessControlAllowOrigin(clientUrl); + response.getHeaders().setAccessControlAllowCredentials(true); + String responseBody = EXPIRED_TOKEN.getMessage(); + DataBuffer dataBuffer = response.bufferFactory().wrap(responseBody.getBytes(StandardCharsets.UTF_8)); + return response.writeWith(Mono.just(dataBuffer)); + } + +} \ No newline at end of file diff --git a/gateway/src/main/java/com/ticketPing/gateway/infrastructure/config/RouteConfig.java b/gateway/src/main/java/com/ticketPing/gateway/infrastructure/config/RouteConfig.java index 33b96406..6e3180cf 100644 --- a/gateway/src/main/java/com/ticketPing/gateway/infrastructure/config/RouteConfig.java +++ b/gateway/src/main/java/com/ticketPing/gateway/infrastructure/config/RouteConfig.java @@ -26,17 +26,21 @@ public RouteLocator gatewayRoutes(RouteLocatorBuilder builder) { .filters(f -> addCircuitBreaker(f, "userServiceCircuitBreaker", "default")) .uri("lb://user")) .route("performance-service", r -> r.path("/api/v1/performances/**", "/api/v1/schedules/**", "/api/v1/seats/**") - .filters(f -> addCircuitBreaker(f, "performanceServiceCircuitBreaker", "default")) + .filters(f -> f.filter(queueCheckFilter::filter) + .circuitBreaker(c -> c.setName("performanceServiceCircuitBreaker") + .setFallbackUri("forward:/fallback/default"))) .uri("lb://performance")) .route("order-service", r -> r.path("/api/v1/orders/**") - .filters(f -> addCircuitBreaker(f, "orderServiceCircuitBreaker", "default")) + .filters(f -> f.filter(queueCheckFilter::filter) + .circuitBreaker(c -> c.setName("orderServiceCircuitBreaker") + .setFallbackUri("forward:/fallback/default"))) .uri("lb://order")) .route("payment-service", r -> r.path("/api/v1/payments/**") .filters(f -> addCircuitBreaker(f, "paymentServiceCircuitBreaker", "default")) .uri("lb://payment")) .route("queue-manage-service", r -> r.path("/api/v1/waiting-queue/**", "/api/v1/working-queue/**") .filters(f -> f.filter(queueCheckFilter::filter) - .circuitBreaker(c -> c.setName("queueManageCircuitBreaker") + .circuitBreaker(c -> c.setName("queueManageServiceCircuitBreaker") .setFallbackUri("forward:/fallback/default"))) .uri("lb://queue-manage")) diff --git a/gateway/src/main/java/com/ticketPing/gateway/infrastructure/config/SecurityConfig.java b/gateway/src/main/java/com/ticketPing/gateway/infrastructure/config/SecurityConfig.java index a4810be8..e2a9ef40 100644 --- a/gateway/src/main/java/com/ticketPing/gateway/infrastructure/config/SecurityConfig.java +++ b/gateway/src/main/java/com/ticketPing/gateway/infrastructure/config/SecurityConfig.java @@ -8,6 +8,9 @@ import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.config.web.server.ServerHttpSecurity.CsrfSpec; +import org.springframework.security.config.web.server.ServerHttpSecurity.FormLoginSpec; +import org.springframework.security.config.web.server.ServerHttpSecurity.HttpBasicSpec; import org.springframework.security.web.server.SecurityWebFilterChain; @Slf4j @@ -17,15 +20,16 @@ public class SecurityConfig { private final JwtFilter jwtFilter; + private final CustomAuthenticationEntryPoint authenticationEntryPoint; @Bean public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) { return http - .csrf(ServerHttpSecurity.CsrfSpec::disable) - .formLogin(ServerHttpSecurity.FormLoginSpec::disable) - .httpBasic(ServerHttpSecurity.HttpBasicSpec::disable) - .securityContextRepository(jwtFilter) + .csrf(CsrfSpec::disable) + .formLogin(FormLoginSpec::disable) + .httpBasic(HttpBasicSpec::disable) .authorizeExchange(exchange -> exchange + .pathMatchers(HttpMethod.OPTIONS).permitAll() .pathMatchers("/api/v1/auth/login").permitAll() .pathMatchers("/api/v1/auth/validate").permitAll() .pathMatchers("/api/v1/auth/refresh").permitAll() @@ -36,6 +40,11 @@ public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) { .pathMatchers("/swagger-ui/**", "/v3/api-docs/**", "/webjars/**").permitAll() .anyExchange().authenticated() ) + .securityContextRepository(jwtFilter) + .exceptionHandling(exceptionHandling -> + exceptionHandling.authenticationEntryPoint(authenticationEntryPoint) + ) .build(); } + } \ No newline at end of file diff --git a/gateway/src/main/java/com/ticketPing/gateway/infrastructure/filter/APIType.java b/gateway/src/main/java/com/ticketPing/gateway/infrastructure/filter/ApiType.java similarity index 58% rename from gateway/src/main/java/com/ticketPing/gateway/infrastructure/filter/APIType.java rename to gateway/src/main/java/com/ticketPing/gateway/infrastructure/filter/ApiType.java index c3f1187c..309d569d 100644 --- a/gateway/src/main/java/com/ticketPing/gateway/infrastructure/filter/APIType.java +++ b/gateway/src/main/java/com/ticketPing/gateway/infrastructure/filter/ApiType.java @@ -2,31 +2,37 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpMethod; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import org.springframework.util.AntPathMatcher; +@Slf4j @Getter @RequiredArgsConstructor -public enum APIType { +public enum ApiType { ENTER_WAITING_QUEUE("/api/v1/waiting-queue", HttpMethod.POST), GET_QUEUE_INFO("/api/v1/waiting-queue", HttpMethod.GET), + PRE_RESERVE_SEAT("/api/v1/seats/**/pre-reserve", HttpMethod.POST), CREATE_ORDER("/api/v1/orders", HttpMethod.POST), - REQUEST_PAYMENT("/api/v1/payments", HttpMethod.POST); + VALIDATE_ORDER("/api/v1/orders/**/validate", HttpMethod.POST); private final String path; private final HttpMethod method; - public static Mono findByRequest(String requestPath, String httpMethod) { - return Flux.fromArray(APIType.values()) + private static final AntPathMatcher pathMatcher = new AntPathMatcher(); + + public static Mono findByRequest(String requestPath, String httpMethod) { + return Flux.fromArray(ApiType.values()) .filter(api -> api.matches(requestPath, httpMethod)) .next() .switchIfEmpty(Mono.empty()); } private boolean matches(String requestPath, String httpMethod) { - return requestPath.startsWith(this.path) && this.method.name().equals(httpMethod); + return pathMatcher.match(this.path, requestPath) && this.method.name().equals(httpMethod); } -} +} \ No newline at end of file diff --git a/gateway/src/main/java/com/ticketPing/gateway/infrastructure/filter/JwtFilter.java b/gateway/src/main/java/com/ticketPing/gateway/infrastructure/filter/JwtFilter.java index 8dd9bda9..428631a8 100644 --- a/gateway/src/main/java/com/ticketPing/gateway/infrastructure/filter/JwtFilter.java +++ b/gateway/src/main/java/com/ticketPing/gateway/infrastructure/filter/JwtFilter.java @@ -1,8 +1,11 @@ package com.ticketPing.gateway.infrastructure.filter; import com.ticketPing.gateway.application.client.AuthClient; +import exception.ApplicationException; import lombok.RequiredArgsConstructor; +import org.springframework.core.io.buffer.DataBuffer; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; @@ -11,9 +14,11 @@ import org.springframework.security.core.context.SecurityContextImpl; import org.springframework.security.web.server.context.ServerSecurityContextRepository; import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClientResponseException; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; +import java.nio.charset.StandardCharsets; import java.util.List; @Component @@ -31,8 +36,11 @@ public Mono save(ServerWebExchange exchange, SecurityContext context) { public Mono load(ServerWebExchange exchange) { String authHeader = exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION); - if (authHeader != null) { - return authClient.validateToken(authHeader) + if (authHeader == null) { + return Mono.empty(); + } + + return authClient.validateToken(authHeader) .flatMap(response -> { List authorities = List.of(new SimpleGrantedAuthority(response.role())); Authentication authentication = new UsernamePasswordAuthenticationToken( @@ -46,11 +54,17 @@ public Mono load(ServerWebExchange exchange) { })) .build(); - return Mono.just(new SecurityContextImpl(authentication)); - }); - } - - return Mono.empty(); + return Mono.just((SecurityContext) new SecurityContextImpl(authentication)); + }) + .onErrorResume(ApplicationException.class, e -> { + exchange.getResponse().setStatusCode(HttpStatus.SERVICE_UNAVAILABLE); + DataBuffer buffer = exchange.getResponse() + .bufferFactory() + .wrap(e.getMessage().getBytes(StandardCharsets.UTF_8)); + return exchange.getResponse().writeWith(Mono.just(buffer)) + .then(Mono.empty()); + }) + .onErrorResume(WebClientResponseException.Unauthorized.class, e -> Mono.empty()); } -} +} \ No newline at end of file diff --git a/gateway/src/main/java/com/ticketPing/gateway/infrastructure/filter/QueueCheckFilter.java b/gateway/src/main/java/com/ticketPing/gateway/infrastructure/filter/QueueCheckFilter.java index d3b8e715..4c6a3376 100644 --- a/gateway/src/main/java/com/ticketPing/gateway/infrastructure/filter/QueueCheckFilter.java +++ b/gateway/src/main/java/com/ticketPing/gateway/infrastructure/filter/QueueCheckFilter.java @@ -24,56 +24,57 @@ public class QueueCheckFilter { private final QueueCheckService queueCheckService; public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { - return Mono.zip( - getPerformanceIdFromQueryParams(exchange), - getUserIdFromAuthentication() - ) - .doOnSuccess(tuple -> log.info("공연 ID: {}, 유저 ID: {}", tuple.getT1(), tuple.getT2())) - .flatMap(tuple -> handleApiRequest(exchange, chain, tuple.getT1(), tuple.getT2())); - } - - private Mono getUserIdFromAuthentication() { - return ReactiveSecurityContextHolder.getContext() - .map(context -> context.getAuthentication().getName()) - .switchIfEmpty(Mono.error(new ApplicationException(USER_ID_NOT_FOUND))); - } - - private Mono getPerformanceIdFromQueryParams(ServerWebExchange exchange) { - return Optional.ofNullable(exchange.getRequest().getQueryParams().getFirst(PERFORMANCE_ID_PARAM)) - .map(Mono::just) - .orElseGet(() -> Mono.error(new ApplicationException(PERFORMANCE_ID_NOT_FOUND))); - } - - private Mono handleApiRequest(ServerWebExchange exchange, GatewayFilterChain chain, String performanceId, String userId) { String path = exchange.getRequest().getURI().getPath(); String method = exchange.getRequest().getMethod().name(); - return APIType.findByRequest(path, method) + return ApiType.findByRequest(path, method) .doOnSuccess(api -> log.info("API 타입: {}", api)) .flatMap(api -> switch (api) { - case ENTER_WAITING_QUEUE -> handleEnterWaitingQueueAPI(exchange, chain, performanceId); - case GET_QUEUE_INFO -> handleGetQueueInfoAPI(exchange, chain, performanceId); - case CREATE_ORDER, REQUEST_PAYMENT -> handleReservationAPI(exchange, chain, userId, performanceId); + case ENTER_WAITING_QUEUE -> handleEnterWaitingQueueApi(exchange, chain); + case GET_QUEUE_INFO -> handleGetQueueInfoApi(exchange, chain); + case PRE_RESERVE_SEAT, CREATE_ORDER, VALIDATE_ORDER -> handleReservationApi(exchange, chain); }) .switchIfEmpty(chain.filter(exchange)); } - private Mono handleEnterWaitingQueueAPI(ServerWebExchange exchange, GatewayFilterChain chain, String performanceId) { - return queueCheckService.checkIsPerformanceSoldOut(performanceId) - .then(queueCheckService.checkHasTooManyWaitingUsers(performanceId)) - .then(chain.filter(exchange)); + private Mono handleEnterWaitingQueueApi(ServerWebExchange exchange, GatewayFilterChain chain) { + return getPerformanceIdFromQueryParams(exchange) + .flatMap(performanceId -> + queueCheckService.checkIsPerformanceSoldOut(performanceId) + .then(queueCheckService.checkHasTooManyWaitingUsers(performanceId)) + .then(chain.filter(exchange)) + ); } - private Mono handleGetQueueInfoAPI(ServerWebExchange exchange, GatewayFilterChain chain, String performanceId) { - return queueCheckService.checkIsPerformanceSoldOut(performanceId) + private Mono handleGetQueueInfoApi(ServerWebExchange exchange, GatewayFilterChain chain) { + return getPerformanceIdFromQueryParams(exchange) + .flatMap(queueCheckService::checkIsPerformanceSoldOut) .then(chain.filter(exchange)); } - private Mono handleReservationAPI(ServerWebExchange exchange, GatewayFilterChain chain, String userId, String performanceId) { - return queueCheckService.checkIsPerformanceSoldOut(performanceId) - .then(queueCheckService.checkIsUserAvailable(userId, performanceId)) - .then(chain.filter(exchange)); + private Mono handleReservationApi(ServerWebExchange exchange, GatewayFilterChain chain) { + return Mono.zip( + getPerformanceIdFromQueryParams(exchange), + getUserIdFromAuthentication() + ) + .flatMap(tuple -> + queueCheckService.checkIsPerformanceSoldOut(tuple.getT1()) + .then(queueCheckService.checkIsUserAvailable(tuple.getT2(), tuple.getT1())) + .then(chain.filter(exchange)) + ); + } + + private Mono getPerformanceIdFromQueryParams(ServerWebExchange exchange) { + return Optional.ofNullable(exchange.getRequest().getQueryParams().getFirst(PERFORMANCE_ID_PARAM)) + .map(Mono::just) + .orElseGet(() -> Mono.error(new ApplicationException(PERFORMANCE_ID_NOT_FOUND))); + } + + private Mono getUserIdFromAuthentication() { + return ReactiveSecurityContextHolder.getContext() + .map(context -> context.getAuthentication().getName()) + .switchIfEmpty(Mono.error(new ApplicationException(USER_ID_NOT_FOUND))); } -} \ No newline at end of file +} diff --git a/gateway/src/main/java/com/ticketPing/gateway/presentation/controller/FallbackController.java b/gateway/src/main/java/com/ticketPing/gateway/presentation/controller/FallbackController.java index 0ab4d30b..9420b119 100644 --- a/gateway/src/main/java/com/ticketPing/gateway/presentation/controller/FallbackController.java +++ b/gateway/src/main/java/com/ticketPing/gateway/presentation/controller/FallbackController.java @@ -27,7 +27,7 @@ public Mono>> defaultFallback(ServerWebExc private ResponseEntity> handleCircuitBreakerException(Throwable ex) { if (ex instanceof TimeoutException) { - return createServiceUnavailableResponse(CircuitBreakerErrorCase.SERVICE_UNAVAILABLE); + return createServiceUnavailableResponse(CircuitBreakerErrorCase.CONNECTION_TIMEOUT); } else if (ex instanceof CallNotPermittedException) { return createServiceUnavailableResponse(CircuitBreakerErrorCase.SERVICE_IS_OPEN); } else { diff --git a/gateway/src/main/resources/application.yml b/gateway/src/main/resources/application.yml index ef20b447..f4ddc3ce 100644 --- a/gateway/src/main/resources/application.yml +++ b/gateway/src/main/resources/application.yml @@ -8,7 +8,7 @@ spring: cors-configurations: '[/**]': allow-credentials: true - allowed-origins: ${FRONTEND_APP_URL} + allowed-origins: ${client.url} allowed-headers: "*" allowed-methods: - PUT @@ -26,10 +26,17 @@ spring: server: port: 10001 +client: + url: ${FRONTEND_APP_URL} + token-value: secret-key: ${USER_TOKEN_SECRET_KEY} resilience4j: + timelimiter: + configs: + default: + timeout-duration: 15s circuitbreaker: configs: default: @@ -47,8 +54,6 @@ resilience4j: - org.springframework.cloud.gateway.support.NotFoundException - io.netty.channel.AbstractChannel$AnnotatedConnectException instances: - authServiceCircuitBreaker: - baseConfig: default userServiceCircuitBreaker: baseConfig: default performanceServiceCircuitBreaker: @@ -57,5 +62,16 @@ resilience4j: baseConfig: default paymentServiceCircuitBreaker: baseConfig: default - queueManageCircuitBreaker: - baseConfig: default \ No newline at end of file + queueManageServiceCircuitBreaker: + baseConfig: default + authServiceCircuitBreaker: + baseConfig: default + record-exceptions: + - java.util.concurrent.TimeoutException + - org.springframework.cloud.gateway.support.NotFoundException + - io.netty.channel.AbstractChannel$AnnotatedConnectException + - org.springframework.web.reactive.function.client.WebClientRequestException + - org.springframework.web.reactive.function.client.WebClientResponseException.BadGateway + - org.springframework.web.reactive.function.client.WebClientResponseException.GatewayTimeout + - org.springframework.web.reactive.function.client.WebClientResponseException.TooManyRequests + - org.springframework.web.reactive.function.client.WebClientResponseException.ServiceUnavailable \ No newline at end of file diff --git a/monitoring/grafana/provisioning/alerting/alert-rule.yml b/monitoring/grafana/provisioning/alerting/alert-rule.yml index 51fd21b4..db916e05 100644 --- a/monitoring/grafana/provisioning/alerting/alert-rule.yml +++ b/monitoring/grafana/provisioning/alerting/alert-rule.yml @@ -1,12 +1,12 @@ apiVersion: 1 groups: - orgId: 1 - name: TicketPing + name: TicketPing-CPU folder: TicketPing interval: 1m rules: - uid: de31h1522x1j4c - title: Gateway CPU Usage Over 50% + title: CPU Usage Over 80% condition: C data: - refId: A @@ -36,7 +36,7 @@ groups: conditions: - evaluator: params: - - 0.5 + - 0.8 type: gt operator: type: and @@ -57,16 +57,143 @@ groups: type: threshold dashboardUid: "" panelId: 0 - noDataState: Alerting + noDataState: OK execErrState: Alerting - for: 1m + for: 0s annotations: {} labels: {} isPaused: false notification_settings: receiver: TicketPing-Discord - orgId: 1 - name: CircuitBreaker Open or Half_Open for 5m + name: TicketPing-CPU + folder: TicketPing + interval: 1m + rules: + - uid: ee8sedq6voge8c + title: CPU Usage Over 50% in 1m + condition: C + data: + - refId: A + relativeTimeRange: + from: 300 + to: 0 + datasourceUid: PBFA97CFB590B2093 + model: + disableTextWrap: false + editorMode: builder + expr: avg_over_time(system_cpu_usage{instance="host.docker.internal:10001"}[1m]) + fullMetaSearch: false + includeNullMetadata: true + instant: true + intervalMs: 1000 + legendFormat: __auto + maxDataPoints: 43200 + range: false + refId: A + useBackend: false + - refId: C + relativeTimeRange: + from: 300 + to: 0 + datasourceUid: __expr__ + model: + conditions: + - evaluator: + params: + - 0.5 + type: gt + operator: + type: and + query: + params: + - C + reducer: + params: [ ] + type: last + type: query + datasource: + type: __expr__ + uid: __expr__ + expression: A + intervalMs: 1000 + maxDataPoints: 43200 + refId: C + type: threshold + noDataState: OK + execErrState: Error + for: 1m + annotations: { } + labels: { } + isPaused: false + notification_settings: + receiver: TicketPing-Discord + - orgId: 1 + name: CircuitBreaker + folder: TicketPing + interval: 1m + rules: + - uid: ae8shaqrswcn4a + title: CircuitBreaker Open + condition: C + data: + - refId: A + relativeTimeRange: + from: 600 + to: 0 + datasourceUid: PBFA97CFB590B2093 + model: + disableTextWrap: false + editorMode: builder + expr: sum by(name, instance) (resilience4j_circuitbreaker_state{state=~"open|half_open"}) + fullMetaSearch: false + includeNullMetadata: true + instant: true + intervalMs: 1000 + legendFormat: __auto + maxDataPoints: 43200 + range: false + refId: A + useBackend: false + - refId: C + relativeTimeRange: + from: 600 + to: 0 + datasourceUid: __expr__ + model: + conditions: + - evaluator: + params: + - 0.99 + type: gt + operator: + type: and + query: + params: + - C + reducer: + params: [ ] + type: last + type: query + datasource: + type: __expr__ + uid: __expr__ + expression: A + intervalMs: 1000 + maxDataPoints: 43200 + refId: C + type: threshold + noDataState: OK + execErrState: Error + for: 0s + annotations: + description: CircuitBreaker ''{{ $labels.instance }}'' ''{{ $labels.name }}'' has change from CLOSED to OPEN. + labels: { } + isPaused: false + notification_settings: + receiver: TicketPing-Discord + - orgId: 1 + name: CircuitBreaker folder: TicketPing interval: 1m rules: @@ -83,7 +210,7 @@ groups: disableTextWrap: false editorMode: builder exemplar: false - expr: sum by(name) (avg_over_time(resilience4j_circuitbreaker_state{state=~"open|half_open"}[5m])) + expr: sum by(name, instance) (avg_over_time(resilience4j_circuitbreaker_state{state=~"open|half_open"}[5m])) fullMetaSearch: false includeNullMetadata: true instant: true @@ -125,7 +252,7 @@ groups: execErrState: Error for: 1m annotations: - description: '"CircuitBreaker ''{{ $labels.name }}'' has been in an OPEN or HALF_OPEN state for 5 minutes."' + description: 'CircuitBreaker ''{{ $labels.instance }}'' ''{{ $labels.name }}'' has been in an OPEN or HALF_OPEN state for 5 minutes.' labels: {} isPaused: false notification_settings: diff --git a/services/auth/build.gradle b/services/auth/build.gradle index 4a8483d6..882ae4ca 100644 --- a/services/auth/build.gradle +++ b/services/auth/build.gradle @@ -39,9 +39,11 @@ dependencies { implementation project(':common:dtos') implementation project(':common:caching') implementation project(':common:monitoring') + implementation project(':common:circuit-breaker') // MVC implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' // Cloud implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client' diff --git a/services/auth/src/main/java/com/ticketPing/auth/AuthApplication.java b/services/auth/src/main/java/com/ticketPing/auth/AuthApplication.java index aabe031e..07207ca8 100644 --- a/services/auth/src/main/java/com/ticketPing/auth/AuthApplication.java +++ b/services/auth/src/main/java/com/ticketPing/auth/AuthApplication.java @@ -7,7 +7,7 @@ @SpringBootApplication @EnableFeignClients -@ComponentScan(basePackages = {"com.ticketPing.auth", "aop", "exception", "caching"}) +@ComponentScan(basePackages = {"com.ticketPing.auth", "aop", "exception", "caching", "circuit"}) public class AuthApplication { public static void main(String[] args) { SpringApplication.run(AuthApplication.class, args); diff --git a/services/auth/src/main/java/com/ticketPing/auth/application/service/AuthService.java b/services/auth/src/main/java/com/ticketPing/auth/application/service/AuthService.java index 55844a3f..bde25986 100644 --- a/services/auth/src/main/java/com/ticketPing/auth/application/service/AuthService.java +++ b/services/auth/src/main/java/com/ticketPing/auth/application/service/AuthService.java @@ -5,12 +5,9 @@ import com.ticketPing.auth.application.dto.TokenResponse; import com.ticketPing.auth.common.enums.Role; import com.ticketPing.auth.common.exception.AuthErrorCase; -import com.ticketPing.auth.infrastructure.jwt.JwtTokenProvider; import com.ticketPing.auth.presentation.request.LoginRequest; import exception.ApplicationException; import io.jsonwebtoken.Claims; -import io.jsonwebtoken.ExpiredJwtException; -import io.jsonwebtoken.JwtException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -26,39 +23,43 @@ @Service @RequiredArgsConstructor public class AuthService { - private final JwtTokenProvider jwtTokenProvider; - private final RefreshTokenCacheService refreshTokenCacheService; - private final RefreshTokenCookieService refreshTokenCookieService; + private final TokenService tokenService; + private final CacheService cacheService; + private final CookieService cookieService; private final UserClient userClient; public TokenResponse login(LoginRequest loginRequest, HttpServletResponse response) { UUID userId = authenticateUser(loginRequest); - createAndSaveRefreshToken(userId, response); + createAndSaveRefreshToken(userId, Role.USER, response); String accessToken = createAccessToken(userId, Role.USER); return TokenResponse.of(accessToken); } public UserCacheDto validateToken(String authHeader) { - String accessToken = jwtTokenProvider.parseToken(authHeader); - jwtTokenProvider.validateToken(accessToken); - Claims claims = jwtTokenProvider.getClaimsFromToken(accessToken); + String accessToken = tokenService.parseToken(authHeader); + tokenService.validateToken(accessToken); + Claims claims = tokenService.getClaimsFromToken(accessToken); return extractUserFromClaims(claims); } - public TokenResponse refreshAccessToken(String authHeader, HttpServletRequest request, HttpServletResponse response) { - Claims claims = validateAndExtractClaimsFromExpiredToken(authHeader, response); - UUID userId = jwtTokenProvider.getUserId(claims); - Role userRole = jwtTokenProvider.getUserRole(claims); + public TokenResponse refreshAccessToken(HttpServletRequest request, HttpServletResponse response) { + String cookieRefreshToken = getValidatedRefreshToken(request); - validateAndRotateRefreshToken(userId, request, response); + Claims claims = tokenService.getClaimsFromToken(cookieRefreshToken); + UUID userId = tokenService.getUserId(claims); + Role userRole = tokenService.getUserRole(claims); + validateStoredRefreshToken(cookieRefreshToken, userId, response); + + createAndSaveRefreshToken(userId, userRole, response); String newAccessToken = createAccessToken(userId, userRole); + return TokenResponse.of(newAccessToken); } public void logout(UUID userId, HttpServletResponse response) { - refreshTokenCacheService.deleteRefreshToken(userId); - refreshTokenCookieService.deleteRefreshToken(response); + cacheService.deleteRefreshToken(userId); + cookieService.deleteRefreshToken(response); } private UUID authenticateUser(LoginRequest loginRequest) { @@ -68,47 +69,32 @@ private UUID authenticateUser(LoginRequest loginRequest) { } private String createAccessToken(UUID userId, Role role) { - return BEARER_PREFIX + jwtTokenProvider.createAccessToken(userId, role); + return BEARER_PREFIX + tokenService.createAccessToken(userId, role); } - private void createAndSaveRefreshToken(UUID userId, HttpServletResponse response) { - String refreshToken = jwtTokenProvider.createRefreshToken(userId); - refreshTokenCacheService.saveRefreshToken(userId, refreshToken); - refreshTokenCookieService.setRefreshToken(response, refreshToken); + private void createAndSaveRefreshToken(UUID userId, Role role, HttpServletResponse response) { + String refreshToken = tokenService.createRefreshToken(userId, role); + cacheService.saveRefreshToken(userId, refreshToken); + cookieService.setRefreshToken(response, refreshToken); } - private Claims validateAndExtractClaimsFromExpiredToken(String authHeader, HttpServletResponse response) { - String token = jwtTokenProvider.parseToken(authHeader); - try { - Claims claims = jwtTokenProvider.getClaimsFromToken(token); - UUID userId = jwtTokenProvider.getUserId(claims); - logout(userId, response); - throw new ApplicationException(AuthErrorCase.ACCESS_TOKEN_NOT_EXPIRED); - } catch (ExpiredJwtException e) { - return e.getClaims(); - } catch (JwtException e) { - throw new ApplicationException(AuthErrorCase.INVALID_TOKEN); - } + private UserCacheDto extractUserFromClaims(Claims claims) { + UUID userId = tokenService.getUserId(claims); + Role role = tokenService.getUserRole(claims); + return new UserCacheDto(userId, role.getValue()); } - private void validateAndRotateRefreshToken(UUID userId, HttpServletRequest request, HttpServletResponse response) { - String storedRefreshToken = refreshTokenCacheService.getRefreshToken(userId); - String cookieRefreshToken = refreshTokenCookieService.getRefreshToken(request); - validateRefreshToken(userId, response, cookieRefreshToken, storedRefreshToken); - createAndSaveRefreshToken(userId, response); + private String getValidatedRefreshToken(HttpServletRequest request) { + String refreshToken = cookieService.getRefreshToken(request); + tokenService.validateToken(refreshToken); + return refreshToken; } - private void validateRefreshToken(UUID userId, HttpServletResponse response, String cookieRefreshToken, String storedRefreshToken) { + private void validateStoredRefreshToken(String cookieRefreshToken, UUID userId, HttpServletResponse response) { + String storedRefreshToken = cacheService.getRefreshToken(userId); if (!cookieRefreshToken.equals(storedRefreshToken)) { logout(userId, response); throw new ApplicationException(AuthErrorCase.INVALID_REFRESH_TOKEN); } - jwtTokenProvider.validateToken(storedRefreshToken); - } - - private UserCacheDto extractUserFromClaims(Claims claims) { - UUID userId = jwtTokenProvider.getUserId(claims); - Role role = jwtTokenProvider.getUserRole(claims); - return new UserCacheDto(userId, role.getValue()); } } \ No newline at end of file diff --git a/services/auth/src/main/java/com/ticketPing/auth/application/service/CacheService.java b/services/auth/src/main/java/com/ticketPing/auth/application/service/CacheService.java new file mode 100644 index 00000000..6a496f6e --- /dev/null +++ b/services/auth/src/main/java/com/ticketPing/auth/application/service/CacheService.java @@ -0,0 +1,11 @@ +package com.ticketPing.auth.application.service; + +import java.util.UUID; + +public interface CacheService { + public void saveRefreshToken(UUID userId, String refreshToken); + + public String getRefreshToken(UUID userId); + + public void deleteRefreshToken(UUID userId); +} diff --git a/services/auth/src/main/java/com/ticketPing/auth/application/service/CookieService.java b/services/auth/src/main/java/com/ticketPing/auth/application/service/CookieService.java new file mode 100644 index 00000000..4cba0762 --- /dev/null +++ b/services/auth/src/main/java/com/ticketPing/auth/application/service/CookieService.java @@ -0,0 +1,12 @@ +package com.ticketPing.auth.application.service; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +public interface CookieService { + public void setRefreshToken(HttpServletResponse response, String refreshToken); + + public String getRefreshToken(HttpServletRequest request); + + public void deleteRefreshToken(HttpServletResponse response); +} diff --git a/services/auth/src/main/java/com/ticketPing/auth/application/service/TokenService.java b/services/auth/src/main/java/com/ticketPing/auth/application/service/TokenService.java new file mode 100644 index 00000000..07daa26d --- /dev/null +++ b/services/auth/src/main/java/com/ticketPing/auth/application/service/TokenService.java @@ -0,0 +1,22 @@ +package com.ticketPing.auth.application.service; + +import com.ticketPing.auth.common.enums.Role; +import io.jsonwebtoken.Claims; +import java.util.UUID; + +public interface TokenService { + + public String createAccessToken(UUID userId, Role role); + + public String createRefreshToken(UUID userId, Role role); + + public String parseToken(String authHeader); + + public void validateToken(String token); + + public Claims getClaimsFromToken(String token); + + public UUID getUserId(Claims claims); + + public Role getUserRole(Claims claims); +} diff --git a/services/auth/src/main/java/com/ticketPing/auth/common/constants/AuthConstants.java b/services/auth/src/main/java/com/ticketPing/auth/common/constants/AuthConstants.java index ffd95541..39bcb7d0 100644 --- a/services/auth/src/main/java/com/ticketPing/auth/common/constants/AuthConstants.java +++ b/services/auth/src/main/java/com/ticketPing/auth/common/constants/AuthConstants.java @@ -1,9 +1,20 @@ package com.ticketPing.auth.common.constants; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component public final class AuthConstants { public static final String AUTHORIZATION_HEADER = "Authorization"; public static final String BEARER_PREFIX = "Bearer "; public static final String REFRESH_COOKIE = "refreshToken"; - private AuthConstants() {} + public static long REFRESH_TOKEN_EXPIRATION; + public static long ACCESS_TOKEN_EXPIRATION; + + private AuthConstants(@Value("${jwt.refreshToken.expiration}") long refreshTokenExpiration, + @Value("${jwt.accessToken.expiration}") long accessTokenExpiration) { + REFRESH_TOKEN_EXPIRATION = refreshTokenExpiration; + ACCESS_TOKEN_EXPIRATION = accessTokenExpiration; + } } \ No newline at end of file diff --git a/services/auth/src/main/java/com/ticketPing/auth/common/exception/CircuitBreakerErrorCase.java b/services/auth/src/main/java/com/ticketPing/auth/common/exception/CircuitBreakerErrorCase.java new file mode 100644 index 00000000..7ec866bf --- /dev/null +++ b/services/auth/src/main/java/com/ticketPing/auth/common/exception/CircuitBreakerErrorCase.java @@ -0,0 +1,16 @@ +package com.ticketPing.auth.common.exception; + +import exception.ErrorCase; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum CircuitBreakerErrorCase implements ErrorCase { + SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "서비스가 연결 불가능합니다. 잠시 후 다시 시도해주세요."), + SERVICE_IS_OPEN(HttpStatus.SERVICE_UNAVAILABLE, "서비스가 연결 불가능합니다. 관리자에게 문의해주세요."); + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/services/auth/src/main/java/com/ticketPing/auth/infrastructure/client/UserFeignClient.java b/services/auth/src/main/java/com/ticketPing/auth/infrastructure/client/UserFeignClient.java index d41aa515..c07591ce 100644 --- a/services/auth/src/main/java/com/ticketPing/auth/infrastructure/client/UserFeignClient.java +++ b/services/auth/src/main/java/com/ticketPing/auth/infrastructure/client/UserFeignClient.java @@ -1,17 +1,43 @@ package com.ticketPing.auth.infrastructure.client; import com.ticketPing.auth.application.client.UserClient; +import com.ticketPing.auth.common.exception.CircuitBreakerErrorCase; +import com.ticketPing.auth.infrastructure.config.CustomFeignConfig; +import exception.ApplicationException; +import feign.FeignException; +import feign.RetryableException; +import io.github.resilience4j.circuitbreaker.CallNotPermittedException; +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestParam; -import java.util.UUID; import response.CommonResponse; import user.UserLookupRequest; import user.UserResponse; -@FeignClient(name = "user") +@FeignClient(name = "user", configuration = CustomFeignConfig.class) public interface UserFeignClient extends UserClient { @GetMapping("/api/v1/users/login") + @CircuitBreaker(name = "userServiceCircuitBreaker", fallbackMethod = "fallbackForUserService") CommonResponse getUserByEmailAndPassword(@RequestBody UserLookupRequest userLookupRequest); + + default CommonResponse fallbackForUserService(UserLookupRequest userLookupRequest, Throwable cause) { + if (cause instanceof CallNotPermittedException) { + throw new ApplicationException(CircuitBreakerErrorCase.SERVICE_IS_OPEN); + } + else if ( + cause instanceof FeignException.GatewayTimeout || + cause instanceof FeignException.ServiceUnavailable || + cause instanceof FeignException.BadGateway || + cause instanceof FeignException.TooManyRequests || + cause instanceof RetryableException + ) { + throw new ApplicationException(CircuitBreakerErrorCase.SERVICE_UNAVAILABLE); + } + else if (cause instanceof FeignException) { + throw (FeignException) cause; + } else { + throw new ApplicationException(CircuitBreakerErrorCase.SERVICE_UNAVAILABLE); + } + } } diff --git a/services/auth/src/main/java/com/ticketPing/auth/infrastructure/config/CustomFeignConfig.java b/services/auth/src/main/java/com/ticketPing/auth/infrastructure/config/CustomFeignConfig.java new file mode 100644 index 00000000..07694870 --- /dev/null +++ b/services/auth/src/main/java/com/ticketPing/auth/infrastructure/config/CustomFeignConfig.java @@ -0,0 +1,14 @@ +package com.ticketPing.auth.infrastructure.config; + +import feign.Request; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class CustomFeignConfig { + + @Bean + public Request.Options requestOptions() { + return new Request.Options(1000, 12000); + } +} diff --git a/services/auth/src/main/java/com/ticketPing/auth/application/service/RefreshTokenCookieService.java b/services/auth/src/main/java/com/ticketPing/auth/infrastructure/service/HttpCookieService.java similarity index 76% rename from services/auth/src/main/java/com/ticketPing/auth/application/service/RefreshTokenCookieService.java rename to services/auth/src/main/java/com/ticketPing/auth/infrastructure/service/HttpCookieService.java index e245bf18..8cc9a99b 100644 --- a/services/auth/src/main/java/com/ticketPing/auth/application/service/RefreshTokenCookieService.java +++ b/services/auth/src/main/java/com/ticketPing/auth/infrastructure/service/HttpCookieService.java @@ -1,28 +1,27 @@ -package com.ticketPing.auth.application.service; +package com.ticketPing.auth.infrastructure.service; +import com.ticketPing.auth.application.service.CookieService; import com.ticketPing.auth.common.exception.AuthErrorCase; import com.ticketPing.auth.infrastructure.http.HttpCookieManager; import exception.ApplicationException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import java.util.concurrent.TimeUnit; import static com.ticketPing.auth.common.constants.AuthConstants.REFRESH_COOKIE; +import static com.ticketPing.auth.common.constants.AuthConstants.REFRESH_TOKEN_EXPIRATION; @Service @RequiredArgsConstructor -public class RefreshTokenCookieService { +public class HttpCookieService implements CookieService { - @Value("${jwt.refreshToken.expiration}") - private long refreshTokenExpiration; private final HttpCookieManager cookieManager; public void setRefreshToken(HttpServletResponse response, String refreshToken) { - cookieManager.setCookie(response, REFRESH_COOKIE, refreshToken, (int) TimeUnit.MILLISECONDS.toSeconds(refreshTokenExpiration)); + cookieManager.setCookie(response, REFRESH_COOKIE, refreshToken, (int) TimeUnit.MILLISECONDS.toSeconds(REFRESH_TOKEN_EXPIRATION)); } public String getRefreshToken(HttpServletRequest request) { diff --git a/services/auth/src/main/java/com/ticketPing/auth/infrastructure/jwt/JwtTokenProvider.java b/services/auth/src/main/java/com/ticketPing/auth/infrastructure/service/JwtTokenService.java similarity index 80% rename from services/auth/src/main/java/com/ticketPing/auth/infrastructure/jwt/JwtTokenProvider.java rename to services/auth/src/main/java/com/ticketPing/auth/infrastructure/service/JwtTokenService.java index c4dd56b5..25c13195 100644 --- a/services/auth/src/main/java/com/ticketPing/auth/infrastructure/jwt/JwtTokenProvider.java +++ b/services/auth/src/main/java/com/ticketPing/auth/infrastructure/service/JwtTokenService.java @@ -1,5 +1,6 @@ -package com.ticketPing.auth.infrastructure.jwt; +package com.ticketPing.auth.infrastructure.service; +import com.ticketPing.auth.application.service.TokenService; import com.ticketPing.auth.common.exception.AuthErrorCase; import com.ticketPing.auth.common.enums.Role; import exception.ApplicationException; @@ -13,22 +14,16 @@ import java.util.Date; import java.util.UUID; -import static com.ticketPing.auth.common.constants.AuthConstants.BEARER_PREFIX; +import static com.ticketPing.auth.common.constants.AuthConstants.*; @Component -public class JwtTokenProvider { +public class JwtTokenService implements TokenService { private final Key secretKey; - private final long accessTokenExpiration; - private final long refreshTokenExpiration; - public JwtTokenProvider(@Value("${jwt.secret}") String secret, - @Value("${jwt.accessToken.expiration}") long accessTokenExpiration, - @Value("${jwt.refreshToken.expiration}") long refreshTokenExpiration) { + public JwtTokenService(@Value("${jwt.secret}") String secret) { byte[] bytes = Base64.getDecoder().decode(secret); this.secretKey = Keys.hmacShaKeyFor(bytes); - this.accessTokenExpiration = accessTokenExpiration; - this.refreshTokenExpiration = refreshTokenExpiration; } public String createAccessToken(UUID userId, Role role) { @@ -37,17 +32,18 @@ public String createAccessToken(UUID userId, Role role) { .setSubject(userId.toString()) .claim("role", role) .setIssuedAt(now) - .setExpiration(new Date(now.getTime() + accessTokenExpiration)) + .setExpiration(new Date(now.getTime() + ACCESS_TOKEN_EXPIRATION)) .signWith(this.secretKey, SignatureAlgorithm.HS256) .compact(); } - public String createRefreshToken(UUID userId) { + public String createRefreshToken(UUID userId, Role role) { Date now = new Date(); return Jwts.builder() .setSubject(userId.toString()) + .claim("role", role) .setIssuedAt(now) - .setExpiration(new Date(now.getTime() + refreshTokenExpiration)) + .setExpiration(new Date(now.getTime() + REFRESH_TOKEN_EXPIRATION)) .signWith(this.secretKey, SignatureAlgorithm.HS256) .compact(); } diff --git a/services/auth/src/main/java/com/ticketPing/auth/application/service/RefreshTokenCacheService.java b/services/auth/src/main/java/com/ticketPing/auth/infrastructure/service/RedisCacheService.java similarity index 74% rename from services/auth/src/main/java/com/ticketPing/auth/application/service/RefreshTokenCacheService.java rename to services/auth/src/main/java/com/ticketPing/auth/infrastructure/service/RedisCacheService.java index b9ef2d1d..40ceaf72 100644 --- a/services/auth/src/main/java/com/ticketPing/auth/application/service/RefreshTokenCacheService.java +++ b/services/auth/src/main/java/com/ticketPing/auth/infrastructure/service/RedisCacheService.java @@ -1,27 +1,27 @@ -package com.ticketPing.auth.application.service; +package com.ticketPing.auth.infrastructure.service; import caching.repository.RedisRepository; +import com.ticketPing.auth.application.service.CacheService; import com.ticketPing.auth.common.exception.AuthErrorCase; import exception.ApplicationException; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import java.time.Duration; import java.util.Optional; import java.util.UUID; +import static com.ticketPing.auth.common.constants.AuthConstants.REFRESH_TOKEN_EXPIRATION; + @Service @RequiredArgsConstructor -public class RefreshTokenCacheService { +public class RedisCacheService implements CacheService { - @Value("${jwt.refreshToken.expiration}") - private long refreshTokenExpiration; private final RedisRepository redisRepository; public void saveRefreshToken(UUID userId, String refreshToken) { String key = generateKey(userId); - redisRepository.setValueWithTTL(key, refreshToken, Duration.ofMillis(refreshTokenExpiration)); + redisRepository.setValueWithTTL(key, refreshToken, Duration.ofMillis(REFRESH_TOKEN_EXPIRATION)); } public String getRefreshToken(UUID userId) { @@ -36,7 +36,7 @@ public void deleteRefreshToken(UUID userId) { } private String generateKey(UUID userId) { - return String.format("auth:refreshToken:user:%s", userId); + return String.format("refreshToken:%s", userId); } } diff --git a/services/auth/src/main/java/com/ticketPing/auth/presentation/controller/AuthController.java b/services/auth/src/main/java/com/ticketPing/auth/presentation/controller/AuthController.java index 808c281a..21f2fd0a 100644 --- a/services/auth/src/main/java/com/ticketPing/auth/presentation/controller/AuthController.java +++ b/services/auth/src/main/java/com/ticketPing/auth/presentation/controller/AuthController.java @@ -47,9 +47,8 @@ public ResponseEntity> validateToken(@RequestHeader @Operation(summary = "토큰 재발급") @PostMapping("/refresh") - public ResponseEntity> refreshAccessToken(@RequestHeader(AUTHORIZATION_HEADER) String authHeader, - HttpServletRequest request, HttpServletResponse response) { - TokenResponse tokenResponse = authService.refreshAccessToken(authHeader, request, response); + public ResponseEntity> refreshAccessToken(HttpServletRequest request, HttpServletResponse response) { + TokenResponse tokenResponse = authService.refreshAccessToken(request, response); return ResponseEntity .status(HttpStatus.OK) .body(CommonResponse.success(tokenResponse)); diff --git a/services/auth/src/main/java/com/ticketPing/auth/presentation/request/LoginErrorMessage.java b/services/auth/src/main/java/com/ticketPing/auth/presentation/request/LoginErrorMessage.java index 37d553dd..c82d7b07 100644 --- a/services/auth/src/main/java/com/ticketPing/auth/presentation/request/LoginErrorMessage.java +++ b/services/auth/src/main/java/com/ticketPing/auth/presentation/request/LoginErrorMessage.java @@ -2,6 +2,6 @@ public class LoginErrorMessage { public static final String EMAIL_REQUIRED = "이메일을 입력해주세요."; - public static final String INVALID_EMAIL = "존재하지 않는 이메일입니다."; + public static final String INVALID_EMAIL = "이메일 형식이 맞지 않습니다."; public static final String PASSWORD_REQUIRED = "비밀번호가 존재하지 않습니다."; } \ No newline at end of file diff --git a/services/auth/src/main/resources/application.yml b/services/auth/src/main/resources/application.yml index b821b984..02a8b409 100644 --- a/services/auth/src/main/resources/application.yml +++ b/services/auth/src/main/resources/application.yml @@ -7,6 +7,7 @@ spring: - "classpath:application-eureka.yml" - "classpath:application-redis.yml" - "classpath:application-monitoring.yml" + - "classpath:application-circuit-breaker.yml" server: port: 10010 @@ -16,4 +17,10 @@ jwt: accessToken: expiration: 1800000 # 30분 refreshToken: - expiration: 604800000 # 7일 \ No newline at end of file + expiration: 604800000 # 7일 + +resilience4j: + circuitbreaker: + instances: + userServiceCircuitBreaker: + baseConfig: default \ No newline at end of file diff --git a/services/order/build.gradle b/services/order/build.gradle index b85c0997..bff8500e 100644 --- a/services/order/build.gradle +++ b/services/order/build.gradle @@ -38,9 +38,9 @@ dependencies { implementation project(':common:core') implementation project(':common:dtos') implementation project(':common:rdb') - implementation project(':common:caching') implementation project(':common:messaging') implementation project(':common:monitoring') + implementation project(':common:circuit-breaker') // MVC implementation 'org.springframework.boot:spring-boot-starter-web' diff --git a/services/order/src/main/java/com/ticketPing/order/OrderApplication.java b/services/order/src/main/java/com/ticketPing/order/OrderApplication.java index ebafa1de..1eff9a35 100644 --- a/services/order/src/main/java/com/ticketPing/order/OrderApplication.java +++ b/services/order/src/main/java/com/ticketPing/order/OrderApplication.java @@ -7,7 +7,7 @@ @SpringBootApplication @EnableFeignClients -@ComponentScan(basePackages = {"com.ticketPing.order", "aop", "exception", "audit", "caching", "messaging"}) +@ComponentScan(basePackages = {"com.ticketPing.order", "aop", "exception", "audit", "messaging", "circuit"}) public class OrderApplication { public static void main(String[] args) { SpringApplication.run(OrderApplication.class, args); diff --git a/services/order/src/main/java/com/ticketPing/order/application/client/PaymentClient.java b/services/order/src/main/java/com/ticketPing/order/application/client/PaymentClient.java new file mode 100644 index 00000000..5a3063d5 --- /dev/null +++ b/services/order/src/main/java/com/ticketPing/order/application/client/PaymentClient.java @@ -0,0 +1,12 @@ +package com.ticketPing.order.application.client; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestParam; +import payment.PaymentResponse; +import response.CommonResponse; + +import java.util.UUID; + +public interface PaymentClient { + ResponseEntity> getCompletedPaymentByOrderId(@RequestParam("orderId") UUID orderId); +} diff --git a/services/order/src/main/java/com/ticketPing/order/application/client/PerformanceClient.java b/services/order/src/main/java/com/ticketPing/order/application/client/PerformanceClient.java new file mode 100644 index 00000000..9a2d9f24 --- /dev/null +++ b/services/order/src/main/java/com/ticketPing/order/application/client/PerformanceClient.java @@ -0,0 +1,13 @@ +package com.ticketPing.order.application.client; + +import org.springframework.http.ResponseEntity; +import performance.OrderSeatResponse; +import response.CommonResponse; + +import java.util.UUID; + +public interface PerformanceClient { + ResponseEntity> getOrderInfo(UUID userId, UUID scheduleId, UUID seatId); + + ResponseEntity> extendPreReserveTTL(UUID scheduleId, UUID seatId); +} diff --git a/services/order/src/main/java/com/ticketPing/order/application/dtos/OrderInfoForPaymentResponse.java b/services/order/src/main/java/com/ticketPing/order/application/dtos/OrderInfoForPaymentResponse.java deleted file mode 100644 index 692b0330..00000000 --- a/services/order/src/main/java/com/ticketPing/order/application/dtos/OrderInfoForPaymentResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.ticketPing.order.application.dtos; - -import com.ticketPing.order.domain.model.entity.Order; -import java.util.UUID; - -public record OrderInfoForPaymentResponse( - UUID id, - Long amount -) { - public static OrderInfoForPaymentResponse from(Order order) { - return new OrderInfoForPaymentResponse(order.getId(), 5000L); - } -} diff --git a/services/order/src/main/java/com/ticketPing/order/application/dtos/OrderInfoResponse.java b/services/order/src/main/java/com/ticketPing/order/application/dtos/OrderInfoResponse.java deleted file mode 100644 index 87f36ce2..00000000 --- a/services/order/src/main/java/com/ticketPing/order/application/dtos/OrderInfoResponse.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.ticketPing.order.application.dtos; - -import java.time.LocalDateTime; -import java.util.UUID; - -public record OrderInfoResponse( - UUID seatId, - int row, - int col, - boolean seatState, - String seatRate, - int cost, - UUID scheduleId, - LocalDateTime startTime, - UUID performanceHallId, - String performanceHallName, - UUID performanceId, - String performanceName, - int performanceGrade, - UUID companyId -) {} diff --git a/services/order/src/main/java/com/ticketPing/order/application/dtos/OrderResponse.java b/services/order/src/main/java/com/ticketPing/order/application/dtos/OrderResponse.java index 77d9a5e6..1c6c71ab 100644 --- a/services/order/src/main/java/com/ticketPing/order/application/dtos/OrderResponse.java +++ b/services/order/src/main/java/com/ticketPing/order/application/dtos/OrderResponse.java @@ -4,36 +4,45 @@ import lombok.AccessLevel; import lombok.Builder; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.UUID; @Builder(access = AccessLevel.PRIVATE) public record OrderResponse( UUID id, + UUID performanceId, + String performanceName, + UUID scheduleId, + LocalDate startDate, + UUID performanceHallId, + String performanceHallName, + int amount, String orderStatus, LocalDateTime reservationDate, - UUID userId, - UUID scheduleId, - UUID companyId, - String performanceName, + UUID paymentId, int row, int col, String seatGrade, - int price + UUID userId ) { public static OrderResponse from(Order order) { return OrderResponse.builder() - .id(order.getId()) - .row(order.getOrderSeat().getRow()) - .col(order.getOrderSeat().getCol()) - .price(order.getOrderSeat().getCost()) - .userId(order.getUserId()) - .performanceName(order.getPerformanceName()) - .companyId(order.getCompanyId()) - .orderStatus(order.getOrderStatus().toString()) - .reservationDate(order.getReservationDate()) - .scheduleId(order.getScheduleId()) - .seatGrade(order.getOrderSeat().getSeatRate()) - .build(); + .id(order.getId()) + .performanceId(order.getPerformanceId()) + .performanceName(order.getPerformanceName()) + .scheduleId(order.getScheduleId()) + .startDate(order.getStartDate()) + .performanceHallId(order.getPerformanceHallId()) + .performanceHallName(order.getPerformanceHallName()) + .amount(order.getAmount()) + .orderStatus(order.getOrderStatus().getValue()) + .reservationDate(order.getReservationDate()) + .paymentId(order.getPaymentId()) + .row(order.getOrderSeat().getRow()) + .col(order.getOrderSeat().getCol()) + .seatGrade(order.getOrderSeat().getSeatGrade()) + .userId(order.getUserId()) + .build(); } } \ No newline at end of file diff --git a/services/order/src/main/java/com/ticketPing/order/application/dtos/OrderSeatInfo.java b/services/order/src/main/java/com/ticketPing/order/application/dtos/OrderSeatInfo.java deleted file mode 100644 index e42ed44b..00000000 --- a/services/order/src/main/java/com/ticketPing/order/application/dtos/OrderSeatInfo.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.ticketPing.order.application.dtos; - -public record OrderSeatInfo ( - String seatId, - int row, - int col, - boolean seatState, - String seatRate, - int cost -) { - public OrderSeatInfo(String seatId, int row, int col, String seatRate, int cost) { - this(seatId, row, col, false, seatRate, cost); - } -} - diff --git a/services/order/src/main/java/com/ticketPing/order/application/dtos/temp/SeatResponse.java b/services/order/src/main/java/com/ticketPing/order/application/dtos/temp/SeatResponse.java deleted file mode 100644 index 037edd40..00000000 --- a/services/order/src/main/java/com/ticketPing/order/application/dtos/temp/SeatResponse.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.ticketPing.order.application.dtos.temp; - -import lombok.Data; - -import java.util.UUID; - -@Data -public class SeatResponse { - UUID seatId; - Integer row; - Integer col; - Boolean seatState; - String seatRate; - Integer cost; - - public void updateSeatState(Boolean seatState) { - this.seatState = seatState; - } -} - diff --git a/services/order/src/main/java/com/ticketPing/order/application/service/EventApplicationService.java b/services/order/src/main/java/com/ticketPing/order/application/service/EventApplicationService.java index 988debce..eea4946f 100644 --- a/services/order/src/main/java/com/ticketPing/order/application/service/EventApplicationService.java +++ b/services/order/src/main/java/com/ticketPing/order/application/service/EventApplicationService.java @@ -1,7 +1,9 @@ package com.ticketPing.order.application.service; +import messaging.events.OrderCompletedForQueueTokenRemovalEvent; +import messaging.events.OrderCompletedForSeatReservationEvent; +import messaging.events.OrderFailedEvent; import messaging.utils.EventSerializer; -import messaging.events.OrderCompletedEvent; import lombok.RequiredArgsConstructor; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Service; @@ -13,9 +15,19 @@ public class EventApplicationService { private final KafkaTemplate kafkaTemplate; - public void publishOrderCompletedEvent(OrderCompletedEvent event) { + public void publishForSeatReservation(OrderCompletedForSeatReservationEvent event) { String message = EventSerializer.serialize(event); - kafkaTemplate.send(OrderTopic.COMPLETED.getTopic(), message); + kafkaTemplate.send(OrderTopic.COMPLETED_FOR_SEAT_RESERVATION.getTopic(), message); + } + + public void publishForQueueTokenRemoval(OrderCompletedForQueueTokenRemovalEvent event) { + String message = EventSerializer.serialize(event); + kafkaTemplate.send(OrderTopic.COMPLETED_FOR_QUEUE_TOKEN_REMOVAL.getTopic(), message); + } + + public void publishOrderFailed(OrderFailedEvent event) { + String message = EventSerializer.serialize(event); + kafkaTemplate.send(OrderTopic.FAILED.getTopic(), message); } } diff --git a/services/order/src/main/java/com/ticketPing/order/application/service/OrderService.java b/services/order/src/main/java/com/ticketPing/order/application/service/OrderService.java index d94b8d07..02622b16 100644 --- a/services/order/src/main/java/com/ticketPing/order/application/service/OrderService.java +++ b/services/order/src/main/java/com/ticketPing/order/application/service/OrderService.java @@ -1,31 +1,34 @@ package com.ticketPing.order.application.service; -import com.ticketPing.order.application.dtos.OrderInfoForPaymentResponse; -import com.ticketPing.order.infrastructure.service.RedisLuaService; -import com.ticketPing.order.presentation.request.OrderCreateDto; -import com.ticketPing.order.application.dtos.OrderInfoResponse; +import com.ticketPing.order.application.client.PaymentClient; +import com.ticketPing.order.application.client.PerformanceClient; import com.ticketPing.order.application.dtos.OrderResponse; +import com.ticketPing.order.common.exception.OrderExceptionCase; import com.ticketPing.order.domain.model.entity.Order; import com.ticketPing.order.domain.model.entity.OrderSeat; import com.ticketPing.order.domain.model.enums.OrderStatus; -import com.ticketPing.order.infrastructure.client.PerformanceClient; -import com.ticketPing.order.infrastructure.repository.OrderRepository; -import messaging.events.OrderCompletedEvent; +import com.ticketPing.order.domain.repository.OrderRepository; +import com.ticketPing.order.presentation.request.CreateOrderRequest; import exception.ApplicationException; +import feign.FeignException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import lombok.val; +import messaging.events.OrderCompletedForQueueTokenRemovalEvent; +import messaging.events.OrderCompletedForSeatReservationEvent; +import messaging.events.OrderFailedEvent; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import performance.OrderSeatResponse; -import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; import java.util.UUID; -import caching.repository.RedisRepository; -import static caching.enums.RedisKeyPrefix.AVAILABLE_SEATS; -import static caching.enums.RedisKeyPrefix.SEAT_CACHE; -import static com.ticketPing.order.common.exception.OrderExceptionCase.*; +import static com.ticketPing.order.common.exception.OrderExceptionCase.DUPLICATED_ORDER; +import static com.ticketPing.order.common.exception.OrderExceptionCase.ORDER_NOT_FOUND; @Service @RequiredArgsConstructor @@ -34,97 +37,105 @@ public class OrderService { private final OrderRepository orderRepository; private final EventApplicationService eventApplicationService; - private final RedisRepository redisRepository; - private final RedisLuaService redisLuaService; private final PerformanceClient performanceClient; - - private final static String SEAT_PREFIX = SEAT_CACHE.getValue(); - private final static String TTL_PREFIX = "{Seat}:seat_ttl:"; - private final static String SEAT_LOCK_CACHE_EXPIRE_SECONDS = "300"; + private final PaymentClient paymentClient; @Transactional - public OrderResponse createOrder(OrderCreateDto orderCreateRequestDto, UUID userId) { - UUID scheduleId = orderCreateRequestDto.scheduleId(); - UUID seatId = orderCreateRequestDto.seatId(); - - Order order = saveOrderWithOrderSeat(seatId, userId); - - String seatKey = SEAT_PREFIX + scheduleId + ":" + seatId; - String ttlKey = TTL_PREFIX + scheduleId + ":" + seatId + ":" + order.getId(); - redisLuaService.updateSeatStatus(seatKey, ttlKey, SEAT_LOCK_CACHE_EXPIRE_SECONDS); + public OrderResponse createOrder(CreateOrderRequest createOrderRequest, UUID userId) { + UUID scheduleId = createOrderRequest.scheduleId(); + UUID seatId = createOrderRequest.seatId(); + validateDuplicateOrder(seatId); + OrderSeatResponse orderData = performanceClient.getOrderInfo(userId, scheduleId, seatId).getBody().getData(); + Order order = saveOrderWithOrderSeat(userId, orderData); return OrderResponse.from(order); } - @Transactional - public Order saveOrderWithOrderSeat(UUID seatId, UUID userId) { - OrderInfoResponse orderData = performanceClient.getOrderInfo(seatId.toString()).getBody().getData(); - - Order order = Order.create(userId, orderData.companyId(), orderData.performanceId(), orderData.performanceName(), LocalDateTime.now(), OrderStatus.PENDING, orderData.scheduleId()); - Order savedOrder = orderRepository.save(order); + public Slice getUserOrders(UUID userId, Pageable pageable) { + Slice orders = orderRepository.findUserOrdersExcludingStatus( + userId, List.of(OrderStatus.PENDING, OrderStatus.FAIL), pageable); + return orders.map(OrderResponse::from); + } - OrderSeat orderSeat = OrderSeat.create(orderData.seatId(), orderData.row(), orderData.col(), orderData.seatRate(), orderData.cost()); - savedOrder.updateOrderSeat(orderSeat); + public void validateOrderAndExtendTTL(UUID orderId, UUID userId) { + Order order = validateAndGetOrder(orderId, userId); + performanceClient.extendPreReserveTTL(order.getScheduleId(), order.getOrderSeat().getSeatId()); + } - return savedOrder; + @Transactional + public void failOrder(UUID scheduleId, UUID seatId) { + Optional optionalOrder = orderRepository.findByOrderSeatSeatIdAndOrderStatus(seatId, OrderStatus.PENDING); + + if (optionalOrder.isPresent()) { + Order order = optionalOrder.get(); + try { + paymentClient.getCompletedPaymentByOrderId(order.getId()); + } catch (FeignException.NotFound e) { + order.fail(); + publishOrderFailed(scheduleId, seatId); + } + } else { + publishOrderFailed(scheduleId, seatId); + } } - @Transactional(readOnly = true) - public OrderInfoForPaymentResponse getOrderInfoForPayment(UUID orderId, UUID userId) { + @Transactional + public void completeOrder(UUID orderId, UUID paymentId) { Order order = findOrderById(orderId); - validateDuplicateOrder(order, userId); - return OrderInfoForPaymentResponse.from(order); - } + order.complete(paymentId); - public void validateDuplicateOrder(Order order, UUID userId) { - UUID seatId = order.getOrderSeat().getSeatId(); - UUID scheduleId = order.getScheduleId(); + publishForSeatReservation(order.getScheduleId(), order.getOrderSeat().getSeatId()); + publishForQueueTokenRemoval(order.getUserId(), order.getPerformanceId()); + } - List duplicateOrders = orderRepository.findByScheduleIdAndOrderSeatSeatId(seatId, scheduleId) - .stream() - .filter(o -> !o.getUserId().equals(userId) && - (o.getOrderStatus().equals(OrderStatus.PENDING) || - o.getOrderStatus().equals(OrderStatus.COMPLETED))) - .toList(); + @Transactional + private Order saveOrderWithOrderSeat(UUID userId, OrderSeatResponse orderData) { + Order order = Order.from(userId, orderData); + Order savedOrder = orderRepository.save(order); - if(!duplicateOrders.isEmpty()) - throw new ApplicationException(SEAT_ALREADY_TAKEN); - } + OrderSeat orderSeat = OrderSeat.from(orderData, savedOrder); + savedOrder.updateOrderSeat(orderSeat); - public List getUserOrders(UUID userId) { - List orders = orderRepository.findByUserId(userId); - return orders.stream().map(OrderResponse::from).toList(); + return savedOrder; } - @Transactional - public void updateOrderStatus(UUID orderId) { - Order order = findOrderById(orderId); - order.complete(); - - UUID performanceId = order.getPerformanceId(); - UUID scheduleId = order.getScheduleId(); - UUID seatId = order.getOrderSeat().getSeatId(); + private void validateDuplicateOrder(UUID seatId) { + boolean hasDuplicate = orderRepository.existsByOrderSeatSeatIdAndOrderStatusIn( + seatId, List.of(OrderStatus.PENDING, OrderStatus.COMPLETED)); - performanceClient.updateSeatState(order.getOrderSeat().getSeatId(), true); // 1. 좌석 db 업데이트 (kafka로 변경?) + if (hasDuplicate) { + throw new ApplicationException(DUPLICATED_ORDER); + } + } - String ttlKey = TTL_PREFIX + scheduleId + ":" + seatId + ":" + orderId; - redisRepository.deleteKey(ttlKey); + private Order validateAndGetOrder(UUID orderId, UUID userId) { + Order order = orderRepository.findByIdAndOrderStatus(orderId, OrderStatus.PENDING) + .orElseThrow(() -> new ApplicationException(OrderExceptionCase.INVALID_ORDER)); - String counterKey = AVAILABLE_SEATS.getValue() + performanceId; - redisRepository.decrement(counterKey); + if (!order.getUserId().equals(userId)) { + throw new ApplicationException(OrderExceptionCase.INVALID_ORDER); + } - publishOrderCompletedEvent(order.getUserId(), performanceId); + return order; } - @Transactional(readOnly = true) - public Order findOrderById(UUID orderId){ + private Order findOrderById(UUID orderId){ return orderRepository.findById(orderId) .orElseThrow(() -> new ApplicationException(ORDER_NOT_FOUND)); } - private void publishOrderCompletedEvent(UUID userId, UUID performanceId) { - val event = OrderCompletedEvent.create(userId, performanceId); - eventApplicationService.publishOrderCompletedEvent(event); + private void publishForSeatReservation(UUID scheduleId, UUID seatId) { + val event = OrderCompletedForSeatReservationEvent.create(scheduleId, seatId); + eventApplicationService.publishForSeatReservation(event); } + private void publishForQueueTokenRemoval(UUID userId, UUID performanceId) { + val event = OrderCompletedForQueueTokenRemovalEvent.create(userId, performanceId); + eventApplicationService.publishForQueueTokenRemoval(event); + } + + private void publishOrderFailed(UUID scheduleId, UUID seatId) { + val event = OrderFailedEvent.create(scheduleId, seatId); + eventApplicationService.publishOrderFailed(event); + } } diff --git a/services/order/src/main/java/com/ticketPing/order/common/exception/CircuitBreakerErrorCase.java b/services/order/src/main/java/com/ticketPing/order/common/exception/CircuitBreakerErrorCase.java new file mode 100644 index 00000000..ab9425d4 --- /dev/null +++ b/services/order/src/main/java/com/ticketPing/order/common/exception/CircuitBreakerErrorCase.java @@ -0,0 +1,16 @@ +package com.ticketPing.order.common.exception; + +import exception.ErrorCase; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum CircuitBreakerErrorCase implements ErrorCase { + SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "서비스가 연결 불가능합니다. 잠시 후 다시 시도해주세요."), + SERVICE_IS_OPEN(HttpStatus.SERVICE_UNAVAILABLE, "서비스가 연결 불가능합니다. 관리자에게 문의해주세요."); + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/services/order/src/main/java/com/ticketPing/order/common/exception/OrderExceptionCase.java b/services/order/src/main/java/com/ticketPing/order/common/exception/OrderExceptionCase.java index 1fba4ec4..741541e2 100644 --- a/services/order/src/main/java/com/ticketPing/order/common/exception/OrderExceptionCase.java +++ b/services/order/src/main/java/com/ticketPing/order/common/exception/OrderExceptionCase.java @@ -1,6 +1,5 @@ package com.ticketPing.order.common.exception; - import exception.ErrorCase; import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -13,20 +12,12 @@ public enum OrderExceptionCase implements ErrorCase { ORDER_NOT_FOUND(HttpStatus.NOT_FOUND, "예매정보를 찾을 수 없습니다."), INVALID_TTL_NAME(HttpStatus.BAD_REQUEST, "유효하지 않은 TTL 명입니다."), NOT_FOUND_ORDER_ID_IN_TTL(HttpStatus.NOT_FOUND,"TTL 정보에서 order_id를 찾을 수 없습니다."), - SEAT_ALREADY_TAKEN(HttpStatus.BAD_REQUEST, "좌석이 이미 점유되어 있습니다."), + DUPLICATED_ORDER(HttpStatus.BAD_REQUEST, "중복된 주문입니다."), + INVALID_ORDER(HttpStatus.BAD_REQUEST, "유효하지 않은 주문입니다."), SEAT_CACHE_NOT_FOUND(HttpStatus.NOT_FOUND, "레디스에 공연관련 정보가 캐싱되어 있지 않습니다."), ORDER_STATUS_UNKNOWN(HttpStatus.CONFLICT,"저장된 enum 상태값을 사용해야 합니다."), - PRE_RESERVE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "좌석 선점 과정에서 오류가 발생했습니다."), - SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 오류가 발생했습니다."); + INVALID_SEAT_STATUS(HttpStatus.BAD_REQUEST, "유효하지 않은 좌석 상태를 사용했습니다."); private final HttpStatus httpStatus; private final String message; - - public static OrderExceptionCase getByValue(String value) { - try { - return OrderExceptionCase.valueOf(value); - } catch (IllegalArgumentException e) { - return OrderExceptionCase.SERVER_ERROR; - } - } } diff --git a/services/order/src/main/java/com/ticketPing/order/common/utils/FeignFallbackUtils.java b/services/order/src/main/java/com/ticketPing/order/common/utils/FeignFallbackUtils.java new file mode 100644 index 00000000..0254f4c2 --- /dev/null +++ b/services/order/src/main/java/com/ticketPing/order/common/utils/FeignFallbackUtils.java @@ -0,0 +1,33 @@ +package com.ticketPing.order.common.utils; + +import com.ticketPing.order.common.exception.CircuitBreakerErrorCase; +import exception.ApplicationException; +import feign.FeignException; +import io.github.resilience4j.circuitbreaker.CallNotPermittedException; +import feign.RetryableException; + +public class FeignFallbackUtils { + + private FeignFallbackUtils() { + } + + public static T handleFallback(Throwable cause) { + if (cause instanceof CallNotPermittedException) { + throw new ApplicationException(CircuitBreakerErrorCase.SERVICE_IS_OPEN); + } + else if ( + cause instanceof FeignException.GatewayTimeout || + cause instanceof FeignException.ServiceUnavailable || + cause instanceof FeignException.BadGateway || + cause instanceof FeignException.TooManyRequests || + cause instanceof RetryableException + ) { + throw new ApplicationException(CircuitBreakerErrorCase.SERVICE_UNAVAILABLE); + } + else if (cause instanceof FeignException) { + throw (FeignException) cause; + } else { + throw new ApplicationException(CircuitBreakerErrorCase.SERVICE_UNAVAILABLE); + } + } +} diff --git a/services/order/src/main/java/com/ticketPing/order/domain/model/entity/Order.java b/services/order/src/main/java/com/ticketPing/order/domain/model/entity/Order.java index 9a8693ac..d5fcd10b 100644 --- a/services/order/src/main/java/com/ticketPing/order/domain/model/entity/Order.java +++ b/services/order/src/main/java/com/ticketPing/order/domain/model/entity/Order.java @@ -5,8 +5,9 @@ import com.ticketPing.order.domain.model.enums.OrderStatus; import jakarta.persistence.*; import lombok.*; -import org.hibernate.annotations.Where; +import performance.OrderSeatResponse; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.UUID; @@ -15,53 +16,54 @@ @AllArgsConstructor(access = AccessLevel.PROTECTED) @Builder(access = AccessLevel.PRIVATE) @Table(name = "p_orders") -@Where(clause = "is_deleted = false") @Entity public class Order extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.UUID) @Column(name = "order_id") private UUID id; + private UUID performanceId; + private String performanceName; + private UUID scheduleId; + private LocalDate startDate; + private UUID performanceHallId; + private String performanceHallName; + private int amount; private OrderStatus orderStatus; - private Boolean isOrderCancelled; private LocalDateTime reservationDate; private UUID userId; - private UUID scheduleId; private UUID companyId; - private UUID performanceId; private UUID paymentId; - private String performanceName; - // paymentId, amount - @OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY) - @JoinColumn(name = "order_seat_id") + @OneToOne(mappedBy = "order", cascade = CascadeType.PERSIST, orphanRemoval = true, fetch = FetchType.LAZY) private OrderSeat orderSeat; - public static Order create(UUID userId, UUID companyId, UUID performanceId, String performanceName, - LocalDateTime reservationDate, OrderStatus orderStatus, UUID scheduleId) { + public static Order from(UUID userId, OrderSeatResponse orderData) { return Order.builder() - .companyId(companyId) - .performanceId(performanceId) - .performanceName(performanceName) - .orderStatus(orderStatus) - .isOrderCancelled(false) - .userId(userId) - .scheduleId(scheduleId) + .performanceId(orderData.performanceId()) + .performanceName(orderData.performanceName()) + .performanceHallId(orderData.performanceHallId()) + .performanceHallName(orderData.performanceHallName()) + .scheduleId(orderData.scheduleId()) + .startDate(orderData.startDate()) + .amount(orderData.cost()) + .orderStatus(OrderStatus.PENDING) .reservationDate(LocalDateTime.now()) - .reservationDate(reservationDate) + .userId(userId) + .companyId(orderData.companyId()) .build(); } - public void updateOrderStatus(OrderStatus orderStatus) { - this.orderStatus = orderStatus; - } - public void updateOrderSeat(OrderSeat orderSeat) { this.orderSeat = orderSeat; } - public void complete(){ + public void complete(UUID paymentId){ this.orderStatus = OrderStatus.COMPLETED; + this.paymentId = paymentId; } + public void fail() { + this.orderStatus = OrderStatus.FAIL; + } } diff --git a/services/order/src/main/java/com/ticketPing/order/domain/model/entity/OrderSeat.java b/services/order/src/main/java/com/ticketPing/order/domain/model/entity/OrderSeat.java index 1fd2e99f..8d918f3a 100644 --- a/services/order/src/main/java/com/ticketPing/order/domain/model/entity/OrderSeat.java +++ b/services/order/src/main/java/com/ticketPing/order/domain/model/entity/OrderSeat.java @@ -9,7 +9,7 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; -import org.hibernate.annotations.Where; +import performance.OrderSeatResponse; @Getter @@ -17,7 +17,6 @@ @AllArgsConstructor(access = AccessLevel.PROTECTED) @Builder(access = AccessLevel.PRIVATE) @Table(name = "p_order_seats") -@Where(clause = "is_deleted = false") @Entity public class OrderSeat extends BaseEntity { @Id @@ -27,20 +26,22 @@ public class OrderSeat extends BaseEntity { private UUID seatId; private int row; private int col; - private String seatRate; + private String seatGrade; private int cost; - @OneToOne(mappedBy = "orderSeat") + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "performance_id", nullable = false) private Order order; - public static OrderSeat create(UUID seatId, int row, int col, String seatRate, int cost) { + public static OrderSeat from(OrderSeatResponse orderData, Order order) { return OrderSeat.builder() - .seatId(seatId) - .col(col) - .seatRate(seatRate) - .cost(cost) - .row(row) - .build(); + .seatId(orderData.seatId()) + .row(orderData.row()) + .col(orderData.col()) + .seatGrade(orderData.seatGrade()) + .cost(orderData.cost()) + .order(order) + .build(); } } diff --git a/services/order/src/main/java/com/ticketPing/order/domain/repository/OrderRepository.java b/services/order/src/main/java/com/ticketPing/order/domain/repository/OrderRepository.java new file mode 100644 index 00000000..1f8a5187 --- /dev/null +++ b/services/order/src/main/java/com/ticketPing/order/domain/repository/OrderRepository.java @@ -0,0 +1,24 @@ +package com.ticketPing.order.domain.repository; + +import com.ticketPing.order.domain.model.entity.Order; +import com.ticketPing.order.domain.model.enums.OrderStatus; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface OrderRepository { + Order save(Order order); + + Optional findById(UUID orderId); + + Optional findByOrderSeatSeatIdAndOrderStatus(UUID seatId, OrderStatus orderStatus); + + Optional findByIdAndOrderStatus(UUID orderId, OrderStatus orderStatus); + + boolean existsByOrderSeatSeatIdAndOrderStatusIn(UUID seatId, List statuses); + + Slice findUserOrdersExcludingStatus(UUID userId, List statuses, Pageable pageable); +} diff --git a/services/order/src/main/java/com/ticketPing/order/infrastructure/client/PaymentFeignClient.java b/services/order/src/main/java/com/ticketPing/order/infrastructure/client/PaymentFeignClient.java new file mode 100644 index 00000000..eee471b3 --- /dev/null +++ b/services/order/src/main/java/com/ticketPing/order/infrastructure/client/PaymentFeignClient.java @@ -0,0 +1,28 @@ +package com.ticketPing.order.infrastructure.client; + +import com.ticketPing.order.application.client.PaymentClient; +import com.ticketPing.order.infrastructure.config.CustomFeignConfig; +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import payment.PaymentResponse; +import response.CommonResponse; + +import java.util.UUID; + +import static com.ticketPing.order.common.utils.FeignFallbackUtils.handleFallback; + +@FeignClient(name = "payment", configuration = CustomFeignConfig.class) +public interface PaymentFeignClient extends PaymentClient { + @GetMapping("/api/v1/payments/completed") + @CircuitBreaker(name = "paymentServiceCircuitBreaker", fallbackMethod = "fallbackForPaymentService") + ResponseEntity> getCompletedPaymentByOrderId(@RequestParam("orderId") UUID orderId); + + default ResponseEntity> fallbackForPaymentService(UUID orderId, Throwable cause) { + handleFallback(cause); + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(null); + } +} diff --git a/services/order/src/main/java/com/ticketPing/order/infrastructure/client/PerformanceClient.java b/services/order/src/main/java/com/ticketPing/order/infrastructure/client/PerformanceClient.java deleted file mode 100644 index 720bed22..00000000 --- a/services/order/src/main/java/com/ticketPing/order/infrastructure/client/PerformanceClient.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.ticketPing.order.infrastructure.client; - - -import com.ticketPing.order.application.dtos.OrderInfoResponse; -import com.ticketPing.order.application.dtos.temp.SeatResponse; -import org.springframework.cloud.openfeign.FeignClient; -import response.CommonResponse; -import java.util.UUID; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestParam; - -@FeignClient(name = "performance") -public interface PerformanceClient { - - @GetMapping("/api/v1/seats/{seatId}/order-info") - ResponseEntity> getOrderInfo(@PathVariable("seatId") String seatId); - - @PutMapping("/api/v1/seats/{seatId}") - ResponseEntity> updateSeatState(@PathVariable("seatId") UUID seatId, - @RequestParam("seatState") Boolean seatState); -} - diff --git a/services/order/src/main/java/com/ticketPing/order/infrastructure/client/PerformanceFeignClient.java b/services/order/src/main/java/com/ticketPing/order/infrastructure/client/PerformanceFeignClient.java new file mode 100644 index 00000000..16f74a8c --- /dev/null +++ b/services/order/src/main/java/com/ticketPing/order/infrastructure/client/PerformanceFeignClient.java @@ -0,0 +1,43 @@ +package com.ticketPing.order.infrastructure.client; + + +import com.ticketPing.order.application.client.PerformanceClient; +import com.ticketPing.order.infrastructure.config.CustomFeignConfig; +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import performance.OrderSeatResponse; +import response.CommonResponse; + +import java.util.UUID; + +import static com.ticketPing.order.common.utils.FeignFallbackUtils.handleFallback; + +@FeignClient(name = "performance", configuration = CustomFeignConfig.class) +public interface PerformanceFeignClient extends PerformanceClient { + + @GetMapping("/api/v1/client/seats/{seatId}/order-info") + @CircuitBreaker(name = "performanceServiceCircuitBreaker", fallbackMethod = "fallbackForGetOrderInfo") + ResponseEntity> getOrderInfo(@RequestHeader("X_USER_ID") UUID userId, + @RequestParam("scheduleId") UUID scheduleId, + @PathVariable("seatId") UUID seatId); + + @PostMapping("/api/v1/client/seats/{seatId}/extend-ttl") + @CircuitBreaker(name = "performanceServiceCircuitBreaker", fallbackMethod = "fallbackForExtendTTL") + ResponseEntity> extendPreReserveTTL(@RequestParam("scheduleId") UUID scheduleId, + @PathVariable("seatId") UUID seatId); + + default ResponseEntity> fallbackForGetOrderInfo(UUID userId, UUID scheduleId, UUID seatId, Throwable cause) { + handleFallback(cause); + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(null); + } + + default ResponseEntity> fallbackForExtendTTL(UUID scheduleId, UUID seatId, Throwable cause) { + handleFallback(cause); + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(null); + } +} + + diff --git a/services/order/src/main/java/com/ticketPing/order/infrastructure/config/CustomFeignConfig.java b/services/order/src/main/java/com/ticketPing/order/infrastructure/config/CustomFeignConfig.java new file mode 100644 index 00000000..a534bbf3 --- /dev/null +++ b/services/order/src/main/java/com/ticketPing/order/infrastructure/config/CustomFeignConfig.java @@ -0,0 +1,14 @@ +package com.ticketPing.order.infrastructure.config; + +import feign.Request; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class CustomFeignConfig { + + @Bean + public Request.Options requestOptions() { + return new Request.Options(1000, 12000); + } +} diff --git a/services/order/src/main/java/com/ticketPing/order/infrastructure/config/KafkaTopicConfig.java b/services/order/src/main/java/com/ticketPing/order/infrastructure/config/KafkaTopicConfig.java index 2eaf7717..8e6e297d 100644 --- a/services/order/src/main/java/com/ticketPing/order/infrastructure/config/KafkaTopicConfig.java +++ b/services/order/src/main/java/com/ticketPing/order/infrastructure/config/KafkaTopicConfig.java @@ -10,8 +10,16 @@ public class KafkaTopicConfig { @Bean - public NewTopic orderCompletedTopic() { - return TopicBuilder.name(OrderTopic.COMPLETED.getTopic()) + public NewTopic seatReservationTopic() { + return TopicBuilder.name(OrderTopic.COMPLETED_FOR_SEAT_RESERVATION.getTopic()) + .partitions(3) + .replicas(3) + .build(); + } + + @Bean + public NewTopic queueTokenRemovalTopic() { + return TopicBuilder.name(OrderTopic.COMPLETED_FOR_QUEUE_TOKEN_REMOVAL.getTopic()) .partitions(3) .replicas(3) .build(); diff --git a/services/order/src/main/java/com/ticketPing/order/infrastructure/config/RedisLuaScriptConfig.java b/services/order/src/main/java/com/ticketPing/order/infrastructure/config/RedisLuaScriptConfig.java deleted file mode 100644 index b0c25155..00000000 --- a/services/order/src/main/java/com/ticketPing/order/infrastructure/config/RedisLuaScriptConfig.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.ticketPing.order.infrastructure.config; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.core.io.ClassPathResource; -import org.springframework.data.redis.core.script.DefaultRedisScript; -import org.springframework.scripting.support.ResourceScriptSource; - -@Configuration -public class RedisLuaScriptConfig { - @Bean - public DefaultRedisScript preReserveScript() { - DefaultRedisScript redisScript = new DefaultRedisScript<>(); - redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("scripts/preReserve.lua"))); - redisScript.setResultType(String.class); - return redisScript; - } -} diff --git a/services/order/src/main/java/com/ticketPing/order/infrastructure/config/RedisMessageListenerConfig.java b/services/order/src/main/java/com/ticketPing/order/infrastructure/config/RedisMessageListenerConfig.java deleted file mode 100644 index db9a4482..00000000 --- a/services/order/src/main/java/com/ticketPing/order/infrastructure/config/RedisMessageListenerConfig.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.ticketPing.order.infrastructure.config; - -import com.ticketPing.order.infrastructure.listener.RedisKeyExpiredListener; -import java.util.concurrent.Executor; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.data.redis.connection.RedisConnectionFactory; -import org.springframework.data.redis.listener.ChannelTopic; -import org.springframework.data.redis.listener.RedisMessageListenerContainer; -import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; - -@Configuration -public class RedisMessageListenerConfig { - - @Bean(name = "redisMessageTaskExecutor") - public Executor redisMessageTaskExecutor() { - ThreadPoolTaskExecutor threadPoolTaskExecutor = new ThreadPoolTaskExecutor(); - threadPoolTaskExecutor.setCorePoolSize(2); - threadPoolTaskExecutor.setMaxPoolSize(4); - return threadPoolTaskExecutor; - } - - @Bean - public RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory, - RedisKeyExpiredListener redisKeyExpiredListener) { - RedisMessageListenerContainer container = new RedisMessageListenerContainer(); - container.setConnectionFactory(connectionFactory); - container.addMessageListener(redisKeyExpiredListener, new ChannelTopic("__keyevent@0__:expired")); - container.setTaskExecutor(redisMessageTaskExecutor()); - return container; - } - -} \ No newline at end of file diff --git a/services/order/src/main/java/com/ticketPing/order/infrastructure/listener/EventConsumer.java b/services/order/src/main/java/com/ticketPing/order/infrastructure/listener/EventConsumer.java index 669f84ca..74ed7799 100644 --- a/services/order/src/main/java/com/ticketPing/order/infrastructure/listener/EventConsumer.java +++ b/services/order/src/main/java/com/ticketPing/order/infrastructure/listener/EventConsumer.java @@ -2,8 +2,8 @@ import com.ticketPing.order.application.service.OrderService; import messaging.events.PaymentCompletedEvent; -import messaging.events.PaymentCreatedEvent; import lombok.RequiredArgsConstructor; +import messaging.events.SeatPreReserveExpiredEvent; import messaging.utils.EventLogger; import messaging.utils.EventSerializer; import org.apache.kafka.clients.consumer.ConsumerRecord; @@ -17,19 +17,19 @@ public class EventConsumer { private final OrderService orderService; - @KafkaListener(topics = "payment-created", groupId = "order-group") - public void handlePaymentCreatedEvent(ConsumerRecord record, Acknowledgment acknowledgment) { + @KafkaListener(topics = "payment-completed", groupId = "order-group") + public void handlePaymentCompletedEvent(ConsumerRecord record, Acknowledgment acknowledgment) { EventLogger.logReceivedMessage(record); - PaymentCreatedEvent event = EventSerializer.deserialize(record.value(), PaymentCreatedEvent.class); - // TODO Order 엔티티 paymentId 주입 + PaymentCompletedEvent event = EventSerializer.deserialize(record.value(), PaymentCompletedEvent.class); + orderService.completeOrder(event.orderId(), event.paymentId()); acknowledgment.acknowledge(); } - @KafkaListener(topics = "payment-completed", groupId = "order-group") - public void handlePaymentCompletedEvent(ConsumerRecord record, Acknowledgment acknowledgment) { + @KafkaListener(topics = "pre-reserve-expired", groupId = "order-group") + public void handleSeatPreReserveExpiredEvent(ConsumerRecord record, Acknowledgment acknowledgment) { EventLogger.logReceivedMessage(record); - PaymentCompletedEvent event = EventSerializer.deserialize(record.value(), PaymentCompletedEvent.class); - orderService.updateOrderStatus(event.orderId()); + SeatPreReserveExpiredEvent event = EventSerializer.deserialize(record.value(), SeatPreReserveExpiredEvent.class); + orderService.failOrder(event.scheduleId(), event.seatId()); acknowledgment.acknowledge(); } diff --git a/services/order/src/main/java/com/ticketPing/order/infrastructure/listener/RedisKeyExpiredListener.java b/services/order/src/main/java/com/ticketPing/order/infrastructure/listener/RedisKeyExpiredListener.java deleted file mode 100644 index 99728f5f..00000000 --- a/services/order/src/main/java/com/ticketPing/order/infrastructure/listener/RedisKeyExpiredListener.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.ticketPing.order.infrastructure.listener; - -import caching.repository.RedisRepository; -import com.ticketPing.order.application.dtos.temp.SeatResponse; -import com.ticketPing.order.domain.model.entity.Order; -import com.ticketPing.order.domain.model.enums.OrderStatus; -import com.ticketPing.order.infrastructure.repository.OrderRepository; -import exception.ApplicationException; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.redis.connection.Message; -import org.springframework.data.redis.connection.MessageListener; -import org.springframework.stereotype.Component; - -import java.util.UUID; - -import static com.ticketPing.order.common.exception.OrderExceptionCase.INVALID_TTL_NAME; -import static com.ticketPing.order.common.exception.OrderExceptionCase.NOT_FOUND_ORDER_ID_IN_TTL; - -@Slf4j -@Component -@RequiredArgsConstructor -public class RedisKeyExpiredListener implements MessageListener { - private final RedisRepository redisRepository; - private final OrderRepository orderRepository; - - @Override - public void onMessage(Message message, byte[] pattern) { - String expiredKey = message.toString(); // 만료된 키 - log.info("Expired key: {}", expiredKey); - - String[] parts = expiredKey.split(":"); - if (parts.length != 5) - throw new ApplicationException(INVALID_TTL_NAME); - - String scheduleId = parts[2]; - String seatId = parts[3]; - String orderId = parts[4]; - - updateRedisSeatState(scheduleId, seatId); - updateOrderStatus(orderId); - } - - private void updateRedisSeatState(String scheduleId, String seatId) { - String key = "seat:" + scheduleId + ":" + seatId; - SeatResponse seatResponse = redisRepository.getValueAsClass(key, SeatResponse.class); - seatResponse.updateSeatState(false); - redisRepository.setValue(key, seatResponse); - } - - private void updateOrderStatus(String orderId) { - Order order = orderRepository.findById(UUID.fromString(orderId)) - .orElseThrow(() -> new ApplicationException(NOT_FOUND_ORDER_ID_IN_TTL)); - - order.updateOrderStatus(OrderStatus.FAIL); - orderRepository.save(order); - } -} diff --git a/services/order/src/main/java/com/ticketPing/order/infrastructure/repository/OrderJpaRepository.java b/services/order/src/main/java/com/ticketPing/order/infrastructure/repository/OrderJpaRepository.java new file mode 100644 index 00000000..621d16fa --- /dev/null +++ b/services/order/src/main/java/com/ticketPing/order/infrastructure/repository/OrderJpaRepository.java @@ -0,0 +1,20 @@ +package com.ticketPing.order.infrastructure.repository; + +import com.ticketPing.order.domain.model.entity.Order; +import com.ticketPing.order.domain.model.enums.OrderStatus; +import com.ticketPing.order.domain.repository.OrderRepository; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; +import java.util.UUID; + +public interface OrderJpaRepository extends OrderRepository, JpaRepository { + @Query("SELECT o FROM Order o " + + "JOIN FETCH o.orderSeat os " + + "WHERE o.userId = :userId " + + "AND o.orderStatus NOT IN :statuses") + Slice findUserOrdersExcludingStatus(UUID userId, List statuses, Pageable pageable); +} diff --git a/services/order/src/main/java/com/ticketPing/order/infrastructure/repository/OrderRepository.java b/services/order/src/main/java/com/ticketPing/order/infrastructure/repository/OrderRepository.java deleted file mode 100644 index 2fd477ef..00000000 --- a/services/order/src/main/java/com/ticketPing/order/infrastructure/repository/OrderRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.ticketPing.order.infrastructure.repository; - -import com.ticketPing.order.domain.model.entity.Order; -import java.util.List; -import java.util.UUID; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface OrderRepository extends JpaRepository { - List findByUserId(UUID userId); - List findByScheduleIdAndOrderSeatSeatId(UUID seatId, UUID scheduleId); -} diff --git a/services/order/src/main/java/com/ticketPing/order/infrastructure/service/RedisLuaService.java b/services/order/src/main/java/com/ticketPing/order/infrastructure/service/RedisLuaService.java deleted file mode 100644 index e9a6fa00..00000000 --- a/services/order/src/main/java/com/ticketPing/order/infrastructure/service/RedisLuaService.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.ticketPing.order.infrastructure.service; - -import com.ticketPing.order.common.exception.OrderExceptionCase; -import exception.ApplicationException; -import lombok.RequiredArgsConstructor; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.core.script.DefaultRedisScript; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.Optional; - -@Component -@RequiredArgsConstructor -public class RedisLuaService { - - private final RedisTemplate redisTemplate; - private final DefaultRedisScript preReserveScript; - - public void updateSeatStatus(String seatKey, String ttlKey, String ttl) { - String result = Optional.ofNullable( - redisTemplate.execute(preReserveScript, List.of(seatKey, ttlKey), ttl) - ).orElseThrow(() -> new ApplicationException(OrderExceptionCase.PRE_RESERVE_ERROR)); - - if(!result.equals("SUCCESS")) { - throw new ApplicationException(OrderExceptionCase.getByValue(result)); - } - } -} diff --git a/services/order/src/main/java/com/ticketPing/order/presentation/controller/OrderController.java b/services/order/src/main/java/com/ticketPing/order/presentation/controller/OrderController.java index ef30a392..f8bda59d 100644 --- a/services/order/src/main/java/com/ticketPing/order/presentation/controller/OrderController.java +++ b/services/order/src/main/java/com/ticketPing/order/presentation/controller/OrderController.java @@ -1,17 +1,20 @@ package com.ticketPing.order.presentation.controller; -import com.ticketPing.order.application.dtos.OrderInfoForPaymentResponse; -import com.ticketPing.order.presentation.request.OrderCreateDto; import com.ticketPing.order.application.dtos.OrderResponse; import com.ticketPing.order.application.service.OrderService; +import com.ticketPing.order.presentation.request.CreateOrderRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.http.ResponseEntity; import response.CommonResponse; import io.swagger.v3.oas.annotations.Operation; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; -import java.util.List; import java.util.UUID; +import static response.CommonResponse.success; + @RestController @RequiredArgsConstructor @@ -20,26 +23,36 @@ public class OrderController { private final OrderService orderService; - @Operation(summary = "예매 좌석 생성 + 좌석 선점") + @Operation(summary = "예매 좌석 생성") @PostMapping - public CommonResponse createOrder(@RequestBody OrderCreateDto requestDto, - @RequestParam("performanceId") UUID performanceId, - @RequestHeader("X_User_Id") UUID userId) { - OrderResponse orderResponse = orderService.createOrder(requestDto, userId); - return CommonResponse.success(orderResponse); + public ResponseEntity> createOrder(@RequestHeader("X_USER_ID") UUID userId, + @RequestParam("performanceId") UUID performanceId, + @RequestBody CreateOrderRequest createOrderRequest) { + OrderResponse orderResponse = orderService.createOrder(createOrderRequest, userId); + return ResponseEntity + .status(201) + .body(success(orderResponse)); } @Operation(summary = "사용자 예매 목록 전체 조회") - @GetMapping("/user/reservations") - public CommonResponse> getUserReservation(@RequestHeader("X_USER_ID") UUID userId) { - List userReservationDto = orderService.getUserOrders(userId); - return CommonResponse.success(userReservationDto); + @GetMapping("/user-orders") + public ResponseEntity>> getUserReservation(@RequestHeader("X_USER_ID") UUID userId, + Pageable pageable) { + Slice userReservationDto = orderService.getUserOrders(userId, pageable); + return ResponseEntity + .status(200) + .body(success(userReservationDto)); } - @Operation(summary = "주문 정보 조회 (결제 서버용)") - @GetMapping("/{orderId}") - public OrderInfoForPaymentResponse getOrderInfoForPayment(@PathVariable("orderId") UUID orderId, - @RequestParam("userId") UUID userId) { - return orderService.getOrderInfoForPayment(orderId, userId); + @Operation(summary = "주문 정보 검증") + @PostMapping("/{orderId}/validate") + public ResponseEntity> validateOrder(@RequestHeader("X_USER_ID") UUID userId, + @RequestParam("performanceId") UUID performanceId, + @PathVariable("orderId") UUID orderId) { + orderService.validateOrderAndExtendTTL(orderId, userId); + return ResponseEntity + .status(200) + .body(success()); } + } diff --git a/services/order/src/main/java/com/ticketPing/order/presentation/request/CreateOrderRequest.java b/services/order/src/main/java/com/ticketPing/order/presentation/request/CreateOrderRequest.java new file mode 100644 index 00000000..e34825ae --- /dev/null +++ b/services/order/src/main/java/com/ticketPing/order/presentation/request/CreateOrderRequest.java @@ -0,0 +1,9 @@ +package com.ticketPing.order.presentation.request; + +import java.util.UUID; + +public record CreateOrderRequest ( + UUID scheduleId, + UUID seatId +) { +} diff --git a/services/order/src/main/java/com/ticketPing/order/presentation/request/OrderCreateDto.java b/services/order/src/main/java/com/ticketPing/order/presentation/request/OrderCreateDto.java deleted file mode 100644 index 70afd8b8..00000000 --- a/services/order/src/main/java/com/ticketPing/order/presentation/request/OrderCreateDto.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.ticketPing.order.presentation.request; - -import java.util.UUID; - -public record OrderCreateDto( - UUID seatId, - UUID scheduleId -) {} - diff --git a/services/order/src/main/resources/application.yml b/services/order/src/main/resources/application.yml index 640a8479..a5693fe0 100644 --- a/services/order/src/main/resources/application.yml +++ b/services/order/src/main/resources/application.yml @@ -12,9 +12,17 @@ spring: import: - "classpath:application-eureka.yml" - "classpath:application-jpa.yml" - - "classpath:application-redis.yml" - "classpath:application-kafka.yml" - "classpath:application-monitoring.yml" + - "classpath:application-circuit-breaker.yml" server: port: 10013 + +resilience4j: + circuitbreaker: + instances: + performanceServiceCircuitBreaker: + baseConfig: default + paymentServiceCircuitBreaker: + baseConfig: default diff --git a/services/order/src/main/resources/scripts/preReserve.lua b/services/order/src/main/resources/scripts/preReserve.lua deleted file mode 100644 index df74deab..00000000 --- a/services/order/src/main/resources/scripts/preReserve.lua +++ /dev/null @@ -1,20 +0,0 @@ -local seatData = redis.call("GET", KEYS[1]) -if not seatData then - return "SEAT_CACHE_NOT_FOUND" -end - -local seatObj = cjson.decode(seatData) - -if seatObj.seatState then - return "SEAT_ALREADY_TAKEN" -end - -seatObj.seatState = true -redis.call("SET", KEYS[1], cjson.encode(seatObj)) - -local newKey = KEYS[2] -local ttl = tonumber(ARGV[1]) -redis.call("SET", newKey, "true") -redis.call("EXPIRE", newKey, ttl) - -return "SUCCESS" \ No newline at end of file diff --git a/services/payment/build.gradle b/services/payment/build.gradle index aacced62..52ebbe7e 100644 --- a/services/payment/build.gradle +++ b/services/payment/build.gradle @@ -46,10 +46,11 @@ dependencies { // Cloud implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client' - implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' // Swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2' + + implementation 'com.googlecode.json-simple:json-simple:1.1.1' } tasks.named('test') { diff --git a/services/payment/src/main/java/com/ticketPing/payment/PaymentApplication.java b/services/payment/src/main/java/com/ticketPing/payment/PaymentApplication.java index 052f3595..e850cb1f 100644 --- a/services/payment/src/main/java/com/ticketPing/payment/PaymentApplication.java +++ b/services/payment/src/main/java/com/ticketPing/payment/PaymentApplication.java @@ -2,11 +2,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.cloud.openfeign.EnableFeignClients; import org.springframework.context.annotation.ComponentScan; @ComponentScan(basePackages = {"com.ticketPing.payment", "aop", "exception", "audit", "messaging"}) -@EnableFeignClients @SpringBootApplication public class PaymentApplication { public static void main(String[] args) { diff --git a/services/payment/src/main/java/com/ticketPing/payment/application/client/OrderClient.java b/services/payment/src/main/java/com/ticketPing/payment/application/client/OrderClient.java deleted file mode 100644 index 2d147134..00000000 --- a/services/payment/src/main/java/com/ticketPing/payment/application/client/OrderClient.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.ticketPing.payment.application.client; - -import java.util.UUID; -import order.OrderInfoForPaymentResponse; - -public interface OrderClient { - OrderInfoForPaymentResponse getOrderInfo(UUID orderId, UUID userId); -} diff --git a/services/payment/src/main/java/com/ticketPing/payment/application/constants/TossPaymentConstants.java b/services/payment/src/main/java/com/ticketPing/payment/application/constants/TossPaymentConstants.java new file mode 100644 index 00000000..97112d67 --- /dev/null +++ b/services/payment/src/main/java/com/ticketPing/payment/application/constants/TossPaymentConstants.java @@ -0,0 +1,29 @@ +package com.ticketPing.payment.application.constants; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class TossPaymentConstants { + + private static String widgetSecretKey; + private static String paymentConfirmUrl; + + @Value("${toss.payment.widget-secret-key}") + public void setWidgetSecretKey(String widgetSecretKeyValue) { + widgetSecretKey = widgetSecretKeyValue; + } + + @Value("${toss.payment.payment-confirm-url}") + public void setPaymentConfirmUrl(String paymentConfirmUrlValue) { + paymentConfirmUrl = paymentConfirmUrlValue; + } + + public static String widgetSecretKey() { + return widgetSecretKey; + } + + public static String paymentConfirmUrl() { + return paymentConfirmUrl; + } +} diff --git a/services/payment/src/main/java/com/ticketPing/payment/application/dto/PaymentResponse.java b/services/payment/src/main/java/com/ticketPing/payment/application/dto/PaymentResponse.java index 078e246b..cc42edcb 100644 --- a/services/payment/src/main/java/com/ticketPing/payment/application/dto/PaymentResponse.java +++ b/services/payment/src/main/java/com/ticketPing/payment/application/dto/PaymentResponse.java @@ -1,7 +1,6 @@ package com.ticketPing.payment.application.dto; import com.ticketPing.payment.domain.model.entity.Payment; -import java.time.LocalDateTime; import mapper.ObjectMapperBasedVoMapper; import java.util.UUID; @@ -10,9 +9,7 @@ public record PaymentResponse( UUID userId, String status, UUID orderId, - Long amount, - LocalDateTime createdAt, - LocalDateTime updatedAt + Long amount ) { public static PaymentResponse from(Payment payment) { return ObjectMapperBasedVoMapper.convert(payment, PaymentResponse.class); diff --git a/services/payment/src/main/java/com/ticketPing/payment/application/service/EventApplicationService.java b/services/payment/src/main/java/com/ticketPing/payment/application/service/EventApplicationService.java index 5f4f4e79..c154be0e 100644 --- a/services/payment/src/main/java/com/ticketPing/payment/application/service/EventApplicationService.java +++ b/services/payment/src/main/java/com/ticketPing/payment/application/service/EventApplicationService.java @@ -2,7 +2,6 @@ import messaging.events.PaymentCompletedEvent; import lombok.RequiredArgsConstructor; -import messaging.events.PaymentCreatedEvent; import messaging.utils.EventSerializer; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Service; @@ -14,11 +13,6 @@ public class EventApplicationService { private final KafkaTemplate kafkaTemplate; - public void publishPaymentCreatedEvent(PaymentCreatedEvent event) { - String message = EventSerializer.serialize(event); - kafkaTemplate.send(PaymentTopic.CREATED.getTopic(), message); - } - public void publishPaymentCompletedEvent(PaymentCompletedEvent event) { String message = EventSerializer.serialize(event); kafkaTemplate.send(PaymentTopic.COMPLETED.getTopic(), message); diff --git a/services/payment/src/main/java/com/ticketPing/payment/application/service/PaymentApplicationService.java b/services/payment/src/main/java/com/ticketPing/payment/application/service/PaymentApplicationService.java index 773fddae..ec499e66 100644 --- a/services/payment/src/main/java/com/ticketPing/payment/application/service/PaymentApplicationService.java +++ b/services/payment/src/main/java/com/ticketPing/payment/application/service/PaymentApplicationService.java @@ -1,57 +1,76 @@ package com.ticketPing.payment.application.service; -import com.ticketPing.payment.application.client.OrderClient; +import static com.ticketPing.payment.common.exception.PaymentErrorCase.TOSS_PAYMENT_CONFIRM_REQUEST_FAILED; + +import com.ticketPing.payment.application.constants.TossPaymentConstants; +import com.ticketPing.payment.common.exception.PaymentException; +import exception.ApplicationException; +import org.json.simple.JSONObject; import com.ticketPing.payment.application.dto.PaymentResponse; import com.ticketPing.payment.domain.model.entity.Payment; import com.ticketPing.payment.domain.service.PaymentDomainService; +import com.ticketPing.payment.presentation.request.PaymentConfirmRequest; +import java.nio.charset.StandardCharsets; +import java.util.Base64; import messaging.events.PaymentCompletedEvent; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import lombok.val; -import messaging.events.PaymentCreatedEvent; import org.springframework.stereotype.Service; - import java.util.UUID; +import org.springframework.web.client.RestTemplate; @Service @RequiredArgsConstructor public class PaymentApplicationService { private final PaymentDomainService paymentDomainService; - private final OrderClient orderClient; + private final RestTemplate restTemplate; private final EventApplicationService eventApplicationService; @Transactional - public PaymentResponse requestPayment(UUID userId, UUID orderId) { - val orderInfo = orderClient.getOrderInfo(orderId, userId); - - // TODO: PG사 결제 요청 - - Payment payment = paymentDomainService.createPayment(userId, orderInfo); - - publishPaymentCreatedEvent(payment); - - return PaymentResponse.from(payment); + public JSONObject confirmPayment(UUID userId, PaymentConfirmRequest request) { + ResponseEntity response = confirmTossPayment(request); + + if (response.getStatusCode().is2xxSuccessful()) { + Payment payment = paymentDomainService.createPayment(userId, response.getBody()); + publishPaymentCompletedEvent(payment); + return response.getBody(); + } + throw new PaymentException((HttpStatus) response.getStatusCode(), response.getBody().toJSONString()); } - private void publishPaymentCreatedEvent(Payment payment) { - val event = PaymentCreatedEvent.create(payment.getId(), payment.getOrderId()); - eventApplicationService.publishPaymentCreatedEvent(event); - } - - @Transactional - public PaymentResponse checkPaymentStatus(UUID paymentId) { - // TODO: PG사 결제 상태 확인 - - Payment payment = paymentDomainService.completePayment(paymentId); - - publishPaymentCompletedEvent(payment); - - return PaymentResponse.from(payment); + private ResponseEntity confirmTossPayment(PaymentConfirmRequest request) { + try { + // Basic 인증 정보 설정 + String auth = "Basic " + Base64.getEncoder() + .encodeToString((TossPaymentConstants.widgetSecretKey() + ":").getBytes(StandardCharsets.UTF_8)); + + // HTTP 헤더 설정 + HttpHeaders headers = new HttpHeaders(); + headers.set(HttpHeaders.AUTHORIZATION, auth); + headers.setContentType(MediaType.APPLICATION_JSON); + + // TOSS 결제 확인 요청 + return restTemplate.exchange( + TossPaymentConstants.paymentConfirmUrl(), + HttpMethod.POST, + new HttpEntity<>(request.toJson(), headers), + JSONObject.class + ); + } catch (Exception e) { + throw new ApplicationException(TOSS_PAYMENT_CONFIRM_REQUEST_FAILED); + } } private void publishPaymentCompletedEvent(Payment payment) { - val event = PaymentCompletedEvent.create(payment.getOrderId()); + val event = PaymentCompletedEvent.create(payment.getOrderId(), payment.getId()); eventApplicationService.publishPaymentCompletedEvent(event); } @@ -61,5 +80,10 @@ public PaymentResponse getPayment(UUID paymentId) { return PaymentResponse.from(payment); } + public PaymentResponse getCompletedPaymentByOrderId(UUID orderId) { + Payment payment = paymentDomainService.getCompletedPaymentByOrderId(orderId); + return PaymentResponse.from(payment); + } + } diff --git a/services/payment/src/main/java/com/ticketPing/payment/common/exception/PaymentErrorCase.java b/services/payment/src/main/java/com/ticketPing/payment/common/exception/PaymentErrorCase.java index 33cf8364..d058291a 100644 --- a/services/payment/src/main/java/com/ticketPing/payment/common/exception/PaymentErrorCase.java +++ b/services/payment/src/main/java/com/ticketPing/payment/common/exception/PaymentErrorCase.java @@ -9,7 +9,8 @@ @RequiredArgsConstructor public enum PaymentErrorCase implements ErrorCase { - PAYMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "결제 정보가 존재하지 않습니다."); + PAYMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "결제 정보가 존재하지 않습니다."), + TOSS_PAYMENT_CONFIRM_REQUEST_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "토스 서버 요청에 실패하였습니다."); private final HttpStatus httpStatus; private final String message; diff --git a/services/payment/src/main/java/com/ticketPing/payment/common/exception/PaymentException.java b/services/payment/src/main/java/com/ticketPing/payment/common/exception/PaymentException.java new file mode 100644 index 00000000..97a5d821 --- /dev/null +++ b/services/payment/src/main/java/com/ticketPing/payment/common/exception/PaymentException.java @@ -0,0 +1,17 @@ +package com.ticketPing.payment.common.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public class PaymentException extends RuntimeException { + + private final HttpStatus httpStatus; + private final String message; + + public PaymentException(HttpStatus httpStatus, String message) { + this.httpStatus = httpStatus; + this.message = message; + } + +} \ No newline at end of file diff --git a/services/payment/src/main/java/com/ticketPing/payment/common/exception/PaymentExceptionHandler.java b/services/payment/src/main/java/com/ticketPing/payment/common/exception/PaymentExceptionHandler.java new file mode 100644 index 00000000..602114cb --- /dev/null +++ b/services/payment/src/main/java/com/ticketPing/payment/common/exception/PaymentExceptionHandler.java @@ -0,0 +1,19 @@ +package com.ticketPing.payment.common.exception; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +@ControllerAdvice +@RequiredArgsConstructor +public class PaymentExceptionHandler { + + @ExceptionHandler(PaymentException.class) + public ResponseEntity handlePaymentException(PaymentException e) { + return ResponseEntity + .status(e.getHttpStatus()) + .body(e.getMessage()); + } + +} \ No newline at end of file diff --git a/services/payment/src/main/java/com/ticketPing/payment/domain/model/entity/Payment.java b/services/payment/src/main/java/com/ticketPing/payment/domain/model/entity/Payment.java index f92f9d64..b1bfda9e 100644 --- a/services/payment/src/main/java/com/ticketPing/payment/domain/model/entity/Payment.java +++ b/services/payment/src/main/java/com/ticketPing/payment/domain/model/entity/Payment.java @@ -4,14 +4,14 @@ import com.ticketPing.payment.domain.model.enums.PaymentStatus; import jakarta.persistence.*; import jakarta.validation.constraints.NotNull; +import java.time.ZonedDateTime; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; - import java.util.UUID; -import order.OrderInfoForPaymentResponse; +import org.json.simple.JSONObject; @Getter @NoArgsConstructor @@ -35,20 +35,32 @@ public class Payment extends BaseEntity { @NotNull private UUID orderId; + @NotNull + private String orderName; + + @NotNull + private String method; + @NotNull private Long amount; - public static Payment create(UUID userId, OrderInfoForPaymentResponse orderInfo) { + @NotNull + ZonedDateTime requestedAt; + + @NotNull + ZonedDateTime approvedAt; + + public static Payment create(UUID userId, JSONObject responseData) { return Payment.builder() .userId(userId) - .status(PaymentStatus.PENDING) - .orderId(orderInfo.id()) - .amount(orderInfo.amount()) + .status(PaymentStatus.COMPLETED) + .orderId(UUID.fromString((String) responseData.get("orderId"))) + .orderName((String) responseData.get("orderName")) + .method((String) responseData.get("method")) + .amount(((Number) responseData.get("totalAmount")).longValue()) + .requestedAt(ZonedDateTime.parse((String) responseData.get("requestedAt"))) + .approvedAt(ZonedDateTime.parse((String) responseData.get("approvedAt"))) .build(); } - public void complete() { - this.status = PaymentStatus.COMPLETED; - } - } diff --git a/services/payment/src/main/java/com/ticketPing/payment/domain/model/enums/PaymentStatus.java b/services/payment/src/main/java/com/ticketPing/payment/domain/model/enums/PaymentStatus.java index 12fc77c0..7693602d 100644 --- a/services/payment/src/main/java/com/ticketPing/payment/domain/model/enums/PaymentStatus.java +++ b/services/payment/src/main/java/com/ticketPing/payment/domain/model/enums/PaymentStatus.java @@ -1,7 +1,6 @@ package com.ticketPing.payment.domain.model.enums; public enum PaymentStatus { - PENDING, COMPLETED, FAILED, REFUNDED diff --git a/services/payment/src/main/java/com/ticketPing/payment/domain/repository/PaymentRepository.java b/services/payment/src/main/java/com/ticketPing/payment/domain/repository/PaymentRepository.java index d71ddb6a..d3ad7006 100644 --- a/services/payment/src/main/java/com/ticketPing/payment/domain/repository/PaymentRepository.java +++ b/services/payment/src/main/java/com/ticketPing/payment/domain/repository/PaymentRepository.java @@ -1,10 +1,13 @@ package com.ticketPing.payment.domain.repository; import com.ticketPing.payment.domain.model.entity.Payment; +import com.ticketPing.payment.domain.model.enums.PaymentStatus; + import java.util.Optional; import java.util.UUID; public interface PaymentRepository { void save(Payment payment); Optional findById(UUID paymentId); + Optional findByOrderIdAndStatus(UUID orderId, PaymentStatus status); } diff --git a/services/payment/src/main/java/com/ticketPing/payment/domain/service/PaymentDomainService.java b/services/payment/src/main/java/com/ticketPing/payment/domain/service/PaymentDomainService.java index d93893c5..e803d7c4 100644 --- a/services/payment/src/main/java/com/ticketPing/payment/domain/service/PaymentDomainService.java +++ b/services/payment/src/main/java/com/ticketPing/payment/domain/service/PaymentDomainService.java @@ -3,11 +3,12 @@ import static com.ticketPing.payment.common.exception.PaymentErrorCase.PAYMENT_NOT_FOUND; import com.ticketPing.payment.domain.model.entity.Payment; +import com.ticketPing.payment.domain.model.enums.PaymentStatus; import com.ticketPing.payment.domain.repository.PaymentRepository; import exception.ApplicationException; import java.util.UUID; import lombok.RequiredArgsConstructor; -import order.OrderInfoForPaymentResponse; +import org.json.simple.JSONObject; import org.springframework.stereotype.Service; @Service @@ -16,21 +17,20 @@ public class PaymentDomainService { private final PaymentRepository paymentRepository; - public Payment createPayment(UUID userId, OrderInfoForPaymentResponse orderInfo) { - Payment payment = Payment.create(userId, orderInfo); + public Payment createPayment(UUID userId, JSONObject responseData) { + Payment payment = Payment.create(userId, responseData); paymentRepository.save(payment); return payment; } - public Payment completePayment(UUID paymentId) { - Payment payment = findPayment(paymentId); - payment.complete(); - return payment; - } - public Payment findPayment(UUID paymentId) { return paymentRepository.findById(paymentId) .orElseThrow(() -> new ApplicationException(PAYMENT_NOT_FOUND)); } + public Payment getCompletedPaymentByOrderId(UUID orderId) { + return paymentRepository.findByOrderIdAndStatus(orderId, PaymentStatus.COMPLETED) + .orElseThrow(() -> new ApplicationException(PAYMENT_NOT_FOUND)); + } + } \ No newline at end of file diff --git a/services/payment/src/main/java/com/ticketPing/payment/infrastructure/client/OrderFeignClient.java b/services/payment/src/main/java/com/ticketPing/payment/infrastructure/client/OrderFeignClient.java deleted file mode 100644 index f7b8c6d3..00000000 --- a/services/payment/src/main/java/com/ticketPing/payment/infrastructure/client/OrderFeignClient.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.ticketPing.payment.infrastructure.client; - -import com.ticketPing.payment.application.client.OrderClient; -import order.OrderInfoForPaymentResponse; -import org.springframework.cloud.openfeign.FeignClient; -import org.springframework.web.bind.annotation.*; - -import java.util.UUID; - -@FeignClient(name = "order", path = "/api/v1/orders") -public interface OrderFeignClient extends OrderClient { - @GetMapping("/{orderId}") - OrderInfoForPaymentResponse getOrderInfo(@PathVariable("orderId") UUID orderId, - @RequestParam("userId") UUID userId); -} diff --git a/services/payment/src/main/java/com/ticketPing/payment/infrastructure/config/KafkaTopicConfig.java b/services/payment/src/main/java/com/ticketPing/payment/infrastructure/config/KafkaTopicConfig.java index 11375d35..bdc131c8 100644 --- a/services/payment/src/main/java/com/ticketPing/payment/infrastructure/config/KafkaTopicConfig.java +++ b/services/payment/src/main/java/com/ticketPing/payment/infrastructure/config/KafkaTopicConfig.java @@ -9,14 +9,6 @@ @Configuration public class KafkaTopicConfig { - @Bean - public NewTopic paymentCreatedTopic() { - return TopicBuilder.name(PaymentTopic.CREATED.getTopic()) - .partitions(3) - .replicas(3) - .build(); - } - @Bean public NewTopic paymentCompletedTopic() { return TopicBuilder.name(PaymentTopic.COMPLETED.getTopic()) diff --git a/services/payment/src/main/java/com/ticketPing/payment/infrastructure/config/RestTemplateConfig.java b/services/payment/src/main/java/com/ticketPing/payment/infrastructure/config/RestTemplateConfig.java new file mode 100644 index 00000000..f8cdb090 --- /dev/null +++ b/services/payment/src/main/java/com/ticketPing/payment/infrastructure/config/RestTemplateConfig.java @@ -0,0 +1,40 @@ +package com.ticketPing.payment.infrastructure.config; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.BufferingClientHttpRequestFactory; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.http.converter.StringHttpMessageConverter; +import org.springframework.web.client.ResponseErrorHandler; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + @Bean + public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) { + return restTemplateBuilder + .requestFactory(() -> new BufferingClientHttpRequestFactory(new SimpleClientHttpRequestFactory())) + .setConnectTimeout(Duration.ofMillis(5000)) + .setReadTimeout(Duration.ofMillis(300000)) + .additionalMessageConverters(new StringHttpMessageConverter(StandardCharsets.UTF_8)) + .errorHandler(new ResponseErrorHandler() { + @Override + public boolean hasError(ClientHttpResponse response) throws IOException { + return response.getStatusCode().is5xxServerError(); + } + + @Override + public void handleError(ClientHttpResponse response) throws IOException { + if (response.getStatusCode().is5xxServerError()) { + throw new RuntimeException(); + } + } + }) + .build(); + } +} diff --git a/services/payment/src/main/java/com/ticketPing/payment/infrastructure/repository/PaymentJpaRepository.java b/services/payment/src/main/java/com/ticketPing/payment/infrastructure/repository/PaymentJpaRepository.java index 81aa2ec7..2f6f1f5e 100644 --- a/services/payment/src/main/java/com/ticketPing/payment/infrastructure/repository/PaymentJpaRepository.java +++ b/services/payment/src/main/java/com/ticketPing/payment/infrastructure/repository/PaymentJpaRepository.java @@ -1,6 +1,7 @@ package com.ticketPing.payment.infrastructure.repository; import com.ticketPing.payment.domain.model.entity.Payment; +import com.ticketPing.payment.domain.model.enums.PaymentStatus; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; @@ -8,4 +9,5 @@ public interface PaymentJpaRepository extends JpaRepository { Optional findById(UUID paymentId); + Optional findByOrderIdAndStatus(UUID orderId, PaymentStatus status); } diff --git a/services/payment/src/main/java/com/ticketPing/payment/infrastructure/repository/PaymentRepositoryImpl.java b/services/payment/src/main/java/com/ticketPing/payment/infrastructure/repository/PaymentRepositoryImpl.java index fa5f23d3..0fcca9ba 100644 --- a/services/payment/src/main/java/com/ticketPing/payment/infrastructure/repository/PaymentRepositoryImpl.java +++ b/services/payment/src/main/java/com/ticketPing/payment/infrastructure/repository/PaymentRepositoryImpl.java @@ -1,6 +1,7 @@ package com.ticketPing.payment.infrastructure.repository; import com.ticketPing.payment.domain.model.entity.Payment; +import com.ticketPing.payment.domain.model.enums.PaymentStatus; import com.ticketPing.payment.domain.repository.PaymentRepository; import java.util.Optional; import java.util.UUID; @@ -23,4 +24,9 @@ public Optional findById(UUID paymentId) { return paymentJpaRepository.findById(paymentId); } + @Override + public Optional findByOrderIdAndStatus(UUID orderId, PaymentStatus status) { + return paymentJpaRepository.findByOrderIdAndStatus(orderId, status); + } + } diff --git a/services/payment/src/main/java/com/ticketPing/payment/presentation/controller/PaymentController.java b/services/payment/src/main/java/com/ticketPing/payment/presentation/controller/PaymentController.java index df68fe94..74ea242c 100644 --- a/services/payment/src/main/java/com/ticketPing/payment/presentation/controller/PaymentController.java +++ b/services/payment/src/main/java/com/ticketPing/payment/presentation/controller/PaymentController.java @@ -4,7 +4,9 @@ import com.ticketPing.payment.application.dto.PaymentResponse; import com.ticketPing.payment.application.service.PaymentApplicationService; +import com.ticketPing.payment.presentation.request.PaymentConfirmRequest; import jakarta.validation.Valid; +import org.json.simple.JSONObject; import response.CommonResponse; import io.swagger.v3.oas.annotations.Operation; import java.util.UUID; @@ -19,33 +21,31 @@ public class PaymentController { private final PaymentApplicationService paymentApplicationService; - @Operation(summary = "PG사 결제 요청") - @PostMapping - public ResponseEntity> requestPayment( - @Valid @RequestHeader("X-USER-ID") UUID userId, - @Valid @RequestParam("performanceId") UUID performanceId, - @Valid @RequestParam("orderId") UUID orderId) { - return ResponseEntity - .status(201) - .body(success(paymentApplicationService.requestPayment(userId, orderId))); - } - - @Operation(summary = "PG사 결제 상태 확인") - @GetMapping("/{paymentId}/status") - public ResponseEntity> checkPaymentStatus( - @Valid @PathVariable("paymentId") UUID paymentId) { + @Operation(summary = "TOSS 결제 상태 확인") + @PostMapping("/confirm") + public ResponseEntity> confirmPayment(@Valid @RequestHeader("X_USER_ID") UUID userId, + @Valid @RequestBody PaymentConfirmRequest request) { return ResponseEntity .status(200) - .body(success(paymentApplicationService.checkPaymentStatus(paymentId))); + .body(success(paymentApplicationService.confirmPayment(userId, request))); } @Operation(summary = "결제 단일 조회 (Feign)") @GetMapping("/{paymentId}") - public ResponseEntity> getPayment( + public ResponseEntity> getPaymentInfo( @Valid @PathVariable("paymentId") UUID paymentId) { return ResponseEntity .status(200) .body(success(paymentApplicationService.getPayment(paymentId))); } + @Operation(summary = "결제 성공 확인 (Feign)") + @GetMapping("/completed") + public ResponseEntity> getCompletedPaymentByOrderId( + @Valid @RequestParam("orderId") UUID orderId) { + return ResponseEntity + .status(200) + .body(success(paymentApplicationService.getCompletedPaymentByOrderId(orderId))); + } + } \ No newline at end of file diff --git a/services/payment/src/main/java/com/ticketPing/payment/presentation/request/PaymentConfirmRequest.java b/services/payment/src/main/java/com/ticketPing/payment/presentation/request/PaymentConfirmRequest.java new file mode 100644 index 00000000..43716dee --- /dev/null +++ b/services/payment/src/main/java/com/ticketPing/payment/presentation/request/PaymentConfirmRequest.java @@ -0,0 +1,18 @@ +package com.ticketPing.payment.presentation.request; + +import java.util.UUID; +import org.json.simple.JSONObject; + +public record PaymentConfirmRequest( + UUID orderId, + long amount, + String paymentKey +) { + public JSONObject toJson() { + JSONObject json = new JSONObject(); + json.put("orderId", orderId); + json.put("amount", amount); + json.put("paymentKey", paymentKey); + return json; + } +} diff --git a/services/payment/src/main/resources/application.yml b/services/payment/src/main/resources/application.yml index 9ab7e01a..fc73c11c 100644 --- a/services/payment/src/main/resources/application.yml +++ b/services/payment/src/main/resources/application.yml @@ -17,3 +17,8 @@ spring: server: port: 10014 + +toss: + payment: + widget-secret-key: ${TOSS_PAYMENT_WIDGET_SECRET_KEY} + payment-confirm-url: ${TOSS_PAYMENT_CONFIRM_URL} \ No newline at end of file diff --git a/services/performance/build.gradle b/services/performance/build.gradle index 00257706..5b02df92 100644 --- a/services/performance/build.gradle +++ b/services/performance/build.gradle @@ -38,7 +38,10 @@ dependencies { implementation project(':common:core') implementation project(':common:dtos') implementation project(':common:rdb') - implementation project(':common:caching') + implementation(project(':common:caching')) { + exclude(group: 'org.springframework.boot', module: 'spring-boot-starter-data-redis') + } + implementation project(':common:messaging') implementation project(':common:monitoring') // MVC @@ -47,6 +50,9 @@ dependencies { // Cloud implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client' + // Redisson + implementation 'org.redisson:redisson-spring-boot-starter:3.33.0' + // Swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2' } diff --git a/services/performance/src/main/java/com/ticketPing/performance/PerformanceApplication.java b/services/performance/src/main/java/com/ticketPing/performance/PerformanceApplication.java index e3dfbb0e..49c75a08 100644 --- a/services/performance/src/main/java/com/ticketPing/performance/PerformanceApplication.java +++ b/services/performance/src/main/java/com/ticketPing/performance/PerformanceApplication.java @@ -3,9 +3,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.ComponentScan; +import org.springframework.scheduling.annotation.EnableScheduling; +@EnableScheduling @SpringBootApplication -@ComponentScan(basePackages = {"com.ticketPing.performance", "aop", "exception", "audit", "caching"}) +@ComponentScan(basePackages = {"com.ticketPing.performance", "aop", "exception", "audit", "messaging"}) public class PerformanceApplication { public static void main(String[] args) { SpringApplication.run(PerformanceApplication.class, args); diff --git a/services/performance/src/main/java/com/ticketPing/performance/application/dtos/OrderInfoResponse.java b/services/performance/src/main/java/com/ticketPing/performance/application/dtos/OrderSeatResponse.java similarity index 62% rename from services/performance/src/main/java/com/ticketPing/performance/application/dtos/OrderInfoResponse.java rename to services/performance/src/main/java/com/ticketPing/performance/application/dtos/OrderSeatResponse.java index f0bb4bea..2d78897c 100644 --- a/services/performance/src/main/java/com/ticketPing/performance/application/dtos/OrderInfoResponse.java +++ b/services/performance/src/main/java/com/ticketPing/performance/application/dtos/OrderSeatResponse.java @@ -7,46 +7,41 @@ import lombok.AccessLevel; import lombok.Builder; -import java.time.LocalDateTime; +import java.time.LocalDate; import java.util.UUID; @Builder(access = AccessLevel.PRIVATE) -public record OrderInfoResponse( - UUID seatId, - Integer row, - Integer col, - Boolean seatState, - String seatRate, - Integer cost, +public record OrderSeatResponse( + UUID performanceId, + String performanceName, UUID scheduleId, - LocalDateTime startTime, + LocalDate startDate, UUID performanceHallId, String performanceHallName, - UUID performanceId, - String performanceName, - Integer performanceGrade, - UUID companyId + UUID companyId, + UUID seatId, + Integer row, + Integer col, + String seatGrade, + Integer cost ) { - public static OrderInfoResponse of(Seat seat) { + public static OrderSeatResponse of(Seat seat) { Schedule schedule = seat.getSchedule(); - PerformanceHall performanceHall = schedule.getPerformanceHall(); Performance performance = schedule.getPerformance(); + PerformanceHall performanceHall = performance.getPerformanceHall(); - return OrderInfoResponse.builder() - .seatId(seat.getId()) - .row(seat.getRow()) - .col(seat.getCol()) - .seatState(seat.getSeatState()) - .seatRate(seat.getSeatCost().getSeatRate().getValue()) - .cost(seat.getSeatCost().getCost()) + return OrderSeatResponse.builder() + .performanceId(performance.getId()) + .performanceName(performance.getName()) .scheduleId(schedule.getId()) - .startTime(schedule.getStartTime()) + .startDate(schedule.getStartDate()) .performanceHallId(performanceHall.getId()) .performanceHallName(performanceHall.getName()) - .performanceId(performance.getId()) - .performanceName(performance.getName()) - .performanceGrade(performance.getGrade()) - .companyId(performance.getCompanyId()) + .companyId(performance.getCompanyId()).seatId(seat.getId()) + .row(seat.getRow()) + .col(seat.getCol()) + .seatGrade(seat.getSeatCost().getSeatGrade()) + .cost(seat.getSeatCost().getCost()) .build(); } } diff --git a/services/performance/src/main/java/com/ticketPing/performance/application/dtos/PerformanceListResponse.java b/services/performance/src/main/java/com/ticketPing/performance/application/dtos/PerformanceListResponse.java new file mode 100644 index 00000000..99d98a5e --- /dev/null +++ b/services/performance/src/main/java/com/ticketPing/performance/application/dtos/PerformanceListResponse.java @@ -0,0 +1,39 @@ +package com.ticketPing.performance.application.dtos; + +import com.ticketPing.performance.domain.model.entity.Performance; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.Builder; + +@Builder(access = AccessLevel.PRIVATE) +public record PerformanceListResponse( + UUID id, + String name, + String posterUrl, + int runTime, + LocalDateTime reservationStartDate, + LocalDateTime reservationEndDate, + LocalDate startDate, + LocalDate endDate, + int grade, + UUID companyId, + String performanceHallName +){ + public static PerformanceListResponse of(Performance performance) { + return PerformanceListResponse.builder() + .id(performance.getId()) + .name(performance.getName()) + .posterUrl(performance.getPosterUrl()) + .runTime(performance.getRunTime()) + .reservationStartDate(performance.getReservationStartDate()) + .reservationEndDate(performance.getReservationEndDate()) + .startDate(performance.getStartDate()) + .endDate(performance.getEndDate()) + .grade(performance.getGrade()) + .companyId(performance.getCompanyId()) + .performanceHallName(performance.getPerformanceHall().getName()) + .build(); + } +} diff --git a/services/performance/src/main/java/com/ticketPing/performance/application/dtos/PerformanceResponse.java b/services/performance/src/main/java/com/ticketPing/performance/application/dtos/PerformanceResponse.java index dea7d798..8cbdcfbe 100644 --- a/services/performance/src/main/java/com/ticketPing/performance/application/dtos/PerformanceResponse.java +++ b/services/performance/src/main/java/com/ticketPing/performance/application/dtos/PerformanceResponse.java @@ -1,33 +1,52 @@ package com.ticketPing.performance.application.dtos; import com.ticketPing.performance.domain.model.entity.Performance; +import com.ticketPing.performance.domain.model.entity.SeatCost; +import lombok.AccessLevel; +import lombok.Builder; + import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.List; import java.util.UUID; -import lombok.AccessLevel; -import lombok.Builder; +import java.util.stream.Collectors; @Builder(access = AccessLevel.PRIVATE) public record PerformanceResponse( UUID id, String name, + String posterUrl, + int runTime, LocalDateTime reservationStartDate, LocalDateTime reservationEndDate, LocalDate startDate, LocalDate endDate, - Integer grade, - UUID companyId + int grade, + UUID companyId, + String performanceHallName, + int rows, + int columns, + List seatCostResponses ){ public static PerformanceResponse of(Performance performance) { return PerformanceResponse.builder() - .id(performance.getId()) - .name(performance.getName()) - .reservationStartDate(performance.getReservationStartDate()) - .reservationEndDate(performance.getReservationEndDate()) - .startDate(performance.getStartDate()) - .endDate(performance.getEndDate()) - .grade(performance.getGrade()) - .companyId(performance.getCompanyId()) - .build(); + .id(performance.getId()) + .name(performance.getName()) + .posterUrl(performance.getPosterUrl()) + .runTime(performance.getRunTime()) + .reservationStartDate(performance.getReservationStartDate()) + .reservationEndDate(performance.getReservationEndDate()) + .startDate(performance.getStartDate()) + .endDate(performance.getEndDate()) + .grade(performance.getGrade()) + .companyId(performance.getCompanyId()) + .performanceHallName(performance.getPerformanceHall().getName()) + .rows(performance.getPerformanceHall().getRows()) + .columns(performance.getPerformanceHall().getColumns()) + .seatCostResponses(performance.getSeatCosts().stream() + .map(SeatCostResponse::of) + .collect(Collectors.toList()) + ) + .build(); } } diff --git a/services/performance/src/main/java/com/ticketPing/performance/application/dtos/ScheduleResponse.java b/services/performance/src/main/java/com/ticketPing/performance/application/dtos/ScheduleResponse.java index decb4aad..94933f25 100644 --- a/services/performance/src/main/java/com/ticketPing/performance/application/dtos/ScheduleResponse.java +++ b/services/performance/src/main/java/com/ticketPing/performance/application/dtos/ScheduleResponse.java @@ -3,23 +3,21 @@ import com.ticketPing.performance.domain.model.entity.Schedule; import lombok.*; -import java.time.LocalDateTime; +import java.time.LocalDate; import java.util.UUID; @Builder(access = AccessLevel.PRIVATE) public record ScheduleResponse ( UUID id, - LocalDateTime startTime, - UUID performanceHallId, - UUID performanceId + UUID performanceId, + LocalDate startDate ){ public static ScheduleResponse of(Schedule schedule) { return ScheduleResponse.builder() - .id(schedule.getId()) - .startTime(schedule.getStartTime()) - .performanceHallId(schedule.getPerformanceHall().getId()) - .performanceId(schedule.getPerformance().getId()) - .build(); + .id(schedule.getId()) + .performanceId(schedule.getPerformance().getId()) + .startDate(schedule.getStartDate()) + .build(); } } diff --git a/services/performance/src/main/java/com/ticketPing/performance/application/dtos/SeatCostResponse.java b/services/performance/src/main/java/com/ticketPing/performance/application/dtos/SeatCostResponse.java new file mode 100644 index 00000000..df950d59 --- /dev/null +++ b/services/performance/src/main/java/com/ticketPing/performance/application/dtos/SeatCostResponse.java @@ -0,0 +1,18 @@ +package com.ticketPing.performance.application.dtos; + +import com.ticketPing.performance.domain.model.entity.SeatCost; +import lombok.AccessLevel; +import lombok.Builder; + +@Builder(access = AccessLevel.PRIVATE) +public record SeatCostResponse( + String seatGrade, + int cost +) { + public static SeatCostResponse of(SeatCost seatCost) { + return SeatCostResponse.builder() + .seatGrade(seatCost.getSeatGrade()) + .cost(seatCost.getCost()) + .build(); + } +} \ No newline at end of file diff --git a/services/performance/src/main/java/com/ticketPing/performance/application/dtos/SeatResponse.java b/services/performance/src/main/java/com/ticketPing/performance/application/dtos/SeatResponse.java index a6bb9391..7f67f6e3 100644 --- a/services/performance/src/main/java/com/ticketPing/performance/application/dtos/SeatResponse.java +++ b/services/performance/src/main/java/com/ticketPing/performance/application/dtos/SeatResponse.java @@ -1,28 +1,38 @@ package com.ticketPing.performance.application.dtos; import com.ticketPing.performance.domain.model.entity.Seat; +import com.ticketPing.performance.domain.model.entity.SeatCache; import lombok.AccessLevel; import lombok.Builder; import java.util.UUID; + @Builder(access = AccessLevel.PRIVATE) public record SeatResponse ( - UUID seatId, - Integer row, - Integer col, - Boolean seatState, - String seatRate, - Integer cost + UUID seatId, + Integer row, + Integer col, + String seatStatus, + String seatGrade ) { public static SeatResponse of(Seat seat) { return SeatResponse.builder() .seatId(seat.getId()) .row(seat.getRow()) .col(seat.getCol()) - .seatState(seat.getSeatState()) - .seatRate(seat.getSeatCost().getSeatRate().getValue()) - .cost(seat.getSeatCost().getCost()) + .seatStatus(seat.getSeatStatus().getValue()) + .seatGrade(seat.getSeatCost().getSeatGrade()) + .build(); + } + + public static SeatResponse of(SeatCache seatCache) { + return SeatResponse.builder() + .seatId(seatCache.getId()) + .row(seatCache.getRow()) + .col(seatCache.getCol()) + .seatStatus(seatCache.getSeatStatus()) + .seatGrade(seatCache.getSeatGrade()) .build(); } } diff --git a/services/performance/src/main/java/com/ticketPing/performance/application/scheduler/SeatCacheScheduler.java b/services/performance/src/main/java/com/ticketPing/performance/application/scheduler/SeatCacheScheduler.java new file mode 100644 index 00000000..b6ca6eee --- /dev/null +++ b/services/performance/src/main/java/com/ticketPing/performance/application/scheduler/SeatCacheScheduler.java @@ -0,0 +1,51 @@ +package com.ticketPing.performance.application.scheduler; + +import com.ticketPing.performance.application.service.NotificationService; +import com.ticketPing.performance.application.service.PerformanceService; +import com.ticketPing.performance.domain.model.entity.Performance; +import com.ticketPing.performance.infrastructure.service.DistributedLockService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import static com.ticketPing.performance.common.constants.SeatConstants.CACHE_SCHEDULER_LOCK_KEY; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SeatCacheScheduler { + + private final PerformanceService performanceService; + private final DistributedLockService lockService; + private final NotificationService notificationService; + + private static final int LOCK_TIMEOUT = 300; + + @Scheduled(cron = "0 0/10 * * * *") + public void runScheduler() { + log.info("Scheduler triggered"); + try { + boolean executed = lockService.executeWithLock(CACHE_SCHEDULER_LOCK_KEY, 0, LOCK_TIMEOUT, this::cacheSeatsForUpcomingPerformance); + if (!executed) { + log.warn("Another server is running the scheduler"); + } + } catch (Exception e) { + log.error("Unexpected error in scheduler: {}", e.getMessage(), e); + notificationService.sendErrorNotification( + String.format("Error in SeatCacheScheduler: %s", e.getMessage()) + ); + } + } + + private void cacheSeatsForUpcomingPerformance() { + Performance performance = performanceService.getUpcomingPerformance(); + if (performance != null) { + performanceService.cacheAllSeatsForPerformance(performance.getId()); + log.info("Caching completed for performance ID: {}", performance.getId()); + } else { + log.info("No upcoming performance found"); + } + } +} + diff --git a/services/performance/src/main/java/com/ticketPing/performance/application/service/EventApplicationService.java b/services/performance/src/main/java/com/ticketPing/performance/application/service/EventApplicationService.java new file mode 100644 index 00000000..6daedc14 --- /dev/null +++ b/services/performance/src/main/java/com/ticketPing/performance/application/service/EventApplicationService.java @@ -0,0 +1,19 @@ +package com.ticketPing.performance.application.service; + +import lombok.RequiredArgsConstructor; +import messaging.events.SeatPreReserveExpiredEvent; +import messaging.topics.SeatTopic; +import messaging.utils.EventSerializer; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class EventApplicationService { + private final KafkaTemplate kafkaTemplate; + + public void publishSeatPreReserveExpiredEvent(SeatPreReserveExpiredEvent event) { + String message = EventSerializer.serialize(event); + kafkaTemplate.send(SeatTopic.PRE_RESERVE_EXPIRED.getTopic(), message); + } +} diff --git a/services/performance/src/main/java/com/ticketPing/performance/application/service/NotificationService.java b/services/performance/src/main/java/com/ticketPing/performance/application/service/NotificationService.java new file mode 100644 index 00000000..ec589818 --- /dev/null +++ b/services/performance/src/main/java/com/ticketPing/performance/application/service/NotificationService.java @@ -0,0 +1,5 @@ +package com.ticketPing.performance.application.service; + +public interface NotificationService { + void sendErrorNotification(String errorMessage); +} diff --git a/services/performance/src/main/java/com/ticketPing/performance/application/service/PerformanceService.java b/services/performance/src/main/java/com/ticketPing/performance/application/service/PerformanceService.java index f1ebeab1..277fc9a8 100644 --- a/services/performance/src/main/java/com/ticketPing/performance/application/service/PerformanceService.java +++ b/services/performance/src/main/java/com/ticketPing/performance/application/service/PerformanceService.java @@ -1,18 +1,21 @@ package com.ticketPing.performance.application.service; +import com.ticketPing.performance.application.dtos.PerformanceListResponse; import com.ticketPing.performance.application.dtos.PerformanceResponse; +import com.ticketPing.performance.application.dtos.ScheduleResponse; +import com.ticketPing.performance.common.exception.PerformanceExceptionCase; import com.ticketPing.performance.domain.model.entity.Performance; +import com.ticketPing.performance.domain.model.entity.Schedule; import com.ticketPing.performance.domain.repository.PerformanceRepository; -import com.ticketPing.performance.common.exception.PerformanceExceptionCase; -import com.ticketPing.performance.common.exception.ScheduleExceptionCase; +import com.ticketPing.performance.infrastructure.repository.CacheRepositoryImpl; import exception.ApplicationException; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.util.List; import java.util.UUID; @@ -21,35 +24,52 @@ public class PerformanceService { private final PerformanceRepository performanceRepository; + private final CacheRepositoryImpl cacheRepositoryImpl; + private final SeatService seatService; - @Transactional(readOnly = true) - public PerformanceResponse getPerformance(UUID id) { - Performance performance = findPerformanceById(id); + public PerformanceResponse getPerformance(UUID performanceId) { + Performance performance = findPerformanceWithDetails(performanceId); return PerformanceResponse.of(performance); } - @Transactional - public Page getAllPerformances(Pageable pageable) { - Page performances = performanceRepository.findAll(pageable); - return performances.map(PerformanceResponse::of); + public Slice getAllPerformances(Pageable pageable) { + return performanceRepository.findAllWithPerformanceHall(pageable) + .map(PerformanceListResponse::of); } - @Transactional - public Performance getAndValidatePerformance(UUID performanceId) { - Performance performance = findPerformanceById(performanceId); + public List getPerformanceSchedules(UUID performanceId) { + Performance performance = findPerformanceWithSchedules(performanceId); + return performance.getSchedules().stream() + .map(ScheduleResponse::of) + .toList(); + } - LocalDateTime cur = LocalDateTime.now(); - if(performance.getReservationStartDate().isAfter(cur) - || performance.getReservationEndDate().isBefore(cur)) { - throw new ApplicationException(ScheduleExceptionCase.NOT_RESERVATION_DATE); + public Performance getUpcomingPerformance() { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime tenMinutesLater = now.plusMinutes(10); + + return performanceRepository.findUpcomingPerformance(now, tenMinutesLater); + } + + public void cacheAllSeatsForPerformance(UUID performanceId) { + Performance performance = findPerformanceWithSchedules(performanceId); + List schedules = performance.getSchedules(); + + long totalAvailableSeats = 0; + for (Schedule schedule : schedules) { + long availableSeats = seatService.cacheSeatsForSchedule(schedule); + totalAvailableSeats += availableSeats; } + cacheRepositoryImpl.cacheAvailableSeats(performanceId, totalAvailableSeats); + } - return performance; + private Performance findPerformanceWithSchedules(UUID id) { + return performanceRepository.findByIdWithSchedules(id) + .orElseThrow(() -> new ApplicationException(PerformanceExceptionCase.PERFORMANCE_NOT_FOUND)); } - @Transactional - public Performance findPerformanceById(UUID id) { - return performanceRepository.findById(id) + private Performance findPerformanceWithDetails(UUID id) { + return performanceRepository.findByIdWithDetails(id) .orElseThrow(() -> new ApplicationException(PerformanceExceptionCase.PERFORMANCE_NOT_FOUND)); } } diff --git a/services/performance/src/main/java/com/ticketPing/performance/application/service/ScheduleService.java b/services/performance/src/main/java/com/ticketPing/performance/application/service/ScheduleService.java index 0081e7b8..92ae35b9 100644 --- a/services/performance/src/main/java/com/ticketPing/performance/application/service/ScheduleService.java +++ b/services/performance/src/main/java/com/ticketPing/performance/application/service/ScheduleService.java @@ -1,56 +1,23 @@ package com.ticketPing.performance.application.service; -import com.ticketPing.performance.application.dtos.ScheduleResponse; -import com.ticketPing.performance.domain.model.entity.Performance; -import com.ticketPing.performance.domain.model.entity.Schedule; -import com.ticketPing.performance.domain.repository.ScheduleRepository; -import com.ticketPing.performance.common.exception.ScheduleExceptionCase; -import exception.ApplicationException; +import com.ticketPing.performance.application.dtos.SeatResponse; +import com.ticketPing.performance.domain.model.entity.SeatCache; +import com.ticketPing.performance.infrastructure.repository.CacheRepositoryImpl; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; import java.util.List; +import java.util.Map; import java.util.UUID; @Service @RequiredArgsConstructor public class ScheduleService { - private final ScheduleRepository scheduleRepository; - @Transactional - public ScheduleResponse getSchedule(UUID id) { - Schedule schedule = findScheduleById(id); + private final CacheRepositoryImpl cacheRepositoryImpl; - LocalDateTime cur = LocalDateTime.now(); - if(schedule.getPerformance().getReservationStartDate().isAfter(cur) - || schedule.getPerformance().getReservationEndDate().isBefore(cur)) { - throw new ApplicationException(ScheduleExceptionCase.NOT_RESERVATION_DATE); - } - - return ScheduleResponse.of(schedule); - } - - @Transactional - public Schedule findScheduleById(UUID id) { - return scheduleRepository.findById(id) - .orElseThrow(() -> new ApplicationException(ScheduleExceptionCase.SCHEDULE_NOT_FOUND)); - } - - @Transactional - public Page getSchedulesByPerformance(Performance performance, Pageable pageable) { - Page schedules = scheduleRepository.findByPerformance(performance, pageable); - return schedules.map(ScheduleResponse::of); - } - - public List finadAllScheduleByPerformance(Performance performance) { - List schedules = scheduleRepository.findByPerformance(performance); - if(schedules.isEmpty()) { - throw new ApplicationException(ScheduleExceptionCase.SCHEDULE_NOT_FOUND); - } - return schedules; + public List getAllScheduleSeats(UUID scheduleId) { + Map seatMap = cacheRepositoryImpl.getSeatCaches(scheduleId); + return seatMap.values().stream().map(SeatResponse::of).toList(); } } diff --git a/services/performance/src/main/java/com/ticketPing/performance/application/service/SeatService.java b/services/performance/src/main/java/com/ticketPing/performance/application/service/SeatService.java index 942d4851..506f6e21 100644 --- a/services/performance/src/main/java/com/ticketPing/performance/application/service/SeatService.java +++ b/services/performance/src/main/java/com/ticketPing/performance/application/service/SeatService.java @@ -1,85 +1,103 @@ package com.ticketPing.performance.application.service; -import caching.repository.RedisRepository; -import com.ticketPing.performance.application.dtos.OrderInfoResponse; -import com.ticketPing.performance.application.dtos.SeatResponse; +import com.ticketPing.performance.application.dtos.OrderSeatResponse; +import com.ticketPing.performance.common.exception.SeatExceptionCase; import com.ticketPing.performance.domain.model.entity.Schedule; import com.ticketPing.performance.domain.model.entity.Seat; +import com.ticketPing.performance.domain.model.entity.SeatCache; +import com.ticketPing.performance.domain.model.enums.SeatStatus; +import com.ticketPing.performance.domain.repository.CacheRepository; import com.ticketPing.performance.domain.repository.SeatRepository; -import com.ticketPing.performance.common.exception.SeatExceptionCase; import exception.ApplicationException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.*; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; -import static caching.enums.RedisKeyPrefix.AVAILABLE_SEATS; -import static caching.enums.RedisKeyPrefix.SEAT_CACHE; +import static com.ticketPing.performance.common.constants.SeatConstants.PRE_RESERVE_TTL; @Service @RequiredArgsConstructor public class SeatService { + private final SeatRepository seatRepository; - private final RedisRepository redisRepository; // TODO: 상위 서비스 만들어서 불러오기 + private final CacheRepository cacheRepository; - @Transactional(readOnly = true) - public SeatResponse getSeat(UUID id) { - Seat seat = findSeatByIdJoinSeatCost(id); - return SeatResponse.of(seat); + public void preReserveSeat(UUID scheduleId, UUID seatId, UUID userId) { + cacheRepository.preReserveSeatCache(scheduleId, seatId, userId); } - @Transactional - public SeatResponse updateSeatState(UUID seatId, Boolean seatState) { - Seat seat = findSeatByIdJoinSeatCost(seatId); - seat.updateSeatState(seatState); - return SeatResponse.of(seat); + public void cancelPreReserveSeat(UUID scheduleId, UUID seatId, UUID userId) { + validatePreserve(scheduleId, seatId, userId); + cacheRepository.deletePreReserveTTL(scheduleId, seatId); + cancelPreReserveSeatInCache(scheduleId, seatId); } - @Transactional - public Seat findSeatByIdJoinSeatCost(UUID id) { - return seatRepository.findByIdJoinSeatCost(id) - .orElseThrow(() -> new ApplicationException(SeatExceptionCase.SEAT_NOT_FOUND)); + public void cancelPreReserveSeatInCache(UUID scheduleId, UUID seatId) { + SeatCache seatCache = cacheRepository.getSeatCache(scheduleId, seatId); + seatCache.cancelPreReserveSeat(); + cacheRepository.putSeatCache(seatCache, scheduleId, seatId); } @Transactional - public OrderInfoResponse getOrderInfo(UUID seatId) { - Seat seat = seatRepository.findByIdJoinAll(seatId) - .orElseThrow(() -> new ApplicationException(SeatExceptionCase.SEAT_NOT_FOUND)); - return OrderInfoResponse.of(seat); + public void reserveSeat(String scheduleId, String seatId) { + reserveSeatInDB(seatId); + reserveSeatInCache(UUID.fromString(scheduleId), UUID.fromString(seatId)); } - @Transactional - public List getAllScheduleSeats(UUID scheduleId) { - Set ids = redisRepository.getKeys(SEAT_CACHE.getValue() + scheduleId + ":*"); - if(ids.isEmpty()) { - throw new ApplicationException(SeatExceptionCase.SEAT_CACHE_NOT_FOUND); - } - return redisRepository.getValuesAsClass(ids.stream().toList(), SeatResponse.class); + public OrderSeatResponse getOrderSeatInfo(UUID scheduleId, UUID seatId, UUID userId) { + validatePreserve(scheduleId, seatId, userId); + Seat seat = getSeatWithDetails(seatId); + extendPreReserveTTL(scheduleId, seatId); + return OrderSeatResponse.of(seat); } - @Transactional - public void createSeatsCache(List schedules, UUID performanceId) { - long availableSeats = 0; + public void extendPreReserveTTL(UUID scheduleId, UUID seatId) { + cacheRepository.extendPreReserveTTL(scheduleId, seatId, Duration.ofSeconds(PRE_RESERVE_TTL)); + } + + public long cacheSeatsForSchedule(Schedule schedule) { + List seats = seatRepository.findByScheduleWithSeatCost(schedule); - for(Schedule schedule : schedules) { - List seats = findSeatsByScheduleJoinSeatCost(schedule); + Map seatMap = seats.stream().collect(Collectors.toMap(seat -> seat.getId().toString(), SeatCache::from)); - availableSeats += seats.stream().filter(s -> !s.getSeatState()).count(); + LocalDateTime expiration = schedule.getStartDate().atTime(23, 59, 59); + Duration ttl = Duration.between(LocalDateTime.now(), expiration); - // 좌석 캐싱 - String prefix = SEAT_CACHE.getValue() + schedule.getId() + ":"; - Map seatMap = new HashMap<>(); - seats.forEach(seat -> {seatMap.put(prefix+seat.getId(), SeatResponse.of(seat));}); - redisRepository.setValues(seatMap); - } + cacheRepository.cacheSeats(schedule.getId(), seatMap, ttl); - // counter 생성 - redisRepository.setValue(AVAILABLE_SEATS.getValue() + performanceId, availableSeats); + return seats.stream().filter(seat -> seat.getSeatStatus() == SeatStatus.AVAILABLE).count(); } - @Transactional - public List findSeatsByScheduleJoinSeatCost(Schedule schedule) { - return seatRepository.findByScheduleJoinSeatCost(schedule); + private Seat getSeatWithDetails(UUID seatId) { + return seatRepository.findByIdWithAll(seatId) + .orElseThrow(() -> new ApplicationException(SeatExceptionCase.SEAT_NOT_FOUND)); + } + + private void reserveSeatInDB(String seatId) { + Seat seat = seatRepository.findById(UUID.fromString(seatId)) + .orElseThrow(() -> new ApplicationException(SeatExceptionCase.SEAT_NOT_FOUND)); + seat.reserveSeat(); + } + + private void validatePreserve(UUID scheduleId, UUID seatId, UUID userId) { + String preReserveUserId = cacheRepository.getPreReserveUserId(scheduleId, seatId); + if(!preReserveUserId.equals(userId.toString())) + throw new ApplicationException(SeatExceptionCase.USER_NOT_MATCH); + } + + private void reserveSeatInCache(UUID scheduleId, UUID seatId) { + cacheRepository.deletePreReserveTTL(scheduleId, seatId); + SeatCache seatCache = cacheRepository.getSeatCache(scheduleId, seatId); + seatCache.reserveSeat(); + cacheRepository.putSeatCache(seatCache, scheduleId, seatId); } } + + diff --git a/services/performance/src/main/java/com/ticketPing/performance/common/constants/SeatConstants.java b/services/performance/src/main/java/com/ticketPing/performance/common/constants/SeatConstants.java new file mode 100644 index 00000000..95dd9ce6 --- /dev/null +++ b/services/performance/src/main/java/com/ticketPing/performance/common/constants/SeatConstants.java @@ -0,0 +1,20 @@ +package com.ticketPing.performance.common.constants; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class SeatConstants { + + public static final String SEAT_CACHE_KEY = "seat"; + public static final String PRE_RESERVE_SEAT_KEY = "seat-ttl"; + public static final String CACHE_SCHEDULER_LOCK_KEY = "SchedulerLock"; + public static final String PRE_RESERVE_EXPIRE_LOCK_KEY = "PreReserveLock:"; + + + public static int PRE_RESERVE_TTL; + + private SeatConstants(@Value("${seat.pre-reserve-ttl}") int preReserveTtl) { + PRE_RESERVE_TTL = preReserveTtl; + } +} diff --git a/services/performance/src/main/java/com/ticketPing/performance/common/exception/MessageExceptionCase.java b/services/performance/src/main/java/com/ticketPing/performance/common/exception/MessageExceptionCase.java new file mode 100644 index 00000000..0840dbba --- /dev/null +++ b/services/performance/src/main/java/com/ticketPing/performance/common/exception/MessageExceptionCase.java @@ -0,0 +1,15 @@ +package com.ticketPing.performance.common.exception; + +import exception.ErrorCase; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum MessageExceptionCase implements ErrorCase { + MESSAGE_SEND_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, "알람을 보낼 수 없습니다."); + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/services/performance/src/main/java/com/ticketPing/performance/common/exception/PerformanceExceptionCase.java b/services/performance/src/main/java/com/ticketPing/performance/common/exception/PerformanceExceptionCase.java index 808d81d7..3b9ac080 100644 --- a/services/performance/src/main/java/com/ticketPing/performance/common/exception/PerformanceExceptionCase.java +++ b/services/performance/src/main/java/com/ticketPing/performance/common/exception/PerformanceExceptionCase.java @@ -8,7 +8,7 @@ @Getter @RequiredArgsConstructor public enum PerformanceExceptionCase implements ErrorCase { - PERFORMANCE_NOT_FOUND(HttpStatus.BAD_REQUEST, "공연을 찾을 수 없습니다."); + PERFORMANCE_NOT_FOUND(HttpStatus.NOT_FOUND, "공연을 찾을 수 없습니다."); private final HttpStatus httpStatus; private final String message; diff --git a/services/performance/src/main/java/com/ticketPing/performance/common/exception/ScheduleExceptionCase.java b/services/performance/src/main/java/com/ticketPing/performance/common/exception/ScheduleExceptionCase.java index 0b09b3c3..213f7042 100644 --- a/services/performance/src/main/java/com/ticketPing/performance/common/exception/ScheduleExceptionCase.java +++ b/services/performance/src/main/java/com/ticketPing/performance/common/exception/ScheduleExceptionCase.java @@ -8,8 +8,7 @@ @Getter @RequiredArgsConstructor public enum ScheduleExceptionCase implements ErrorCase { - SCHEDULE_NOT_FOUND(HttpStatus.BAD_REQUEST, "공연 스케줄을 찾을 수 없습니다."), - NOT_RESERVATION_DATE(HttpStatus.BAD_REQUEST, "예약 가능 날짜가 아닙니다"); + SCHEDULE_NOT_FOUND(HttpStatus.BAD_REQUEST, "공연 스케줄을 찾을 수 없습니다."); private final HttpStatus httpStatus; private final String message; diff --git a/services/performance/src/main/java/com/ticketPing/performance/common/exception/SeatExceptionCase.java b/services/performance/src/main/java/com/ticketPing/performance/common/exception/SeatExceptionCase.java index c3d9f504..569f81b9 100644 --- a/services/performance/src/main/java/com/ticketPing/performance/common/exception/SeatExceptionCase.java +++ b/services/performance/src/main/java/com/ticketPing/performance/common/exception/SeatExceptionCase.java @@ -9,8 +9,23 @@ @RequiredArgsConstructor public enum SeatExceptionCase implements ErrorCase { SEAT_NOT_FOUND(HttpStatus.BAD_REQUEST, "좌석 정보를 찾을 수 없습니다."), - SEAT_CACHE_NOT_FOUND(HttpStatus.BAD_REQUEST, "좌석 캐싱 정보를 찾을 수 없습니다."); + INVALID_SEAT_STATUS(HttpStatus.BAD_REQUEST, "유효하지 않은 좌석 상태입니다."), + SEAT_CACHE_NOT_FOUND(HttpStatus.BAD_REQUEST, "좌석 캐싱 정보를 찾을 수 없습니다."), + SEAT_ALREADY_TAKEN(HttpStatus.BAD_REQUEST, "좌석이 이미 점유되어 있습니다."), + PRE_RESERVE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "좌석 선점 과정에서 오류가 발생했습니다."), + SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 오류가 발생했습니다."), + USER_NOT_MATCH(HttpStatus.BAD_REQUEST, "본인이 선점한 좌석이 아닙니다."), + SEAT_NOT_PRE_RESERVED(HttpStatus.BAD_REQUEST, "좌석이 선점 상태가 아닙니다."), + TTL_NOT_EXIST(HttpStatus.BAD_REQUEST, "좌석 선점 상태가 아닙니다."); private final HttpStatus httpStatus; private final String message; + + public static SeatExceptionCase getByValue(String value) { + try { + return SeatExceptionCase.valueOf(value); + } catch (IllegalArgumentException e) { + return SeatExceptionCase.SERVER_ERROR; + } + } } diff --git a/services/performance/src/main/java/com/ticketPing/performance/domain/DataInitializer.java b/services/performance/src/main/java/com/ticketPing/performance/domain/DataInitializer.java index 8a1af0b5..951d81be 100644 --- a/services/performance/src/main/java/com/ticketPing/performance/domain/DataInitializer.java +++ b/services/performance/src/main/java/com/ticketPing/performance/domain/DataInitializer.java @@ -1,10 +1,10 @@ package com.ticketPing.performance.domain; +import com.ticketPing.performance.application.service.PerformanceService; import com.ticketPing.performance.domain.model.entity.*; -import com.ticketPing.performance.domain.model.enums.SeatRate; +import com.ticketPing.performance.domain.model.enums.SeatStatus; import com.ticketPing.performance.domain.repository.PerformanceHallRepository; import com.ticketPing.performance.domain.repository.PerformanceRepository; -import com.ticketPing.performance.domain.repository.ScheduleRepository; import com.ticketPing.performance.domain.repository.SeatRepository; import lombok.RequiredArgsConstructor; import org.springframework.boot.CommandLineRunner; @@ -19,70 +19,129 @@ @RequiredArgsConstructor public class DataInitializer implements CommandLineRunner { + private final PerformanceService performanceService; private final PerformanceRepository performanceRepository; private final PerformanceHallRepository performanceHallRepository; - private final ScheduleRepository scheduleRepository; private final SeatRepository seatRepository; @Override @Transactional - public void run(String... args) throws Exception { + public void run(String... args) { if (performanceHallRepository.count() > 0) { + Performance performance = performanceRepository.findByName("햄릿"); + if(performance != null) { + performanceService.cacheAllSeatsForPerformance(performance.getId()); + } return; } // 공연장 더미 데이터 생성 - PerformanceHall hall1 = PerformanceHall.createTestData("국립극장", "서울특별시 남산동 1-1", 800, 10, 5); + PerformanceHall hall1 = PerformanceHall.createTestData("국립극장", "서울특별시 남산동 1-1", 50, 10, 5); performanceHallRepository.save(hall1); PerformanceHall hall2 = PerformanceHall.createTestData("세종문화회관", "서울특별시 세종로 81", 1200, 40, 30); performanceHallRepository.save(hall2); // 공연 더미 데이터 생성 - Performance performance1 = Performance.createTestData("햄릿", LocalDateTime.now().minusDays(10), LocalDateTime.now().plusDays(10), - LocalDate.now().minusDays(5), LocalDate.now().plusDays(10), 19, UUID.randomUUID()); + Performance performance1 = Performance.createTestData( + "햄릿", + "https://image.kmib.co.kr/online_image/2017/0622/201706222053_61170011561894_1.jpg", + 120, + LocalDateTime.now().minusDays(5), + LocalDateTime.now().plusDays(10), + LocalDate.now().plusDays(5), + LocalDate.now().plusDays(10), + 19, + UUID.randomUUID(), + hall1); performance1 = performanceRepository.save(performance1); - Performance performance2 = Performance.createTestData("라이온 킹", LocalDateTime.now().minusDays(5), LocalDateTime.now().plusDays(15), - LocalDate.now().minusDays(3), LocalDate.now().plusDays(15), 12, UUID.randomUUID()); + Performance performance2 = Performance.createTestData( + "라이온 킹", + "https://image.yes24.com/themusical/upFiles/Themusical/Play/post_%EB%9D%BC%EC%9D%B4%EC%98%A8%ED%82%B9-.JPG", + 120, + LocalDateTime.now().plusDays(5), + LocalDateTime.now().plusDays(15), + LocalDate.now().plusDays(15), + LocalDate.now().plusDays(15), + 12, + UUID.randomUUID(), + hall2); performance2 = performanceRepository.save(performance2); + Performance performance3 = Performance.createTestData( + "데스노트", + "https://image.yes24.com/themusical/fileStorage/ThemusicalAdmin/Play/Image/20230207843266905c36ce1ad354a823e902457bc904d112.jpg", + 120, + LocalDateTime.now().minusDays(7), + LocalDateTime.now().plusDays(13), + LocalDate.now().plusDays(7), + LocalDate.now().plusDays(13), + 19, + UUID.randomUUID(), + hall1); + performance3 = performanceRepository.save(performance3); + + // 공연 일정 더미 데이터 생성 + Schedule schedule1 = Schedule.createTestData(LocalDate.now().plusDays(5), performance1); + performance1.addSchedule(schedule1); + + Schedule schedule2 = Schedule.createTestData(LocalDate.now().plusDays(10), performance1); + performance1.addSchedule(schedule2); + + Schedule schedule3 = Schedule.createTestData(LocalDate.now().plusDays(15), performance2); + performance2.addSchedule(schedule3); + + Schedule schedule4 = Schedule.createTestData(LocalDate.now().plusDays(5), performance2); + performance3.addSchedule(schedule4); + // 좌석 가격 더미 데이터 생성 - SeatCost seatCost1 = SeatCost.createTestData(SeatRate.S, 120000, performance1); + SeatCost seatCost1 = SeatCost.createTestData("S", 120000, performance1); performance1.addSeatCost(seatCost1); - SeatCost seatCost2 = SeatCost.createTestData(SeatRate.A, 90000, performance1); + SeatCost seatCost2 = SeatCost.createTestData("A", 90000, performance1); performance1.addSeatCost(seatCost2); - SeatCost seatCost3 = SeatCost.createTestData(SeatRate.B, 60000, performance1); + SeatCost seatCost3 = SeatCost.createTestData("B", 60000, performance1); performance1.addSeatCost(seatCost3); - SeatCost seatCost4 = SeatCost.createTestData(SeatRate.S, 150000, performance2); + SeatCost seatCost4 = SeatCost.createTestData("S", 150000, performance2); performance2.addSeatCost(seatCost4); - SeatCost seatCost5 = SeatCost.createTestData(SeatRate.A, 110000, performance2); + SeatCost seatCost5 = SeatCost.createTestData("A", 110000, performance2); performance2.addSeatCost(seatCost5); - SeatCost seatCost6 = SeatCost.createTestData(SeatRate.B, 80000, performance2); + SeatCost seatCost6 = SeatCost.createTestData("B", 80000, performance2); performance2.addSeatCost(seatCost6); - // 공연 일정 더미 데이터 생성 - Schedule schedule1 = Schedule.createTestData(LocalDateTime.now().plusDays(1), hall1, performance1); - scheduleRepository.save(schedule1); + SeatCost seatCost7 = SeatCost.createTestData("S", 150000, performance3); + performance3.addSeatCost(seatCost7); + + SeatCost seatCost8 = SeatCost.createTestData("A", 110000, performance3); + performance3.addSeatCost(seatCost8); - Schedule schedule2 = Schedule.createTestData(LocalDateTime.now().plusDays(2), hall2, performance2); - scheduleRepository.save(schedule2); + SeatCost seatCost9 = SeatCost.createTestData("B", 80000, performance3); + performance3.addSeatCost(seatCost9); // 좌석 더미 데이터 생성 createSeats(schedule1); createSeats(schedule2); + createSeats(schedule3); + createSeats(schedule4); + + // 공연 좌석 캐싱 + performanceService.cacheAllSeatsForPerformance(performance1.getId()); + performanceService.cacheAllSeatsForPerformance(performance3.getId()); } private void createSeats(Schedule schedule) { - for (int row = 1; row <= schedule.getPerformanceHall().getRows(); row++) { - for (int column = 1; column <= schedule.getPerformanceHall().getColumns(); column++) { - SeatCost seatCost = determineSeatCost(schedule.getPerformance(), row, schedule.getPerformanceHall().getRows()); - Seat seat = Seat.createTestData(row, column, false, seatCost,schedule); + Performance performance = schedule.getPerformance(); + PerformanceHall performanceHall = performance.getPerformanceHall(); + + for (int row = 1; row <= performanceHall.getRows(); row++) { + for (int column = 1; column <= performanceHall.getColumns(); column++) { + SeatCost seatCost = determineSeatCost(performance, row, performanceHall.getRows()); + Seat seat = Seat.createTestData(row, column, SeatStatus.AVAILABLE, seatCost, schedule); seatRepository.save(seat); } } diff --git a/services/performance/src/main/java/com/ticketPing/performance/domain/model/entity/Performance.java b/services/performance/src/main/java/com/ticketPing/performance/domain/model/entity/Performance.java index e70bbdfa..07f1a6f7 100644 --- a/services/performance/src/main/java/com/ticketPing/performance/domain/model/entity/Performance.java +++ b/services/performance/src/main/java/com/ticketPing/performance/domain/model/entity/Performance.java @@ -23,33 +23,47 @@ public class Performance extends BaseEntity { @Column(name = "performance_id") private UUID id; private String name; + private String posterUrl; + private int runTime; private LocalDateTime reservationStartDate; private LocalDateTime reservationEndDate; private LocalDate startDate; private LocalDate endDate; - private Integer grade; + private int grade; private UUID companyId; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "performance_hall_id") + private PerformanceHall performanceHall; + @OneToMany(mappedBy = "performance", cascade = CascadeType.PERSIST, orphanRemoval = true, fetch = FetchType.LAZY) private List seatCosts; - @OneToMany(mappedBy = "performance", fetch = FetchType.LAZY) - private List performanceSchedules; + @OneToMany(mappedBy = "performance", cascade = CascadeType.PERSIST, orphanRemoval = true, fetch = FetchType.LAZY) + private List schedules; public void addSeatCost(SeatCost seatCost) { seatCosts.add(seatCost); } - public static Performance createTestData(String name, LocalDateTime reservationStartDate, LocalDateTime reservationEndDate, - LocalDate startDate, LocalDate endDate, Integer grade, UUID companyId) { + public void addSchedule(Schedule schedule) { + schedules.add(schedule); + } + + public static Performance createTestData(String name, String posterUrl, int runTime, LocalDateTime reservationStartDate, LocalDateTime reservationEndDate, + LocalDate startDate, LocalDate endDate, int grade, UUID companyId, PerformanceHall performanceHall) { return Performance.builder() .name(name) + .posterUrl(posterUrl) + .runTime(runTime) .reservationStartDate(reservationStartDate) .reservationEndDate(reservationEndDate) .startDate(startDate) .endDate(endDate) .grade(grade) .companyId(companyId) + .performanceHall(performanceHall) + .schedules(new ArrayList<>()) .seatCosts(new ArrayList<>()) .build(); } diff --git a/services/performance/src/main/java/com/ticketPing/performance/domain/model/entity/PerformanceHall.java b/services/performance/src/main/java/com/ticketPing/performance/domain/model/entity/PerformanceHall.java index 7bf35425..fbcc3115 100644 --- a/services/performance/src/main/java/com/ticketPing/performance/domain/model/entity/PerformanceHall.java +++ b/services/performance/src/main/java/com/ticketPing/performance/domain/model/entity/PerformanceHall.java @@ -24,9 +24,6 @@ public class PerformanceHall extends BaseEntity { private Integer rows; private Integer columns; - @OneToMany(mappedBy = "performanceHall", fetch = FetchType.LAZY) - private List performanceSchedules; - public static PerformanceHall createTestData(String name, String address, Integer seatNumber, Integer rows, Integer columns) { return PerformanceHall.builder() diff --git a/services/performance/src/main/java/com/ticketPing/performance/domain/model/entity/Schedule.java b/services/performance/src/main/java/com/ticketPing/performance/domain/model/entity/Schedule.java index 835722ef..caaf1046 100644 --- a/services/performance/src/main/java/com/ticketPing/performance/domain/model/entity/Schedule.java +++ b/services/performance/src/main/java/com/ticketPing/performance/domain/model/entity/Schedule.java @@ -4,7 +4,7 @@ import jakarta.persistence.*; import lombok.*; -import java.time.LocalDateTime; +import java.time.LocalDate; import java.util.ArrayList; import java.util.List; import java.util.UUID; @@ -20,24 +20,19 @@ public class Schedule extends BaseEntity { @GeneratedValue(strategy = GenerationType.UUID) @Column(name = "schedule_id") private UUID id; - private LocalDateTime startTime; + private LocalDate startDate; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "performance_hall_id") - private PerformanceHall performanceHall; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "performance_id") + @JoinColumn(name = "performance_id", nullable = false) private Performance performance; @OneToMany(mappedBy = "schedule", fetch = FetchType.LAZY) private List seats; - public static Schedule createTestData(LocalDateTime startTime, PerformanceHall performanceHall, Performance performance) { + public static Schedule createTestData(LocalDate startDate, Performance performance) { return Schedule.builder() - .startTime(startTime) + .startDate(startDate) .performance(performance) - .performanceHall(performanceHall) .seats(new ArrayList<>()) .build(); } diff --git a/services/performance/src/main/java/com/ticketPing/performance/domain/model/entity/Seat.java b/services/performance/src/main/java/com/ticketPing/performance/domain/model/entity/Seat.java index 2b17eaa9..988368eb 100644 --- a/services/performance/src/main/java/com/ticketPing/performance/domain/model/entity/Seat.java +++ b/services/performance/src/main/java/com/ticketPing/performance/domain/model/entity/Seat.java @@ -1,5 +1,6 @@ package com.ticketPing.performance.domain.model.entity; +import com.ticketPing.performance.domain.model.enums.SeatStatus; import jakarta.persistence.*; import lombok.*; @@ -18,7 +19,7 @@ public class Seat { private UUID id; private Integer row; private Integer col; - private Boolean seatState; + private SeatStatus seatStatus; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "seat_cost_id") @@ -28,17 +29,17 @@ public class Seat { @JoinColumn(name = "schedule_id") private Schedule schedule; - public static Seat createTestData(Integer row, Integer col, Boolean seatSate, SeatCost seatCosts, Schedule schedule) { + public static Seat createTestData(Integer row, Integer col, SeatStatus seatStatus, SeatCost seatCosts, Schedule schedule) { return Seat.builder() .row(row) .col(col) - .seatState(seatSate) + .seatStatus(seatStatus) .seatCost(seatCosts) .schedule(schedule) .build(); } - public void updateSeatState(Boolean seatState) { - this.seatState = seatState; + public void reserveSeat() { + this.seatStatus = SeatStatus.RESERVED; } } diff --git a/services/performance/src/main/java/com/ticketPing/performance/domain/model/entity/SeatCache.java b/services/performance/src/main/java/com/ticketPing/performance/domain/model/entity/SeatCache.java new file mode 100644 index 00000000..348d6a6c --- /dev/null +++ b/services/performance/src/main/java/com/ticketPing/performance/domain/model/entity/SeatCache.java @@ -0,0 +1,38 @@ +package com.ticketPing.performance.domain.model.entity; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.ticketPing.performance.domain.model.enums.SeatStatus; +import lombok.*; + +import java.util.UUID; + +@Getter +@NoArgsConstructor +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Builder(access = AccessLevel.PRIVATE) +@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, property = "@class") +public class SeatCache { + private UUID id; + private Integer row; + private Integer col; + private String seatStatus; + private String seatGrade; + + public static SeatCache from(Seat seat) { + return SeatCache.builder() + .id(seat.getId()) + .row(seat.getRow()) + .col(seat.getCol()) + .seatStatus(seat.getSeatStatus().getValue()) + .seatGrade(seat.getSeatCost().getSeatGrade()) + .build(); + } + + public void cancelPreReserveSeat() { + seatStatus = SeatStatus.AVAILABLE.getValue(); + } + + public void reserveSeat() { + seatStatus = SeatStatus.RESERVED.getValue(); + } +} diff --git a/services/performance/src/main/java/com/ticketPing/performance/domain/model/entity/SeatCost.java b/services/performance/src/main/java/com/ticketPing/performance/domain/model/entity/SeatCost.java index 05619927..03b62551 100644 --- a/services/performance/src/main/java/com/ticketPing/performance/domain/model/entity/SeatCost.java +++ b/services/performance/src/main/java/com/ticketPing/performance/domain/model/entity/SeatCost.java @@ -1,7 +1,6 @@ package com.ticketPing.performance.domain.model.entity; import audit.BaseEntity; -import com.ticketPing.performance.domain.model.enums.SeatRate; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -28,18 +27,16 @@ public class SeatCost extends BaseEntity { @GeneratedValue(strategy = GenerationType.UUID) @Column(name = "seat_cost_id") private UUID id; - - @Enumerated(EnumType.STRING) - private SeatRate seatRate; + private String seatGrade; private Integer cost; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "performance_id", nullable = false) private Performance performance; - public static SeatCost createTestData(SeatRate seatRate, Integer cost, Performance performance) { + public static SeatCost createTestData(String seatGrade, Integer cost, Performance performance) { return SeatCost.builder() - .seatRate(seatRate) + .seatGrade(seatGrade) .cost(cost) .performance(performance) .build(); diff --git a/services/performance/src/main/java/com/ticketPing/performance/domain/model/enums/SeatRate.java b/services/performance/src/main/java/com/ticketPing/performance/domain/model/enums/SeatRate.java deleted file mode 100644 index 8e241ea0..00000000 --- a/services/performance/src/main/java/com/ticketPing/performance/domain/model/enums/SeatRate.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.ticketPing.performance.domain.model.enums; - -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -@Getter -@RequiredArgsConstructor -public enum SeatRate { - S("S"), - A("A"), - B("B"); - - private final String value; -} - diff --git a/services/performance/src/main/java/com/ticketPing/performance/domain/model/enums/SeatStatus.java b/services/performance/src/main/java/com/ticketPing/performance/domain/model/enums/SeatStatus.java new file mode 100644 index 00000000..f8f011e4 --- /dev/null +++ b/services/performance/src/main/java/com/ticketPing/performance/domain/model/enums/SeatStatus.java @@ -0,0 +1,24 @@ +package com.ticketPing.performance.domain.model.enums; + +import com.ticketPing.performance.common.exception.SeatExceptionCase; +import exception.ApplicationException; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +@Getter +@RequiredArgsConstructor +public enum SeatStatus { + AVAILABLE("AVAILABLE"), + HELD("HELD"), + RESERVED("RESERVED"); + + private final String value; + + public static SeatStatus getSeatStatus(final String value) { + return Arrays.stream(SeatStatus.values()) + .filter(t -> t.getValue().equals(value)) + .findAny().orElseThrow(() -> new ApplicationException(SeatExceptionCase.INVALID_SEAT_STATUS)); + } +} diff --git a/services/performance/src/main/java/com/ticketPing/performance/domain/repository/CacheRepository.java b/services/performance/src/main/java/com/ticketPing/performance/domain/repository/CacheRepository.java new file mode 100644 index 00000000..c6a9f7a3 --- /dev/null +++ b/services/performance/src/main/java/com/ticketPing/performance/domain/repository/CacheRepository.java @@ -0,0 +1,27 @@ +package com.ticketPing.performance.domain.repository; + +import com.ticketPing.performance.domain.model.entity.SeatCache; + +import java.time.Duration; +import java.util.Map; +import java.util.UUID; + +public interface CacheRepository { + void cacheSeats(UUID scheduleId, Map seatMap, Duration ttl); + + Map getSeatCaches(UUID scheduleId); + + SeatCache getSeatCache(UUID scheduleId, UUID seatId); + + void putSeatCache(SeatCache seatCache, UUID scheduleId, UUID seatId); + + void preReserveSeatCache(UUID scheduleId, UUID seatId, UUID userId); + + String getPreReserveUserId(UUID scheduleId, UUID seatId); + + void extendPreReserveTTL(UUID scheduleId, UUID seatId, Duration ttl); + + void deletePreReserveTTL(UUID scheduleId, UUID seatId); + + void cacheAvailableSeats(UUID performanceId, long availableSeats); +} diff --git a/services/performance/src/main/java/com/ticketPing/performance/domain/repository/PerformanceRepository.java b/services/performance/src/main/java/com/ticketPing/performance/domain/repository/PerformanceRepository.java index 6a075181..9116979c 100644 --- a/services/performance/src/main/java/com/ticketPing/performance/domain/repository/PerformanceRepository.java +++ b/services/performance/src/main/java/com/ticketPing/performance/domain/repository/PerformanceRepository.java @@ -1,16 +1,23 @@ package com.ticketPing.performance.domain.repository; import com.ticketPing.performance.domain.model.entity.Performance; -import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import java.time.LocalDateTime; import java.util.Optional; import java.util.UUID; public interface PerformanceRepository { Performance save(Performance performance); - Optional findById(UUID id); + Slice findAllWithPerformanceHall(Pageable pageable); - Page findAll(Pageable pageable); + Performance findByName(String name); + + Optional findByIdWithSchedules(UUID id); + + Optional findByIdWithDetails(UUID id); + + Performance findUpcomingPerformance(LocalDateTime start, LocalDateTime end); } diff --git a/services/performance/src/main/java/com/ticketPing/performance/domain/repository/ScheduleRepository.java b/services/performance/src/main/java/com/ticketPing/performance/domain/repository/ScheduleRepository.java index 87e104ab..5029393e 100644 --- a/services/performance/src/main/java/com/ticketPing/performance/domain/repository/ScheduleRepository.java +++ b/services/performance/src/main/java/com/ticketPing/performance/domain/repository/ScheduleRepository.java @@ -1,20 +1,4 @@ package com.ticketPing.performance.domain.repository; -import com.ticketPing.performance.domain.model.entity.Performance; -import com.ticketPing.performance.domain.model.entity.Schedule; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; - -import java.util.List; -import java.util.Optional; -import java.util.UUID; - public interface ScheduleRepository { - Schedule save(Schedule schedule); - - Optional findById(UUID id); - - Page findByPerformance(Performance performance, Pageable pageable); - - List findByPerformance(Performance performance); } diff --git a/services/performance/src/main/java/com/ticketPing/performance/domain/repository/SeatRepository.java b/services/performance/src/main/java/com/ticketPing/performance/domain/repository/SeatRepository.java index 6a4cb47b..3144ceb9 100644 --- a/services/performance/src/main/java/com/ticketPing/performance/domain/repository/SeatRepository.java +++ b/services/performance/src/main/java/com/ticketPing/performance/domain/repository/SeatRepository.java @@ -10,9 +10,9 @@ public interface SeatRepository { Seat save(Seat seat); - Optional findByIdJoinSeatCost(UUID id); + Optional findById(UUID uuid); - List findByScheduleJoinSeatCost(Schedule schedule); + Optional findByIdWithAll(UUID seatId); - Optional findByIdJoinAll(UUID seatId); + List findByScheduleWithSeatCost(Schedule schedule); } diff --git a/services/performance/src/main/java/com/ticketPing/performance/infrastructure/config/KafkaTopicConfig.java b/services/performance/src/main/java/com/ticketPing/performance/infrastructure/config/KafkaTopicConfig.java new file mode 100644 index 00000000..2b0ce4ba --- /dev/null +++ b/services/performance/src/main/java/com/ticketPing/performance/infrastructure/config/KafkaTopicConfig.java @@ -0,0 +1,20 @@ +package com.ticketPing.performance.infrastructure.config; + +import messaging.topics.SeatTopic; +import org.apache.kafka.clients.admin.NewTopic; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.config.TopicBuilder; + +@Configuration +public class KafkaTopicConfig { + + @Bean + public NewTopic preReserveExpiredTopic() { + return TopicBuilder.name(SeatTopic.PRE_RESERVE_EXPIRED.getTopic()) + .partitions(3) + .replicas(3) + .build(); + } + +} \ No newline at end of file diff --git a/services/performance/src/main/java/com/ticketPing/performance/infrastructure/config/RedissonConfig.java b/services/performance/src/main/java/com/ticketPing/performance/infrastructure/config/RedissonConfig.java new file mode 100644 index 00000000..91f7d8eb --- /dev/null +++ b/services/performance/src/main/java/com/ticketPing/performance/infrastructure/config/RedissonConfig.java @@ -0,0 +1,38 @@ +package com.ticketPing.performance.infrastructure.config; + +import caching.config.RedisClusterProperties; +import lombok.RequiredArgsConstructor; +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.client.codec.StringCodec; +import org.redisson.config.ClusterServersConfig; +import org.redisson.config.Config; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@EnableConfigurationProperties(RedisClusterProperties.class) +@Configuration +@RequiredArgsConstructor +public class RedissonConfig { + + private static final String REDISSON_PREFIX = "redis://"; + + private final RedisClusterProperties redisClusterProperties; + + @Bean + public RedissonClient redissonClient() { + Config config = new Config(); + config.setCodec(StringCodec.INSTANCE); + + ClusterServersConfig csc = config.useClusterServers() + .setScanInterval(2000) + .setConnectTimeout(100) + .setTimeout(3000) + .setRetryAttempts(3) + .setRetryInterval(1500); + redisClusterProperties.getNodes().forEach(node -> csc.addNodeAddress(REDISSON_PREFIX + node)); + return Redisson.create(config); + } + +} \ No newline at end of file diff --git a/services/performance/src/main/java/com/ticketPing/performance/infrastructure/config/RedissonLuaScriptConfig.java b/services/performance/src/main/java/com/ticketPing/performance/infrastructure/config/RedissonLuaScriptConfig.java new file mode 100644 index 00000000..5dbb3862 --- /dev/null +++ b/services/performance/src/main/java/com/ticketPing/performance/infrastructure/config/RedissonLuaScriptConfig.java @@ -0,0 +1,32 @@ +package com.ticketPing.performance.infrastructure.config; + +import lombok.RequiredArgsConstructor; +import org.redisson.api.RedissonReactiveClient; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; +import org.springframework.util.StreamUtils; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +@Configuration +@RequiredArgsConstructor +public class RedissonLuaScriptConfig { + + private final RedissonReactiveClient redissonClient; + + @Bean + public String PreReserveScript() throws IOException { + return loadScript("scripts/preReserveScript.lua"); + } + + private String loadScript(String scriptPath) throws IOException { + String script = StreamUtils.copyToString( + new ClassPathResource(scriptPath).getInputStream(), + StandardCharsets.UTF_8 + ); + return redissonClient.getScript().scriptLoad(script).block(); + } + +} diff --git a/services/performance/src/main/java/com/ticketPing/performance/infrastructure/config/RedissonMessageListenerConfig.java b/services/performance/src/main/java/com/ticketPing/performance/infrastructure/config/RedissonMessageListenerConfig.java new file mode 100644 index 00000000..ffd8dd6a --- /dev/null +++ b/services/performance/src/main/java/com/ticketPing/performance/infrastructure/config/RedissonMessageListenerConfig.java @@ -0,0 +1,24 @@ +package com.ticketPing.performance.infrastructure.config; + +import com.ticketPing.performance.infrastructure.listener.RedisKeyExpiredListener; +import lombok.RequiredArgsConstructor; +import org.redisson.api.RTopic; +import org.redisson.api.RedissonClient; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.event.EventListener; + +@Configuration +@RequiredArgsConstructor +public class RedissonMessageListenerConfig { + + private final RedissonClient redissonClient; + private final RedisKeyExpiredListener redisKeyExpiredListener; + + @EventListener(ApplicationReadyEvent.class) + public void addMessageListener() { + RTopic topic = redissonClient.getTopic("__keyevent@0__:expired"); + topic.addListener(String.class, redisKeyExpiredListener); + } + +} diff --git a/services/performance/src/main/java/com/ticketPing/performance/infrastructure/config/RestTemplateConfig.java b/services/performance/src/main/java/com/ticketPing/performance/infrastructure/config/RestTemplateConfig.java new file mode 100644 index 00000000..7909ce92 --- /dev/null +++ b/services/performance/src/main/java/com/ticketPing/performance/infrastructure/config/RestTemplateConfig.java @@ -0,0 +1,25 @@ +package com.ticketPing.performance.infrastructure.config; + +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.BufferingClientHttpRequestFactory; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.http.converter.StringHttpMessageConverter; +import org.springframework.web.client.RestTemplate; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +@Configuration +public class RestTemplateConfig { + @Bean + public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) { + return restTemplateBuilder + .requestFactory(() -> new BufferingClientHttpRequestFactory(new SimpleClientHttpRequestFactory())) + .setConnectTimeout(Duration.ofMillis(5000)) + .setReadTimeout(Duration.ofMillis(300000)) + .additionalMessageConverters(new StringHttpMessageConverter(StandardCharsets.UTF_8)) + .build(); + } +} diff --git a/services/performance/src/main/java/com/ticketPing/performance/infrastructure/listener/EventConsumer.java b/services/performance/src/main/java/com/ticketPing/performance/infrastructure/listener/EventConsumer.java new file mode 100644 index 00000000..2914ed28 --- /dev/null +++ b/services/performance/src/main/java/com/ticketPing/performance/infrastructure/listener/EventConsumer.java @@ -0,0 +1,36 @@ +package com.ticketPing.performance.infrastructure.listener; + +import com.ticketPing.performance.application.service.SeatService; +import lombok.RequiredArgsConstructor; +import messaging.events.OrderCompletedForSeatReservationEvent; +import messaging.events.OrderFailedEvent; +import messaging.utils.EventLogger; +import messaging.utils.EventSerializer; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class EventConsumer { + + private final SeatService seatService; + + @KafkaListener(topics = "order-completed-for-seat-reservation", groupId = "performance-group") + public void handleOrderCompletedForSeatReservationEvent(ConsumerRecord record, Acknowledgment acknowledgment) { + EventLogger.logReceivedMessage(record); + OrderCompletedForSeatReservationEvent event = EventSerializer.deserialize(record.value(), OrderCompletedForSeatReservationEvent.class); + seatService.reserveSeat(event.scheduleId(), event.seatId()); + acknowledgment.acknowledge(); + } + + @KafkaListener(topics = "order-failed", groupId = "performance-group") + public void handleOrderFailedEvent(ConsumerRecord record, Acknowledgment acknowledgment) { + EventLogger.logReceivedMessage(record); + OrderFailedEvent event = EventSerializer.deserialize(record.value(), OrderFailedEvent.class); + seatService.cancelPreReserveSeatInCache(event.scheduleId(), event.seatId()); + acknowledgment.acknowledge(); + } + +} diff --git a/services/performance/src/main/java/com/ticketPing/performance/infrastructure/listener/RedisKeyExpiredListener.java b/services/performance/src/main/java/com/ticketPing/performance/infrastructure/listener/RedisKeyExpiredListener.java new file mode 100644 index 00000000..adeac4fe --- /dev/null +++ b/services/performance/src/main/java/com/ticketPing/performance/infrastructure/listener/RedisKeyExpiredListener.java @@ -0,0 +1,57 @@ +package com.ticketPing.performance.infrastructure.listener; + +import com.ticketPing.performance.application.service.EventApplicationService; +import com.ticketPing.performance.infrastructure.service.DistributedLockService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import lombok.val; +import messaging.events.SeatPreReserveExpiredEvent; +import org.redisson.api.listener.MessageListener; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +import static com.ticketPing.performance.common.constants.SeatConstants.*; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RedisKeyExpiredListener implements MessageListener { + + private final DistributedLockService lockService; + private final EventApplicationService eventApplicationService; + + private static final int LOCK_TIMEOUT = 60; + + @Override + public void onMessage(CharSequence channel, String expiredKey) { + if(expiredKey.startsWith(PRE_RESERVE_SEAT_KEY) && expiredKey.split(":").length == 3) { + log.info("Seat ttl key has expired: {}", expiredKey); + + String scheduleId = expiredKey.split(":")[1].replaceAll("[{}]", ""); + String seatId = expiredKey.split(":")[2]; + String lockKey = PRE_RESERVE_EXPIRE_LOCK_KEY + ":" + seatId; + + try { + boolean executed = lockService.executeWithLock(lockKey, 0, LOCK_TIMEOUT, () -> { + publishPreReserveExpire(UUID.fromString(scheduleId), UUID.fromString(seatId)); + }); + + if (!executed) { + log.warn("Another server is running"); + } + + log.info("Successfully handle expired seat TTL key: {}", expiredKey); + } catch (Exception e) { + log.error("Error occurred while handling expired seat TTL key [{}]: {}", expiredKey, e.getMessage(), e); + } + + } + } + + private void publishPreReserveExpire(UUID scheduleId, UUID seatId) { + val event = SeatPreReserveExpiredEvent.create(scheduleId, seatId); + eventApplicationService.publishSeatPreReserveExpiredEvent(event); + } + +} diff --git a/services/performance/src/main/java/com/ticketPing/performance/infrastructure/repository/CacheRepositoryImpl.java b/services/performance/src/main/java/com/ticketPing/performance/infrastructure/repository/CacheRepositoryImpl.java new file mode 100644 index 00000000..a0eba568 --- /dev/null +++ b/services/performance/src/main/java/com/ticketPing/performance/infrastructure/repository/CacheRepositoryImpl.java @@ -0,0 +1,91 @@ +package com.ticketPing.performance.infrastructure.repository; + +import com.ticketPing.performance.common.exception.SeatExceptionCase; +import com.ticketPing.performance.domain.model.entity.SeatCache; +import com.ticketPing.performance.domain.repository.CacheRepository; +import com.ticketPing.performance.infrastructure.service.LuaScriptService; +import exception.ApplicationException; +import lombok.RequiredArgsConstructor; +import org.redisson.api.RBucket; +import org.redisson.api.RMap; +import org.redisson.api.RedissonClient; +import org.redisson.codec.JsonJacksonCodec; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static caching.enums.RedisKeyPrefix.AVAILABLE_SEATS; +import static com.ticketPing.performance.common.constants.SeatConstants.*; + +@Service +@RequiredArgsConstructor +public class CacheRepositoryImpl implements CacheRepository { + + private final RedissonClient redissonClient; + private final LuaScriptService luaScriptService; + + public void cacheSeats(UUID scheduleId, Map seatMap, Duration ttl) { + String key = SEAT_CACHE_KEY +":{" + scheduleId + "}"; + RMap seatCache = redissonClient.getMap(key, JsonJacksonCodec.INSTANCE); + seatCache.putAll(seatMap); + seatCache.expire(ttl); + } + + public Map getSeatCaches(UUID scheduleId) { + String key = SEAT_CACHE_KEY +":{" + scheduleId + "}"; + RMap seatCacheRMap = redissonClient.getMap(key, JsonJacksonCodec.INSTANCE); + return seatCacheRMap.readAllMap(); + } + + public SeatCache getSeatCache(UUID scheduleId, UUID seatId) { + String seatKey = SEAT_CACHE_KEY +":{" + scheduleId + "}"; + RMap seatCacheMap = redissonClient.getMap(seatKey, JsonJacksonCodec.INSTANCE); + return Optional.ofNullable(seatCacheMap.get(seatId.toString())) + .orElseThrow(() -> new ApplicationException(SeatExceptionCase.SEAT_CACHE_NOT_FOUND)); + } + + public void putSeatCache(SeatCache seatCache, UUID scheduleId, UUID seatId) { + String seatKey = SEAT_CACHE_KEY +":{" + scheduleId + "}"; + RMap seatCacheMap = redissonClient.getMap(seatKey, JsonJacksonCodec.INSTANCE); + seatCacheMap.put(seatId.toString(), seatCache); + } + + public void preReserveSeatCache(UUID scheduleId, UUID seatId, UUID userId) { + luaScriptService.preReserveSeat(scheduleId, seatId, userId); + } + + public String getPreReserveUserId(UUID scheduleId, UUID seatId) { + String ttlKey = PRE_RESERVE_SEAT_KEY + ":{" + scheduleId + "}:" + seatId; + RBucket bucket = redissonClient.getBucket(ttlKey); + return Optional.ofNullable(bucket.get()) + .orElseThrow(() -> new ApplicationException(SeatExceptionCase.TTL_NOT_EXIST)); + } + + public void extendPreReserveTTL(UUID scheduleId, UUID seatId, Duration ttl) { + String ttlKey = PRE_RESERVE_SEAT_KEY + ":{" + scheduleId + "}:" + seatId; + RBucket bucket = redissonClient.getBucket(ttlKey); + + boolean success = bucket.expire(ttl); + if (!success) { + throw new ApplicationException(SeatExceptionCase.TTL_NOT_EXIST); + } + } + + public void deletePreReserveTTL(UUID scheduleId, UUID seatId) { + String ttlKey = PRE_RESERVE_SEAT_KEY + ":{" + scheduleId + "}:" + seatId; + RBucket bucket = redissonClient.getBucket(ttlKey); + + boolean deleted = bucket.delete(); + if (!deleted) { + throw new ApplicationException(SeatExceptionCase.TTL_NOT_EXIST); + } + } + + public void cacheAvailableSeats(UUID performanceId, long availableSeats) { + String key = AVAILABLE_SEATS.getValue() + performanceId; + redissonClient.getBucket(key).set(availableSeats); + } +} diff --git a/services/performance/src/main/java/com/ticketPing/performance/infrastructure/repository/PerformanceJpaRepository.java b/services/performance/src/main/java/com/ticketPing/performance/infrastructure/repository/PerformanceJpaRepository.java index 98e4c8f0..24187daf 100644 --- a/services/performance/src/main/java/com/ticketPing/performance/infrastructure/repository/PerformanceJpaRepository.java +++ b/services/performance/src/main/java/com/ticketPing/performance/infrastructure/repository/PerformanceJpaRepository.java @@ -1,13 +1,40 @@ package com.ticketPing.performance.infrastructure.repository; import com.ticketPing.performance.domain.model.entity.Performance; + +import java.time.LocalDateTime; +import java.util.Optional; import java.util.UUID; import com.ticketPing.performance.domain.repository.PerformanceRepository; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @Repository public interface PerformanceJpaRepository extends PerformanceRepository, JpaRepository { + @Query(value = "SELECT p FROM Performance p LEFT JOIN FETCH p.performanceHall ph", + countQuery = "SELECT count(p) FROM Performance p") + Slice findAllWithPerformanceHall(Pageable pageable); + + @Query("SELECT p FROM Performance p " + + "LEFT JOIN FETCH p.schedules s " + + "WHERE p.id = :id ") + Optional findByIdWithSchedules(UUID id); + + @Query("SELECT p FROM Performance p " + + "LEFT JOIN FETCH p.performanceHall ph " + + "LEFT JOIN FETCH p.seatCosts sc " + + "WHERE p.id = :id ") + Optional findByIdWithDetails(UUID id); + + @Query("SELECT p " + + "FROM Performance p " + + "WHERE p.reservationStartDate > :now " + + "AND p.reservationStartDate <= :tenMinutesLater") + Performance findUpcomingPerformance(@Param("now") LocalDateTime now, @Param("tenMinutesLater") LocalDateTime tenMinutesLater); } diff --git a/services/performance/src/main/java/com/ticketPing/performance/infrastructure/repository/SeatJpaRepository.java b/services/performance/src/main/java/com/ticketPing/performance/infrastructure/repository/SeatJpaRepository.java index ce452796..b858ed47 100644 --- a/services/performance/src/main/java/com/ticketPing/performance/infrastructure/repository/SeatJpaRepository.java +++ b/services/performance/src/main/java/com/ticketPing/performance/infrastructure/repository/SeatJpaRepository.java @@ -11,21 +11,17 @@ import java.util.UUID; public interface SeatJpaRepository extends SeatRepository, JpaRepository { - @Query(value = "select s from Seat s " + - "join fetch s.seatCost sc " + - "where s.id=:seatId") - Optional findByIdJoinSeatCost(UUID seatId); @Query(value = "select s from Seat s " + "join fetch s.seatCost sc " + "where s.schedule=:schedule") - List findByScheduleJoinSeatCost(Schedule schedule); + List findByScheduleWithSeatCost(Schedule schedule); @Query(value = "select s from Seat s " + "join fetch s.seatCost sc " + "join fetch s.schedule sd " + "join fetch sd.performance p " + - "join fetch sd.performanceHall ph " + + "join fetch p.performanceHall ph " + "where s.id=:seatId") - Optional findByIdJoinAll(UUID seatId); + Optional findByIdWithAll(UUID seatId); } diff --git a/services/performance/src/main/java/com/ticketPing/performance/infrastructure/service/DiscordNotificationService.java b/services/performance/src/main/java/com/ticketPing/performance/infrastructure/service/DiscordNotificationService.java new file mode 100644 index 00000000..177eae3f --- /dev/null +++ b/services/performance/src/main/java/com/ticketPing/performance/infrastructure/service/DiscordNotificationService.java @@ -0,0 +1,42 @@ +package com.ticketPing.performance.infrastructure.service; + +import com.ticketPing.performance.application.service.NotificationService; +import com.ticketPing.performance.common.exception.MessageExceptionCase; +import exception.ApplicationException; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.web.client.RestTemplate; + +@Slf4j +@Service +@RequiredArgsConstructor +public class DiscordNotificationService implements NotificationService { + + @Value("${discord.webhook-url}") + private String discordWebhookUrl; + + private final RestTemplate restTemplate; + + @Override + public void sendErrorNotification(String errorMessage) { + try { + String message = String.format("**Error occurred in SeatCacheScheduler**: %s", errorMessage); + String payload = String.format("{\"content\": \"%s\"}", message); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity entity = new HttpEntity<>(payload, headers); + + restTemplate.exchange(discordWebhookUrl, HttpMethod.POST, entity, String.class); + } catch (Exception e) { + log.error("Error occurred during execution: {}", e.getMessage(), e); + throw new ApplicationException(MessageExceptionCase.MESSAGE_SEND_FAIL); + } + } +} diff --git a/services/performance/src/main/java/com/ticketPing/performance/infrastructure/service/DistributedLockService.java b/services/performance/src/main/java/com/ticketPing/performance/infrastructure/service/DistributedLockService.java new file mode 100644 index 00000000..146d92d8 --- /dev/null +++ b/services/performance/src/main/java/com/ticketPing/performance/infrastructure/service/DistributedLockService.java @@ -0,0 +1,34 @@ +package com.ticketPing.performance.infrastructure.service; + +import lombok.RequiredArgsConstructor; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Service; + +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +public class DistributedLockService { + private final RedissonClient redissonClient; + + public boolean executeWithLock(String lockKey, int waitTimeout, int lockTimeout, Runnable task) { + RLock lock = redissonClient.getLock(lockKey); + try { + boolean acquired = lock.tryLock(waitTimeout, lockTimeout, TimeUnit.SECONDS); + if (acquired) { + try { + task.run(); + return true; + } finally { + lock.unlock(); + } + } else { + return false; + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Failed to acquire lock", e); + } + } +} diff --git a/services/performance/src/main/java/com/ticketPing/performance/infrastructure/service/LuaScriptService.java b/services/performance/src/main/java/com/ticketPing/performance/infrastructure/service/LuaScriptService.java new file mode 100644 index 00000000..41a1ae7e --- /dev/null +++ b/services/performance/src/main/java/com/ticketPing/performance/infrastructure/service/LuaScriptService.java @@ -0,0 +1,39 @@ +package com.ticketPing.performance.infrastructure.service; + +import com.ticketPing.performance.common.exception.SeatExceptionCase; +import exception.ApplicationException; +import lombok.RequiredArgsConstructor; +import org.redisson.api.RScript; +import org.redisson.api.RedissonClient; +import org.redisson.client.codec.StringCodec; +import org.springframework.stereotype.Service; + +import java.util.Arrays; +import java.util.UUID; + +import static com.ticketPing.performance.common.constants.SeatConstants.*; + +@Service +@RequiredArgsConstructor +public class LuaScriptService { + private final RedissonClient redissonClient; + private final String preReserveScript; + + public void preReserveSeat(UUID scheduleId, UUID seatId, UUID userId) { + String hashKey = SEAT_CACHE_KEY +":{" + scheduleId + "}"; + String ttlKey = PRE_RESERVE_SEAT_KEY + ":{" + scheduleId + "}:" + seatId; + + String response = redissonClient.getScript(StringCodec.INSTANCE) + .evalSha( + RScript.Mode.READ_WRITE, + preReserveScript, + RScript.ReturnType.VALUE, + Arrays.asList(hashKey, ttlKey), + seatId.toString(), userId.toString(), PRE_RESERVE_TTL + ); + + if (!response.equals("SUCCESS")) { + throw new ApplicationException(SeatExceptionCase.getByValue(response)); + } + } +} \ No newline at end of file diff --git a/services/performance/src/main/java/com/ticketPing/performance/presentation/controller/PerformanceController.java b/services/performance/src/main/java/com/ticketPing/performance/presentation/controller/PerformanceController.java index b8961115..734dfbe8 100644 --- a/services/performance/src/main/java/com/ticketPing/performance/presentation/controller/PerformanceController.java +++ b/services/performance/src/main/java/com/ticketPing/performance/presentation/controller/PerformanceController.java @@ -1,16 +1,13 @@ package com.ticketPing.performance.presentation.controller; +import com.ticketPing.performance.application.dtos.PerformanceListResponse; import com.ticketPing.performance.application.dtos.PerformanceResponse; import com.ticketPing.performance.application.dtos.ScheduleResponse; import com.ticketPing.performance.application.service.PerformanceService; -import com.ticketPing.performance.application.service.ScheduleService; -import com.ticketPing.performance.application.service.SeatService; -import com.ticketPing.performance.domain.model.entity.Performance; -import com.ticketPing.performance.domain.model.entity.Schedule; import io.swagger.v3.oas.annotations.Operation; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import response.CommonResponse; @@ -23,8 +20,6 @@ @RequiredArgsConstructor public class PerformanceController { private final PerformanceService performanceService; - private final ScheduleService scheduleService; - private final SeatService seatService; @Operation(summary = "공연 조회") @GetMapping("/{performanceId}") @@ -37,18 +32,17 @@ public ResponseEntity> getPerformance(@PathV @Operation(summary = "공연 목록 조회") @GetMapping - public ResponseEntity>> getAllPerformances(Pageable pageable) { - Page performanceResponses = performanceService.getAllPerformances(pageable); + public ResponseEntity>> getAllPerformances(Pageable pageable) { + Slice response = performanceService.getAllPerformances(pageable); return ResponseEntity .status(200) - .body(CommonResponse.success(performanceResponses)); + .body(CommonResponse.success(response)); } @Operation(summary = "공연 스케줄 목록 조회") @GetMapping("/{performanceId}/schedules") - public ResponseEntity>> getSchedulesByPerformance(@PathVariable("performanceId") UUID performanceId, Pageable pageable) { - Performance performance = performanceService.getAndValidatePerformance(performanceId); - Page scheduleResponses = scheduleService.getSchedulesByPerformance(performance, pageable); + public ResponseEntity>> getSchedulesByPerformance(@PathVariable("performanceId") UUID performanceId) { + List scheduleResponses = performanceService.getPerformanceSchedules(performanceId); return ResponseEntity .status(200) .body(CommonResponse.success(scheduleResponses)); @@ -57,9 +51,7 @@ public ResponseEntity>> getSchedulesByPerf @Operation(summary = "공연 전체 좌석 캐싱 생성") @PostMapping("/{performanceId}/seats-cache") public ResponseEntity> createSeatsCache(@PathVariable("performanceId") UUID performanceId) { - Performance performance = performanceService.findPerformanceById(performanceId); - List schedules = scheduleService.finadAllScheduleByPerformance(performance); - seatService.createSeatsCache(schedules, performanceId); + performanceService.cacheAllSeatsForPerformance(performanceId); return ResponseEntity .status(201) .body(CommonResponse.success()); diff --git a/services/performance/src/main/java/com/ticketPing/performance/presentation/controller/ScheduleController.java b/services/performance/src/main/java/com/ticketPing/performance/presentation/controller/ScheduleController.java index 5f1bf7ca..71b03d1e 100644 --- a/services/performance/src/main/java/com/ticketPing/performance/presentation/controller/ScheduleController.java +++ b/services/performance/src/main/java/com/ticketPing/performance/presentation/controller/ScheduleController.java @@ -1,9 +1,7 @@ package com.ticketPing.performance.presentation.controller; -import com.ticketPing.performance.application.dtos.ScheduleResponse; import com.ticketPing.performance.application.dtos.SeatResponse; import com.ticketPing.performance.application.service.ScheduleService; -import com.ticketPing.performance.application.service.SeatService; import io.swagger.v3.oas.annotations.Operation; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -21,21 +19,11 @@ @RequiredArgsConstructor public class ScheduleController { private final ScheduleService scheduleService; - private final SeatService seatService; - - @Operation(summary = "스케줄 조회") - @GetMapping("/{scheduleId}") - public ResponseEntity> getSchedule(@PathVariable("scheduleId") UUID scheduleId) { - ScheduleResponse scheduleResponse = scheduleService.getSchedule(scheduleId); - return ResponseEntity - .status(200) - .body(CommonResponse.success(scheduleResponse)); - } @Operation(summary = "스케줄 전체 좌석 조회") @GetMapping("/{scheduleId}/seats") public ResponseEntity>> getAllScheduleSeats(@PathVariable("scheduleId") UUID scheduleId) { - List seatResponses = seatService.getAllScheduleSeats(scheduleId); + List seatResponses = scheduleService.getAllScheduleSeats(scheduleId); return ResponseEntity .status(200) .body(CommonResponse.success(seatResponses)); diff --git a/services/performance/src/main/java/com/ticketPing/performance/presentation/controller/SeatClientController.java b/services/performance/src/main/java/com/ticketPing/performance/presentation/controller/SeatClientController.java new file mode 100644 index 00000000..bf2e703a --- /dev/null +++ b/services/performance/src/main/java/com/ticketPing/performance/presentation/controller/SeatClientController.java @@ -0,0 +1,40 @@ +package com.ticketPing.performance.presentation.controller; + +import com.ticketPing.performance.application.dtos.OrderSeatResponse; +import com.ticketPing.performance.application.service.SeatService; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import response.CommonResponse; + +import java.util.UUID; + +@RestController +@RequestMapping("/api/v1/client/seats") +@RequiredArgsConstructor +public class SeatClientController { + + private final SeatService seatService; + + @Operation(summary = "좌석 주문 정보 조회 (order 서비스에서 호출용)") + @GetMapping("/{seatId}/order-info") + public ResponseEntity> getOrderSeatInfo(@RequestHeader("X_USER_ID") UUID userId, + @PathVariable("seatId") UUID seatId, + @RequestParam("scheduleId") UUID scheduleId) { + OrderSeatResponse orderSeatResponse = seatService.getOrderSeatInfo(scheduleId, seatId, userId); + return ResponseEntity + .status(200) + .body(CommonResponse.success(orderSeatResponse)); + } + + @Operation(summary = "좌석 선점 ttl 연장 (order 서비스 호출용)") + @PostMapping("/{seatId}/extend-ttl") + ResponseEntity> extendPreReserveTTL (@PathVariable("seatId") UUID seatId, + @RequestParam("scheduleId") UUID scheduleId) { + seatService.extendPreReserveTTL(scheduleId, seatId); + return ResponseEntity + .status(200) + .body(CommonResponse.success()); + } +} diff --git a/services/performance/src/main/java/com/ticketPing/performance/presentation/controller/SeatController.java b/services/performance/src/main/java/com/ticketPing/performance/presentation/controller/SeatController.java index 9b49dbc9..0924b36e 100644 --- a/services/performance/src/main/java/com/ticketPing/performance/presentation/controller/SeatController.java +++ b/services/performance/src/main/java/com/ticketPing/performance/presentation/controller/SeatController.java @@ -1,7 +1,5 @@ package com.ticketPing.performance.presentation.controller; -import com.ticketPing.performance.application.dtos.OrderInfoResponse; -import com.ticketPing.performance.application.dtos.SeatResponse; import com.ticketPing.performance.application.service.SeatService; import io.swagger.v3.oas.annotations.Operation; import lombok.RequiredArgsConstructor; @@ -17,31 +15,27 @@ public class SeatController { private final SeatService seatService; - @Operation(summary = "좌석 정보 조회") - @GetMapping("/{seatId}") - public ResponseEntity> getSeat(@PathVariable("seatId") UUID seatId) { - SeatResponse seatResponse = seatService.getSeat(seatId); + @Operation(summary = "좌석 선점") + @PostMapping("/{seatId}/pre-reserve") + public ResponseEntity> preReserveSeat(@RequestHeader("X_USER_ID") UUID userId, + @PathVariable("seatId") UUID seatId, + @RequestParam("performanceId") UUID performanceId, + @RequestParam("scheduleId") UUID scheduleId) { + seatService.preReserveSeat(scheduleId, seatId, userId); return ResponseEntity .status(200) - .body(CommonResponse.success(seatResponse)); + .body(CommonResponse.success()); } - @Operation(summary = "좌석 주문 정보 조회 (order 서비스에서 호출용)") - @GetMapping("/{seatId}/order-info") - public ResponseEntity> getOrderInfo(@PathVariable("seatId") UUID seatId) { - OrderInfoResponse orderInfoResponse = seatService.getOrderInfo(seatId); + @Operation(summary = "좌석 선점 취소") + @PostMapping("/{seatId}/cancel-reserve") + public ResponseEntity> cancelPreReserveSeat(@RequestHeader("X_USER_ID") UUID userId, + @PathVariable("seatId") UUID seatId, + @RequestParam("performanceId") UUID performanceId, + @RequestParam("scheduleId") UUID scheduleId) { + seatService.cancelPreReserveSeat(scheduleId, seatId, userId); return ResponseEntity .status(200) - .body(CommonResponse.success(orderInfoResponse)); - } - - @Operation(summary = "좌석 상태 수정 (order 서비스에서 호출용)") - @PutMapping("/{seatId}") - public ResponseEntity> updateSeatState(@PathVariable("seatId") UUID seatId, - @RequestParam("seatState") Boolean seatState) { - SeatResponse seatResponse = seatService.updateSeatState(seatId, seatState); - return ResponseEntity - .status(200) - .body(CommonResponse.success(seatResponse)); + .body(CommonResponse.success()); } } diff --git a/services/performance/src/main/resources/application.yml b/services/performance/src/main/resources/application.yml index 4396b35a..86470e92 100644 --- a/services/performance/src/main/resources/application.yml +++ b/services/performance/src/main/resources/application.yml @@ -13,8 +13,14 @@ spring: - "classpath:application-eureka.yml" - "classpath:application-jpa.yml" - "classpath:application-redis.yml" + - "classpath:application-kafka.yml" - "classpath:application-monitoring.yml" server: port: 10012 +seat: + pre-reserve-ttl: 300 + +discord: + webhook-url: ${DISCORD_WEBHOOK_URL} \ No newline at end of file diff --git a/services/performance/src/main/resources/scripts/preReserveScript.lua b/services/performance/src/main/resources/scripts/preReserveScript.lua new file mode 100644 index 00000000..c685e6ae --- /dev/null +++ b/services/performance/src/main/resources/scripts/preReserveScript.lua @@ -0,0 +1,26 @@ +local hashKey = KEYS[1] +local ttlKey = KEYS[2] +local seatId = "\"" .. ARGV[1] .. "\"" +local userId = ARGV[2] +local ttl = tonumber(ARGV[3]) + +local seatData = redis.call("HGET", hashKey, seatId) + +if not seatData then + return "SEAT_CACHE_NOT_FOUND" +end + +local seatData = redis.call("HGET", hashKey, seatId) +local seatObj = cjson.decode(seatData) + +if seatObj.seatStatus ~= "AVAILABLE" then + return "SEAT_ALREADY_TAKEN" +end + +seatObj.seatStatus = "HELD" +redis.call("HSET", hashKey, seatId, cjson.encode(seatObj)) + +redis.call("SET", ttlKey, userId) +redis.call("EXPIRE", ttlKey, ttl) + +return "SUCCESS" \ No newline at end of file diff --git a/services/performance/src/test/java/com/ticketPing/performance/infrastructure/service/LuaScriptServiceTest.java b/services/performance/src/test/java/com/ticketPing/performance/infrastructure/service/LuaScriptServiceTest.java new file mode 100644 index 00000000..86124e9f --- /dev/null +++ b/services/performance/src/test/java/com/ticketPing/performance/infrastructure/service/LuaScriptServiceTest.java @@ -0,0 +1,52 @@ +package com.ticketPing.performance.infrastructure.service; + +import exception.ApplicationException; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.UUID; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest +@ActiveProfiles("test") +public class LuaScriptServiceTest { + + @Autowired + private LuaScriptService luaScriptService; + + @Test + public void testPreReserveSeatConcurrency() throws InterruptedException { + UUID scheduleId = UUID.fromString("8fb9facb-2a07-47f7-aed6-05f5e7928b3e"); + UUID seatId = UUID.fromString("59f33c13-7aa7-49be-9caa-767972ec12b9"); + UUID userId = UUID.randomUUID(); + + ExecutorService executor = Executors.newFixedThreadPool(5); + CountDownLatch latch = new CountDownLatch(5); + AtomicInteger successCount = new AtomicInteger(0); + + for (int i = 0; i < 5; i++) { + executor.submit(() -> { + try { + luaScriptService.preReserveSeat(scheduleId, seatId, userId); + if (successCount.incrementAndGet() == 1) { + System.out.println("예약 성공!"); + } + } catch (ApplicationException e) { + assertEquals(e.getMessage(), "좌석이 이미 점유되어 있습니다."); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + + assertEquals(1, successCount.get(), "성공한 예약은 1번만 있어야 합니다."); + executor.shutdown(); + } +} diff --git a/services/queue-manage/src/main/java/com/ticketPing/queue_manage/application/service/WorkingQueueService.java b/services/queue-manage/src/main/java/com/ticketPing/queue_manage/application/service/WorkingQueueService.java index 329f22ef..e412702f 100644 --- a/services/queue-manage/src/main/java/com/ticketPing/queue_manage/application/service/WorkingQueueService.java +++ b/services/queue-manage/src/main/java/com/ticketPing/queue_manage/application/service/WorkingQueueService.java @@ -5,6 +5,7 @@ import com.ticketPing.queue_manage.application.dto.GeneralQueueTokenResponse; import com.ticketPing.queue_manage.domain.command.waitingQueue.DeleteFirstWaitingQueueTokenCommand; import com.ticketPing.queue_manage.domain.command.workingQueue.DeleteWorkingQueueTokenCommand; +import com.ticketPing.queue_manage.domain.command.workingQueue.ExtendWorkingQueueTokenTTLCommand; import com.ticketPing.queue_manage.domain.command.workingQueue.FindWorkingQueueTokenCommand; import com.ticketPing.queue_manage.domain.command.workingQueue.InsertWorkingQueueTokenCommand; import com.ticketPing.queue_manage.domain.model.WaitingQueueToken; @@ -60,4 +61,13 @@ private Mono enterWorkingQueue(WaitingQueueToken deletedWaitingToken) { .doOnSuccess(isWorkingQueueTokenSaved -> log.info("작업열 토큰 저장 완료 {}", isWorkingQueueTokenSaved)); } + public Mono extendWorkingQueueTokenTTL(String userId, String performanceId) { + val command = ExtendWorkingQueueTokenTTLCommand.create(userId, performanceId); + + return workingQueueRepository.extendWorkingQueueTokenTTL(command) + .doOnSuccess(token -> log.info("작업열 토큰 TTL 연장 완료 {}", token)) + .map(GeneralQueueTokenResponse::from) + .switchIfEmpty(Mono.error(new ApplicationException(WORKING_QUEUE_TOKEN_NOT_FOUND))); + } + } \ No newline at end of file diff --git a/services/queue-manage/src/main/java/com/ticketPing/queue_manage/common/utils/ConfigHolder.java b/services/queue-manage/src/main/java/com/ticketPing/queue_manage/common/utils/ConfigHolder.java index 097b1499..44498241 100644 --- a/services/queue-manage/src/main/java/com/ticketPing/queue_manage/common/utils/ConfigHolder.java +++ b/services/queue-manage/src/main/java/com/ticketPing/queue_manage/common/utils/ConfigHolder.java @@ -11,7 +11,8 @@ public class ConfigHolder { private static String tokenValueSecretKey; private static int workingQueueMaxSize; - private static int workingQueueTokenTTL; + private static int initialWorkingQueueTokenTTL; + private static int extendedWorkingQueueTokenTTL; @Value("${token-value.secret-key}") public void setSecretKey(String secretKey) { @@ -23,9 +24,14 @@ public void setWorkingQueueMaxSize(int maxSize) { workingQueueMaxSize = maxSize; } - @Value("${working-queue.token-ttl}") - public void setWorkingQueueTokenTTL(int tokenTTL) { - workingQueueTokenTTL = tokenTTL; + @Value("${working-queue.initial-token-ttl}") + public void setInitialWorkingQueueTokenTTL(int tokenTTL) { + initialWorkingQueueTokenTTL = tokenTTL; + } + + @Value("${working-queue.extended-token-ttl}") + public void setExtendedWorkingQueueTokenTTL(int tokenTTL) { + extendedWorkingQueueTokenTTL = tokenTTL; } public static String tokenValueSecretKey() { @@ -36,8 +42,10 @@ public static int workingQueueMaxSize() { return workingQueueMaxSize; } - public static int workingQueueTokenTTL() { - return workingQueueTokenTTL; + public static int initialWorkingQueueTokenTTL() { + return initialWorkingQueueTokenTTL; } + public static int extendedWorkingQueueTokenTTL() { return extendedWorkingQueueTokenTTL; } + } \ No newline at end of file diff --git a/services/queue-manage/src/main/java/com/ticketPing/queue_manage/domain/command/waitingQueue/InsertWaitingQueueTokenCommand.java b/services/queue-manage/src/main/java/com/ticketPing/queue_manage/domain/command/waitingQueue/InsertWaitingQueueTokenCommand.java index d65bb73d..ff0cc16f 100644 --- a/services/queue-manage/src/main/java/com/ticketPing/queue_manage/domain/command/waitingQueue/InsertWaitingQueueTokenCommand.java +++ b/services/queue-manage/src/main/java/com/ticketPing/queue_manage/domain/command/waitingQueue/InsertWaitingQueueTokenCommand.java @@ -2,9 +2,9 @@ import static caching.enums.RedisKeyPrefix.WAITING_QUEUE; import static caching.enums.RedisKeyPrefix.WORKING_QUEUE; +import static com.ticketPing.queue_manage.common.utils.ConfigHolder.initialWorkingQueueTokenTTL; import static com.ticketPing.queue_manage.common.utils.TokenValueGenerator.generateTokenValue; import static com.ticketPing.queue_manage.common.utils.ConfigHolder.workingQueueMaxSize; -import static com.ticketPing.queue_manage.common.utils.ConfigHolder.workingQueueTokenTTL; import lombok.AccessLevel; import lombok.Builder; @@ -42,7 +42,7 @@ public static InsertWaitingQueueTokenCommand create(String userId, String perfor .enterTime(System.currentTimeMillis() / 1000.0) .workingQueueName(WORKING_QUEUE.getValue() + performanceId) .cacheValue("NA") - .ttlInMinutes(workingQueueTokenTTL()) + .ttlInMinutes(initialWorkingQueueTokenTTL()) .workingQueueMaxSlots(workingQueueMaxSize()) .build(); } diff --git a/services/queue-manage/src/main/java/com/ticketPing/queue_manage/domain/command/workingQueue/ExtendWorkingQueueTokenTTLCommand.java b/services/queue-manage/src/main/java/com/ticketPing/queue_manage/domain/command/workingQueue/ExtendWorkingQueueTokenTTLCommand.java new file mode 100644 index 00000000..661fa916 --- /dev/null +++ b/services/queue-manage/src/main/java/com/ticketPing/queue_manage/domain/command/workingQueue/ExtendWorkingQueueTokenTTLCommand.java @@ -0,0 +1,29 @@ +package com.ticketPing.queue_manage.domain.command.workingQueue; + +import static com.ticketPing.queue_manage.common.utils.ConfigHolder.extendedWorkingQueueTokenTTL; +import static com.ticketPing.queue_manage.common.utils.TokenValueGenerator.generateTokenValue; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder(access = AccessLevel.PRIVATE) +public class ExtendWorkingQueueTokenTTLCommand { + + private String userId; + private String performanceId; + private String tokenValue; + private String cacheValue; + private long ttlInMinutes; + + public static ExtendWorkingQueueTokenTTLCommand create(String userId, String performanceId) { + return ExtendWorkingQueueTokenTTLCommand.builder() + .userId(userId) + .performanceId(performanceId) + .tokenValue(generateTokenValue(userId, performanceId)) + .ttlInMinutes(extendedWorkingQueueTokenTTL()) + .build(); + } + +} diff --git a/services/queue-manage/src/main/java/com/ticketPing/queue_manage/domain/command/workingQueue/InsertWorkingQueueTokenCommand.java b/services/queue-manage/src/main/java/com/ticketPing/queue_manage/domain/command/workingQueue/InsertWorkingQueueTokenCommand.java index 253b8669..d5675809 100644 --- a/services/queue-manage/src/main/java/com/ticketPing/queue_manage/domain/command/workingQueue/InsertWorkingQueueTokenCommand.java +++ b/services/queue-manage/src/main/java/com/ticketPing/queue_manage/domain/command/workingQueue/InsertWorkingQueueTokenCommand.java @@ -1,7 +1,7 @@ package com.ticketPing.queue_manage.domain.command.workingQueue; import static caching.enums.RedisKeyPrefix.WORKING_QUEUE; -import static com.ticketPing.queue_manage.common.utils.ConfigHolder.workingQueueTokenTTL; +import static com.ticketPing.queue_manage.common.utils.ConfigHolder.initialWorkingQueueTokenTTL; import com.ticketPing.queue_manage.domain.model.WorkingQueueToken; import lombok.AccessLevel; @@ -28,7 +28,8 @@ public static InsertWorkingQueueTokenCommand create(WorkingQueueToken token) { .tokenValue(token.getTokenValue()) .queueName(WORKING_QUEUE.getValue() + token.getPerformanceId()) .cacheValue("NA") - .ttlInMinutes(workingQueueTokenTTL()).build(); + .ttlInMinutes(initialWorkingQueueTokenTTL()) + .build(); } } diff --git a/services/queue-manage/src/main/java/com/ticketPing/queue_manage/domain/repository/WorkingQueueRepository.java b/services/queue-manage/src/main/java/com/ticketPing/queue_manage/domain/repository/WorkingQueueRepository.java index 46a06821..16bb644f 100644 --- a/services/queue-manage/src/main/java/com/ticketPing/queue_manage/domain/repository/WorkingQueueRepository.java +++ b/services/queue-manage/src/main/java/com/ticketPing/queue_manage/domain/repository/WorkingQueueRepository.java @@ -1,6 +1,7 @@ package com.ticketPing.queue_manage.domain.repository; import com.ticketPing.queue_manage.domain.command.workingQueue.DeleteWorkingQueueTokenCommand; +import com.ticketPing.queue_manage.domain.command.workingQueue.ExtendWorkingQueueTokenTTLCommand; import com.ticketPing.queue_manage.domain.command.workingQueue.InsertWorkingQueueTokenCommand; import com.ticketPing.queue_manage.domain.command.workingQueue.FindWorkingQueueTokenCommand; import com.ticketPing.queue_manage.domain.model.WorkingQueueToken; @@ -10,4 +11,5 @@ public interface WorkingQueueRepository { Mono insertWorkingQueueToken(InsertWorkingQueueTokenCommand command); Mono findWorkingQueueToken(FindWorkingQueueTokenCommand command); Mono deleteWorkingQueueToken(DeleteWorkingQueueTokenCommand command); + Mono extendWorkingQueueTokenTTL(ExtendWorkingQueueTokenTTLCommand command); } diff --git a/services/queue-manage/src/main/java/com/ticketPing/queue_manage/infrastructure/config/ReactiveKafkaConfig.java b/services/queue-manage/src/main/java/com/ticketPing/queue_manage/infrastructure/config/ReactiveKafkaConfig.java index 168f622a..f871a9b2 100644 --- a/services/queue-manage/src/main/java/com/ticketPing/queue_manage/infrastructure/config/ReactiveKafkaConfig.java +++ b/services/queue-manage/src/main/java/com/ticketPing/queue_manage/infrastructure/config/ReactiveKafkaConfig.java @@ -39,7 +39,7 @@ public ReceiverOptions receiverOptions() { props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false); // 토픽 할당 - List topics = Arrays.asList(OrderTopic.COMPLETED.getTopic()); + List topics = Arrays.asList(OrderTopic.COMPLETED_FOR_QUEUE_TOKEN_REMOVAL.getTopic()); return ReceiverOptions.create(props) .subscription(topics) diff --git a/services/queue-manage/src/main/java/com/ticketPing/queue_manage/infrastructure/listener/EventConsumer.java b/services/queue-manage/src/main/java/com/ticketPing/queue_manage/infrastructure/listener/EventConsumer.java index 55080750..401085e5 100644 --- a/services/queue-manage/src/main/java/com/ticketPing/queue_manage/infrastructure/listener/EventConsumer.java +++ b/services/queue-manage/src/main/java/com/ticketPing/queue_manage/infrastructure/listener/EventConsumer.java @@ -7,6 +7,7 @@ import java.time.Duration; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import messaging.events.OrderCompletedForQueueTokenRemovalEvent; import messaging.utils.EventLogger; import messaging.utils.EventSerializer; import org.springframework.boot.context.event.ApplicationReadyEvent; @@ -16,7 +17,6 @@ import reactor.core.publisher.Mono; import reactor.kafka.receiver.ReceiverRecord; import reactor.util.retry.Retry; -import messaging.events.OrderCompletedEvent; import messaging.topics.OrderTopic; @Slf4j @@ -38,7 +38,7 @@ public void consumeMessage() { } private Mono handleMessage(ReceiverRecord record) { - if (record.topic().equals(OrderTopic.COMPLETED.getTopic())) { + if (record.topic().equals(OrderTopic.COMPLETED_FOR_QUEUE_TOKEN_REMOVAL.getTopic())) { return handleOrderCompletedEvent(record); } return Mono.empty(); @@ -46,7 +46,7 @@ private Mono handleMessage(ReceiverRecord record) { private Mono handleOrderCompletedEvent(ReceiverRecord record) { EventLogger.logReceivedMessage(record); - OrderCompletedEvent event = EventSerializer.deserialize(record.value(), OrderCompletedEvent.class); + OrderCompletedForQueueTokenRemovalEvent event = EventSerializer.deserialize(record.value(), OrderCompletedForQueueTokenRemovalEvent.class); String tokenValue = generateTokenValue(event.userId(), event.performanceId()); return Mono.fromRunnable(() -> workingQueueService.transferToken(ORDER_COMPLETED, tokenValue)) diff --git a/services/queue-manage/src/main/java/com/ticketPing/queue_manage/infrastructure/repository/WorkingQueueRepositoryImpl.java b/services/queue-manage/src/main/java/com/ticketPing/queue_manage/infrastructure/repository/WorkingQueueRepositoryImpl.java index 4cdd1fb3..8035bf5c 100644 --- a/services/queue-manage/src/main/java/com/ticketPing/queue_manage/infrastructure/repository/WorkingQueueRepositoryImpl.java +++ b/services/queue-manage/src/main/java/com/ticketPing/queue_manage/infrastructure/repository/WorkingQueueRepositoryImpl.java @@ -3,6 +3,7 @@ import static com.ticketPing.queue_manage.common.utils.TTLConverter.toLocalDateTime; import com.ticketPing.queue_manage.domain.command.workingQueue.DeleteWorkingQueueTokenCommand; +import com.ticketPing.queue_manage.domain.command.workingQueue.ExtendWorkingQueueTokenTTLCommand; import com.ticketPing.queue_manage.domain.command.workingQueue.InsertWorkingQueueTokenCommand; import com.ticketPing.queue_manage.domain.command.workingQueue.FindWorkingQueueTokenCommand; import com.ticketPing.queue_manage.domain.model.WorkingQueueToken; @@ -73,6 +74,7 @@ private Mono handleTokenExpired(String queueName) { private Mono handleOrderCompleted(String queueName, String tokenValue) { RBucketReactive bucket = redissonRepository.getBucket(tokenValue); + return handleIfTokenExists(queueName, bucket) .defaultIfEmpty(false); } @@ -94,4 +96,20 @@ private Mono decrementQueueCounter(String queueName) { return redissonRepository.getCounter(queueName).decrementAndGet(); } + @Override + public Mono extendWorkingQueueTokenTTL(ExtendWorkingQueueTokenTTLCommand command) { + RBucketReactive bucket = redissonRepository.getBucket(command.getTokenValue()); + + return bucket.expire(command.getTtlInMinutes(), TimeUnit.MINUTES) + .then( + bucket.remainTimeToLive() + .flatMap(ttl -> WorkingQueueToken.withValidUntil( + command.getUserId(), + command.getPerformanceId(), + command.getTokenValue(), + toLocalDateTime(ttl) + )) + ); + } + } \ No newline at end of file diff --git a/services/queue-manage/src/main/java/com/ticketPing/queue_manage/presentaion/controller/WaitingQueueController.java b/services/queue-manage/src/main/java/com/ticketPing/queue_manage/presentaion/controller/WaitingQueueController.java index 7a3f85c0..1f334fcd 100644 --- a/services/queue-manage/src/main/java/com/ticketPing/queue_manage/presentaion/controller/WaitingQueueController.java +++ b/services/queue-manage/src/main/java/com/ticketPing/queue_manage/presentaion/controller/WaitingQueueController.java @@ -4,6 +4,7 @@ import com.ticketPing.queue_manage.application.service.WaitingQueueService; import io.swagger.v3.oas.annotations.Operation; import jakarta.validation.Valid; +import java.util.UUID; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -25,9 +26,9 @@ public class WaitingQueueController { @Operation(summary = "대기열 진입") @PostMapping public Mono>> enterWaitingQueue( - @Valid @RequestHeader("X-USER-ID") String userId, + @Valid @RequestHeader("X_USER_ID") UUID userId, @Valid @RequestParam("performanceId") String performanceId) { - return waitingQueueService.enterWaitingQueue(userId, performanceId) + return waitingQueueService.enterWaitingQueue(userId.toString(), performanceId) .map(CommonResponse::success) .map(ResponseEntity::ok); } @@ -35,9 +36,9 @@ public Mono>> enterWait @Operation(summary = "대기열 상태 조회") @GetMapping public Mono>> getQueueInfo( - @Valid @RequestHeader("X-USER-ID") String userId, + @Valid @RequestHeader("X_USER_ID") UUID userId, @Valid @RequestParam("performanceId") String performanceId) { - return waitingQueueService.getQueueInfo(userId, performanceId) + return waitingQueueService.getQueueInfo(userId.toString(), performanceId) .map(CommonResponse::success) .map(ResponseEntity::ok); } diff --git a/services/queue-manage/src/main/java/com/ticketPing/queue_manage/presentaion/controller/WorkingQueueController.java b/services/queue-manage/src/main/java/com/ticketPing/queue_manage/presentaion/controller/WorkingQueueController.java index 78a8c1ce..f4d7aa4c 100644 --- a/services/queue-manage/src/main/java/com/ticketPing/queue_manage/presentaion/controller/WorkingQueueController.java +++ b/services/queue-manage/src/main/java/com/ticketPing/queue_manage/presentaion/controller/WorkingQueueController.java @@ -4,9 +4,11 @@ import com.ticketPing.queue_manage.application.service.WorkingQueueService; import io.swagger.v3.oas.annotations.Operation; import jakarta.validation.Valid; +import java.util.UUID; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -24,9 +26,19 @@ public class WorkingQueueController { @Operation(summary = "작업열 토큰 조회") @GetMapping public Mono>> getWorkingQueueToken( - @Valid @RequestHeader("X-USER-ID") String userId, + @Valid @RequestHeader("X_USER_ID") UUID userId, @Valid @RequestParam("performanceId") String performanceId) { - return workingQueueService.getWorkingQueueToken(userId, performanceId) + return workingQueueService.getWorkingQueueToken(userId.toString(), performanceId) + .map(CommonResponse::success) + .map(ResponseEntity::ok); + } + + @Operation(summary = "작업열 토큰 TTL 연장") + @PostMapping("/extend-ttl") + public Mono>> extendWorkingQueueTokenTTL( + @Valid @RequestHeader("X_USER_ID") UUID userId, + @Valid @RequestParam("performanceId") String performanceId) { + return workingQueueService.extendWorkingQueueTokenTTL(userId.toString(), performanceId) .map(CommonResponse::success) .map(ResponseEntity::ok); } diff --git a/services/queue-manage/src/main/resources/application.yml b/services/queue-manage/src/main/resources/application.yml index 941d8710..b4f2d109 100644 --- a/services/queue-manage/src/main/resources/application.yml +++ b/services/queue-manage/src/main/resources/application.yml @@ -17,4 +17,5 @@ token-value: working-queue: max-size: ${WORKING_QUEUE_MAX_SIZE} - token-ttl: ${WORKING_QUEUE_TOKEN_TTL} \ No newline at end of file + initial-token-ttl: ${INITIAL_WORKING_QUEUE_TOKEN_TTL} + extended-token-ttl: ${EXTENDED_WORKING_QUEUE_TOKEN_TTL} \ No newline at end of file diff --git a/services/user/build.gradle b/services/user/build.gradle index 3552f87c..0e609671 100644 --- a/services/user/build.gradle +++ b/services/user/build.gradle @@ -42,6 +42,7 @@ dependencies { // MVC implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' // Cloud implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client' diff --git a/services/user/src/main/java/com/ticketPing/user/application/dto/UserResponse.java b/services/user/src/main/java/com/ticketPing/user/application/dto/UserResponse.java index 3a8a0373..8733d581 100644 --- a/services/user/src/main/java/com/ticketPing/user/application/dto/UserResponse.java +++ b/services/user/src/main/java/com/ticketPing/user/application/dto/UserResponse.java @@ -3,24 +3,19 @@ import lombok.AccessLevel; import lombok.Builder; -import java.time.LocalDate; import java.util.UUID; @Builder(access = AccessLevel.PRIVATE) public record UserResponse( UUID userId, String email, - String nickname, - LocalDate birthday, - String gender + String nickname ) { public static UserResponse of(User user) { return UserResponse.builder() .userId(user.getId()) .email(user.getEmail()) .nickname(user.getNickname()) - .birthday(user.getBirthday()) - .gender(user.getGender().getValue()) .build(); } } diff --git a/services/user/src/main/java/com/ticketPing/user/common/exception/UserErrorCase.java b/services/user/src/main/java/com/ticketPing/user/common/exception/UserErrorCase.java index 0795c504..288fa79d 100644 --- a/services/user/src/main/java/com/ticketPing/user/common/exception/UserErrorCase.java +++ b/services/user/src/main/java/com/ticketPing/user/common/exception/UserErrorCase.java @@ -9,7 +9,6 @@ @AllArgsConstructor public enum UserErrorCase implements ErrorCase { DUPLICATE_EMAIL(HttpStatus.BAD_REQUEST, "이미 존재하는 이메일입니다."), - INVALID_GENDER(HttpStatus.BAD_REQUEST, "성별 형식이 맞지 않습니다."), USER_NOT_FOUND(HttpStatus.BAD_REQUEST, "사용자 정보를 찾을 수 없습니다."), PASSWORD_NOT_EQUAL(HttpStatus.BAD_REQUEST, "비밀번호가 일치하지 않습니다."); diff --git a/services/user/src/main/java/com/ticketPing/user/domain/model/entity/User.java b/services/user/src/main/java/com/ticketPing/user/domain/model/entity/User.java index 4cebbd78..46f4417b 100644 --- a/services/user/src/main/java/com/ticketPing/user/domain/model/entity/User.java +++ b/services/user/src/main/java/com/ticketPing/user/domain/model/entity/User.java @@ -1,12 +1,9 @@ package com.ticketPing.user.domain.model.entity; import audit.BaseEntity; -import com.ticketPing.user.domain.model.enums.Gender; +import com.ticketPing.user.presentation.request.CreateUserRequest; import jakarta.persistence.*; import lombok.*; -import com.ticketPing.user.presentation.request.CreateUserRequest; - -import java.time.LocalDate; import java.util.UUID; @Getter @@ -24,16 +21,12 @@ public class User extends BaseEntity { private String email; private String password; private String nickname; - private LocalDate birthday; - private Gender gender; public static User from(CreateUserRequest request, String encodedPassword) { return User.builder() .email(request.email()) .password(encodedPassword) .nickname(request.nickname()) - .birthday(request.birthday()) - .gender(Gender.getGender(request.gender())) .build(); } } diff --git a/services/user/src/main/java/com/ticketPing/user/domain/model/enums/Gender.java b/services/user/src/main/java/com/ticketPing/user/domain/model/enums/Gender.java deleted file mode 100644 index b356610e..00000000 --- a/services/user/src/main/java/com/ticketPing/user/domain/model/enums/Gender.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.ticketPing.user.domain.model.enums; - -import com.ticketPing.user.common.exception.UserErrorCase; -import exception.ApplicationException; -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -import java.util.Arrays; - -@Getter -@RequiredArgsConstructor -public enum Gender { - MALE("MALE"), - FEMALE("FEMALE"); - - private final String value; - - public static Gender getGender(final String value) { - return Arrays.stream(Gender.values()) - .filter(t -> t.getValue().equals(value)) - .findAny().orElseThrow(() -> new ApplicationException(UserErrorCase.INVALID_GENDER)); - } -} diff --git a/services/user/src/main/java/com/ticketPing/user/presentation/request/CreateUserRequest.java b/services/user/src/main/java/com/ticketPing/user/presentation/request/CreateUserRequest.java index 93872564..fb7e7027 100644 --- a/services/user/src/main/java/com/ticketPing/user/presentation/request/CreateUserRequest.java +++ b/services/user/src/main/java/com/ticketPing/user/presentation/request/CreateUserRequest.java @@ -1,11 +1,8 @@ package com.ticketPing.user.presentation.request; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; -import java.time.LocalDate; - public record CreateUserRequest( @NotBlank(message = UserInfoErrorMessage.EMAIL_REQUIRED) @Pattern(regexp = "^[A-Za-z0-9]+@[A-Za-z0-9]+\\.[A-Za-z]{2,6}", @@ -16,11 +13,5 @@ public record CreateUserRequest( String password, @NotBlank(message = UserInfoErrorMessage.NICKNAME_REQUIRED) - String nickname, - - @NotNull(message = UserInfoErrorMessage.BIRTHDAY_REQUIRED) - LocalDate birthday, - - @NotBlank(message = UserInfoErrorMessage.GENDER_REQUIRED) - String gender + String nickname ) { } diff --git a/services/user/src/main/java/com/ticketPing/user/presentation/request/UserInfoErrorMessage.java b/services/user/src/main/java/com/ticketPing/user/presentation/request/UserInfoErrorMessage.java index f03d565f..9928de46 100644 --- a/services/user/src/main/java/com/ticketPing/user/presentation/request/UserInfoErrorMessage.java +++ b/services/user/src/main/java/com/ticketPing/user/presentation/request/UserInfoErrorMessage.java @@ -5,6 +5,4 @@ public class UserInfoErrorMessage { public static final String INVALID_EMAIL = "이메일 형식이 올바르지 않습니다."; public static final String PASSWORD_REQUIRED = "비밀번호를 입력해주세요."; public static final String NICKNAME_REQUIRED = "닉네임을 입력해주세요."; - public static final String BIRTHDAY_REQUIRED = "생일을 입력해주세요."; - public static final String GENDER_REQUIRED = "성별을 입력해주세요."; } \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 467ee252..da4bb1cb 100644 --- a/settings.gradle +++ b/settings.gradle @@ -9,6 +9,7 @@ include ("common:rdb") include ("common:caching") include ("common:messaging") include ("common:monitoring") +include ("common:circuit-breaker") include ("services:auth") include ("services:user")