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
-
-
-
-
-
- WebFlux
-
-
-
-
-
+결과 상세
+
+- MVC
+
+
+
+
+
+- WebFlux
+
+
+
+
+
+
+
+
+
+## 🥇 Jmeter 성능 비교를 통한 좌석 선점 방식 선정
+
+Redis 데이터의 동시성 문제를 해결할 수 있는 Lua Script를 이용한 원자적 처리와, 분산락을 이용한 처리 두 가지 방법을 고민하였습니다.
+
+### 장단점 비교
+
+ | 장점 | 단점
+-- | -- | --
+Lua Script | - 네트워크 호출을 최소화할 수 있음 - 락 해제 오류에 대한 위험성 없음 | - Redis 기본 함수를 알아야 함 - Spring 실행 시 디버깅이 어려움
+분산락 | - Spring 코드로 디버깅이 편리 - Redis 함수를 알지 못해도 쉽게 구현 가능 | - 분산락을 얻기 위한 네트워크 호출이 늘어남 - 분산락이 해제되지 않을 가능성이 존재
+
+
+
+### Jmeter 성능 테스트
+
+좌석 선점은 빠른 속도가 중요하다고 생각해 로컬 컴퓨터에서 `1,000`명의 동시 좌석 선점 속도를 비교해보았습니다.
+
+(CPU: AMD Ryzen 7 5700G, RAM: 32GB)
+
+
+
+동일 환경에서 테스트한 결과 Lua Script에서 응답 속도가 2배 빠르고, 처리량도 더 높은 것을 확인할 수 있었습니다.
+
+
+결과 상세
+
+- Lua Script
+
+ 
+
+- 분산락
+
+ 
+
+
+
+### 결론
+
+두 방식의 장단점과 실제 성능 결과를 바탕으로 속도도 빠르고 더 안정성도 높은 Lua Script를 활용해 좌석 선점을 구현하였습니다.
+
+
+
## 📃 다이어그램
### 🧑 유저 플로우
@@ -180,7 +239,8 @@ CPU 사용량과 Load Average가 상대적으로 낮은 것을 확인할 수 있
🎫 예매 시퀀스 다이어그램
-
+
+
@@ -192,7 +252,8 @@ CPU 사용량과 Load Average가 상대적으로 낮은 것을 확인할 수 있
✏️ ERD
-
+
+
@@ -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")