diff --git a/.github/workflows/api-booking-docker-dispatch.yml b/.github/workflows/api-booking-docker-dispatch.yml new file mode 100644 index 0000000..16f9249 --- /dev/null +++ b/.github/workflows/api-booking-docker-dispatch.yml @@ -0,0 +1,99 @@ +name: "πŸ’° api-booking – Docker Build & GitOps Dispatch" + +on: + push: + branches: [ main, develop ] + workflow_dispatch: {} + +permissions: + contents: read + +concurrency: + group: api-booking-${{ github.ref_name }} + cancel-in-progress: true + +env: + SERVICE: booking + IMAGE: ${{ secrets.DOCKER_USERNAME }}/api-booking + DEVOPS_REPO: 3-mnms/gitops-repo + +jobs: + build-push: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: '17' + + - run: chmod +x ./gradlew + - run: ./gradlew bootJar --no-daemon + + - name: ⏱️ Tag + run: echo "TAG=v$(date -u +'%Y%m%d%H%M%S')" >> $GITHUB_ENV + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + + - id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE }} + tags: | + # 1) νƒ€μž„μŠ€νƒ¬ν”„ νƒœκ·Έ (κΈ°λ³Έ) + type=raw,value=${{ env.TAG }} + # 2) git sha νƒœκ·Έ + type=sha + # 3) κ³ μ • 별칭 + type=raw,value=v0.1 + # 4) latest (main, develop λͺ¨λ‘μ—μ„œ λ°œν–‰) + type=raw,value=latest + + - uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + push: true + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # develop β†’ ν•œλ²ˆλ§Œ λ””μŠ€νŒ¨μΉ˜ (두 타깃 λ™μ‹œ) + - name: Dispatch to GitOps (develop-gke + develop-aws) + if: ${{ github.ref_name == 'develop' }} + uses: peter-evans/repository-dispatch@v3 + with: + token: ${{ secrets.GH_PAT }} + repository: ${{ env.DEVOPS_REPO }} + event-type: image-updated + client-payload: | + { + "service": "${{ env.SERVICE }}", + "image": "${{ env.IMAGE }}", + "tag": "${{ env.TAG }}", + "targets": "develop-gke,develop-aws" + } + + # main β†’ prod + - name: Dispatch to GitOps (prod) + if: ${{ github.ref_name == 'main' }} + uses: peter-evans/repository-dispatch@v3 + with: + token: ${{ secrets.GH_PAT }} + repository: ${{ env.DEVOPS_REPO }} + event-type: image-updated + client-payload: | + { + "service": "${{ env.SERVICE }}", + "image": "${{ env.IMAGE }}", + "tag": "${{ env.TAG }}", + "env": "prod" + } \ No newline at end of file diff --git a/.gitignore b/.gitignore index c2065bc..dd7c0db 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ bin/ out/ !**/src/main/**/out/ !**/src/test/**/out/ +!**/logs/ ### NetBeans ### /nbproject/private/ @@ -35,3 +36,19 @@ out/ ### VS Code ### .vscode/ + + +# 둜그 파일 +logs/ +*.log +*.gz + +# νž™ 덀프 파일 +*.hprof + +# env +.env +.env.local +.env.development.local +.env.test.local +.env.production.local diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..838322d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +# Debian/Ubuntu 계열 JRE (amd64/arm64 λͺ¨λ‘ 제곡) +FROM eclipse-temurin:17-jre-jammy + +# spring μ‚¬μš©μž/κ·Έλ£Ή 생성 (Debian ν‘œμ€€ λͺ…λ Ή) +RUN groupadd -r spring \ + && useradd -r -g spring -d /home/spring -s /usr/sbin/nologin spring \ + && mkdir -p /app /home/spring + +WORKDIR /app + +# λΉŒλ“œ μ‚°μΆœλ¬Ό 볡사 (κΆŒν•œλ„ ν•¨κ»˜ μ„€μ •) +ARG JAR_FILE=build/libs/*.jar +COPY --chown=spring:spring ${JAR_FILE} /app/app.jar + +# λΉ„λ£¨νŠΈ μ‹€ν–‰ +USER spring + +EXPOSE 8080 +ENV JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75" +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app/app.jar"] \ No newline at end of file diff --git a/build.gradle b/build.gradle index c1d5d4f..e167d2c 100644 --- a/build.gradle +++ b/build.gradle @@ -28,12 +28,76 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.kafka:spring-kafka' - compileOnly 'org.projectlombok:lombok' - runtimeOnly 'com.h2database:h2' - annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.kafka:spring-kafka-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + + // security + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' + + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + // mariadb + h2 + runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' + runtimeOnly 'com.h2database:h2' + + // mongodb + //implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' + + + // websocket + implementation 'org.springframework.boot:spring-boot-starter-websocket' + + // redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'redis.clients:jedis' // μ‚­μ œ κ°€λŠ₯ + + // kaptcha + implementation 'com.github.penggle:kaptcha:2.3.2' + + // Kafka + implementation 'org.springframework.kafka:spring-kafka' + + // JSON μ§λ ¬ν™”μš© + //implementation 'com.fasterxml.jackson.core:jackson-databind' + implementation 'com.fasterxml.jackson.core:jackson-databind:2.19.2' + implementation 'org.springframework.boot:spring-boot-starter-json' + testImplementation 'org.springframework.kafka:spring-kafka-test' + + // 역직렬화 + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.15.2' + + // QR 생성 + implementation 'com.google.zxing:core:3.5.2' + implementation 'com.google.zxing:javase:3.5.2' + + // swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0' + + // WebClient μ‚¬μš© + implementation 'org.springframework.boot:spring-boot-starter-webflux' + + // test + testImplementation 'it.ozimov:embedded-redis:0.7.3' + + //Mail + implementation 'org.springframework.boot:spring-boot-starter-mail' + + // prometheus + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'io.micrometer:micrometer-registry-prometheus' + + // email retry + implementation 'org.springframework.retry:spring-retry' + implementation 'org.springframework.boot:spring-boot-starter-aop' } tasks.named('test') { diff --git a/src/main/java/com/mnms/booking/BookingApplication.java b/src/main/java/com/mnms/booking/BookingApplication.java deleted file mode 100644 index ca8955f..0000000 --- a/src/main/java/com/mnms/booking/BookingApplication.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.mnms.booking; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -@SpringBootApplication -public class BookingApplication { - - public static void main(String[] args) { - SpringApplication.run(BookingApplication.class, args); - } - -} diff --git a/src/main/java/com/mnms/booking/TekcitApplication.java b/src/main/java/com/mnms/booking/TekcitApplication.java new file mode 100644 index 0000000..a336bcd --- /dev/null +++ b/src/main/java/com/mnms/booking/TekcitApplication.java @@ -0,0 +1,17 @@ +package com.mnms.booking; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.kafka.annotation.EnableKafka; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication +@EnableScheduling +@EnableKafka +public class TekcitApplication { + + public static void main(String[] args) { + SpringApplication.run(TekcitApplication.class, args); + } + +} diff --git a/src/main/java/com/mnms/booking/config/AsyncConfig.java b/src/main/java/com/mnms/booking/config/AsyncConfig.java new file mode 100644 index 0000000..1118cf6 --- /dev/null +++ b/src/main/java/com/mnms/booking/config/AsyncConfig.java @@ -0,0 +1,26 @@ +package com.mnms.booking.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.retry.annotation.EnableRetry; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +@Configuration +@EnableAsync +@EnableRetry +public class AsyncConfig { + + @Bean(name = "taskExecutor") + public Executor taskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(5); + executor.setMaxPoolSize(10); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("Async-"); + executor.initialize(); + return executor; + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/config/CaptchaConfig.java b/src/main/java/com/mnms/booking/config/CaptchaConfig.java new file mode 100644 index 0000000..4718b8d --- /dev/null +++ b/src/main/java/com/mnms/booking/config/CaptchaConfig.java @@ -0,0 +1,29 @@ +package com.mnms.booking.config; + +import com.google.code.kaptcha.impl.DefaultKaptcha; +import com.google.code.kaptcha.util.Config; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.Properties; + +@Configuration +public class CaptchaConfig { + + @Bean + public DefaultKaptcha captchaProducer() { + DefaultKaptcha kaptcha = new DefaultKaptcha(); + Properties properties = new Properties(); + properties.setProperty("kaptcha.border", "no"); + properties.setProperty("kaptcha.textproducer.font.color", "black"); + properties.setProperty("kaptcha.image.width", "200"); + properties.setProperty("kaptcha.image.height", "60"); + properties.setProperty("kaptcha.textproducer.font.size", "50"); + properties.setProperty("kaptcha.session.key", "captchaCode"); + properties.setProperty("kaptcha.textproducer.char.length", "5"); + properties.setProperty("kaptcha.textproducer.font.names", "Arial,Courier"); + Config config = new Config(properties); + kaptcha.setConfig(config); + return kaptcha; + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/config/RedisConfig.java b/src/main/java/com/mnms/booking/config/RedisConfig.java new file mode 100644 index 0000000..9c11fb9 --- /dev/null +++ b/src/main/java/com/mnms/booking/config/RedisConfig.java @@ -0,0 +1,80 @@ +package com.mnms.booking.config; + +import com.mnms.booking.service.KeyExpirationListener; +import com.mnms.booking.service.RedisMessageSubscriber; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.listener.PatternTopic; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; +import org.springframework.data.redis.listener.adapter.MessageListenerAdapter; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; +import org.springframework.data.redis.core.StringRedisTemplate; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +@Configuration +@Slf4j +public class RedisConfig { + + + @PostConstruct + public void init() { + log.info("RedisConfig bean initialized"); + } + + /// Redis Pub/Sub λ©”μ‹œμ§€λ₯Ό κ΅¬λ…ν•˜κ³  μ²˜λ¦¬ν•  μ»¨ν…Œμ΄λ„ˆ + @Bean + public RedisMessageListenerContainer redisMessageListenerContainer( + RedisConnectionFactory connectionFactory, + MessageListenerAdapter waitingNotificationListenerAdapter, + KeyExpirationListener keyExpirationListener + ) { + RedisMessageListenerContainer container = new RedisMessageListenerContainer(); + container.setConnectionFactory(connectionFactory); + + // waiting_notification:* ꡬ독 + container.addMessageListener(waitingNotificationListenerAdapter, new PatternTopic("waiting_notification/*")); + log.info("Subscribed to Redis channels with pattern: waiting_notification/*"); + + // Keyspace ꡬ독 (예맀 μ™„λ£Œ 이벀트) + container.addMessageListener(keyExpirationListener, new PatternTopic("__keyevent@0__:expired")); + log.info("Subscribed to Redis key expiration events"); + + return container; + } + + + /// Redis λ©”μ‹œμ§€λ₯Ό λ°›μ•„ μ²˜λ¦¬ν•  λ¦¬μŠ€λ„ˆ μ–΄λŒ‘ν„° (RedisMessageSubscriber와 μ—°κ²°) + @Bean + public MessageListenerAdapter listenerAdapter(RedisMessageSubscriber subscriber) { + // RedisMessageSubscriber의 "onMessage" λ©”μ„œλ“œλ₯Ό ν˜ΈμΆœν•˜λ„λ‘ μ„€μ • + return new MessageListenerAdapter(subscriber, "onMessage"); + } + + /// Redis Pub/Sub λ©”μ‹œμ§€λ₯Ό λ°œν–‰ν•˜λŠ” 데 μ‚¬μš©λ  ν…œν”Œλ¦Ώ + @Bean + public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) { + return new StringRedisTemplate(connectionFactory); + } + + /// ZSet, Hash λ“± 일반적인 Redis 데이터 ꡬ쑰 관리에 μ‚¬μš©λ  RedisTemplate μ„€μ • μΆ”κ°€ + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory, ObjectMapper objectMapper) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new GenericJackson2JsonRedisSerializer(objectMapper)); + template.afterPropertiesSet(); + return template; + } + +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/config/RestTemplateConfig.java b/src/main/java/com/mnms/booking/config/RestTemplateConfig.java new file mode 100644 index 0000000..a5ae2f4 --- /dev/null +++ b/src/main/java/com/mnms/booking/config/RestTemplateConfig.java @@ -0,0 +1,13 @@ +package com.mnms.booking.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/config/SchedulerConfig.java b/src/main/java/com/mnms/booking/config/SchedulerConfig.java new file mode 100644 index 0000000..3b40a42 --- /dev/null +++ b/src/main/java/com/mnms/booking/config/SchedulerConfig.java @@ -0,0 +1,17 @@ +package com.mnms.booking.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; + +@Configuration +public class SchedulerConfig { + @Bean + public ThreadPoolTaskScheduler taskScheduler() { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(5); + scheduler.setThreadNamePrefix("booking-scheduler-"); + scheduler.initialize(); + return scheduler; + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/config/WebClientConfig.java b/src/main/java/com/mnms/booking/config/WebClientConfig.java new file mode 100644 index 0000000..63c1a43 --- /dev/null +++ b/src/main/java/com/mnms/booking/config/WebClientConfig.java @@ -0,0 +1,14 @@ +package com.mnms.booking.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration +public class WebClientConfig { + + @Bean + public WebClient webClient(WebClient.Builder builder) { + return builder.build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/config/WebSocketConfig.java b/src/main/java/com/mnms/booking/config/WebSocketConfig.java new file mode 100644 index 0000000..ec88eee --- /dev/null +++ b/src/main/java/com/mnms/booking/config/WebSocketConfig.java @@ -0,0 +1,38 @@ +package com.mnms.booking.config; + +import com.mnms.booking.security.StompConnectInterceptor; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +@Configuration +@EnableWebSocketMessageBroker +@RequiredArgsConstructor +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + private final StompConnectInterceptor interceptors; + @Override + public void configureMessageBroker(MessageBrokerRegistry config) { + // κ΅¬λ…κ²½λ‘œ + config.enableSimpleBroker("/topic", "/queue"); + // ν΄λΌμ΄μ–ΈνŠΈ β†’ μ„œλ²„ λ©”μ‹œμ§€ 경둜 + config.setApplicationDestinationPrefixes("/app"); + config.setUserDestinationPrefix("/user"); + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/ws") + .setAllowedOriginPatterns("*") + .withSockJS(); + } + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.interceptors(interceptors); + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/controller/BookingController.java b/src/main/java/com/mnms/booking/controller/BookingController.java new file mode 100644 index 0000000..523e3ad --- /dev/null +++ b/src/main/java/com/mnms/booking/controller/BookingController.java @@ -0,0 +1,105 @@ +package com.mnms.booking.controller; + +import com.mnms.booking.dto.request.BookingRequestDTO; +import com.mnms.booking.dto.request.BookingSelectDeliveryRequestDTO; +import com.mnms.booking.dto.request.BookingSelectRequestDTO; +import com.mnms.booking.dto.response.BookingDetailResponseDTO; +import com.mnms.booking.dto.response.BookingUserResponseDTO; +import com.mnms.booking.dto.response.FestivalDetailResponseDTO; +import com.mnms.booking.enums.ReservationStatus; +import com.mnms.booking.exception.global.SuccessResponse; +import com.mnms.booking.service.BookingQueryService; +import com.mnms.booking.service.BookingCommandService; +import com.mnms.booking.specification.BookingSpecification; +import com.mnms.booking.util.ApiResponseUtil; +import com.mnms.booking.util.SecurityResponseUtil; +import com.mnms.booking.util.UserApiClient; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/booking") +public class BookingController implements BookingSpecification { + + private final BookingCommandService bookingCommandService; + private final BookingQueryService bookingQueryService; + private final UserApiClient userApiClient; + private final SecurityResponseUtil securityResponseUtil; + + /// GET : νŽ˜μŠ€ν‹°λ²Œ 예맀 정보 쑰회 + @PostMapping("/detail/phases/1") + public ResponseEntity> getFestivalDetail(@Valid @RequestBody BookingSelectRequestDTO request) { + return ApiResponseUtil.success(bookingQueryService.getFestivalDetail(request)); + } + + /// POST : 2μ°¨ 예맀 상세 쑰회 + @PostMapping("/detail/phases/2") + public ResponseEntity> getFestivalBookingDetail( + @Valid @RequestBody BookingRequestDTO request, + Authentication authentication + ) { + Long userId = securityResponseUtil.requireUserId(authentication); + return ApiResponseUtil.success(bookingQueryService.getFestivalBookingDetail(request, userId)); + } + + /// POST + @PostMapping("/selectDate") + public ResponseEntity> selectFestivalDate( + @Valid @RequestBody BookingSelectRequestDTO request, + Authentication authentication + ) { + return ApiResponseUtil.success(bookingCommandService.selectFestivalDate(request, securityResponseUtil.requireUserId(authentication))); + } + + /// POST : 배솑 선택 + @PostMapping("/selectDeliveryMethod") + public ResponseEntity> selectFestivalDelivery( + @Valid @RequestBody BookingSelectDeliveryRequestDTO request, + Authentication authentication + ) { + bookingCommandService.selectFestivalDelivery(request, securityResponseUtil.requireUserId(authentication)); + return ApiResponseUtil.success(null, "예맀 ν‹°μΌ“ 수령 방법 선택 μ™„λ£Œ"); + } + + /// POST : 3μ°¨ 예맀 μ™„λ£Œ (결제 직전) + @PostMapping("/qr") + public ResponseEntity> reserveTicket( + @Valid @RequestBody BookingRequestDTO request, + Authentication authentication + ) { + bookingCommandService.reserveTicket(request, securityResponseUtil.requireUserId(authentication)); + return ApiResponseUtil.success(null, "예맀 ν‹°μΌ“ 생성 μ™„λ£Œ"); + } + + /// Websocket λ©”μ‹œμ§€ λˆ„λ½ λ°©μ§€ apiμš”μ²­ + @GetMapping("reservation/status") + public ResponseEntity> checkStatus(@RequestParam String reservationNumber){ + return ApiResponseUtil.success(bookingCommandService.checkStatus(reservationNumber)); + } + + /// GET : 예맀자 정보 쑰회 + @GetMapping("/user/info") + @Override + public ResponseEntity> getUserInfo(Authentication authentication) { + return ApiResponseUtil.success(userApiClient.getUserInfoById(securityResponseUtil.requireUserId(authentication))); + } + + /// POST : 이메일 μž„μ‹œ ν…ŒμŠ€νŠΈ + @PostMapping("/email/test") + public ResponseEntity confirmTicket( + @RequestParam String reservationNumber, + @RequestParam boolean paymentStatus) { + try { + bookingCommandService.confirmTicket(reservationNumber, paymentStatus); + return ResponseEntity.ok("예맀 μƒνƒœκ°€ μ„±κ³΅μ μœΌλ‘œ μ—…λ°μ΄νŠΈλ˜κ³  이메일이 μ „μ†‘λ˜μ—ˆμŠ΅λ‹ˆλ‹€."); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body("μ—λŸ¬ λ°œμƒ: " + e.getMessage()); + } + } +} diff --git a/src/main/java/com/mnms/booking/controller/BookingEventController.java b/src/main/java/com/mnms/booking/controller/BookingEventController.java new file mode 100644 index 0000000..d149a58 --- /dev/null +++ b/src/main/java/com/mnms/booking/controller/BookingEventController.java @@ -0,0 +1,53 @@ +package com.mnms.booking.controller; + +import com.mnms.booking.dto.request.LeaveQueueRequestDTO; +import com.mnms.booking.service.WaitingService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.stereotype.Controller; + +import java.security.Principal; + +/// front -> back websocket μ†Œν†΅μ„ μœ„ν•œ controllerμž…λ‹ˆλ‹€. + +@RequiredArgsConstructor +@Slf4j +@Controller +public class BookingEventController { + + private final WaitingService waitingService; + + // /app/queue/waiting/leave둜 front λ©”μ‹œμ§€ 보내기 + // λŒ€κΈ°μ—΄μ—μ„œ λ‚˜κ°ˆ λ•Œ + @MessageMapping("/queue/waiting/leave") + public void leaveWaitingQueue(LeaveQueueRequestDTO request, Principal principal) { + try { + String userId = principal.getName(); + boolean removed = waitingService.removeUserFromQueue(request.getFestivalId(), request.getReservationDate(), userId); + if (removed) { + log.info("λŒ€κΈ°ν•˜λ˜ μ‚¬μš©μžκ°€ λŒ€κΈ°μ—΄μ„ λ‚˜κ°”μŠ΅λ‹ˆλ‹€."); // 일단 ν…ŒμŠ€νŠΈλ‘œ log μΆ”κ°€ - μΆ”ν›„ λ³€κ²½ μ˜ˆμ • + } else { + log.info("ν•΄λ‹Ή μ‚¬μš©μžλŠ” λŒ€κΈ°μ—΄ λͺ©λ‘μ— μ—†μŠ΅λ‹ˆλ‹€."); + } + } catch (Exception e) { + log.info("μ„œλ²„ 였λ₯˜λ‘œ 인해 μ‚¬μš©μžλ₯Ό μ²˜λ¦¬ν•˜μ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€."); + } + } + + // 예맀 νŽ˜μ΄μ§€μ—μ„œ 퇴μž₯ + @MessageMapping("/queue/reservation/leave") + public void leaveReservationQueue(LeaveQueueRequestDTO request, Principal principal) { + try { + String userId = principal.getName(); + boolean removed = waitingService.userExitBookingPage(request.getFestivalId(), request.getReservationDate(), userId); + if (removed) { + log.info("μ‚¬μš©μžκ°€ 예맀 νŽ˜μ΄μ§€λ₯Ό λ‚˜κ°”κ³ , λ‹€μŒ λŒ€κΈ°μžκ°€ μž…μž₯ν–ˆμŠ΅λ‹ˆλ‹€."); + } else { + log.info("ν•΄λ‹Ή μ‚¬μš©μžλŠ” 예맀 μ‚¬μš©μž λͺ©λ‘μ— μ—†μŠ΅λ‹ˆλ‹€."); + } + } catch (Exception e) { + log.info("μ„œλ²„ 였λ₯˜λ‘œ 인해 μ‚¬μš©μžλ₯Ό μ²˜λ¦¬ν•˜μ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€."); + } + } +} diff --git a/src/main/java/com/mnms/booking/controller/CaptchaController.java b/src/main/java/com/mnms/booking/controller/CaptchaController.java new file mode 100644 index 0000000..25b810d --- /dev/null +++ b/src/main/java/com/mnms/booking/controller/CaptchaController.java @@ -0,0 +1,42 @@ +package com.mnms.booking.controller; + +import com.mnms.booking.dto.response.CaptchaResponseDTO; +import com.mnms.booking.exception.global.SuccessResponse; +import com.mnms.booking.service.CaptchaService; +import com.mnms.booking.specification.CaptchaSpecification; +import com.mnms.booking.util.ApiResponseUtil; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.io.IOException; + + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/captcha") +public class CaptchaController implements CaptchaSpecification{ + + private final CaptchaService kaptchaService; + + @GetMapping("/image") + public ResponseEntity> getCaptchaImage(HttpServletRequest request, HttpServletResponse response) throws IOException { + kaptchaService.writeCaptchaImage(request.getSession(), response); + return ApiResponseUtil.success(null, "λ³΄μ•ˆλ¬Έμž 이미지가 생성 μ™„λ£Œ"); + } + + @PostMapping("/verify") + public ResponseEntity> verifyCaptcha( + @RequestParam("captcha") String captcha, + HttpSession session) { + + CaptchaResponseDTO result = kaptchaService.verifyCaptchaResult(captcha, session); + return result.isSuccess() + ? ApiResponseUtil.success(result) + : ApiResponseUtil.fail(result.getMessage() ,HttpStatus.BAD_REQUEST); + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/controller/HostController.java b/src/main/java/com/mnms/booking/controller/HostController.java new file mode 100644 index 0000000..f9b988d --- /dev/null +++ b/src/main/java/com/mnms/booking/controller/HostController.java @@ -0,0 +1,39 @@ +package com.mnms.booking.controller; + +import com.mnms.booking.dto.request.HostRequestDTO; +import com.mnms.booking.dto.response.HostResponseDTO; +import com.mnms.booking.exception.global.SuccessResponse; +import com.mnms.booking.service.HostService; +import com.mnms.booking.specification.HostSpecification; +import com.mnms.booking.util.ApiResponseUtil; +import com.mnms.booking.util.SecurityResponseUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/host") +public class HostController implements HostSpecification{ + + private final SecurityResponseUtil securityResponseUtil; + private final HostService hostService; + + @PostMapping("/list") + public ResponseEntity>> getBookingsByOrganizer(@RequestBody HostRequestDTO hostRequestDTO) { + return ApiResponseUtil.success(hostService.getBookingsByOrganizer(hostRequestDTO)); + } + + /// 주졜자 μΈ‘ 예맀자 쑰회 + @PostMapping("/booking/list") + @PreAuthorize("hasAnyRole('HOST', 'ADMIN')") + public ResponseEntity>> getBookingInfo(@RequestParam String festivalId, + Authentication authentication) { + List bookings = hostService.getBookingInfoByHost(festivalId, securityResponseUtil.requireUserId(authentication), securityResponseUtil.requireRole(authentication)); + return ApiResponseUtil.success(bookings); + } +} diff --git a/src/main/java/com/mnms/booking/controller/QrCodeController.java b/src/main/java/com/mnms/booking/controller/QrCodeController.java new file mode 100644 index 0000000..9449502 --- /dev/null +++ b/src/main/java/com/mnms/booking/controller/QrCodeController.java @@ -0,0 +1,75 @@ +package com.mnms.booking.controller; + +import com.mnms.booking.entity.QrCode; +import com.mnms.booking.exception.global.SuccessResponse; +import com.mnms.booking.service.QrCodeService; +import com.mnms.booking.specification.QrCodeSpecification; +import com.mnms.booking.util.ApiResponseUtil; +import com.mnms.booking.util.SecurityResponseUtil; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/qr") +public class QrCodeController implements QrCodeSpecification { + + private final QrCodeService qrCodeService; + private final SecurityResponseUtil securityResponseUtil; + + /// Qrcode 이미지 쑰회 + @GetMapping(value = "/image/{qrCodeId}", produces = "image/png") + public ResponseEntity getQrCodeImage(@PathVariable String qrCodeId) { + QrCode qrCode = qrCodeService.getQrCodeByCode(qrCodeId); + String qrCodeText = qrCode.getQrCodeId(); + + try { + byte[] imageBytes = qrCodeService.generateQrCodeImage(qrCodeText, 250, 250); + return ResponseEntity.ok().contentType(MediaType.IMAGE_PNG).body(imageBytes); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } + + /// Qrcode 이미지 qrCodeId 리슀트둜 쑰회 + @GetMapping("/images") + @Operation(summary = "QR μ½”λ“œ 이미지 λ‹€μˆ˜ 쑰회", + description = "qrCodeId 리슀트둜 μ—¬λŸ¬ 개의 QR μ½”λ“œ 이미지λ₯Ό Base64 μΈμ½”λ”©λœ λ¬Έμžμ—΄λ‘œ λ°˜ν™˜ν•©λ‹ˆλ‹€.") + public ResponseEntity> getQrCodeImages(@RequestParam List qrCodeIds) { + List images = new ArrayList<>(); + for (String qrCodeId : qrCodeIds) { + QrCode qrCode = qrCodeService.getQrCodeByCode(qrCodeId); + String qrCodeText = qrCode.getQrCodeId(); + + try { + byte[] imageBytes = qrCodeService.generateQrCodeImage(qrCodeText, 250, 250); + String base64Image = Base64.getEncoder().encodeToString(imageBytes); + images.add(base64Image); + } catch (Exception e) { + images.add(null); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } + return ResponseEntity.ok(images); + } + + + /// νŽ˜μŠ€ν‹°λ²Œ 주졜자 QR μŠ€μΊ” + @PostMapping(value = "/validate/{qrCodeId}") + public ResponseEntity> validateAndUseQrCode( + @PathVariable String qrCodeId, + Authentication authentication) { + + qrCodeService.validateAndUseQrCode(securityResponseUtil.requireUserId(authentication), qrCodeId); + return ApiResponseUtil.success(null, "QR μŠ€μΊ” μ™„λ£Œ"); + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/controller/StatisticsController.java b/src/main/java/com/mnms/booking/controller/StatisticsController.java new file mode 100644 index 0000000..07928a3 --- /dev/null +++ b/src/main/java/com/mnms/booking/controller/StatisticsController.java @@ -0,0 +1,83 @@ +package com.mnms.booking.controller; + +import com.mnms.booking.dto.response.StatisticsBookingDTO; +import com.mnms.booking.dto.response.StatisticsQrCodeResponseDTO; +import com.mnms.booking.dto.response.StatisticsUserResponseDTO; +import com.mnms.booking.exception.global.SuccessResponse; +import com.mnms.booking.service.StatisticsQueryService; +import com.mnms.booking.service.StatisticsUserService; +import com.mnms.booking.service.StatisticsQrCodeService; +import com.mnms.booking.specification.StatisticsSpecification; +import com.mnms.booking.util.ApiResponseUtil; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.List; + +@RestController +@RequestMapping("/api/statistics") +@RequiredArgsConstructor +@Tag(name = "톡계 API", description = "곡연 별 예맀자의 정보λ₯Ό 톡해 성별/λ‚˜μ΄, μž…μž₯ 인원 상황을 확인 κ°€λŠ₯") +public class StatisticsController implements StatisticsSpecification { + + private final StatisticsUserService statisticsUserService; + private final StatisticsQrCodeService statisticsQrCodeService; + private final StatisticsQueryService statisticsQueryService; + + @GetMapping("/users/{festivalId}") + public ResponseEntity> getFestivalUserStatistics(@PathVariable String festivalId) { + StatisticsUserResponseDTO statistics = statisticsUserService.getFestivalUserStatistics(festivalId); + return ApiResponseUtil.success(statistics, "예맀자 톡계 정보가 μ„±κ³΅μ μœΌλ‘œ μ‘°νšŒλ˜μ—ˆμŠ΅λ‹ˆλ‹€."); + } + + + @GetMapping("/schedules/{festivalId}") + public ResponseEntity>> getPerformanceDatesForFestival( + @PathVariable String festivalId, + Authentication authentication) { + + boolean isAdmin = authentication.getAuthorities().stream() + .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN")); + + List performanceDates = statisticsQueryService.getPerformanceDatesByFestivalId(festivalId); + return ApiResponseUtil.success(performanceDates, "곡연 λ‚ μ§œ λͺ©λ‘μ΄ μ„±κ³΅μ μœΌλ‘œ μ‘°νšŒλ˜μ—ˆμŠ΅λ‹ˆλ‹€."); + } + + + @GetMapping("/enter/{festivalId}") + public ResponseEntity> getPerformanceEnterStatistics( + @PathVariable String festivalId, + @RequestParam("performanceDate") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime performanceDate, + Authentication authentication) { + String userId = authentication.getName(); + boolean isHost = authentication.getAuthorities().stream() + .anyMatch(a -> a.getAuthority().equals("ROLE_HOST")); + boolean isAdmin = authentication.getAuthorities().stream() + .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN")); + + StatisticsQrCodeResponseDTO statistics = statisticsQrCodeService.getPerformanceEnterStatistics(festivalId, performanceDate, userId, isHost, isAdmin); + + return ApiResponseUtil.success(statistics, "곡연 μž…μž₯ 톡계 정보가 μ„±κ³΅μ μœΌλ‘œ μ‘°νšŒλ˜μ—ˆμŠ΅λ‹ˆλ‹€."); + } + + @GetMapping("/booking/{festivalId}") + public ResponseEntity>> getBookingSummary( + @PathVariable String festivalId, + Authentication authentication) { + + String userId = authentication.getName(); + boolean isAdmin = authentication.getAuthorities().stream() + .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN")); + + statisticsQueryService.validateHostOrAdminAccess(festivalId, userId, isAdmin); + + List summary = statisticsQueryService.getBookingSummary(festivalId); + + return ApiResponseUtil.success(summary, "곡연 μš”μ•½ 정보가 μ„±κ³΅μ μœΌλ‘œ μ‘°νšŒλ˜μ—ˆμŠ΅λ‹ˆλ‹€."); + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/controller/TicketController.java b/src/main/java/com/mnms/booking/controller/TicketController.java new file mode 100644 index 0000000..df95f34 --- /dev/null +++ b/src/main/java/com/mnms/booking/controller/TicketController.java @@ -0,0 +1,37 @@ +package com.mnms.booking.controller; + +import com.mnms.booking.dto.response.TicketDetailResponseDTO; +import com.mnms.booking.dto.response.TicketResponseDTO; +import com.mnms.booking.exception.global.SuccessResponse; +import com.mnms.booking.service.TicketService; +import com.mnms.booking.specification.TicketSpecification; +import com.mnms.booking.util.ApiResponseUtil; +import com.mnms.booking.util.SecurityResponseUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/ticket") +public class TicketController implements TicketSpecification { + + private final TicketService ticketService; + private final SecurityResponseUtil securityResponseUtil; + + @GetMapping + public ResponseEntity>> getUserTickets(Authentication authentication) { + List tickets = ticketService.getTicketsByUser(securityResponseUtil.requireUserId(authentication)); + return ApiResponseUtil.success(tickets); + } + + @GetMapping("/detail") + public ResponseEntity> getUserTicketDetail(@RequestParam String reservationNumber, + Authentication authentication) { + TicketDetailResponseDTO ticket = ticketService.getTicketDetailByUser(reservationNumber, securityResponseUtil.requireUserId(authentication), securityResponseUtil.requireName(authentication)); + return ApiResponseUtil.success(ticket); + } +} diff --git a/src/main/java/com/mnms/booking/controller/TransferController.java b/src/main/java/com/mnms/booking/controller/TransferController.java new file mode 100644 index 0000000..deb3c05 --- /dev/null +++ b/src/main/java/com/mnms/booking/controller/TransferController.java @@ -0,0 +1,115 @@ +package com.mnms.booking.controller; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mnms.booking.dto.request.TicketTransferRequestDTO; +import com.mnms.booking.dto.request.UpdateTicketRequestDTO; +import com.mnms.booking.dto.response.PersonInfoResponseDTO; +import com.mnms.booking.dto.response.TicketResponseDTO; +import com.mnms.booking.dto.response.TicketTransferResponseDTO; +import com.mnms.booking.dto.response.TransferOthersResponseDTO; +import com.mnms.booking.exception.global.SuccessResponse; +import com.mnms.booking.service.OcrParserService; +import com.mnms.booking.service.OcrService; +import com.mnms.booking.service.TransferCompletionService; +import com.mnms.booking.service.TransferService; +import com.mnms.booking.specification.TransferSpecification; +import com.mnms.booking.util.ApiResponseUtil; +import com.mnms.booking.util.SecurityResponseUtil; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.*; + +@RestController +@RequestMapping("/api/transfer") +@RequiredArgsConstructor +public class TransferController implements TransferSpecification { + private final OcrService ocrService; + private final TransferService transferService; + private final SecurityResponseUtil securityResponseUtil; + private final TransferCompletionService transferCompletionService; + + + @GetMapping("/transferor") + public ResponseEntity>> getUserTickets(Authentication authentication) { + List tickets = transferService.getTicketsByUser(securityResponseUtil.requireUserId(authentication)); + return ApiResponseUtil.success(tickets); + } + + + /// 가쑱관계증λͺ…μ„œ 인증 + @PostMapping("/extract") + public ResponseEntity> extractPersonAuth( + @RequestPart("file") MultipartFile file, + @RequestPart("targetInfo") String targetInfoJson) throws IOException { + ObjectMapper objectMapper = new ObjectMapper(); + Map targetInfo = objectMapper.readValue(targetInfoJson, new TypeReference<>() {}); + String ocrJson = ocrService.callOcr(file); + OcrParserService.parseOcrResult(ocrJson, targetInfo); + + return ApiResponseUtil.success(null,"가쑱관계증λͺ…μ„œ 인증이 μ™„λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€."); + } + + + @PostMapping("/extract/result") + public ResponseEntity>> extractPersonInfo( + @RequestPart("file") MultipartFile file, + @RequestPart("targetInfo") String targetInfoJson) throws IOException { + ObjectMapper objectMapper = new ObjectMapper(); + Map targetInfo = objectMapper.readValue(targetInfoJson, new TypeReference<>() {}); + + String ocrJson = ocrService.callOcr(file); + List people = OcrParserService.parseOcrResult(ocrJson, targetInfo); + + return ApiResponseUtil.success(people, "가쑱관계증λͺ…μ„œ 인증이 μ™„λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€."); + } + + + /// 양도 μš”μ²­ + @PostMapping("/request") + public ResponseEntity> requestTransfer( + @RequestBody @Valid TicketTransferRequestDTO dto, + Authentication authentication + ){ + transferService.requestTransfer(dto, securityResponseUtil.requireUserId(authentication)); + return ApiResponseUtil.success(null, "ν‹°μΌ“ 양도 μš”μ²­μ΄ μ™„λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€."); + } + + /// 양도 μš”μ²­ λ°›κΈ° (쑰회) + @GetMapping("/watch") + public ResponseEntity>> watchTransfer(Authentication authentication){ + List response = transferService.watchTransfer(securityResponseUtil.requireUserId(authentication)); + return ApiResponseUtil.success(response); + } + + /// κ°€μ‘± κ°„ 양도 μš”μ²­ 승인 + @PutMapping("/acceptance/family") + public ResponseEntity> responseTicketFamily( + @RequestBody UpdateTicketRequestDTO request, + Authentication authentication) { + transferCompletionService.updateFamilyTicket(request, securityResponseUtil.requireUserId(authentication)); + return ApiResponseUtil.success(null, "ν‹°μΌ“ 양도가 μ„±κ³΅μ μœΌλ‘œ μ§„ν–‰λ˜μ—ˆμŠ΅λ‹ˆλ‹€."); + } + + /// 지인간 양도 μš”μ²­ 승인 + @PutMapping("/acceptance/others") + public ResponseEntity> responseTicketOthers( + @RequestBody UpdateTicketRequestDTO request, + Authentication authentication) { + TransferOthersResponseDTO response = transferCompletionService.proceedOthersTicket(request, securityResponseUtil.requireUserId(authentication)); + return ApiResponseUtil.success(response); + } + + + /// Websocket λ©”μ‹œμ§€ λˆ„λ½ λ°©μ§€ apiμš”μ²­ + @GetMapping("/reservation/status") + public ResponseEntity> checkStatus(@RequestParam Long transferId){ + return ApiResponseUtil.success(transferService.checkStatus(transferId)); + } +} diff --git a/src/main/java/com/mnms/booking/controller/WaitingController.java b/src/main/java/com/mnms/booking/controller/WaitingController.java new file mode 100644 index 0000000..2e96fe7 --- /dev/null +++ b/src/main/java/com/mnms/booking/controller/WaitingController.java @@ -0,0 +1,93 @@ +package com.mnms.booking.controller; + +import com.mnms.booking.dto.response.WaitingNumberResponseDTO; +import com.mnms.booking.exception.global.SuccessResponse; +import com.mnms.booking.service.FestivalService; +import com.mnms.booking.specification.WaitingSpecification; +import com.mnms.booking.util.ApiResponseUtil; +import com.mnms.booking.util.SecurityResponseUtil; +import io.swagger.v3.oas.annotations.Parameter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import com.mnms.booking.service.WaitingService; + +import java.time.LocalDateTime; + +@RestController +@Slf4j +@RequiredArgsConstructor +@RequestMapping("/api/booking") +public class WaitingController implements WaitingSpecification { + + private final WaitingService waitingService; + private final FestivalService festivalService; + private final SecurityResponseUtil securityResponseUtil; + + @Autowired + private SimpMessagingTemplate messagingTemplate; + + /// μ˜ˆλ§€ν•˜κΈ° λ²„νŠΌ(front) 클릭 μ‹œ ν˜ΈμΆœλ˜λŠ” API + @GetMapping("/enter") + public ResponseEntity> enterBookingPage( + @RequestParam String festivalId, + @RequestParam LocalDateTime reservationDate, + Authentication authentication) { + + String userId = authentication != null ? getUserId(authentication) : "swagger-test-user"; + + int availableNOP = festivalService.getCapacity(festivalId); // 수용 인원 κ°€μ Έμ˜€κΈ° + long waitingNumber = waitingService.enterWaitingQueue(festivalId, reservationDate, userId, availableNOP); + + if (waitingNumber == 0) { + return ApiResponseUtil.success(new WaitingNumberResponseDTO(userId, 0, true, "REDIRECT_TO_BOOKING_PAGE")); + } else { + return ApiResponseUtil.success(new WaitingNumberResponseDTO(userId, waitingNumber, false, "WAITING_QUEUE_ENTERED")); + } + } + + /// 예맀 νŽ˜μ΄μ§€ 퇴μž₯ + @GetMapping("/release") + public ResponseEntity> releaseUser( + @RequestParam String festivalId, + @RequestParam LocalDateTime reservationDate, + @Parameter(hidden = true) Authentication authentication) { /// μ˜ˆλ§€μΉΈμ— μžˆλŠ” 예맀자 accessToken + try { + boolean removed = waitingService.userExitBookingPage(festivalId, reservationDate, getUserId(authentication)); + if (removed) { + return ApiResponseUtil.success("μ‚¬μš©μžκ°€ 예맀 νŽ˜μ΄μ§€λ₯Ό λ‚˜κ°”κ³ , λ‹€μŒ λŒ€κΈ°μžκ°€ μž…μž₯ν–ˆμŠ΅λ‹ˆλ‹€."); + } else { + return ApiResponseUtil.fail("ν•΄λ‹Ή μ‚¬μš©μžλŠ” 예맀 μ‚¬μš©μž λͺ©λ‘μ— μ—†μŠ΅λ‹ˆλ‹€.", HttpStatus.NOT_FOUND); + } + } catch (Exception e) { + return ApiResponseUtil.fail("μ„œλ²„ 였λ₯˜λ‘œ 인해 μ‚¬μš©μžλ₯Ό μ²˜λ¦¬ν•˜μ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€.", HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + /// λŒ€κΈ°μ—΄μ—μ„œ 퇴μž₯ + @GetMapping("/exit") + public ResponseEntity> exitWaitingUser( + @RequestParam String festivalId, + @RequestParam LocalDateTime reservationDate, + @Parameter(hidden = true) Authentication authentication) { + try { + boolean removed = waitingService.removeUserFromQueue(festivalId, reservationDate, getUserId(authentication)); + if (removed) { + return ApiResponseUtil.success("λŒ€κΈ°ν•˜λ˜ μ‚¬μš©μžκ°€ λŒ€κΈ°μ—΄μ„ λ‚˜κ°”μŠ΅λ‹ˆλ‹€."); + } else { + return ApiResponseUtil.fail("ν•΄λ‹Ή μ‚¬μš©μžλŠ” λŒ€κΈ°μ—΄ λͺ©λ‘μ— μ—†μŠ΅λ‹ˆλ‹€.", HttpStatus.NOT_FOUND); + } + } catch (Exception e) { + return ApiResponseUtil.fail("μ„œλ²„ 였λ₯˜λ‘œ 인해 μ‚¬μš©μžλ₯Ό μ²˜λ¦¬ν•˜μ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€.", HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + public String getUserId(Authentication authentication) { + return String.valueOf(securityResponseUtil.requireUserId(authentication)); + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/controller/WebSocketAdminController.java b/src/main/java/com/mnms/booking/controller/WebSocketAdminController.java new file mode 100644 index 0000000..24cfe3d --- /dev/null +++ b/src/main/java/com/mnms/booking/controller/WebSocketAdminController.java @@ -0,0 +1,27 @@ +package com.mnms.booking.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.simp.user.SimpUser; +import org.springframework.messaging.simp.user.SimpUserRegistry; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Set; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/ws") +@RequiredArgsConstructor +public class WebSocketAdminController { + + private final SimpUserRegistry simpUserRegistry; + + // ν˜„μž¬ WebSocket에 μ—°κ²°λœ μ‚¬μš©μž 확인 + @GetMapping("/users") + public Set connectedUsers() { + return simpUserRegistry.getUsers().stream() + .map(SimpUser::getName) + .collect(Collectors.toSet()); + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/dto/request/BookingRequestDTO.java b/src/main/java/com/mnms/booking/dto/request/BookingRequestDTO.java new file mode 100644 index 0000000..206ca8d --- /dev/null +++ b/src/main/java/com/mnms/booking/dto/request/BookingRequestDTO.java @@ -0,0 +1,13 @@ +package com.mnms.booking.dto.request; + +import lombok.Data; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Data @Getter +public class BookingRequestDTO { + private String festivalId; + private LocalDateTime performanceDate; + private String reservationNumber; +} diff --git a/src/main/java/com/mnms/booking/dto/request/BookingSelectDeliveryRequestDTO.java b/src/main/java/com/mnms/booking/dto/request/BookingSelectDeliveryRequestDTO.java new file mode 100644 index 0000000..636d38b --- /dev/null +++ b/src/main/java/com/mnms/booking/dto/request/BookingSelectDeliveryRequestDTO.java @@ -0,0 +1,13 @@ +package com.mnms.booking.dto.request; + +import lombok.Data; +import lombok.Getter; + +@Data +@Getter +public class BookingSelectDeliveryRequestDTO { + private String festivalId; + private String reservationNumber; + private String deliveryMethod; + private String address; +} diff --git a/src/main/java/com/mnms/booking/dto/request/BookingSelectRequestDTO.java b/src/main/java/com/mnms/booking/dto/request/BookingSelectRequestDTO.java new file mode 100644 index 0000000..f8f11e8 --- /dev/null +++ b/src/main/java/com/mnms/booking/dto/request/BookingSelectRequestDTO.java @@ -0,0 +1,14 @@ +package com.mnms.booking.dto.request; + + +import lombok.Data; +import lombok.Getter; +import java.time.LocalDateTime; + +@Data +@Getter +public class BookingSelectRequestDTO { + private String festivalId; + private LocalDateTime performanceDate; + private int selectedTicketCount; +} diff --git a/src/main/java/com/mnms/booking/dto/request/CaptchaRequestDTO.java b/src/main/java/com/mnms/booking/dto/request/CaptchaRequestDTO.java new file mode 100644 index 0000000..da696b0 --- /dev/null +++ b/src/main/java/com/mnms/booking/dto/request/CaptchaRequestDTO.java @@ -0,0 +1,24 @@ +package com.mnms.booking.dto.request; + +import lombok.Getter; + +import java.time.Duration; +import java.time.LocalDateTime; + +// μΊ‘μ°¨ 정보 DTO +@Getter +public class CaptchaRequestDTO { + private final String code; + private final LocalDateTime creationTime; + + public CaptchaRequestDTO(String code) { + this.code = code; + this.creationTime = LocalDateTime.now(); + } + + // μΊ‘μ°¨ 만료 μ—¬λΆ€ 확인 + public boolean isExpired(long expirationMillis) { + Duration duration = Duration.between(creationTime, LocalDateTime.now()); + return duration.toMillis() > expirationMillis; + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/dto/request/HostRequestDTO.java b/src/main/java/com/mnms/booking/dto/request/HostRequestDTO.java new file mode 100644 index 0000000..34a71e6 --- /dev/null +++ b/src/main/java/com/mnms/booking/dto/request/HostRequestDTO.java @@ -0,0 +1,10 @@ +package com.mnms.booking.dto.request; + +import lombok.Getter; +import java.time.LocalDateTime; + +@Getter +public class HostRequestDTO { + private String festivalId; + private LocalDateTime performanceDate; +} diff --git a/src/main/java/com/mnms/booking/dto/request/LeaveQueueRequestDTO.java b/src/main/java/com/mnms/booking/dto/request/LeaveQueueRequestDTO.java new file mode 100644 index 0000000..b4e8eea --- /dev/null +++ b/src/main/java/com/mnms/booking/dto/request/LeaveQueueRequestDTO.java @@ -0,0 +1,14 @@ +package com.mnms.booking.dto.request; + +import lombok.Data; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Data +@Getter +public class LeaveQueueRequestDTO { + private String festivalId; + private LocalDateTime reservationDate; + private String userId; +} diff --git a/src/main/java/com/mnms/booking/dto/request/QrRequestDTO.java b/src/main/java/com/mnms/booking/dto/request/QrRequestDTO.java new file mode 100644 index 0000000..a8e8d19 --- /dev/null +++ b/src/main/java/com/mnms/booking/dto/request/QrRequestDTO.java @@ -0,0 +1,22 @@ +package com.mnms.booking.dto.request; + +import com.mnms.booking.entity.Ticket; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class QrRequestDTO { + private Long id; + private String qrCodeId; + private String userId; + private Ticket ticket; + private LocalDate issuedAt; + private LocalDateTime expiredAt; + private String pinCode; +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/dto/request/TargetInfoRequestDTO.java b/src/main/java/com/mnms/booking/dto/request/TargetInfoRequestDTO.java new file mode 100644 index 0000000..472fdd1 --- /dev/null +++ b/src/main/java/com/mnms/booking/dto/request/TargetInfoRequestDTO.java @@ -0,0 +1,12 @@ +package com.mnms.booking.dto.request; + +import lombok.Getter; +import lombok.Setter; + +import java.util.Map; + +@Getter +@Setter +public class TargetInfoRequestDTO { + private Map targetInfo; +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/dto/request/TicketRequestDTO.java b/src/main/java/com/mnms/booking/dto/request/TicketRequestDTO.java new file mode 100644 index 0000000..14cbebb --- /dev/null +++ b/src/main/java/com/mnms/booking/dto/request/TicketRequestDTO.java @@ -0,0 +1,40 @@ +package com.mnms.booking.dto.request; + +import com.mnms.booking.entity.Festival; +import com.mnms.booking.entity.Ticket; +import com.mnms.booking.enums.TicketType; +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@Builder +public class TicketRequestDTO { + + private Long userId; + private String reservationNumber; + + private String fname; // νŽ˜μŠ€ν‹°λ²Œ 이름 + private LocalDateTime performanceDate; // 선택 λ‚ μ§œ + + private Festival festival; + private String festivalFacility; // 곡연μž₯ μž₯μ†Œ + private int ticketPrice; + private int selectedTicketCount; // 선택 맀수 + private TicketType deliveryMethod; + + public static TicketRequestDTO fromEntity(Ticket ticket) { + return TicketRequestDTO.builder() + .userId(ticket.getUserId()) + .reservationNumber(ticket.getReservationNumber()) + .fname(ticket.getFestival().getFname()) + .performanceDate(ticket.getPerformanceDate()) + .festival(ticket.getFestival()) + .festivalFacility(ticket.getFestival().getFcltynm()) + .ticketPrice(ticket.getFestival().getTicketPrice()) + .selectedTicketCount(ticket.getSelectedTicketCount()) + .deliveryMethod(ticket.getDeliveryMethod()) + .build(); + } +} diff --git a/src/main/java/com/mnms/booking/dto/request/TicketTransferRequestDTO.java b/src/main/java/com/mnms/booking/dto/request/TicketTransferRequestDTO.java new file mode 100644 index 0000000..7e35176 --- /dev/null +++ b/src/main/java/com/mnms/booking/dto/request/TicketTransferRequestDTO.java @@ -0,0 +1,11 @@ +package com.mnms.booking.dto.request; + +import lombok.Data; + +@Data +public class TicketTransferRequestDTO { + private String reservationNumber; + private Long recipientId; // 양도받을 κ°€μ‘±/지인 ID + private String transferType; // "FAMILY" λ˜λŠ” "OTHERS" + private String senderName; +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/dto/request/UpdateTicketRequestDTO.java b/src/main/java/com/mnms/booking/dto/request/UpdateTicketRequestDTO.java new file mode 100644 index 0000000..3f10c91 --- /dev/null +++ b/src/main/java/com/mnms/booking/dto/request/UpdateTicketRequestDTO.java @@ -0,0 +1,21 @@ +package com.mnms.booking.dto.request; + +import com.mnms.booking.enums.TicketType; +import com.mnms.booking.enums.TransferStatus; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UpdateTicketRequestDTO { + private Long transferId; + private Long senderId; + private TransferStatus transferStatus; + + private TicketType ticketType; + private String address; +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/dto/response/AddressResponseDTO.java b/src/main/java/com/mnms/booking/dto/response/AddressResponseDTO.java new file mode 100644 index 0000000..8735211 --- /dev/null +++ b/src/main/java/com/mnms/booking/dto/response/AddressResponseDTO.java @@ -0,0 +1,23 @@ +package com.mnms.booking.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Schema(description = "μ‚¬μš©μž μ£Όμ†Œ 응닡 DTO", name = "AddressDTO") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class AddressResponseDTO { + @Schema(description = "μ‚¬μš©μž μ£Όμ†Œ") + private String address; + + @Schema(description = "μ‚¬μš©μž μ£Όμ†Œ 우편번호") + private String zipCode; + + @Schema(description = "μ‚¬μš©μž μ£Όμ†Œ κΈ°λ³Έ 배솑지 μ—¬λΆ€") + private boolean isDefault; +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/dto/response/ApiResponseDTO.java b/src/main/java/com/mnms/booking/dto/response/ApiResponseDTO.java new file mode 100644 index 0000000..9ed4985 --- /dev/null +++ b/src/main/java/com/mnms/booking/dto/response/ApiResponseDTO.java @@ -0,0 +1,13 @@ +package com.mnms.booking.dto.response; + +import lombok.Getter; + +public class ApiResponseDTO { + private boolean success; + + @Getter + private T data; + private String message; + + // getter, setter +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/dto/response/BookingDetailResponseDTO.java b/src/main/java/com/mnms/booking/dto/response/BookingDetailResponseDTO.java new file mode 100644 index 0000000..70dd5bd --- /dev/null +++ b/src/main/java/com/mnms/booking/dto/response/BookingDetailResponseDTO.java @@ -0,0 +1,34 @@ +package com.mnms.booking.dto.response; + +import com.mnms.booking.entity.Festival; +import com.mnms.booking.entity.Ticket; +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; +import java.time.LocalDateTime; + + +@Getter @Setter +@Builder +public class BookingDetailResponseDTO { + + private String festivalName; + private int ticketPrice; + private String posterFile; + private LocalDateTime performanceDate; + private int ticketCount; + private Long sellerId; + private int ticketPick; + + public static BookingDetailResponseDTO fromEntities(Festival festival, Ticket ticket) { + return BookingDetailResponseDTO.builder() + .festivalName(festival.getFname()) + .posterFile(festival.getPosterFile()) + .sellerId(festival.getOrganizer()) + .ticketPrice(festival.getTicketPrice()) + .performanceDate(ticket.getPerformanceDate()) + .ticketCount(ticket.getSelectedTicketCount()) + .ticketPick(festival.getTicketPick()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/dto/response/BookingResponseDTO.java b/src/main/java/com/mnms/booking/dto/response/BookingResponseDTO.java new file mode 100644 index 0000000..79e9f17 --- /dev/null +++ b/src/main/java/com/mnms/booking/dto/response/BookingResponseDTO.java @@ -0,0 +1,45 @@ +package com.mnms.booking.dto.response; + +import com.mnms.booking.entity.Festival; +import com.mnms.booking.enums.ReservationStatus; +import com.mnms.booking.entity.Ticket; +import com.mnms.booking.enums.TicketType; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + + +// 좔후에 μ§€μšΈ 수 있음. - κ°€μ˜ˆλ§€ 3μ°¨μ—μ„œ λ°˜ν™˜κ°’μœΌλ‘œ μ‚¬μš©ν–ˆμ—ˆμŒ. +@Data +@Builder +public class BookingResponseDTO { + + private Long id; + private String reservationNumber; + private ReservationStatus reservationStatus; + private TicketType deliveryMethod; + private LocalDateTime deliveryDate; + private Long userId; + private LocalDateTime reservationDate; + private List qrCodes; + private Festival festival; + + public static BookingResponseDTO fromEntity(Ticket ticket) { + return BookingResponseDTO.builder() + .id(ticket.getId()) + .reservationNumber(ticket.getReservationNumber()) + .reservationStatus(ticket.getReservationStatus()) + .deliveryMethod(ticket.getDeliveryMethod()) + .deliveryDate(ticket.getDeliveryDate()) + .reservationDate(ticket.getReservationDate()) + .qrCodes(ticket.getQrCodes().stream() + .map(QrResponseDTO::fromEntity) + .collect(Collectors.toList())) + .userId(ticket.getUserId()) + .festival(ticket.getFestival()) + .build(); + } +} + diff --git a/src/main/java/com/mnms/booking/dto/response/BookingUserInfoResponseDTO.java b/src/main/java/com/mnms/booking/dto/response/BookingUserInfoResponseDTO.java new file mode 100644 index 0000000..5a07415 --- /dev/null +++ b/src/main/java/com/mnms/booking/dto/response/BookingUserInfoResponseDTO.java @@ -0,0 +1,14 @@ +package com.mnms.booking.dto.response; + +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +@Data +@Builder +public class BookingUserInfoResponseDTO { + private Long userId; + private String name; + private String phone; +} diff --git a/src/main/java/com/mnms/booking/dto/response/BookingUserResponseDTO.java b/src/main/java/com/mnms/booking/dto/response/BookingUserResponseDTO.java new file mode 100644 index 0000000..6dfb136 --- /dev/null +++ b/src/main/java/com/mnms/booking/dto/response/BookingUserResponseDTO.java @@ -0,0 +1,11 @@ +package com.mnms.booking.dto.response; + +import lombok.Data; +import lombok.Getter; + +@Data +@Getter +public class BookingUserResponseDTO { + private String name; + private String email; +} diff --git a/src/main/java/com/mnms/booking/dto/response/CaptchaResponseDTO.java b/src/main/java/com/mnms/booking/dto/response/CaptchaResponseDTO.java new file mode 100644 index 0000000..9b948a3 --- /dev/null +++ b/src/main/java/com/mnms/booking/dto/response/CaptchaResponseDTO.java @@ -0,0 +1,11 @@ +package com.mnms.booking.dto.response; + +import lombok.Builder; +import lombok.Data; + +@Data // Lombok μ‚¬μš© μ‹œ +@Builder +public class CaptchaResponseDTO { + private boolean success; + private String message; +} diff --git a/src/main/java/com/mnms/booking/dto/response/FestivalDetailResponseDTO.java b/src/main/java/com/mnms/booking/dto/response/FestivalDetailResponseDTO.java new file mode 100644 index 0000000..ca5238e --- /dev/null +++ b/src/main/java/com/mnms/booking/dto/response/FestivalDetailResponseDTO.java @@ -0,0 +1,34 @@ +package com.mnms.booking.dto.response; + +import com.mnms.booking.entity.Festival; +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Data +@Builder +public class FestivalDetailResponseDTO { + /// festival 정보 + private String fname; // festival 이름 + private int ticketPrice; + private String posterFile; + private int maxPurchase; // μ΅œλŒ€ ꡬ맀 κ°€λŠ₯ 맀수 + private List schedules; // 곡연 μŠ€μΌ€μ€„ λͺ©λ‘ + + /// ticket 정보 + private LocalDateTime performanceDate; // 선택 λ‚ μ§œ,μ‹œκ°„ + + public static FestivalDetailResponseDTO fromEntity(Festival festival, LocalDateTime performanceDate, List schedules) { + return FestivalDetailResponseDTO.builder() + .fname(festival.getFname()) + .ticketPrice(festival.getTicketPrice()) + .posterFile(festival.getPosterFile()) + .maxPurchase(festival.getMaxPurchase()) + .performanceDate(performanceDate) + .schedules(schedules) + .build(); + } + +} diff --git a/src/main/java/com/mnms/booking/dto/response/HostResponseDTO.java b/src/main/java/com/mnms/booking/dto/response/HostResponseDTO.java new file mode 100644 index 0000000..58d0c08 --- /dev/null +++ b/src/main/java/com/mnms/booking/dto/response/HostResponseDTO.java @@ -0,0 +1,25 @@ +package com.mnms.booking.dto.response; + +import com.mnms.booking.enums.TicketType; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import java.time.LocalDateTime; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class HostResponseDTO { + private String reservationNumber; + private LocalDateTime performanceDate; + private Long userId; + private int selectedTicketCount; + private TicketType deliveryMethod; + private String address; + + // user info + private String userName; + private String phoneNumber; +} diff --git a/src/main/java/com/mnms/booking/dto/response/PersonInfoResponseDTO.java b/src/main/java/com/mnms/booking/dto/response/PersonInfoResponseDTO.java new file mode 100644 index 0000000..25bc284 --- /dev/null +++ b/src/main/java/com/mnms/booking/dto/response/PersonInfoResponseDTO.java @@ -0,0 +1,15 @@ +package com.mnms.booking.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.Getter; +import lombok.ToString; + +@Data +@Getter +@AllArgsConstructor +@ToString +public class PersonInfoResponseDTO { + private final String name; + private final String rrnFront; +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/dto/response/QrResponseDTO.java b/src/main/java/com/mnms/booking/dto/response/QrResponseDTO.java new file mode 100644 index 0000000..34e18b3 --- /dev/null +++ b/src/main/java/com/mnms/booking/dto/response/QrResponseDTO.java @@ -0,0 +1,60 @@ +package com.mnms.booking.dto.response; + +import com.mnms.booking.entity.Festival; +import com.mnms.booking.entity.QrCode; +import com.mnms.booking.entity.Ticket; +import lombok.*; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class QrResponseDTO { + + private Long id; + private String qrCodeId; + private Long userId; + private LocalDate issuedAt; + private LocalDateTime expiredAt; + private Boolean used; + private LocalDateTime usedAt; + private Ticket ticket; + + public static QrResponseDTO fromEntity(QrCode qrCode) { + return QrResponseDTO.builder() + .id(qrCode.getId()) + .qrCodeId(qrCode.getQrCodeId()) + .issuedAt(qrCode.getIssuedAt()) + .expiredAt(qrCode.getExpiredAt()) + .used(qrCode.getUsed()) + .usedAt(qrCode.getUsedAt()) + .userId(qrCode.getUserId()) + .build(); + } + + public QrCode toEntity() { + return QrCode.builder() + .id(this.id) + .qrCodeId(this.qrCodeId) + .issuedAt(this.issuedAt) + .expiredAt(this.expiredAt) + .used(this.used != null ? this.used : false) // κΈ°λ³Έκ°’ 처리 + .usedAt(this.usedAt) + .userId(this.userId) + .ticket(this.ticket) + .build(); + } + + public static QrResponseDTO create(Long userId, String qrCodeId,Festival festival, Ticket ticket) { + QrResponseDTO dto = new QrResponseDTO(); + dto.setUserId(userId); + dto.setQrCodeId(qrCodeId); + dto.setIssuedAt(LocalDate.now()); + dto.setExpiredAt(ticket.getPerformanceDate().plusMinutes(30)); + dto.setTicket(ticket); + return dto; + } +} diff --git a/src/main/java/com/mnms/booking/dto/response/ScheduleResponseDTO.java b/src/main/java/com/mnms/booking/dto/response/ScheduleResponseDTO.java new file mode 100644 index 0000000..14600d5 --- /dev/null +++ b/src/main/java/com/mnms/booking/dto/response/ScheduleResponseDTO.java @@ -0,0 +1,14 @@ +package com.mnms.booking.dto.response; + +import com.mnms.booking.entity.Schedule; +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Builder +public class ScheduleResponseDTO { + private String dayOfWeek; // μš”μΌ μ½”λ“œ + private String time; // 곡연 μ‹œμž‘ μ‹œκ°„ (HH:mm) +} diff --git a/src/main/java/com/mnms/booking/dto/response/StatisticsBookingDTO.java b/src/main/java/com/mnms/booking/dto/response/StatisticsBookingDTO.java new file mode 100644 index 0000000..871ba2d --- /dev/null +++ b/src/main/java/com/mnms/booking/dto/response/StatisticsBookingDTO.java @@ -0,0 +1,24 @@ +package com.mnms.booking.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@AllArgsConstructor +@Schema(description = "곡연 λ‚ μ§œλ³„ 예맀 ν˜„ν™© 및 수용 인원 정보 DTO") +public class StatisticsBookingDTO { + + @Schema(description = "곡연 λ‚ μ§œ 및 μ‹œκ°„", example = "2025-09-01T19:00:00") + private LocalDateTime performanceDate; + + @Schema(description = "ν•΄λ‹Ή κ³΅μ—°μ˜ 총 예맀자 수", example = "150") + private Long bookingCount; + + @Schema(description = "ν•΄λ‹Ή νŽ˜μŠ€ν‹°λ²Œμ˜ 총 수용 인원", example = "500") + private int availableNOP; +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/dto/response/StatisticsQrCodeResponseDTO.java b/src/main/java/com/mnms/booking/dto/response/StatisticsQrCodeResponseDTO.java new file mode 100644 index 0000000..60f1316 --- /dev/null +++ b/src/main/java/com/mnms/booking/dto/response/StatisticsQrCodeResponseDTO.java @@ -0,0 +1,29 @@ +package com.mnms.booking.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "곡연 μ‹€μ‹œκ°„ μž…μž₯ 톡계 응닡 DTO") +public class StatisticsQrCodeResponseDTO { + + @Schema(description = "νŽ˜μŠ€ν‹°λ²Œ ID", example = "PF272573") + private String festivalId; + + @Schema(description = "곡연 λ‚ μ§œ 및 μ‹œκ°„", example = "2025-09-07T17:00:00") + private LocalDateTime performanceDate; + + @Schema(description = "총 예맀 κ°€λŠ₯ 인원", example = "100") + private int availableNOP; + + @Schema(description = "μž…μž₯ μ™„λ£Œλœ 인원", example = "42") + private int checkedInCount; +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/dto/response/StatisticsUserResponseDTO.java b/src/main/java/com/mnms/booking/dto/response/StatisticsUserResponseDTO.java new file mode 100644 index 0000000..3f1331a --- /dev/null +++ b/src/main/java/com/mnms/booking/dto/response/StatisticsUserResponseDTO.java @@ -0,0 +1,32 @@ +package com.mnms.booking.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.Map; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "곡연 예맀자 톡계 응닡 DTO") +public class StatisticsUserResponseDTO { + + @Schema(description = "총 예맀자 수", example = "50") + private int totalPopulation; + + @Schema(description = "성별 예맀자 수", example = "{\"male\": 25, \"female\": 25}") + private Map genderCount; + + @Schema(description = "성별 λΉ„μœ¨(%)", example = "{\"male\": \"50.00%\", \"female\": \"50.00%\"}") + private Map genderPercentage; + + @Schema(description = "μ—°λ ΉλŒ€λ³„ 예맀자 수", example = "{\"10λŒ€\": 5, \"20λŒ€\": 20, \"30λŒ€\": 15, \"40λŒ€\": 8, \"50λŒ€ 이상\": 2}") + private Map ageGroupCount; + + @Schema(description = "μ—°λ ΉλŒ€λ³„ λΉ„μœ¨(%)", example = "{\"10λŒ€\": \"10.00%\", \"20λŒ€\": \"40.00%\", \"30λŒ€\": \"30.00%\", \"40λŒ€\": \"16.00%\", \"50λŒ€ 이상\": \"4.00%\"}") + private Map ageGroupPercentage; +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/dto/response/TicketDetailResponseDTO.java b/src/main/java/com/mnms/booking/dto/response/TicketDetailResponseDTO.java new file mode 100644 index 0000000..8354ccc --- /dev/null +++ b/src/main/java/com/mnms/booking/dto/response/TicketDetailResponseDTO.java @@ -0,0 +1,59 @@ +package com.mnms.booking.dto.response; + +import com.mnms.booking.entity.Festival; +import com.mnms.booking.entity.QrCode; +import com.mnms.booking.entity.Ticket; +import com.mnms.booking.enums.TicketType; +import lombok.Builder; +import lombok.Data; +import java.time.LocalDateTime; +import java.util.List; + + +@Data +@Builder +public class TicketDetailResponseDTO { + + // authentication name + private String userName; + + // ticket + private Long id; + private String reservationNumber; // 예맀 번호 + private LocalDateTime performanceDate; // 곡연 μΌμ‹œ + private TicketType deliveryMethod; // ν‹°μΌ“ 수령 방법 + private List qrId; + private String address; + private String posterFile; + + // festival + private String festivalId; // festivalId + private String fname; // 곡연λͺ… + private String fcltynm; // μž₯μ†Œ + private int ticketPrice; // 1λ§€ ν‹°μΌ“ 가격 + + // qr + private boolean qrUsed; + + public static TicketDetailResponseDTO fromEntity(Ticket ticket, Festival festival, String userName, boolean qrUsed) { + List qrIds = ticket.getQrCodes().stream() + .map(QrCode::getQrCodeId) + .toList(); + + return TicketDetailResponseDTO.builder() + .id(ticket.getId()) + .userName(userName) + .reservationNumber(ticket.getReservationNumber()) + .performanceDate(ticket.getPerformanceDate()) + .deliveryMethod(ticket.getDeliveryMethod()) + .address(ticket.getAddress()) + .qrId(qrIds) + .qrUsed(qrUsed) + .festivalId(festival.getFestivalId()) + .fname(festival.getFname()) + .fcltynm(festival.getFcltynm()) + .ticketPrice(festival.getTicketPrice()) + .posterFile(festival.getPosterFile()) + .build(); + } +} diff --git a/src/main/java/com/mnms/booking/dto/response/TicketResponseDTO.java b/src/main/java/com/mnms/booking/dto/response/TicketResponseDTO.java new file mode 100644 index 0000000..1f95df8 --- /dev/null +++ b/src/main/java/com/mnms/booking/dto/response/TicketResponseDTO.java @@ -0,0 +1,51 @@ +package com.mnms.booking.dto.response; + +import com.mnms.booking.entity.Festival; +import com.mnms.booking.entity.Ticket; +import com.mnms.booking.enums.ReservationStatus; +import com.mnms.booking.enums.TicketType; +import lombok.Builder; +import lombok.Data; +import java.time.LocalDateTime; + + +// 예맀 λ‚΄μ—­ κΈ°λ³Έ 쑰회 +@Data +@Builder +public class TicketResponseDTO { + + // ticket + private Long id; + private String reservationNumber; // 예맀 번호 + private LocalDateTime performanceDate; // 곡연 μΌμ‹œ + private int selectedTicketCount; // 맀수 + private TicketType deliveryMethod; // ν‹°μΌ“ 수령 방법 + private LocalDateTime reservationDate; // 예맀λ₯Ό μˆ˜ν–‰ν•œ λ‚ μ§œ + private ReservationStatus reservationStatus; // μ˜ˆλ§€μƒνƒœ + + // 좔가적 μš”μ†Œ + private boolean othersTransferAvailable; // 지인 양도 κ°€λŠ₯ 유무 + + // festival + private String festivalId; // festivalId + private String posterFile; + private String fname; // 곡연λͺ… + private String fcltynm; // μž₯μ†Œ + + public static TicketResponseDTO fromEntity(Ticket ticket, Festival festival) { + return TicketResponseDTO.builder() + .id(ticket.getId()) + .reservationNumber(ticket.getReservationNumber()) + .performanceDate(ticket.getPerformanceDate()) + .selectedTicketCount(ticket.getSelectedTicketCount()) + .deliveryMethod(ticket.getDeliveryMethod()) + .reservationDate(ticket.getReservationDate()) + .reservationStatus(ticket.getReservationStatus()) + .festivalId(festival.getFestivalId()) + .posterFile(festival.getPosterFile()) + .fname(festival.getFname()) + .fcltynm(festival.getFcltynm()) + .othersTransferAvailable(LocalDateTime.now().isBefore(ticket.getReservationDate().plusMinutes(15))) + .build(); + } +} diff --git a/src/main/java/com/mnms/booking/dto/response/TicketStatusResponseDTO.java b/src/main/java/com/mnms/booking/dto/response/TicketStatusResponseDTO.java new file mode 100644 index 0000000..57fd743 --- /dev/null +++ b/src/main/java/com/mnms/booking/dto/response/TicketStatusResponseDTO.java @@ -0,0 +1,5 @@ +package com.mnms.booking.dto.response; + +import com.mnms.booking.enums.ReservationStatus; + +public record TicketStatusResponseDTO(String reservationNumber, ReservationStatus status) {} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/dto/response/TicketTransferResponseDTO.java b/src/main/java/com/mnms/booking/dto/response/TicketTransferResponseDTO.java new file mode 100644 index 0000000..440721e --- /dev/null +++ b/src/main/java/com/mnms/booking/dto/response/TicketTransferResponseDTO.java @@ -0,0 +1,58 @@ +package com.mnms.booking.dto.response; + +import com.mnms.booking.entity.Festival; +import com.mnms.booking.entity.Ticket; +import com.mnms.booking.entity.Transfer; +import com.mnms.booking.enums.TransferType; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class TicketTransferResponseDTO { + + /// TRANSFER + private Long transferId; + private Long senderId; + private String senderName; + private TransferType transferType; + private LocalDateTime createdAt; + private String status; + + /// FESTIVAL + private String fname; + private String posterFile; + private String fcltynm; + private int ticketPrice; + private int ticketPick; + + /// TICKET + private LocalDateTime performanceDate; + private String reservationNumber; + private int selectedTicketCount; + + public static TicketTransferResponseDTO from(Transfer transfer, Ticket ticket, Festival festival) { + return TicketTransferResponseDTO.builder() + .transferId(transfer.getId()) + .senderId(transfer.getSenderId()) + .senderName(transfer.getSenderName()) + .transferType(transfer.getTransferType()) + .createdAt(transfer.getCreatedAt()) + .status(String.valueOf(transfer.getStatus())) + .fname(festival.getFname()) + .ticketPick(festival.getTicketPick()) + .posterFile(festival.getPosterFile()) + .fcltynm(festival.getFcltynm()) + .ticketPrice(festival.getTicketPrice()) + .performanceDate(ticket.getPerformanceDate()) + .reservationNumber(ticket.getReservationNumber()) + .selectedTicketCount(ticket.getSelectedTicketCount()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/dto/response/TransferOthersResponseDTO.java b/src/main/java/com/mnms/booking/dto/response/TransferOthersResponseDTO.java new file mode 100644 index 0000000..711eabb --- /dev/null +++ b/src/main/java/com/mnms/booking/dto/response/TransferOthersResponseDTO.java @@ -0,0 +1,48 @@ +package com.mnms.booking.dto.response; + +import com.mnms.booking.entity.Festival; +import com.mnms.booking.entity.Ticket; +import com.mnms.booking.entity.Transfer; +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; + +// 지인 κ°„ 결제 μ‹œ λ°˜ν™˜ν•  λ‚΄μš© + +@Data +@Builder +public class TransferOthersResponseDTO { + private Long receiverId; // μ–‘μˆ˜μž id + private Long senderId; // μ–‘λ„μž id + + private String reservationNumber; + private int selectedTicketCount; // ν‹°μΌ“ 개수 + private LocalDateTime performanceDate; + + private int ticketPrice; + private String fname; + private String posterFile; + + public static TransferOthersResponseDTO from(Transfer transfer, Ticket ticket, Festival festival, Long userId) { + return TransferOthersResponseDTO.builder() + .reservationNumber(ticket.getReservationNumber()) + .senderId(transfer.getSenderId()) + .receiverId(userId) + .selectedTicketCount(ticket.getSelectedTicketCount()) + .ticketPrice(festival.getTicketPrice()) + .fname(festival.getFname()) + .posterFile(festival.getPosterFile()) + .performanceDate(ticket.getPerformanceDate()) + .build(); + } + + // μ·¨μ†Œ + public static TransferOthersResponseDTO canceled(String reservationNumber, Long senderId, Long receiverId) { + return TransferOthersResponseDTO.builder() + .reservationNumber(reservationNumber) + .senderId(senderId) + .receiverId(receiverId) + .build(); + } +} diff --git a/src/main/java/com/mnms/booking/dto/response/TransferStatusResponseDTO.java b/src/main/java/com/mnms/booking/dto/response/TransferStatusResponseDTO.java new file mode 100644 index 0000000..bf73945 --- /dev/null +++ b/src/main/java/com/mnms/booking/dto/response/TransferStatusResponseDTO.java @@ -0,0 +1,5 @@ +package com.mnms.booking.dto.response; + +import com.mnms.booking.enums.TransferStatus; + +public record TransferStatusResponseDTO(String reservationNumber, TransferStatus status) {} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/dto/response/TransferUserResponse.java b/src/main/java/com/mnms/booking/dto/response/TransferUserResponse.java new file mode 100644 index 0000000..da29ad2 --- /dev/null +++ b/src/main/java/com/mnms/booking/dto/response/TransferUserResponse.java @@ -0,0 +1,6 @@ +package com.mnms.booking.dto.response; + +public class TransferUserResponse { + private String name; + private String birth; // 주민번호 +} diff --git a/src/main/java/com/mnms/booking/dto/response/WaitingNumberResponseDTO.java b/src/main/java/com/mnms/booking/dto/response/WaitingNumberResponseDTO.java new file mode 100644 index 0000000..d06cbc1 --- /dev/null +++ b/src/main/java/com/mnms/booking/dto/response/WaitingNumberResponseDTO.java @@ -0,0 +1,16 @@ +package com.mnms.booking.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class WaitingNumberResponseDTO { + private String userId; + private long waitingNumber; // λŒ€κΈ° 순번 + // 선택 + private boolean immediateEntry; // μ¦‰μ‹œ μž…μž₯ μ—¬λΆ€ μΆ”κ°€ (λ˜λŠ” λ‹€λ₯Έ μƒνƒœ ν•„λ“œ) + private String message; // μ‚¬μš©μžμ—κ²Œ 보여쀄 λ©”μ‹œμ§€ μΆ”κ°€ (선택 사항) +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/entity/Festival.java b/src/main/java/com/mnms/booking/entity/Festival.java new file mode 100644 index 0000000..12c16b1 --- /dev/null +++ b/src/main/java/com/mnms/booking/entity/Festival.java @@ -0,0 +1,119 @@ +package com.mnms.booking.entity; + +import com.fasterxml.jackson.annotation.JsonManagedReference; +import com.mnms.booking.kafka.dto.FestivalEventDTO; +import jakarta.persistence.*; +import lombok.*; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + + +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Festival { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; // DB PK (μžλ™μ¦κ°€) + + @Column(name = "festival_id", unique = true, nullable = false, length = 20) + private String festivalId; // 곡연 고유 ID (PF000001) + + @Column(name = "fname", nullable = false) + private String fname; // 곡연λͺ… + + @Column(name = "fdfrom", nullable = false) + private LocalDate fdfrom; // 곡연 μ‹œμž‘μΌ (YYYY-MM-DD) + + @Column(name = "fdto", nullable = false) + private LocalDate fdto; // 곡연 μ’…λ£ŒμΌ (YYYY-MM-DD) + + @Column(name = "poster_file") + private String posterFile; // 곡연 λŒ€ν‘œ 이미지 URL + + @Column(name = "fcltynm") + private String fcltynm; // 곡연μž₯ μž₯μ†Œ + + @Column(name = "ticket_pick") + private int ticketPick; // ν‹°μΌ“ 배솑 방법 (1=λ‘˜λ‹€, 2=qr만) + + @Column(name = "max_purchase") + private int maxPurchase; // 1회 μ΅œλŒ€ ꡬ맀 κ°€λŠ₯ μˆ˜λŸ‰ (1~4) + + @Column(name = "ticket_price") + private int ticketPrice; // ν‹°μΌ“ 가격 (원 λ‹¨μœ„) + + @Column(name = "available_nop") + private int availableNOP; // μˆ˜μš©μΈμ› + + @Column(name = "organizer") + private Long organizer; + + @OneToMany(mappedBy = "festival", cascade = CascadeType.ALL, orphanRemoval = true) + @JsonManagedReference + @Builder.Default + @Setter + private List schedules = new ArrayList<>(); + + public static Festival fromDto(FestivalEventDTO dto) { + return Festival.builder() + .festivalId(dto.getId()) + .fname(dto.getFname()) + .fdfrom(dto.getFdfrom()) + .fdto(dto.getFdto()) + .posterFile(dto.getPosterFile()) + .fcltynm(dto.getFcltynm()) + .ticketPick(dto.getTicketPick()) + .maxPurchase(dto.getMaxPurchase()) + .ticketPrice(dto.getTicketPrice()) + .availableNOP(dto.getAvailableNOP()) + .organizer(dto.getUserId()) + .build(); + } + + public void updateFromDto(FestivalEventDTO dto) { + if (dto.getFname() != null) this.fname = dto.getFname(); + if (dto.getUserId() != null) this.organizer = dto.getUserId(); + if (dto.getPosterFile() != null) this.posterFile = dto.getPosterFile(); + if (dto.getFdfrom() != null) this.fdfrom = dto.getFdfrom(); + if (dto.getFdto() != null) this.fdto = dto.getFdto(); + if (dto.getFcltynm() != null) this.fcltynm = dto.getFcltynm(); + if (dto.getMaxPurchase() != 0) this.maxPurchase = dto.getMaxPurchase(); + if (dto.getAvailableNOP() != 0) this.availableNOP = dto.getAvailableNOP(); + if (dto.getTicketPrice() != 0) this.ticketPrice = dto.getTicketPrice(); + if (dto.getTicketPick() != 0) this.ticketPick = dto.getTicketPick(); + } + + @Transactional + public void mergeSchedules(List updatedSchedules) { + Map existingMap = this.schedules.stream() + .collect(Collectors.toMap(Schedule::getScheduleId, s -> s)); + + List merged = new ArrayList<>(); + + for (Schedule updated : updatedSchedules) { + Schedule schedule = existingMap.get(updated.getScheduleId()); + if (schedule != null) { + schedule.setScheduleId(updated.getScheduleId()); + schedule.setDayOfWeek(updated.getDayOfWeek()); + schedule.setTime(updated.getTime()); + merged.add(schedule); + } else { + updated.setFestival(this); + merged.add(updated); + } + } + + // 기쑴에 있고 이번 μ—…λ°μ΄νŠΈμ— μ—†λŠ” μŠ€μΌ€μ€„μ€ 제거 + this.schedules.clear(); + this.schedules.addAll(merged); + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/entity/QrCode.java b/src/main/java/com/mnms/booking/entity/QrCode.java new file mode 100644 index 0000000..2eddb7b --- /dev/null +++ b/src/main/java/com/mnms/booking/entity/QrCode.java @@ -0,0 +1,61 @@ +package com.mnms.booking.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "qr_code") +public class QrCode { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; // qr_id + + @Setter + @Column(name = "qr_code_id", nullable = false) + private String qrCodeId; + + @Column(name = "issued_at", nullable = false) + private LocalDate issuedAt; + + @Column(name = "expired_at", nullable = false) + private LocalDateTime expiredAt; + + @Column(name = "used", nullable = false) + private Boolean used; + + @Column(name = "used_at") + private LocalDateTime usedAt; + + @Setter + @Column(name = "user_id", nullable = false) + private Long userId; + + @Setter + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "ticket_id") + private Ticket ticket; // ticket_id + + // λΉ„μ§€λ‹ˆμŠ€ 둜직 + public void markAsUsed() { + if (this.used) { + throw new IllegalStateException("이미 μ‚¬μš©λœ QR μ½”λ“œμž…λ‹ˆλ‹€."); + } + if (isExpired()) { + throw new IllegalStateException("QR μ½”λ“œμ˜ μœ νš¨κΈ°κ°„μ΄ μ§€λ‚¬μŠ΅λ‹ˆλ‹€."); + } + this.used = true; + this.usedAt = LocalDateTime.now(); + } + + private boolean isExpired() { + return LocalDateTime.now().isAfter(this.expiredAt); + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/entity/Schedule.java b/src/main/java/com/mnms/booking/entity/Schedule.java new file mode 100644 index 0000000..279e080 --- /dev/null +++ b/src/main/java/com/mnms/booking/entity/Schedule.java @@ -0,0 +1,38 @@ +package com.mnms.booking.entity; + +import com.fasterxml.jackson.annotation.JsonBackReference; +import com.mnms.booking.kafka.dto.ScheduleEventDTO; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Builder +@Getter @Setter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Schedule { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long scheduleId; // 곡연 일정 PK + + @Column(name = "dayOfWeek", length = 3) + private String dayOfWeek; // μš”μΌ μ½”λ“œ (MON, TUE...) + + @Column(name = "time", length = 5) + private String time; // 곡연 μ‹œμž‘ μ‹œκ°„ (HH:mm) + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "festival_id", nullable = false) + @JsonBackReference + private Festival festival; + + public static Schedule fromDto( + ScheduleEventDTO schedule, Festival festival){ + return Schedule.builder() + .dayOfWeek(schedule.getDayOfWeek()) + .time(schedule.getTime()) + .festival(festival) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/entity/Ticket.java b/src/main/java/com/mnms/booking/entity/Ticket.java new file mode 100644 index 0000000..a3904ea --- /dev/null +++ b/src/main/java/com/mnms/booking/entity/Ticket.java @@ -0,0 +1,95 @@ +package com.mnms.booking.entity; + +import com.mnms.booking.enums.ReservationStatus; +import com.mnms.booking.enums.TicketType; +import jakarta.persistence.*; +import lombok.*; +import jakarta.persistence.Id; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Builder @Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "ticket") +public class Ticket { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; // ticket_id + + @Column(name = "reservation_number") + private String reservationNumber; // 예맀번호 + + @Setter + @Column(name = "reservation_status") + private ReservationStatus reservationStatus; // μ˜ˆλ§€μƒνƒœ + + @Setter + @Column(name = "delivery_method") + private TicketType deliveryMethod; // μˆ˜λ Ήλ°©λ²• + + @Column(name = "user_id") + private Long userId; // 예맀자 id + + @Setter + @Column(name = "reservation_date") + private LocalDateTime reservationDate; // 예맀λ₯Ό μˆ˜ν–‰ν•œ λ‚ μ§œ + + @Setter + @Column(name = "delivery_date") + + private LocalDateTime deliveryDate; // 택배 λ‚ μ§œ + + @Column(name = "performance_date") + private LocalDateTime performanceDate; // μ„ νƒν•œ 곡연 λ‚ μ§œ μ‹œκ°„ + + @Column(name = "selected_ticket_count") + private int selectedTicketCount; // 선택 맀수 + + @Setter + @Column(name = "address") + private String address; // μˆ˜λ Ήμ£Όμ†Œ + + @Setter + @Builder.Default + @OneToMany(mappedBy = "ticket", cascade = CascadeType.ALL, orphanRemoval = true) + private List qrCodes = new ArrayList<>(); + + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "festival_id") + private Festival festival; + + public void updateTicketInfo(String reservationNumber, + TicketType deliveryMethod, + Long userId, + LocalDateTime reservationDate, + String address) { + + this.reservationNumber = reservationNumber; + this.deliveryMethod = deliveryMethod; + this.userId = userId; + this.reservationDate = reservationDate; + this.address = address; + } + + public boolean isCanceled() { + return reservationStatus.equals(ReservationStatus.CANCELED); + } + + public boolean isExpired() { + return performanceDate.isBefore(LocalDateTime.now()); + } + + @Override + public String toString() { + return "Ticket{" + + "id=" + id + + ", reservationNumber='" + reservationNumber + '\'' + + ", festival=" + (festival != null ? festival : "null") + + '}'; + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/entity/Transfer.java b/src/main/java/com/mnms/booking/entity/Transfer.java new file mode 100644 index 0000000..6378e39 --- /dev/null +++ b/src/main/java/com/mnms/booking/entity/Transfer.java @@ -0,0 +1,46 @@ +package com.mnms.booking.entity; + +import com.mnms.booking.enums.TicketType; +import com.mnms.booking.enums.TransferStatus; +import com.mnms.booking.enums.TransferType; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Builder +@Entity +@Getter +@ToString +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Transfer { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Long senderId; + + private String senderName; + + private Long receiverId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "ticket_id", nullable = false) + private Ticket ticket; + + private TransferType transferType; + + @Setter + private TicketType ticketType; + + @Setter + private String address; + + @Setter + @Builder.Default + private TransferStatus status = TransferStatus.REQUESTED; + + @Builder.Default + private LocalDateTime createdAt = LocalDateTime.now(); +} diff --git a/src/main/java/com/mnms/booking/enums/EventType.java b/src/main/java/com/mnms/booking/enums/EventType.java new file mode 100644 index 0000000..617170b --- /dev/null +++ b/src/main/java/com/mnms/booking/enums/EventType.java @@ -0,0 +1,7 @@ +package com.mnms.booking.enums; + +public enum EventType { + FESTIVAL_CREATED, + FESTIVAL_UPDATED, + FESTIVAL_DELETED, +} diff --git a/src/main/java/com/mnms/booking/enums/ReservationStatus.java b/src/main/java/com/mnms/booking/enums/ReservationStatus.java new file mode 100644 index 0000000..801899c --- /dev/null +++ b/src/main/java/com/mnms/booking/enums/ReservationStatus.java @@ -0,0 +1,8 @@ +package com.mnms.booking.enums; + +public enum ReservationStatus { + TEMP_RESERVED, // κ°€μ˜ˆλ§€ (결제 μ „) + PAYMENT_IN_PROGRESS, // 결제 쀑 + CONFIRMED, // 결제 μ™„λ£Œ, ν™•μ • + CANCELED, // 결제 μ‹€νŒ¨ or μ·¨μ†Œ +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/enums/TicketType.java b/src/main/java/com/mnms/booking/enums/TicketType.java new file mode 100644 index 0000000..7a9dbde --- /dev/null +++ b/src/main/java/com/mnms/booking/enums/TicketType.java @@ -0,0 +1,6 @@ +package com.mnms.booking.enums; + +public enum TicketType { + MOBILE, + PAPER +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/enums/TransferStatus.java b/src/main/java/com/mnms/booking/enums/TransferStatus.java new file mode 100644 index 0000000..07be05d --- /dev/null +++ b/src/main/java/com/mnms/booking/enums/TransferStatus.java @@ -0,0 +1,10 @@ +package com.mnms.booking.enums; + +// μš”μ²­, 승인, 거절 + +public enum TransferStatus { + REQUESTED, // μš”μ²­ + APPROVED, // 승인 + COMPLETED, // μ™„λ£Œ + CANCELED // μ·¨μ†Œ (거절) +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/enums/TransferType.java b/src/main/java/com/mnms/booking/enums/TransferType.java new file mode 100644 index 0000000..b7c9a34 --- /dev/null +++ b/src/main/java/com/mnms/booking/enums/TransferType.java @@ -0,0 +1,7 @@ +package com.mnms.booking.enums; + +// κ°€μ‘±, 지인 + +public enum TransferType { + FAMILY, OTHERS +} diff --git a/src/main/java/com/mnms/booking/event/TicketConfirmedEvent.java b/src/main/java/com/mnms/booking/event/TicketConfirmedEvent.java new file mode 100644 index 0000000..ae25db5 --- /dev/null +++ b/src/main/java/com/mnms/booking/event/TicketConfirmedEvent.java @@ -0,0 +1,5 @@ +package com.mnms.booking.event; + +import com.mnms.booking.dto.request.TicketRequestDTO; + +public record TicketConfirmedEvent(TicketRequestDTO ticket) {} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/event/TicketEventListener.java b/src/main/java/com/mnms/booking/event/TicketEventListener.java new file mode 100644 index 0000000..0eb443c --- /dev/null +++ b/src/main/java/com/mnms/booking/event/TicketEventListener.java @@ -0,0 +1,37 @@ +package com.mnms.booking.event; + +import com.mnms.booking.dto.response.BookingUserResponseDTO; +import com.mnms.booking.service.EmailService; +import com.mnms.booking.service.TempReservationService; +import com.mnms.booking.util.UserApiClient; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class TicketEventListener { + + private final UserApiClient userApiClient; + private final EmailService emailService; + private final TempReservationService tempReservationService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleTicketConfirmed(TicketConfirmedEvent event) { + var ticketDto = event.ticket(); // TicketRequestDTO + + try { + BookingUserResponseDTO user = userApiClient.getUserInfoById(ticketDto.getUserId()); + tempReservationService.deleteTempReservation(ticketDto.getReservationNumber()); + emailService.sendTicketConfirmationEmail(ticketDto, user); + log.info("ν‹°μΌ“ ν™•μ • ν›„ 이메일 전솑 μ™„λ£Œ: reservationNumber={}", ticketDto.getReservationNumber()); + } catch (Exception e) { + log.error("ν‹°μΌ“ ν™•μ • ν›„ 이메일 전솑 μ‹€νŒ¨: reservationNumber={}", ticketDto.getReservationNumber(), e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/exception/BusinessException.java b/src/main/java/com/mnms/booking/exception/BusinessException.java new file mode 100644 index 0000000..c508a34 --- /dev/null +++ b/src/main/java/com/mnms/booking/exception/BusinessException.java @@ -0,0 +1,18 @@ +package com.mnms.booking.exception; + +import lombok.Getter; + +@Getter +public class BusinessException extends RuntimeException{ + private final ErrorCode errorCode; + + public BusinessException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode=errorCode; + } + + public BusinessException(ErrorCode errorCode, String message) { + super(message); + this.errorCode=errorCode; + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/exception/ErrorCode.java b/src/main/java/com/mnms/booking/exception/ErrorCode.java new file mode 100644 index 0000000..c1d0f20 --- /dev/null +++ b/src/main/java/com/mnms/booking/exception/ErrorCode.java @@ -0,0 +1,88 @@ +package com.mnms.booking.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum ErrorCode { + + // USER + USER_INVALID("U001", "잘λͺ»λœ μ‚¬μš©μž ID ν˜•μ‹μž…λ‹ˆλ‹€", HttpStatus.BAD_REQUEST), + USER_API_ERROR("U002", "예맀자 정보λ₯Ό κ°€μ Έμ˜€λŠ”λ° μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.", HttpStatus.CONFLICT), + UNKNOWN_ERROR("U003", "μ•Œ 수 μ—†λŠ” 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.", HttpStatus.INTERNAL_SERVER_ERROR), + USER_UNAUTHORIZED_ACCESS("U004", "잘λͺ»λœ μ‚¬μš©μž μ˜ˆλ§€λ‚΄μ—­ μž…λ‹ˆλ‹€.", HttpStatus.UNAUTHORIZED), + // λ³΄μ•ˆλ¬Έμž + SECURITY_NUMBER_INVALID("S001", "μž…λ ₯ν•œ λ¬Έμžκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.", HttpStatus.BAD_REQUEST), + + // FESTIVAL + FESTIVAL_NOT_FOUND("F001","μž…λ ₯ ID에 ν•΄λ‹Ήν•˜λŠ” νŽ˜μŠ€ν‹°λ²Œμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.",HttpStatus.NOT_FOUND), + FESTIVAL_INVALID_DATE("F002", "ν•΄λ‹Ή λ‚ μ§œμ˜ νŽ˜μŠ€ν‹°λ²Œμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.", HttpStatus.BAD_REQUEST), + FESTIVAL_INVALID_TIME("F003", "ν•΄λ‹Ή μ‹œκ°„μ˜ νŽ˜μŠ€ν‹°λ²Œμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.", HttpStatus.BAD_REQUEST), + FESTIVAL_DELIVERY_INVALID("F004", "배솑 방법 μ„ νƒλ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.", HttpStatus.BAD_REQUEST), + FESTIVAL_MISMATCH("F005", "ν•΄λ‹Ήν•˜λŠ” QR의 νŽ˜μŠ€ν‹°λ²Œ μ£Όμ΅œμžκ°€ μ•„λ‹™λ‹ˆλ‹€.", HttpStatus.FORBIDDEN), + FESTIVAL_LIMIT_AVAILABLE_PEOPLE("F006", "ν•΄λ‹Ή νŽ˜μŠ€ν‹°λ²Œ 수용 인원이 μ΄ˆκ³Όλ˜μ–΄ 예맀λ₯Ό μ§„ν–‰ν•  수 μ—†μŠ΅λ‹ˆλ‹€.", HttpStatus.CONFLICT), + + // TICKET + TICKET_ALREADY_RESERVED("T001", "μ˜ˆμ•½ κ°€λŠ₯ν•œ ν‹°μΌ“ 수λ₯Ό μ΄ˆκ³Όν•˜μ˜€μŠ΅λ‹ˆλ‹€.", HttpStatus.CONFLICT), + TICKET_NOT_FOUND("T002", "예맀 정보가 λ§Œλ£Œλ˜μ–΄ μ‘΄μž¬ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.", HttpStatus.NOT_FOUND), + TICKET_INVALID_DELIVERY_METHOD("T003", "수령 방법이 μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.", HttpStatus.BAD_REQUEST), + TICKET_DELIVERY_NOT_COMPLETED("T004", "ν‹°μΌ“ 수령 방법이 μ„ νƒλ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.", HttpStatus.BAD_REQUEST), + TICKET_FAIL_CANCEL("T005", "ν‹°μΌ“ μ·¨μ†Œκ°€ μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.", HttpStatus.CONFLICT), + TICKET_USER_NOT_SAME("T006", "μ‚¬μš©μžκ°€ ν‹°μΌ“ μ†Œμœ μžκ°€ μ•„λ‹™λ‹ˆλ‹€.", HttpStatus.FORBIDDEN), + TICKET_ALREADY_CANCELED("T007", "티켓이 이미 예맀 μ·¨μ†Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.", HttpStatus.CONFLICT), + TICKET_EXPIRED("T008", "ν‹°μΌ“μ˜ μœ νš¨κΈ°κ°„μ΄ λ§Œλ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.", HttpStatus.CONFLICT), + TICKET_CANCELED("T009", "μ·¨μ†Œλœ ν‹°μΌ“μž…λ‹ˆλ‹€.", HttpStatus.CONFLICT), + TICKET_EMAIL_TEMPLATE_NOT_FOUND("T010", "이메일 ν…œν”Œλ¦Ώ 였λ₯˜λ‘œ 이메일 전솑에 μ‹€νŒ¨ν•˜μ˜€μŠ΅λ‹ˆλ‹€.", HttpStatus.NOT_FOUND), + + // QR + QR_CODE_SAVE_FAILED("Q001", "QR μ½”λ“œ 생성 λ˜λŠ” μ €μž₯을 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.", HttpStatus.INTERNAL_SERVER_ERROR), + QR_CODE_ID_GENERATION_FAILED("Q002", "QR μ½”λ“œ ID 생성을 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.", HttpStatus.INTERNAL_SERVER_ERROR), + QR_CODE_NOT_FOUND("Q003", "QR μ½”λ“œλ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.", HttpStatus.NOT_FOUND), + QR_CODE_INVALID("Q004", "QR μ½”λ“œκ°€ μœ νš¨ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.", HttpStatus.BAD_REQUEST), + QR_CODE_EXPIRED("Q005", "QR μ½”λ“œμ˜ 만료일이 μ§€λ‚¬μŠ΅λ‹ˆλ‹€.", HttpStatus.GONE), + QR_CODE_ALREADY_USED("Q006", "QR μ½”λ“œκ°€ 이미 μ‚¬μš©λ˜μ—ˆμŠ΅λ‹ˆλ‹€.", HttpStatus.CONFLICT), + + // waiting : λŒ€κΈ°μ—΄ κ΄€λ ¨ μ˜ˆμ™Έ + USER_NOT_FOUND_IN_BOOKING("W001", "μ‚¬μš©μžκ°€ 예맀 νŽ˜μ΄μ§€μ— μ‘΄μž¬ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.", HttpStatus.NOT_FOUND), + USER_NOT_FOUND_IN_WAITING("W002", "μ‚¬μš©μžκ°€ λŒ€κΈ°μ—΄μ— μ‘΄μž¬ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.", HttpStatus.NOT_FOUND), + FAILED_TO_ENTER_QUEUE("W003", "λŒ€κΈ°μ—΄ μ§„μž…μ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.", HttpStatus.INTERNAL_SERVER_ERROR), + FAILED_TO_REMOVE_USER("W004", "μ‚¬μš©μž μ œκ±°μ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.", HttpStatus.INTERNAL_SERVER_ERROR), + REDIS_CONNECTION_FAILED("R001", "Redis 연결에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.", HttpStatus.INTERNAL_SERVER_ERROR), + FAILED_TO_EXECUTE_SCRIPT("R002", "λŒ€κΈ°μ—΄ Lua 슀크립트 싀행에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.", HttpStatus.INTERNAL_SERVER_ERROR), + FAILED_TO_ENTER_BOOKING("W005", "예맀 μ§„μž… μ²˜λ¦¬μ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.", HttpStatus.INTERNAL_SERVER_ERROR), + REDIS_PUBLISH_FAILED("R003", "Redis Pub/Sub λ°œν–‰μ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.", HttpStatus.INTERNAL_SERVER_ERROR), + JSON_SERIALIZATION_FAILED("S001", "λ©”μ‹œμ§€ 직렬화에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.", HttpStatus.INTERNAL_SERVER_ERROR), + REDIS_OPERATION_FAILED("R004", "Redis λͺ…λ Ή μ‹€ν–‰ 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.", HttpStatus.INTERNAL_SERVER_ERROR), + + // Transfer + TRANSFER_NOT_VALID_FILE_TYPE("TR001","μœ νš¨ν•˜μ§€ μ•Šμ€ 파일 ν™•μž₯μžμž…λ‹ˆλ‹€.",HttpStatus.NOT_ACCEPTABLE), + TRANSFER_NOT_FOUND_INFORM("TR002", "검사에 μ‹€νŒ¨ν•˜μ˜€μŠ΅λ‹ˆλ‹€.", HttpStatus.NOT_FOUND), + TRANSFER_NOT_FOUND_NAME("TR003", "이름 검사에 μ‹€νŒ¨ν•˜μ˜€μŠ΅λ‹ˆλ‹€.", HttpStatus.NOT_FOUND), + TRANSFER_NOT_FOUND_RRN("TR003", "주민등둝 번호 검사에 μ‹€νŒ¨ν•˜μ˜€μŠ΅λ‹ˆλ‹€.", HttpStatus.NOT_FOUND), + TRANSFER_NOT_EXIST("TR004", "양도 μš”μ²­μ΄ μ‘΄μž¬ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.", HttpStatus.NOT_FOUND), + TRANSFER_NOT_MATCH_RECEIVER("TR005", "양도 μŠΉμΈν•˜λŠ” μ–‘μˆ˜μžκ°€ λ§žμ§€ μ•ŠμŠ΅λ‹ˆλ‹€.", HttpStatus.BAD_REQUEST), + TRANSFER_NOT_MATCH_TYPE("TR006", "양도 νƒ€μž…μ΄ λ§žμ§€ μ•ŠμŠ΅λ‹ˆλ‹€.", HttpStatus.BAD_REQUEST), + TRANSFER_NOT_MATCH_SENDER("TR007", "μ–‘λ„μžμ˜ ν‹°μΌ“κ³Ό λ§€μΉ­λ˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.", HttpStatus.CONFLICT), + TRANSFER_ALREADY_EXIST_REQUEST("T008", "μ§„ν–‰λ˜κ³  μžˆλŠ” 양도 κ±°λž˜κ°€ μ‘΄μž¬ν•˜κ±°λ‚˜, 양도 1회 μ§„ν–‰ν•œ ν‹°μΌ“μž…λ‹ˆλ‹€. μ–‘λ„λŠ” 1회둜 μ œν•œλ©λ‹ˆλ‹€.", HttpStatus.CONFLICT), + TRANSFER_NOT_HAVE_FILE_NAME("T009", "파일 이름이 μ—†μŠ΅λ‹ˆλ‹€.", HttpStatus.NOT_FOUND), + TRANSFER_DETECT_FILE_PATH_SECURITY("T010", "잘λͺ»λœ 파일 경둜 λ˜λŠ” μ„œλ²„ 파일 경둜λ₯Ό μ‘°μž‘ 곡격이 κ°μ§€λ˜μ—ˆμŠ΅λ‹ˆλ‹€.", HttpStatus.UNPROCESSABLE_ENTITY), + TRANSFER_OTHERS_NOT_ALLOWED("T011", "예맀 μ™„λ£Œ ν›„ 15뢄이 μ΄ˆκ³Όλ˜μ–΄ 지인 양도가 λΆˆκ°€λŠ₯ν•©λ‹ˆλ‹€.", HttpStatus.CONFLICT), + + // STATISTICS (톡계 κ΄€λ ¨ μ—λŸ¬ μ½”λ“œ μΆ”κ°€) + STATISTICS_ACCESS_DENIED("ST001", "톡계 정보에 μ ‘κ·Όν•  κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€.", HttpStatus.FORBIDDEN), + STATISTICS_NOT_FOUND("ST002", "ν•΄λ‹Ή νŽ˜μŠ€ν‹°λ²Œμ˜ 톡계 정보λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.", HttpStatus.NOT_FOUND), + + // PAYMENT + PAYMENT_RESPONSE_ERROR("P001", "결제 응닡이 μ‹€νŒ¨ν•˜μ˜€μŠ΅λ‹ˆλ‹€.", HttpStatus.BAD_REQUEST); + + private final String code; // A001, A002 λ“± + private final String message; // μ‚¬μš©μžμ—κ²Œ 보여쀄 λ©”μ‹œμ§€ + private final HttpStatus status; //http status μ½”λ“œ + + ErrorCode(String code, String message, HttpStatus status) { + this.code = code; + this.message = message; + this.status = status; + } + +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/exception/global/ErrorResponse.java b/src/main/java/com/mnms/booking/exception/global/ErrorResponse.java new file mode 100644 index 0000000..0c00cb4 --- /dev/null +++ b/src/main/java/com/mnms/booking/exception/global/ErrorResponse.java @@ -0,0 +1,17 @@ +package com.mnms.booking.exception.global; + + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ErrorResponse { + private boolean success; + private Object data; + private String message; +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/exception/global/GlobalExceptionHandler.java b/src/main/java/com/mnms/booking/exception/global/GlobalExceptionHandler.java new file mode 100644 index 0000000..c67ebed --- /dev/null +++ b/src/main/java/com/mnms/booking/exception/global/GlobalExceptionHandler.java @@ -0,0 +1,63 @@ +package com.mnms.booking.exception.global; + +import com.mnms.booking.exception.BusinessException; +import com.mnms.booking.exception.ErrorCode; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +import java.nio.file.AccessDeniedException; + + +@RestControllerAdvice +public class GlobalExceptionHandler { + + /** + * λΉ„μ¦ˆλ‹ˆμŠ€ λ‘œμ§μ—μ„œ λ°œμƒν•œ μ»€μŠ€ν…€ μ˜ˆμ™Έ 처리 + */ + @ExceptionHandler(BusinessException.class) + public ResponseEntity handleBusinessException(BusinessException e) { + ErrorCode errorCode = e.getErrorCode(); + ErrorResponse response = new ErrorResponse(false, errorCode.name(), errorCode.getMessage()); + return new ResponseEntity<>(response, errorCode.getStatus()); + } + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity handleAccessDenied(AccessDeniedException ex) { + ErrorResponse response = new ErrorResponse(false, "AUTHORIZATION_ERROR", "μ ‘κ·Ό κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€."); + return new ResponseEntity<>(response, HttpStatus.FORBIDDEN); + } + + /** + * @Valid 검증 μ‹€νŒ¨ (DTO 바인딩 였λ₯˜ λ“±) + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidationException(MethodArgumentNotValidException e) { + String message = e.getBindingResult().getAllErrors().get(0).getDefaultMessage(); + ErrorResponse response = new ErrorResponse(false, "VALIDATION_ERROR", message); + return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); + } + + /** + * 잘λͺ»λœ νƒ€μž… 바인딩 (예: Long ν•„λ“œμ— 문자 전달) + */ + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity handleTypeMismatch(MethodArgumentTypeMismatchException e) { + ErrorResponse response = new ErrorResponse(false, "TYPE_MISMATCH", "잘λͺ»λœ νƒ€μž…μ˜ μš”μ²­μž…λ‹ˆλ‹€."); + return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); + } + + /** + * λͺ¨λ“  μ˜ˆμ™Έμ˜ fallback 처리 + */ + @ExceptionHandler(Exception.class) + public ResponseEntity handleOtherExceptions(Exception e) { + e.printStackTrace(); // πŸ” 둜그둜 λ‚¨κ²¨μ„œ 디버깅 + ErrorResponse response = new ErrorResponse(false, "INTERNAL_SERVER_ERROR", "μ•Œ 수 μ—†λŠ” 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€."); + return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR); + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/exception/global/SuccessResponse.java b/src/main/java/com/mnms/booking/exception/global/SuccessResponse.java new file mode 100644 index 0000000..0c200ca --- /dev/null +++ b/src/main/java/com/mnms/booking/exception/global/SuccessResponse.java @@ -0,0 +1,18 @@ +package com.mnms.booking.exception.global; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class SuccessResponse { + private boolean success; + private T data; + private String message; +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/kafka/Handler/CreateFestivalHandler.java b/src/main/java/com/mnms/booking/kafka/Handler/CreateFestivalHandler.java new file mode 100644 index 0000000..9850755 --- /dev/null +++ b/src/main/java/com/mnms/booking/kafka/Handler/CreateFestivalHandler.java @@ -0,0 +1,29 @@ +package com.mnms.booking.kafka.Handler; + +import com.mnms.booking.entity.Festival; +import com.mnms.booking.entity.Schedule; +import com.mnms.booking.kafka.dto.FestivalEventDTO; +import com.mnms.booking.repository.FestivalRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class CreateFestivalHandler implements FestivalEventHandler { + + private final FestivalRepository festivalRepository; + + @Override + public void handle(FestivalEventDTO dto) { + Festival festival = Festival.fromDto(dto); + + List schedules = dto.getSchedules().stream() + .map(s -> Schedule.fromDto(s, festival)) + .toList(); + + festival.setSchedules(schedules); + festivalRepository.save(festival); + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/kafka/Handler/DeleteFestivalHandler.java b/src/main/java/com/mnms/booking/kafka/Handler/DeleteFestivalHandler.java new file mode 100644 index 0000000..0bff7c0 --- /dev/null +++ b/src/main/java/com/mnms/booking/kafka/Handler/DeleteFestivalHandler.java @@ -0,0 +1,18 @@ +package com.mnms.booking.kafka.Handler; + +import com.mnms.booking.kafka.dto.FestivalEventDTO; +import com.mnms.booking.repository.FestivalRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class DeleteFestivalHandler implements FestivalEventHandler { + + private final FestivalRepository festivalRepository; + + @Override + public void handle(FestivalEventDTO dto) { + festivalRepository.findByFestivalId(dto.getId()).ifPresent(festivalRepository::delete); + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/kafka/Handler/FestivalEventHandler.java b/src/main/java/com/mnms/booking/kafka/Handler/FestivalEventHandler.java new file mode 100644 index 0000000..8e44920 --- /dev/null +++ b/src/main/java/com/mnms/booking/kafka/Handler/FestivalEventHandler.java @@ -0,0 +1,7 @@ +package com.mnms.booking.kafka.Handler; + +import com.mnms.booking.kafka.dto.FestivalEventDTO; + +public interface FestivalEventHandler { + void handle(FestivalEventDTO dto); +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/kafka/Handler/UpdateFestivalHandler.java b/src/main/java/com/mnms/booking/kafka/Handler/UpdateFestivalHandler.java new file mode 100644 index 0000000..2797ace --- /dev/null +++ b/src/main/java/com/mnms/booking/kafka/Handler/UpdateFestivalHandler.java @@ -0,0 +1,34 @@ +package com.mnms.booking.kafka.Handler; + +import com.mnms.booking.entity.Schedule; +import com.mnms.booking.kafka.dto.FestivalEventDTO; +import com.mnms.booking.repository.FestivalRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class UpdateFestivalHandler implements FestivalEventHandler { + + private final FestivalRepository festivalRepository; + + @Override + @Transactional + public void handle(FestivalEventDTO dto) { + festivalRepository.findByFestivalId(dto.getId()) + .ifPresent(festival -> { + festival.updateFromDto(dto); + + List updatedSchedules = dto.getSchedules() == null ? List.of() : + dto.getSchedules().stream() + .map(s -> Schedule.fromDto(s, festival)) + .toList(); + + festival.mergeSchedules(updatedSchedules); + festivalRepository.save(festival); + }); + } +} diff --git a/src/main/java/com/mnms/booking/kafka/config/KafkaProducerConfig.java b/src/main/java/com/mnms/booking/kafka/config/KafkaProducerConfig.java new file mode 100644 index 0000000..32303d4 --- /dev/null +++ b/src/main/java/com/mnms/booking/kafka/config/KafkaProducerConfig.java @@ -0,0 +1,35 @@ +package com.mnms.booking.kafka.config; + +import com.mnms.booking.dto.response.WaitingNumberResponseDTO; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringSerializer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.core.DefaultKafkaProducerFactory; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.core.ProducerFactory; +import org.springframework.kafka.support.serializer.JsonSerializer; + +import java.util.HashMap; +import java.util.Map; + +@Configuration +public class KafkaProducerConfig { + + @Value("${spring.kafka.bootstrap-servers}") + private String bootstrapServers; + + public ProducerFactory producerFactory(Class clazz) { + Map config = new HashMap<>(); + config.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + config.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + config.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class); + return new DefaultKafkaProducerFactory<>(config); + } + + @Bean + public KafkaTemplate bookingEventKafkaTemplate() { + return new KafkaTemplate<>(producerFactory(WaitingNumberResponseDTO.class)); + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/kafka/config/KafkaTopicConfig.java b/src/main/java/com/mnms/booking/kafka/config/KafkaTopicConfig.java new file mode 100644 index 0000000..7e90b0b --- /dev/null +++ b/src/main/java/com/mnms/booking/kafka/config/KafkaTopicConfig.java @@ -0,0 +1,18 @@ +package com.mnms.booking.kafka.config; + +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 bookingEventTopic() { + return TopicBuilder.name("booking-topic") + .partitions(1) + .replicas(1) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/kafka/dto/FestivalEventDTO.java b/src/main/java/com/mnms/booking/kafka/dto/FestivalEventDTO.java new file mode 100644 index 0000000..8d54ce6 --- /dev/null +++ b/src/main/java/com/mnms/booking/kafka/dto/FestivalEventDTO.java @@ -0,0 +1,27 @@ +package com.mnms.booking.kafka.dto; + +import com.mnms.booking.enums.EventType; +import lombok.Data; + +import java.time.LocalDate; +import java.util.List; + +@Data +public class FestivalEventDTO { + + private String eventType; // e.g. "FESTIVAL_CREATED" / "FESTIVAL_UPDATED" / "FESTIVAL_DELETED" + private String id; + private Long userId; + + private String fname; + private LocalDate fdfrom; + private LocalDate fdto; + private String posterFile; + private String fcltynm; + private int ticketPick; + private int maxPurchase; + private int ticketPrice; + private int availableNOP; + + private List schedules; // μΆ”κ°€ +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/kafka/dto/PaymentSuccessEventDTO.java b/src/main/java/com/mnms/booking/kafka/dto/PaymentSuccessEventDTO.java new file mode 100644 index 0000000..4cb10cc --- /dev/null +++ b/src/main/java/com/mnms/booking/kafka/dto/PaymentSuccessEventDTO.java @@ -0,0 +1,16 @@ +package com.mnms.booking.kafka.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class PaymentSuccessEventDTO { + private String method; // payment, cancel, transfer + private String reservationNumber; + private boolean success; +} diff --git a/src/main/java/com/mnms/booking/kafka/dto/ScheduleEventDTO.java b/src/main/java/com/mnms/booking/kafka/dto/ScheduleEventDTO.java new file mode 100644 index 0000000..0e19126 --- /dev/null +++ b/src/main/java/com/mnms/booking/kafka/dto/ScheduleEventDTO.java @@ -0,0 +1,31 @@ +package com.mnms.booking.kafka.dto; + +import com.mnms.booking.entity.Schedule; +import jakarta.persistence.Column; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalTime; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ScheduleEventDTO { + + private String dayOfWeek; // μš”μΌ μ½”λ“œ (MON, TUE...) + private String time; // 곡연 μ‹œμž‘ μ‹œκ°„ (HH:mm) + + public static ScheduleEventDTO fromEntity(Schedule schedule) { + return ScheduleEventDTO.builder() + .dayOfWeek(schedule.getDayOfWeek()) + .time(schedule.getTime()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/kafka/listener/FestivalListener.java b/src/main/java/com/mnms/booking/kafka/listener/FestivalListener.java new file mode 100644 index 0000000..5c0ada4 --- /dev/null +++ b/src/main/java/com/mnms/booking/kafka/listener/FestivalListener.java @@ -0,0 +1,44 @@ +package com.mnms.booking.kafka.listener; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.mnms.booking.kafka.Handler.CreateFestivalHandler; +import com.mnms.booking.kafka.Handler.DeleteFestivalHandler; +import com.mnms.booking.kafka.Handler.FestivalEventHandler; +import com.mnms.booking.kafka.Handler.UpdateFestivalHandler; +import com.mnms.booking.kafka.dto.FestivalEventDTO; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class FestivalListener { + + private final CreateFestivalHandler createHandler; + private final UpdateFestivalHandler updateHandler; + private final DeleteFestivalHandler deleteHandler; + + @KafkaListener(topics = "${app.kafka.topic.festival-event}", groupId = "festival-service-group") + public void consumeFestival(String message) throws JsonProcessingException { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + + FestivalEventDTO dto = objectMapper.readValue(message, FestivalEventDTO.class); + + String eventType = dto.getEventType(); + FestivalEventHandler handler = switch (eventType.toUpperCase()) { + case "FESTIVAL_CREATED" -> createHandler; + case "FESTIVAL_UPDATED" -> updateHandler; + case "FESTIVAL_DELETED" -> deleteHandler; + default -> null; + }; + + if (handler != null) { + handler.handle(dto); + } else { + System.out.println("Unknown eventType: " + eventType); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/kafka/listener/PaymentListener.java b/src/main/java/com/mnms/booking/kafka/listener/PaymentListener.java new file mode 100644 index 0000000..f5a0854 --- /dev/null +++ b/src/main/java/com/mnms/booking/kafka/listener/PaymentListener.java @@ -0,0 +1,39 @@ +package com.mnms.booking.kafka.listener; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.mnms.booking.exception.BusinessException; +import com.mnms.booking.exception.ErrorCode; +import com.mnms.booking.kafka.dto.PaymentSuccessEventDTO; +import com.mnms.booking.service.BookingCommandService; +import com.mnms.booking.service.TransferCompletionService; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class PaymentListener { + + private final BookingCommandService bookingCommandService; + private final TransferCompletionService transferCompletionService; + + @KafkaListener(topics = "${app.kafka.topic.payment-event}", groupId = "booking-service-group") + public void consumePaymentSuccess(String message) throws Exception { + ObjectMapper objectMapper = new ObjectMapper(); + + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + + PaymentSuccessEventDTO event = objectMapper.readValue(message, PaymentSuccessEventDTO.class); + String method = event.getMethod(); + + switch (method) { + case "payment" -> bookingCommandService.confirmTicket(event.getReservationNumber(), event.isSuccess()); + case "cancel" -> bookingCommandService.cancelBooking(event.getReservationNumber(), event.isSuccess()); + case "transfer" -> transferCompletionService.updateOthersTicket(event.getReservationNumber(), event.isSuccess()); + default -> throw new BusinessException(ErrorCode.PAYMENT_RESPONSE_ERROR); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/repository/FestivalRepository.java b/src/main/java/com/mnms/booking/repository/FestivalRepository.java new file mode 100644 index 0000000..8538d7f --- /dev/null +++ b/src/main/java/com/mnms/booking/repository/FestivalRepository.java @@ -0,0 +1,17 @@ +package com.mnms.booking.repository; + +import com.mnms.booking.entity.Festival; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface FestivalRepository extends JpaRepository { + Optional findByFestivalId(String festivalId); + Festival findByFestivalIdAndOrganizer(String festivalId , Long organizer); + + // festivalId와 organizer(userId)κ°€ μΌμΉ˜ν•˜λŠ” μ—”ν‹°ν‹°κ°€ μ‘΄μž¬ν•˜λŠ”μ§€ 확인 + boolean existsByFestivalIdAndOrganizer(String festivalId, Long organizer); +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/repository/QrCodeRepository.java b/src/main/java/com/mnms/booking/repository/QrCodeRepository.java new file mode 100644 index 0000000..379fad9 --- /dev/null +++ b/src/main/java/com/mnms/booking/repository/QrCodeRepository.java @@ -0,0 +1,26 @@ +package com.mnms.booking.repository; + +import com.mnms.booking.entity.QrCode; +import io.lettuce.core.dynamic.annotation.Param; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +public interface QrCodeRepository extends JpaRepository { + Optional findByQrCodeId(String qrCodeId); + Boolean existsByQrCodeId(String qrCodeId); + + List findByTicketId(Long ticketId); + + // νŠΉμ • νŽ˜μŠ€ν‹°λ²Œμ˜ νŠΉμ • 곡연 λ‚ μ§œμ— μž…μž₯(used=true)ν•œ 인원 수λ₯Ό 집계 + @Query("SELECT COUNT(q) FROM QrCode q JOIN q.ticket t WHERE q.used = true AND t.festival.festivalId = :festivalId AND t.performanceDate = :performanceDate") + int countAdmittedAttendees(@Param("festivalId") String festivalId, @Param("performanceDate") LocalDateTime performanceDate);; + + boolean existsByTicket_IdAndUsedTrue(Long ticketId); +} + + diff --git a/src/main/java/com/mnms/booking/repository/ScheduleRepository.java b/src/main/java/com/mnms/booking/repository/ScheduleRepository.java new file mode 100644 index 0000000..adf2faf --- /dev/null +++ b/src/main/java/com/mnms/booking/repository/ScheduleRepository.java @@ -0,0 +1,15 @@ +package com.mnms.booking.repository; + +import com.mnms.booking.entity.Schedule; +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; + +import java.util.List; + +@Repository +public interface ScheduleRepository extends JpaRepository { + @Query("SELECT s FROM Schedule s WHERE s.festival.festivalId = :festivalId") + List findByFestivalId(@Param("festivalId") String festivalId); +} diff --git a/src/main/java/com/mnms/booking/repository/StatisticsRepository.java b/src/main/java/com/mnms/booking/repository/StatisticsRepository.java new file mode 100644 index 0000000..c7bc42c --- /dev/null +++ b/src/main/java/com/mnms/booking/repository/StatisticsRepository.java @@ -0,0 +1,23 @@ +package com.mnms.booking.repository; + +import com.mnms.booking.entity.Ticket; +import com.mnms.booking.enums.ReservationStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.stream.Collectors; + +@Repository +public interface StatisticsRepository extends JpaRepository { + + @Query("SELECT t.userId FROM Ticket t WHERE t.festival.festivalId = :festivalId") + List findUserIdsByFestivalId(String festivalId); + + @Query("SELECT DISTINCT CAST(t.userId AS string) FROM Ticket t " + + "WHERE t.festival.festivalId = :festivalId AND t.reservationStatus = :status") + List findUserIdsByFestivalIdAndReservationStatus(@Param("festivalId") String festivalId, + @Param("status") ReservationStatus status); +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/repository/TicketRepository.java b/src/main/java/com/mnms/booking/repository/TicketRepository.java new file mode 100644 index 0000000..4c12834 --- /dev/null +++ b/src/main/java/com/mnms/booking/repository/TicketRepository.java @@ -0,0 +1,128 @@ +package com.mnms.booking.repository; + +import com.mnms.booking.dto.response.StatisticsBookingDTO; +import com.mnms.booking.entity.Festival; +import com.mnms.booking.entity.Ticket; +import com.mnms.booking.enums.ReservationStatus; +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; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +public interface TicketRepository extends JpaRepository { + + @Query("SELECT COALESCE(SUM(t.selectedTicketCount), 0) " + + "FROM Ticket t " + + "WHERE t.userId = :userId " + + "AND t.festival.festivalId = :festivalId " + + "AND t.performanceDate >= :startDate " + + "AND t.performanceDate < :endDate") + Long sumSelectedTicketCount( + @Param("userId") Long userId, + @Param("festivalId") String festivalId, + @Param("startDate") LocalDateTime startDate, + @Param("endDate") LocalDateTime endDate); + + + @Query("SELECT t " + + "FROM Ticket t " + + "WHERE t.festival.festivalId = :festivalId AND t.userId = :userId AND t.reservationNumber = :reservationNumber") + Optional findByIdAndReservationNumber( + @Param("festivalId") String festivalId, + @Param("userId") Long userId, + @Param("reservationNumber") String reservationNumber + ); + + Optional findByReservationNumber(String reservationNumber); + + // host + @Query("SELECT DISTINCT t.userId " + + "FROM Ticket t " + + "WHERE t.festival.festivalId = :festivalId " + + "AND t.performanceDate = :performanceDate " + + "AND t.reservationStatus = :reservationStatus") + List findDistinctUserIds( + @Param("festivalId") String festivalId, + @Param("performanceDate") LocalDateTime performanceDate, + @Param("reservationStatus") ReservationStatus reservationStatus); + + + @Query("SELECT t " + + "FROM Ticket t " + + "WHERE t.festival.festivalId = :festivalId ") + List findByFestivalId(String festivalId); + + @Query("SELECT t.selectedTicketCount " + + "FROM Ticket t " + + "WHERE t.reservationNumber = :reservationNumber") + Long findTicketCountByReservationNumber(@Param("reservationNumber") String reservationNumber); + + @Query("SELECT COALESCE(SUM(t.selectedTicketCount), 0) " + + "FROM Ticket t " + + "WHERE t.festival.festivalId = :festivalId " + + "AND t.performanceDate = :performanceDate " + + "AND t.reservationStatus IN (:statuses)") + int getTotalSelectedTicketCount(@Param("festivalId") String festivalId, + @Param("performanceDate") LocalDateTime performanceDate, + @Param("statuses") List statuses); + + List findByUserIdAndReservationStatusIn(Long userId, List statuses); + Optional findByUserIdAndReservationNumber(Long userId, String reservationNumber); + + @Query("SELECT t FROM Ticket t " + + "WHERE t.festival.festivalId = :festivalId " + + "AND t.reservationStatus = :status") + List findByIdAndReservationStatus(@Param("festivalId") String festivalId, + @Param("status") ReservationStatus status); + + Optional> findByUserId(Long userId); + + // userId와 festivalIdκ°€ μΌμΉ˜ν•˜λŠ” 티켓이 μ‘΄μž¬ν•˜λŠ”μ§€ 확인 + @Query("SELECT CASE WHEN COUNT(t) > 0 THEN true ELSE false END FROM Ticket t WHERE t.userId = :userId AND t.festival.festivalId = :festivalId") + boolean existsByUserIdAndFestivalId(@Param("userId") Long userId, @Param("festivalId") String festivalId); + + // νŠΉμ • festivalId에 λŒ€ν•œ μœ νš¨ν•œ 곡연 λ‚ μ§œ-μ‹œκ°„μ„ λͺ¨λ‘ 쑰회 + @Query("SELECT DISTINCT t.performanceDate FROM Ticket t WHERE t.festival.festivalId = :festivalId") + List findDistinctPerformanceDate(@Param("festivalId") String festivalId); + + @Query("SELECT t.reservationStatus FROM Ticket t WHERE t.reservationNumber = :reservationNumber") + ReservationStatus findReservationStatusByRN(@Param("reservationNumber") String reservationNumber); + + List findByUserIdAndReservationStatus(Long userId, ReservationStatus status); + + @Query("SELECT new com.mnms.booking.dto.response.StatisticsBookingDTO(" + + "t.performanceDate, " + + "SUM(t.selectedTicketCount), " + // 1. 총 예맀 'ν‹°μΌ“ 수' (Query 1의 μž₯점) + "t.festival.availableNOP) " + // 2. '총 수용 인원' (Query 2의 μž₯점) + "FROM Ticket t " + + "WHERE t.festival.festivalId = :festivalId AND t.reservationStatus = :status " + + "GROUP BY t.performanceDate, t.festival.availableNOP") + List findBookedSummary(@Param("festivalId") String festivalId, @Param("status") ReservationStatus status); + + + @Query(""" + SELECT t + FROM Ticket t + JOIN t.festival f + WHERE f.id = :festivalId + AND t.performanceDate = :performanceDate + AND t.userId = :userId + AND t.reservationStatus = :reservationStatus + """) + List findTempReservedTickets( + @Param("festivalId") Long festivalId, + @Param("performanceDate") LocalDateTime performanceDate, + @Param("userId") Long userId, + @Param("reservationStatus") ReservationStatus reservationStatus + ); + + @Query("SELECT t FROM Ticket t JOIN FETCH t.festival WHERE t.reservationNumber = :reservationNumber") + Optional findByReservationNumberWithFestival(@Param("reservationNumber") String reservationNumber); +} + diff --git a/src/main/java/com/mnms/booking/repository/TransferRepository.java b/src/main/java/com/mnms/booking/repository/TransferRepository.java new file mode 100644 index 0000000..8ea8620 --- /dev/null +++ b/src/main/java/com/mnms/booking/repository/TransferRepository.java @@ -0,0 +1,35 @@ +package com.mnms.booking.repository; + +import com.mnms.booking.entity.Ticket; +import com.mnms.booking.entity.Transfer; +import com.mnms.booking.enums.TransferStatus; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface TransferRepository extends JpaRepository { + Transfer findByTicket(Ticket ticket); + + @EntityGraph(attributePaths = {"ticket", "ticket.qrCodes"}) + Optional findById(Long id); + + boolean existsByTicket_Id(Long id); + + @Query("SELECT t FROM Transfer t " + + "JOIN FETCH t.ticket ticket " + + "JOIN FETCH ticket.festival " + + "WHERE t.receiverId = :receiverId " + + "AND t.status NOT IN :excludedStatuses") + List findByReceiverIdWithTicketAndFestival( + @Param("receiverId") Long receiverId, + @Param("excludedStatuses") List excludedStatuses); + + @Query("SELECT t.status FROM Transfer t WHERE t.id = :transferId") + TransferStatus findTransferStatusById(Long transferId); + + boolean existsByTicketId(Long ticketId); +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/security/AuthDetails.java b/src/main/java/com/mnms/booking/security/AuthDetails.java new file mode 100644 index 0000000..f604d77 --- /dev/null +++ b/src/main/java/com/mnms/booking/security/AuthDetails.java @@ -0,0 +1,16 @@ +package com.mnms.booking.security; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.security.web.authentication.WebAuthenticationDetails; + +public class AuthDetails extends WebAuthenticationDetails { + private final String userName; + + public AuthDetails(HttpServletRequest request, String userName) { + super(request); + this.userName = userName; + } + public String getUserName() { + return userName; + } +} diff --git a/src/main/java/com/mnms/booking/security/HeaderAuthenticationFilter.java b/src/main/java/com/mnms/booking/security/HeaderAuthenticationFilter.java new file mode 100644 index 0000000..be62d82 --- /dev/null +++ b/src/main/java/com/mnms/booking/security/HeaderAuthenticationFilter.java @@ -0,0 +1,131 @@ +package com.mnms.booking.security; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mnms.booking.exception.global.ErrorResponse; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; +import java.util.stream.Collectors; + +@Component +@Slf4j +public class HeaderAuthenticationFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain chain + ) throws ServletException, IOException { + + // ActuatorλŠ” κ±΄λ„ˆλœ€ + String uri = request.getRequestURI(); + if (uri != null && uri.startsWith("/actuator")) { + chain.doFilter(request, response); + return; + } + + // κ²Œμ΄νŠΈμ›¨μ΄κ°€ λΆ™μ—¬μ£ΌλŠ” 헀더 + final String userIdHeader = trimToNull(request.getHeader("X-User-Id")); + final String rolesHdr = trimToNull(request.getHeader("X-User-Role")); + final String userNameHeader = trimToNull(request.getHeader("X-User-Name")); + + Authentication current = org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication(); + boolean isAnonymous = (current instanceof AnonymousAuthenticationToken); + boolean canSetAuth = (current == null) || isAnonymous; + + if (canSetAuth) { + // 헀더가 μ—†μœΌλ©΄ 401 + if (userIdHeader == null || rolesHdr == null) { + if (uri.startsWith("/v3/api-docs") + || uri.startsWith("/swagger-ui") + || uri.equals("/swagger-ui.html") + || uri.startsWith("/actuator") + || uri.startsWith("/ws") + || uri.startsWith("/api/host/list") + || uri.startsWith("/api/statistics/users")) { + chain.doFilter(request, response); + return; + } + sendUnauthorized(response, "X-User-Id λ˜λŠ” X-User-Role 헀더가 μ—†μŠ΅λ‹ˆλ‹€."); + return; + } + + + // userId νŒŒμ‹± + final Long userId; + try { + userId = Long.valueOf(userIdHeader); + } catch (NumberFormatException e) { + sendUnauthorized(response, "X-User-Idκ°€ μˆ«μžκ°€ μ•„λ‹™λ‹ˆλ‹€."); + return; + } + + // userName λ””μ½”λ”© + String userName = ""; + if (userNameHeader != null) { + try { + userName = new String( + Base64.getUrlDecoder().decode(userNameHeader), + StandardCharsets.UTF_8 + ); + } catch (IllegalArgumentException e) { + log.warn("[HeaderAuth] userName λ””μ½”λ”© μ‹€νŒ¨: {}", userNameHeader); + } + } + + // κΆŒν•œ μ„ΈνŒ… + List authorities = Arrays.stream(rolesHdr.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .map(String::toUpperCase) + .map(r -> r.startsWith("ROLE_") ? r : "ROLE_" + r) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + + log.info("[HeaderAuth] userId={}, roles={} -> authorities={}", userId, rolesHdr, authorities); + + // 인증 정보 μ„ΈνŒ… + UsernamePasswordAuthenticationToken auth = + new UsernamePasswordAuthenticationToken(String.valueOf(userId), null, authorities); + auth.setDetails(new AuthDetails(request, userName)); + org.springframework.security.core.context.SecurityContextHolder.getContext().setAuthentication(auth); + } + + chain.doFilter(request, response); + } + + // 401 응닡 직접 처리 + private void sendUnauthorized(HttpServletResponse response, String message) throws IOException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=UTF-8"); + + ErrorResponse error = ErrorResponse.builder() + .success(false) + .data("UNAUTHORIZED") + .message(message) + .build(); + String body = new ObjectMapper().writeValueAsString(error); + response.getWriter().write(body); + } + + private static String trimToNull(String s) { + if (s == null) return null; + String t = s.trim(); + return t.isEmpty() ? null : t; + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/security/SecurityConfig.java b/src/main/java/com/mnms/booking/security/SecurityConfig.java new file mode 100644 index 0000000..01c1c68 --- /dev/null +++ b/src/main/java/com/mnms/booking/security/SecurityConfig.java @@ -0,0 +1,49 @@ +package com.mnms.booking.security; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.intercept.AuthorizationFilter; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +public class SecurityConfig { + + private final HeaderAuthenticationFilter headerAuthFilter; + + public SecurityConfig(HeaderAuthenticationFilter headerAuthFilter) { + this.headerAuthFilter = headerAuthFilter; + } + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http + .csrf(AbstractHttpConfigurer::disable) + .cors(AbstractHttpConfigurer::disable) // gateway CORS 쀑볡 λ°©μ§€ + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/host/list").permitAll() + .requestMatchers("/api/host/booking/list").hasAnyRole("HOST", "ADMIN") + .requestMatchers("/ws/**").permitAll() + .requestMatchers("/v3/api-docs/**", "/v3/api-docs","/swagger-ui/**", "/swagger-ui.html").permitAll() + .requestMatchers( + "/public/**", + "/h2-console/**", + "/api/captcha/**", + "/api/qr/**", + "/api/booking/detail/phases/1", + "/api/booking/confirm", + "/api/transfer/**", + "/api/transfer/**", + "/api/ws/**" + ).permitAll() // ν•˜μœ„ 경둜 포함 ν—ˆμš© + .anyRequest().permitAll() + ) + .addFilterBefore(headerAuthFilter, AuthorizationFilter.class) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/security/StompConnectInterceptor.java b/src/main/java/com/mnms/booking/security/StompConnectInterceptor.java new file mode 100644 index 0000000..c30b891 --- /dev/null +++ b/src/main/java/com/mnms/booking/security/StompConnectInterceptor.java @@ -0,0 +1,51 @@ +package com.mnms.booking.security; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Component; +import org.springframework.security.core.Authentication; +import java.security.Principal; +import java.util.List; + +@Component +@Slf4j +public class StompConnectInterceptor implements ChannelInterceptor { + @Override + public Message preSend(Message message, MessageChannel channel) { + // STOMP λ©”μ‹œμ§€ μ ‘κ·Ό + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); + + if (StompCommand.CONNECT.equals(accessor.getCommand())) { + String userId = accessor.getFirstNativeHeader("X-User-Id"); + String userName = accessor.getFirstNativeHeader("X-User-Name"); + String role = accessor.getFirstNativeHeader("X-User-Role"); + + log.info("[STOMP CONNECT] X-User-Id={}, X-User-Name={}, X-User-Role={}", userId, userName, role); + + if (userId != null && role != null) { + Authentication auth = new UsernamePasswordAuthenticationToken( + userId, + null, + List.of(new SimpleGrantedAuthority(role)) + ); + + accessor.setUser(auth); + log.info("[STOMP CONNECT] Authentication 등둝 μ™„λ£Œ: {}", auth); + } + } + + Principal principal = accessor.getUser(); + if (principal != null) { + Authentication auth = (Authentication) principal; + log.info("[STOMP MESSAGE] UserId={}, Roles={}", auth.getName(), auth.getAuthorities()); + } + + return message; + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/security/StompPrincipal.java b/src/main/java/com/mnms/booking/security/StompPrincipal.java new file mode 100644 index 0000000..a766c31 --- /dev/null +++ b/src/main/java/com/mnms/booking/security/StompPrincipal.java @@ -0,0 +1,17 @@ +package com.mnms.booking.security; + + +import java.security.Principal; + +public class StompPrincipal implements Principal { + private final String name; + + public StompPrincipal(String name) { + this.name = name; + } + + @Override + public String getName() { + return name; + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/service/BookingCommandService.java b/src/main/java/com/mnms/booking/service/BookingCommandService.java new file mode 100644 index 0000000..cfcc1f3 --- /dev/null +++ b/src/main/java/com/mnms/booking/service/BookingCommandService.java @@ -0,0 +1,135 @@ +package com.mnms.booking.service; + +import com.mnms.booking.dto.request.BookingRequestDTO; +import com.mnms.booking.dto.request.BookingSelectDeliveryRequestDTO; +import com.mnms.booking.dto.request.BookingSelectRequestDTO; +import com.mnms.booking.dto.request.TicketRequestDTO; +import com.mnms.booking.entity.*; +import com.mnms.booking.enums.ReservationStatus; +import com.mnms.booking.enums.TicketType; +import com.mnms.booking.event.TicketConfirmedEvent; +import com.mnms.booking.exception.BusinessException; +import com.mnms.booking.exception.ErrorCode; +import com.mnms.booking.repository.TicketRepository; +import com.mnms.booking.util.CommonUtils; +import com.mnms.booking.util.UserApiClient; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class BookingCommandService { + private final TicketRepository ticketRepository; + private final EmailService emailService; + private final CommonUtils commonUtils; + private final UserApiClient userApiClient; + private final BookingStatusService bookingStatusService; + private final TempReservationService tempReservationService; + private final ApplicationEventPublisher eventPublisher; + + + /// 1μ°¨: κ°€μ˜ˆλ§€ - μž„μ‹œ μ˜ˆμ•½ (2μ°¨ μ˜ˆλ§€ν•˜κΈ° λˆ„λ₯΄λ©΄ μ‹€ν–‰) + @Transactional + public String selectFestivalDate(BookingSelectRequestDTO request, Long userId) { + Festival festival = bookingStatusService.getFestivalOrThrow(request.getFestivalId()); + LocalDateTime performanceDate = request.getPerformanceDate(); + + bookingStatusService.validatePerformanceDate(festival, performanceDate); + bookingStatusService.validateScheduleExists(festival, performanceDate); + bookingStatusService.recreateHold(festival, performanceDate, userId); // κ°€μ˜ˆλ§€ μƒνƒœμΈ ν‹°μΌ“ λͺ¨λ‘ μ‚­μ œ + bookingStatusService.validateUserReservationLimit(userId, request, festival); + + Ticket ticket = Ticket.builder() + .festival(festival) + .userId(userId) + .reservationNumber(commonUtils.generateReservationNumber()) + .selectedTicketCount(request.getSelectedTicketCount()) + .performanceDate(performanceDate) + .reservationStatus(ReservationStatus.TEMP_RESERVED) + .build(); + + ticketRepository.save(ticket); + // redis ttl : 1λΆ„ μ„€μ • + tempReservationService.createTempReservation(ticket); + return ticket.getReservationNumber(); + } + + /// 2μ°¨: κ°€μ˜ˆλ§€ - 배솑 방법 선택 (κ²°μ œν•˜κΈ° λˆ„λ₯΄λ©΄ μ‹€ν–‰) + @Transactional + public void selectFestivalDelivery(BookingSelectDeliveryRequestDTO request, Long userId) { + Ticket ticket = bookingStatusService.getTicketOrThrow(request.getFestivalId(), userId, request.getReservationNumber()); + TicketType type = bookingStatusService.parseDeliveryMethod(request.getDeliveryMethod()); + ticket.setDeliveryMethod(type); + if(TicketType.PAPER.equals(type)){ + ticket.setAddress(request.getAddress()); + ticket.setDeliveryDate(bookingStatusService.calculateDeliveryDate(ticket, type)); + } + ticketRepository.save(ticket); + + // redis ttl : 5λΆ„ μ„€μ • + tempReservationService.refreshTempReservation(ticket.getReservationNumber(), 5); + } + + /// 3μ°¨: κ°€μ˜ˆλ§€ - μ˜ˆμ•½ - QR생성 (λ§ˆμ§€λ§‰ κ²°μ œν•˜κΈ° λˆŒλ €μ„ λ•Œ μ‹€ν–‰) + @Transactional + public void reserveTicket(BookingRequestDTO request, Long userId) { + Festival festival = bookingStatusService.getFestivalOrThrow(request.getFestivalId()); + Long selectedTicketCount = ticketRepository.findTicketCountByReservationNumber(request.getReservationNumber()); + + bookingStatusService.validateCapacity(festival, request, selectedTicketCount); + + Ticket ticket = bookingStatusService.getTicketByReservationNumberOrThrow(request.getReservationNumber()); + ticket.setReservationStatus(ReservationStatus.PAYMENT_IN_PROGRESS); + bookingStatusService.ensureDeliveryStepCompleted(ticket); + + bookingStatusService.regenerateQrCodes(ticket, userId, festival); + + ticketRepository.save(ticket); + + // redis ttl : 3λΆ„ μ„€μ • (예맀 μ™„λ£Œν•˜λ©΄ ttl λ°”λ‘œ μ™„λ£Œλ¨) + tempReservationService.refreshTempReservation(ticket.getReservationNumber(), 3); + } + + + /// μ΅œμ’… μ™„λ£Œ - status λ³€κ²½ (payment에 kafka λ©”μ‹œμ§€ ꡬ독) + @Transactional + public void confirmTicket(String reservationNumber, boolean paymentStatus) { + Ticket ticket = bookingStatusService.getTicketByReservationNumberOrThrow(reservationNumber); + ReservationStatus newStatus = bookingStatusService.determineReservationStatus(paymentStatus); + + // 결제 μƒνƒœ λ³€κ²½ + bookingStatusService.updateTicketStatusIfNecessary(ticket, newStatus); + eventPublisher.publishEvent(new TicketConfirmedEvent(TicketRequestDTO.fromEntity(ticket))); + } + + /// 예맀 μ·¨μ†Œ + @Transactional + public void cancelBooking(String reservationNumber, boolean paymentStatus) { + Ticket ticket = ticketRepository.findByReservationNumber(reservationNumber) + .orElseThrow(() -> new BusinessException(ErrorCode.TICKET_NOT_FOUND)); + + ReservationStatus status = paymentStatus + ? ReservationStatus.CANCELED + : ticket.getReservationStatus(); + + if(ticket.getReservationStatus() == ReservationStatus.CANCELED){ + throw new BusinessException(ErrorCode.TICKET_ALREADY_CANCELED); + } + if (ticket.getReservationStatus() != ReservationStatus.CONFIRMED) { + throw new BusinessException(ErrorCode.TICKET_FAIL_CANCEL); + } + // qr μ‚­μ œ + ticket μƒνƒœ λ³€κ²½ + ticket.getQrCodes().clear(); + ticket.setReservationStatus(status); + ticketRepository.save(ticket); + } + + // 예맀 μ™„λ£Œ 확인 + public ReservationStatus checkStatus(String reservationNumber) { + return ticketRepository.findReservationStatusByRN(reservationNumber); + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/service/BookingQueryService.java b/src/main/java/com/mnms/booking/service/BookingQueryService.java new file mode 100644 index 0000000..8a3f182 --- /dev/null +++ b/src/main/java/com/mnms/booking/service/BookingQueryService.java @@ -0,0 +1,96 @@ +package com.mnms.booking.service; + +import com.mnms.booking.dto.request.BookingRequestDTO; +import com.mnms.booking.dto.request.BookingSelectRequestDTO; +import com.mnms.booking.dto.response.BookingDetailResponseDTO; +import com.mnms.booking.dto.response.FestivalDetailResponseDTO; +import com.mnms.booking.dto.response.ScheduleResponseDTO; +import com.mnms.booking.entity.Festival; +import com.mnms.booking.entity.Ticket; +import com.mnms.booking.exception.BusinessException; +import com.mnms.booking.exception.ErrorCode; +import com.mnms.booking.repository.FestivalRepository; +import com.mnms.booking.repository.ScheduleRepository; +import com.mnms.booking.repository.TicketRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.time.format.TextStyle; +import java.util.List; +import java.util.Locale; + + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class BookingQueryService { + + private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm"); + private final TicketRepository ticketRepository; + private final FestivalRepository festivalRepository; + private final ScheduleRepository scheduleRepository; + + /// 1μ°¨ : 쑰회 + public FestivalDetailResponseDTO getFestivalDetail(BookingSelectRequestDTO request) { + Festival festival = getFestivalOrThrow(request.getFestivalId()); + LocalDateTime performanceDate = request.getPerformanceDate(); + + validatePerformanceDate(festival, performanceDate); + validateScheduleExists(festival, performanceDate); + List scheduleDTOs = getSchedules(festival); + return FestivalDetailResponseDTO.fromEntity(festival, performanceDate, scheduleDTOs); + } + + /// 2μ°¨ : 쑰회 + public BookingDetailResponseDTO getFestivalBookingDetail(BookingRequestDTO request, Long userId) { + Festival festival = getFestivalOrThrow(request.getFestivalId()); + Ticket ticket = getTicketByReservationNumberOrThrow(request.getReservationNumber()); + + return BookingDetailResponseDTO.fromEntities(festival, ticket); + } + + /// 기타 + private void validatePerformanceDate(Festival festival, LocalDateTime performanceDate) { + LocalDate date = performanceDate.toLocalDate(); + if (date.isBefore(festival.getFdfrom()) || date.isAfter(festival.getFdto())) { + throw new BusinessException(ErrorCode.FESTIVAL_INVALID_DATE); + } + } + + private void validateScheduleExists(Festival festival, LocalDateTime performanceDate) { + String dayOfWeek = performanceDate.getDayOfWeek() + .getDisplayName(TextStyle.SHORT, Locale.ENGLISH) + .toUpperCase(); + + boolean exists = festival.getSchedules().stream() + .anyMatch(s -> s.getDayOfWeek().equalsIgnoreCase(dayOfWeek) + && LocalTime.parse(s.getTime(), TIME_FORMATTER).equals(performanceDate.toLocalTime())); + + if (!exists) throw new BusinessException(ErrorCode.FESTIVAL_INVALID_TIME); + } + + private Festival getFestivalOrThrow(String festivalId) { + return festivalRepository.findByFestivalId(festivalId) + .orElseThrow(() -> new BusinessException(ErrorCode.FESTIVAL_NOT_FOUND)); + } + + private Ticket getTicketByReservationNumberOrThrow(String reservationNumber) { + return ticketRepository.findByReservationNumber(reservationNumber) + .orElseThrow(() -> new BusinessException(ErrorCode.TICKET_NOT_FOUND)); + } + + private List getSchedules(Festival festival) { + return scheduleRepository.findByFestivalId(festival.getFestivalId()) + .stream() + .map(s -> ScheduleResponseDTO.builder() + .dayOfWeek(s.getDayOfWeek()) + .time(s.getTime()) + .build()) + .toList(); + } +} diff --git a/src/main/java/com/mnms/booking/service/BookingStatusService.java b/src/main/java/com/mnms/booking/service/BookingStatusService.java new file mode 100644 index 0000000..9070237 --- /dev/null +++ b/src/main/java/com/mnms/booking/service/BookingStatusService.java @@ -0,0 +1,188 @@ +package com.mnms.booking.service; + +import com.mnms.booking.dto.request.BookingRequestDTO; +import com.mnms.booking.dto.request.BookingSelectRequestDTO; +import com.mnms.booking.dto.response.QrResponseDTO; +import com.mnms.booking.dto.response.TicketStatusResponseDTO; +import com.mnms.booking.entity.Festival; +import com.mnms.booking.entity.QrCode; +import com.mnms.booking.entity.Ticket; +import com.mnms.booking.enums.ReservationStatus; +import com.mnms.booking.enums.TicketType; +import com.mnms.booking.exception.BusinessException; +import com.mnms.booking.exception.ErrorCode; +import com.mnms.booking.repository.FestivalRepository; +import com.mnms.booking.repository.QrCodeRepository; +import com.mnms.booking.repository.TicketRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.time.format.TextStyle; +import java.util.List; +import java.util.Locale; +import java.util.stream.IntStream; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class BookingStatusService { + + private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm"); + private static final int TEMP_RESERVATION_TTL_MINUTES = 1; // κ°€μ˜ˆλ§€ μœ μ§€ μ‹œκ°„ + + private final TicketRepository ticketRepository; + private final QrCodeRepository qrCodeRepository; + private final FestivalRepository festivalRepository; + private final QrCodeService qrCodeService; + private final ThreadPoolTaskScheduler scheduler; + private final SimpMessagingTemplate messagingTemplate; + + /// 검증 + public void validateCapacity(Festival festival, BookingRequestDTO request, Long selectedTicketCount) { + int totalCount = ticketRepository.getTotalSelectedTicketCount( + request.getFestivalId(), + request.getPerformanceDate(), + List.of(ReservationStatus.CONFIRMED, ReservationStatus.PAYMENT_IN_PROGRESS) + ); + + if (totalCount + selectedTicketCount > festival.getAvailableNOP()) { + throw new BusinessException(ErrorCode.FESTIVAL_LIMIT_AVAILABLE_PEOPLE); + } + } + + public void validatePerformanceDate(Festival festival, LocalDateTime performanceDate) { + LocalDate date = performanceDate.toLocalDate(); + if (date.isBefore(festival.getFdfrom()) || date.isAfter(festival.getFdto())) { + throw new BusinessException(ErrorCode.FESTIVAL_INVALID_DATE); + } + } + + public void validateScheduleExists(Festival festival, LocalDateTime performanceDate) { + String dayOfWeek = performanceDate.getDayOfWeek() + .getDisplayName(TextStyle.SHORT, Locale.ENGLISH) + .toUpperCase(); + + boolean exists = festival.getSchedules().stream() + .anyMatch(s -> s.getDayOfWeek().equalsIgnoreCase(dayOfWeek) + && LocalTime.parse(s.getTime(), TIME_FORMATTER).equals(performanceDate.toLocalTime())); + + if (!exists) throw new BusinessException(ErrorCode.FESTIVAL_INVALID_TIME); + } + + public void validateUserReservationLimit(Long userId, BookingSelectRequestDTO request, Festival festival) { + int selectTicketCount = request.getSelectedTicketCount(); + if (festival.getMaxPurchase() < selectTicketCount){ + throw new BusinessException(ErrorCode.TICKET_ALREADY_RESERVED); + } + + LocalDateTime startDate = request.getPerformanceDate(); + LocalDateTime endDate = startDate.plusSeconds(1); + + Long alreadyReserved = ticketRepository.sumSelectedTicketCount( + userId, festival.getFestivalId(), startDate, endDate); + if (alreadyReserved + selectTicketCount > festival.getMaxPurchase()) { + throw new BusinessException(ErrorCode.TICKET_ALREADY_RESERVED); + } + } + + public void ensureDeliveryStepCompleted(Ticket ticket) { + if (ticket.getDeliveryMethod() == null && ticket.getDeliveryDate() == null) { + throw new BusinessException(ErrorCode.TICKET_DELIVERY_NOT_COMPLETED); + } + } + + /// 기타 + public void regenerateQrCodes(Ticket ticket, Long userId, Festival festival) { + ticket.getQrCodes().clear(); + ticket.getQrCodes().addAll( + IntStream.range(0, ticket.getSelectedTicketCount()) + .mapToObj(i -> createAndSaveQrCode(userId, festival, ticket)) + .toList() + ); + } + + public ReservationStatus determineReservationStatus(boolean paymentStatus) { + return paymentStatus ? ReservationStatus.CONFIRMED : ReservationStatus.CANCELED; + } + + public void updateTicketStatusIfNecessary(Ticket ticket, ReservationStatus newStatus) { + if (ticket.getReservationStatus() != ReservationStatus.CONFIRMED + && ticket.getReservationStatus() != ReservationStatus.CANCELED) { + ticket.setReservationStatus(newStatus); + ticket.setReservationDate(LocalDateTime.now()); + ticketRepository.save(ticket); + } + } + + // websocket -> μ‚¬μš©x + public void notifyTicketStatus(Ticket ticket, ReservationStatus status) { + messagingTemplate.convertAndSendToUser( + String.valueOf(ticket.getUserId()), + "/queue/ticket-status", + new TicketStatusResponseDTO(ticket.getReservationNumber(), status) + ); + } + + public Festival getFestivalOrThrow(String festivalId) { + return festivalRepository.findByFestivalId(festivalId) + .orElseThrow(() -> new BusinessException(ErrorCode.FESTIVAL_NOT_FOUND)); + } + + public Ticket getTicketOrThrow(String festivalId, Long userId, String reservationNumber) { + return ticketRepository.findByIdAndReservationNumber(festivalId, userId, reservationNumber) + .orElseThrow(() -> new BusinessException(ErrorCode.TICKET_NOT_FOUND)); + } + + public Ticket getTicketByReservationNumberOrThrow(String reservationNumber) { + return ticketRepository.findByReservationNumber(reservationNumber) + .orElseThrow(() -> new BusinessException(ErrorCode.TICKET_NOT_FOUND)); + } + + public TicketType parseDeliveryMethod(String method) { + if (method == null) throw new BusinessException(ErrorCode.FESTIVAL_DELIVERY_INVALID); + try { + return TicketType.valueOf(method.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new BusinessException(ErrorCode.TICKET_INVALID_DELIVERY_METHOD); + } + } + + /// deliveryDate 생성 + public LocalDateTime calculateDeliveryDate(Ticket ticket, TicketType deliveryMethod) { + if (ticket.getPerformanceDate() == null) throw new BusinessException(ErrorCode.FESTIVAL_INVALID_DATE); + + if (deliveryMethod == TicketType.PAPER) { + LocalDateTime deliveryDate = ticket.getPerformanceDate().minusDays(14); + return deliveryDate.isAfter(LocalDateTime.now()) + ? deliveryDate + : LocalDateTime.now().plusDays(1); + } + return null; // λͺ¨λ°”일 ν‹°μΌ“ + } + + /// Qr정보 생성 + private QrCode createAndSaveQrCode(Long userId, Festival festival, Ticket ticket) { + // 쀑볡 μ—†λŠ” QR μ½”λ“œ ID 생성 + String qrCodeId; + do { + qrCodeId = qrCodeService.generateQrCodeId(); + } while (qrCodeRepository.existsByQrCodeId(qrCodeId)); + + QrCode qrCode = QrResponseDTO.create(userId, qrCodeId, festival, ticket).toEntity(); + qrCodeRepository.save(qrCode); + return qrCode; + } + + /// 예맀 μ‹œλ„ μ‹œ, κ°€μ˜ˆλ§€ μƒνƒœ λͺ¨λ‘ μ§€μš°κΈ° + public void recreateHold(Festival festival, LocalDateTime performanceDate, Long userId) { + List tempReservedTickets = ticketRepository.findTempReservedTickets(festival.getId(), performanceDate, userId, ReservationStatus.TEMP_RESERVED); + ticketRepository.deleteAllInBatch(tempReservedTickets); + } +} diff --git a/src/main/java/com/mnms/booking/service/CaptchaService.java b/src/main/java/com/mnms/booking/service/CaptchaService.java new file mode 100644 index 0000000..3608e77 --- /dev/null +++ b/src/main/java/com/mnms/booking/service/CaptchaService.java @@ -0,0 +1,70 @@ +package com.mnms.booking.service; + +import com.google.code.kaptcha.impl.DefaultKaptcha; +import com.mnms.booking.dto.request.CaptchaRequestDTO; +import com.mnms.booking.dto.response.CaptchaResponseDTO; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.io.OutputStream; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CaptchaService { + + private final DefaultKaptcha captchaProducer; + private static final String CAPTCHA_SESSION_KEY = "captchaCode"; + private static final long CAPTCHA_EXPIRATION_MILLIS = 3 * 60 * 1000L; + + + // μΊ‘μ°¨ 이미지 생성 + public void writeCaptchaImage(HttpSession session, HttpServletResponse response) throws IOException { + // λ³΄μ•ˆ μ„€μ • + response.setDateHeader("Expires", 0); + response.setHeader("Cache-Control", "no-store, no-cache, must-revalidate"); + response.addHeader("Cache-Control", "post-check=0, pre-check=0"); + response.setHeader("Pragma", "no-cache"); + response.setContentType("image/jpeg"); + + BufferedImage image = generateCaptchaImage(session); + + try (OutputStream out = response.getOutputStream()) { + ImageIO.write(image, "jpg", out); + out.flush(); + } + } + + @Transactional + private BufferedImage generateCaptchaImage(HttpSession session) { + String captchaText = captchaProducer.createText(); + session.setAttribute(CAPTCHA_SESSION_KEY, new CaptchaRequestDTO(captchaText)); + return captchaProducer.createImage(captchaText); + } + + public CaptchaResponseDTO verifyCaptchaResult(String userInputCaptcha, HttpSession session) { + CaptchaRequestDTO captchaRequest = (CaptchaRequestDTO) session.getAttribute(CAPTCHA_SESSION_KEY); + + if (captchaRequest == null || captchaRequest.isExpired(CAPTCHA_EXPIRATION_MILLIS)) { // 3λΆ„ 만료 + session.removeAttribute(CAPTCHA_SESSION_KEY); + return CaptchaResponseDTO.builder() + .success(false) + .message("λ³΄μ•ˆλ¬Έμžκ°€ λ§Œλ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.") + .build(); + } + + boolean isValid = captchaRequest.getCode().equalsIgnoreCase(userInputCaptcha); + if (isValid) {session.removeAttribute(CAPTCHA_SESSION_KEY);} + + return CaptchaResponseDTO.builder() + .success(isValid) + .message(isValid ? "λ³΄μ•ˆλ¬Έμž μΈμ¦λ˜μ—ˆμŠ΅λ‹ˆλ‹€." : "λ³΄μ•ˆλ¬Έμž 뢈일치둜 인증이 μ‹€νŒ¨ν•˜μ˜€μŠ΅λ‹ˆλ‹€.") + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/service/EmailService.java b/src/main/java/com/mnms/booking/service/EmailService.java new file mode 100644 index 0000000..1f68ca0 --- /dev/null +++ b/src/main/java/com/mnms/booking/service/EmailService.java @@ -0,0 +1,80 @@ +package com.mnms.booking.service; + +import com.mnms.booking.dto.request.TicketRequestDTO; +import com.mnms.booking.dto.response.BookingUserResponseDTO; +import com.mnms.booking.entity.Ticket; +import com.mnms.booking.enums.TicketType; +import com.mnms.booking.exception.BusinessException; +import com.mnms.booking.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.time.format.DateTimeFormatter; + +@Service +@Slf4j +@RequiredArgsConstructor +public class EmailService { + private final JavaMailSender mailSender; + + @Retryable(retryFor = Exception.class, maxAttempts = 3, backoff = @Backoff(delay = 2000, multiplier = 2)) + public void sendTicketConfirmationEmail(TicketRequestDTO ticket, BookingUserResponseDTO user) { + try (InputStream is = getClass().getClassLoader() + .getResourceAsStream("templates/email/ticket-confirmation.txt")) { + + if (is == null) { + throw new BusinessException(ErrorCode.TICKET_EMAIL_TEMPLATE_NOT_FOUND); + } + + String template = new String(is.readAllBytes(), StandardCharsets.UTF_8); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyλ…„MMμ›”dd일 a hμ‹œ"); + + String content = String.format( + template, + user.getName(), + ticket.getReservationNumber(), + ticket.getFname(), + ticket.getPerformanceDate().format(formatter), + ticket.getFestivalFacility(), + ticket.getTicketPrice() * ticket.getSelectedTicketCount(), + ticket.getDeliveryMethod() == TicketType.MOBILE ? "λͺ¨λ°”일" : "μ§€λ₯˜" + ); + + String subject = String.format("[μ˜ˆλ§€ν™•μΈ] %s ν‹°μΌ“", ticket.getFestival().getFname()); + sendEmail(user.getEmail(), subject, content); + + } catch (IOException e) { + throw new BusinessException(ErrorCode.TICKET_EMAIL_TEMPLATE_NOT_FOUND); + } + } + + @Recover + public void recover(Exception e, TicketRequestDTO ticketDto, BookingUserResponseDTO user) { + log.error("이메일 μž¬μ‹œλ„ μ‹€νŒ¨: μ˜ˆμ•½ 번호 = {}, μ‚¬μš©μž 이메일={}", + ticketDto.getReservationNumber(), user.getEmail(), e); + } + + public void sendEmail(String to, String subject, String text) { + SimpleMailMessage message = new SimpleMailMessage(); + message.setTo(to); // μˆ˜μ‹ μž + message.setSubject(subject); // 제λͺ© + message.setText(text); // λ‚΄μš© + + mailSender.send(message); + } + + public static String loadTemplate(String path) throws Exception { + return new String(Files.readAllBytes(Paths.get(path))); + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/service/FestivalService.java b/src/main/java/com/mnms/booking/service/FestivalService.java new file mode 100644 index 0000000..d0c1e83 --- /dev/null +++ b/src/main/java/com/mnms/booking/service/FestivalService.java @@ -0,0 +1,24 @@ +package com.mnms.booking.service; + +import com.mnms.booking.entity.Festival; +import com.mnms.booking.exception.BusinessException; +import com.mnms.booking.exception.ErrorCode; +import com.mnms.booking.repository.FestivalRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class FestivalService { + + private final FestivalRepository festivalRepository; + + ///νŠΉμ • festivalId에 ν•΄λ‹Ήν•˜λŠ” κ³΅μ—°μ˜ 수용 인원을 쑰회 + public int getCapacity(String festivalId) { + Festival festival = festivalRepository.findByFestivalId(festivalId) + .orElseThrow(() -> new BusinessException(ErrorCode.FESTIVAL_NOT_FOUND)); + return festival.getAvailableNOP(); + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/service/HostService.java b/src/main/java/com/mnms/booking/service/HostService.java new file mode 100644 index 0000000..0d14eb7 --- /dev/null +++ b/src/main/java/com/mnms/booking/service/HostService.java @@ -0,0 +1,98 @@ +package com.mnms.booking.service; + +import com.mnms.booking.dto.request.HostRequestDTO; +import com.mnms.booking.dto.response.BookingUserInfoResponseDTO; +import com.mnms.booking.dto.response.HostResponseDTO; +import com.mnms.booking.entity.Festival; +import com.mnms.booking.entity.Ticket; +import com.mnms.booking.enums.ReservationStatus; +import com.mnms.booking.exception.BusinessException; +import com.mnms.booking.exception.ErrorCode; +import com.mnms.booking.repository.FestivalRepository; +import com.mnms.booking.repository.TicketRepository; +import com.mnms.booking.util.UserApiClient; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class HostService { + + private final TicketRepository ticketRepository; + private final FestivalRepository festivalRepository; + private final UserApiClient userApiClient; + + public List getBookingsByOrganizer(HostRequestDTO request) { + return ticketRepository + .findDistinctUserIds( + request.getFestivalId(), + request.getPerformanceDate(), + ReservationStatus.CONFIRMED + ); + } + + @Transactional + public List getBookingInfoByHost(String festivalId, Long hostUserId, List role) { + + Festival festival; + if (role.contains("ROLE_ADMIN")) { + festival = festivalRepository.findByFestivalId(festivalId) + .orElseThrow(() -> new BusinessException(ErrorCode.FESTIVAL_NOT_FOUND)); + } else { + festival = festivalRepository.findByFestivalIdAndOrganizer(festivalId, hostUserId); + } + + if (festival == null) { + throw new BusinessException(ErrorCode.FESTIVAL_NOT_FOUND); + } + + List tickets = new ArrayList<>(ticketRepository.findByIdAndReservationStatus(festival.getFestivalId(), ReservationStatus.CONFIRMED)); + + if (tickets.isEmpty()) { + return Collections.emptyList(); + } + + List userIds = tickets.stream() + .map(Ticket::getUserId) + .distinct() + .toList(); + + List users; + try { + users = userApiClient.getUsersByIds(userIds); + if (users == null) users = Collections.emptyList(); + } catch (WebClientResponseException e) { + throw new BusinessException(ErrorCode.USER_API_ERROR); + } catch (Exception e) { + throw new BusinessException(ErrorCode.UNKNOWN_ERROR); + } + + Map userMap = users.stream() + .collect(Collectors.toMap(BookingUserInfoResponseDTO::getUserId, u -> u)); + + return tickets.stream() + .map(t -> { + BookingUserInfoResponseDTO user = userMap.get(t.getUserId()); + return new HostResponseDTO( + t.getReservationNumber(), + t.getPerformanceDate(), + t.getUserId(), + t.getSelectedTicketCount(), + t.getDeliveryMethod(), + t.getAddress(), + user != null ? user.getName() : null, + user != null ? user.getPhone() : null + ); + }) + .toList(); + } +} diff --git a/src/main/java/com/mnms/booking/service/KeyExpirationListener.java b/src/main/java/com/mnms/booking/service/KeyExpirationListener.java new file mode 100644 index 0000000..99057d6 --- /dev/null +++ b/src/main/java/com/mnms/booking/service/KeyExpirationListener.java @@ -0,0 +1,64 @@ +package com.mnms.booking.service; + +import com.mnms.booking.entity.QrCode; +import com.mnms.booking.entity.Ticket; +import com.mnms.booking.enums.ReservationStatus; +import com.mnms.booking.exception.BusinessException; +import com.mnms.booking.exception.ErrorCode; +import com.mnms.booking.repository.QrCodeRepository; +import com.mnms.booking.repository.TicketRepository; +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.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class KeyExpirationListener implements MessageListener { + + private final TicketRepository ticketRepository; + private final WaitingService waitingService; + private final QrCodeRepository qrCodeRepository; + + /// ticket κ°€μ˜ˆλ§€ μŠ€μΌ€μ€„λŸ¬ + @Override + public void onMessage(Message message, byte[] pattern) { + String key = message.toString(); + log.info("onMessage key : {}", key); + if (key.startsWith("TEMP_RESERVATION:")) { + String reservationNumber = key.substring("TEMP_RESERVATION:".length()); + log.info("onMessage reservationNumber : {}", reservationNumber); + + // μ˜ˆλ§€μ—΄ μ‚­μ œ + Ticket reservation = loadReservationMeta(reservationNumber); + log.info("onMessage reservation : {}", reservation); + + waitingService.userExitBookingPage( + reservation.getFestival().getFestivalId(), + reservation.getPerformanceDate(), + String.valueOf(reservation.getUserId()) + ); + + // qr 있으면 μ‚­μ œ + List qrCodes = qrCodeRepository.findByTicketId(reservation.getId()); + if (!qrCodes.isEmpty()) { + qrCodeRepository.deleteAll(qrCodes); + } + + // ν‹°μΌ“ κ°€μ˜ˆλ§€ μ‚­μ œ + ticketRepository.findByReservationNumber(reservationNumber) + .filter(t -> t.getReservationStatus() == ReservationStatus.TEMP_RESERVED + || t.getReservationStatus() == ReservationStatus.PAYMENT_IN_PROGRESS) + .ifPresent(ticketRepository::delete); + } + } + + private Ticket loadReservationMeta(String reservationNumber) { + return ticketRepository.findByReservationNumberWithFestival(reservationNumber) + .orElseThrow(() -> new BusinessException(ErrorCode.TICKET_NOT_FOUND)); + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/service/OcrParserService.java b/src/main/java/com/mnms/booking/service/OcrParserService.java new file mode 100644 index 0000000..f4cb7f8 --- /dev/null +++ b/src/main/java/com/mnms/booking/service/OcrParserService.java @@ -0,0 +1,71 @@ +package com.mnms.booking.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mnms.booking.dto.response.PersonInfoResponseDTO; +import com.mnms.booking.exception.BusinessException; +import com.mnms.booking.exception.ErrorCode; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; +import java.util.*; +import java.util.stream.Collectors; + +@Service +@Transactional +public class OcrParserService { + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + public static List parseOcrResult( + String ocrJson, Map targetInfo) throws IOException { + List ocrTexts = extractOcrTexts(ocrJson); + + return targetInfo.entrySet().stream() + .map(entry -> matchPersonInfo(entry.getKey(), entry.getValue(), ocrTexts)) + .collect(Collectors.toList()); + } + + /// OCR 2μ°¨ μΆ”μΆœ + private static List extractOcrTexts(String ocrJson) throws IOException { + JsonNode root = objectMapper.readTree(ocrJson); + JsonNode images = root.path("images"); + + List texts = new ArrayList<>(); + for (JsonNode imageNode : images) { + JsonNode fields = imageNode.path("fields"); + for (JsonNode field : fields) { + texts.add(field.path("inferText").asText()); + } + } + return texts; + } + + /// OCR 3μ°¨ μΆ”μΆœ + private static PersonInfoResponseDTO matchPersonInfo( + String name, String rrn, List ocrTexts) { + + List cleanedOcrTexts = ocrTexts.stream() + .map(t -> t.replaceAll("\\([^)]*\\)", "") + .replaceAll("[\\s\\(\\)\\[\\]{}]", "")) + .toList(); + String foundName = null, foundRrn = null; + + for (String text : cleanedOcrTexts) { + if (foundName == null && text.contains(name)) foundName = text; + if (foundRrn == null && text.contains(rrn) && text.matches(".*\\d{6}-[1-4].*")) { + foundRrn = text; + } + if (foundName != null && foundRrn != null) break; + } + + if (foundName == null) { + throw new BusinessException(ErrorCode.TRANSFER_NOT_FOUND_NAME); + } + if (foundRrn == null) { + throw new BusinessException(ErrorCode.TRANSFER_NOT_FOUND_RRN); + } + return new PersonInfoResponseDTO(foundName, foundRrn); + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/service/OcrService.java b/src/main/java/com/mnms/booking/service/OcrService.java new file mode 100644 index 0000000..f0e6084 --- /dev/null +++ b/src/main/java/com/mnms/booking/service/OcrService.java @@ -0,0 +1,121 @@ +package com.mnms.booking.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mnms.booking.exception.BusinessException; +import com.mnms.booking.exception.ErrorCode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClient; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +@Service +@Transactional +public class OcrService { + + @Value("${ocr.key}") + private String ocrKey; + + @Value("${ocr.invoke_url}") + private String ocrInvokeUrl; + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + public String callOcr(MultipartFile image){ + try { + String imageName = image.getOriginalFilename(); + + if (imageName == null || imageName.isBlank()) { + throw new BusinessException(ErrorCode.TRANSFER_NOT_HAVE_FILE_NAME); + } + + // Path Traversal 곡격 성곡 + String filename = StringUtils.cleanPath(imageName); + if (filename.contains("..")) { + throw new BusinessException(ErrorCode.TRANSFER_DETECT_FILE_PATH_SECURITY); + } + + String extension = getExtension(imageName); + if (!"pdf".equalsIgnoreCase(extension)) { + throw new BusinessException(ErrorCode.TRANSFER_NOT_VALID_FILE_TYPE); + } + + // 2. MultipartFile β†’ Resource + ByteArrayResource fileResource = new ByteArrayResource(image.getBytes()) { + @Override + public String getFilename() { + return image.getOriginalFilename(); + } + }; + + MultiValueMap multipartBody = getApiBody(image, fileResource); + + // 5. 헀더 ꡬ성 + HttpHeaders headers = getApiHeaders(); + + return postApi(headers, multipartBody); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private String postApi(HttpHeaders headers, MultiValueMap multipartBody) { + RestClient restClient=RestClient.create(); + return restClient.post() + .uri(ocrInvokeUrl) + .headers(h -> h.addAll(headers)) + .body(multipartBody) + .retrieve() + .body(String.class); + } + + private HttpHeaders getApiHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + headers.set("X-OCR-SECRET", ocrKey); + return headers; + } + + private static MultiValueMap getApiBody(MultipartFile image, ByteArrayResource fileResource) throws JsonProcessingException { + Map messageBody = new HashMap<>(); + messageBody.put("version","V2"); + messageBody.put("requestId", UUID.randomUUID().toString()); + messageBody.put("timestamp",System.currentTimeMillis()); + messageBody.put("lang","ko"); + messageBody.put("enableTableDetection",false); + + Map imageInfo = new HashMap<>(); + imageInfo.put("format", "pdf"); + imageInfo.put("name", image.getOriginalFilename()); + + messageBody.put("images", new Object[]{imageInfo}); + + String messageJson = objectMapper.writeValueAsString(messageBody); + + // 4. multipart/form-data body ꡬ성 + MultiValueMap multipartBody = new LinkedMultiValueMap<>(); + multipartBody.add("file", fileResource); + multipartBody.add("message", messageJson); + return multipartBody; + } + + private String getExtension(String filename) { + if (filename == null || !filename.contains(".")) { + return ""; + } + return filename.substring(filename.lastIndexOf('.') + 1); + } +} diff --git a/src/main/java/com/mnms/booking/service/QrCodeService.java b/src/main/java/com/mnms/booking/service/QrCodeService.java new file mode 100644 index 0000000..6d60185 --- /dev/null +++ b/src/main/java/com/mnms/booking/service/QrCodeService.java @@ -0,0 +1,93 @@ +package com.mnms.booking.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.zxing.BarcodeFormat; +import com.google.zxing.WriterException; +import com.google.zxing.client.j2se.MatrixToImageWriter; +import com.google.zxing.common.BitMatrix; +import com.google.zxing.qrcode.QRCodeWriter; +import com.mnms.booking.entity.QrCode; +import com.mnms.booking.entity.Ticket; +import com.mnms.booking.exception.BusinessException; +import com.mnms.booking.exception.ErrorCode; +import com.mnms.booking.repository.QrCodeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.security.SecureRandom; +import java.time.LocalDateTime; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class QrCodeService { + + private final ObjectMapper objectMapper; + private final QrCodeRepository qrCodeRepository; + + /// QrCodeId 생성 + private static final SecureRandom secureRandom = new SecureRandom(); + private static final String HEX_CHARS = "0123456789abcdef"; + + /// QrCodeId : 32자리의 UUID-like λ¬Έμžμ—΄ 생성 + public String generateQrCodeId() { + StringBuilder sb = new StringBuilder(32); + for (int i = 0; i < 32; i++) { + int index = secureRandom.nextInt(HEX_CHARS.length()); + sb.append(HEX_CHARS.charAt(index)); + } + return sb.toString(); + } + + /// QR IMG 쑰회 + public byte[] generateQrCodeImage(String qrCodeText, int width, int height) throws WriterException, IOException { + QRCodeWriter qrCodeWriter = new QRCodeWriter(); + BitMatrix bitMatrix = qrCodeWriter.encode(qrCodeText, BarcodeFormat.QR_CODE, width, height); + + try (ByteArrayOutputStream pngOutputStream = new ByteArrayOutputStream()) { + MatrixToImageWriter.writeToStream(bitMatrix, "PNG", pngOutputStream); + return pngOutputStream.toByteArray(); + } + } + + public QrCode getQrCodeByCode(String qrCodeId) { + return qrCodeRepository.findByQrCodeId(qrCodeId) + .orElseThrow(() -> new BusinessException(ErrorCode.QR_CODE_NOT_FOUND)); + } + + + /// ν•΄λ‹Ή Festival 주졜자 QR μŠ€μΊ” + @Transactional + public void validateAndUseQrCode(Long userId, String qrCodeId) { + + // QR μ½”λ“œ 쑰회 + QrCode qrCode = qrCodeRepository.findByQrCodeId(qrCodeId) + .orElseThrow(() -> new BusinessException(ErrorCode.QR_CODE_NOT_FOUND)); + + // 주졜자 확인 + Ticket ticket = qrCode.getTicket(); + if (!ticket.getFestival().getOrganizer().equals(userId)) { + throw new BusinessException(ErrorCode.FESTIVAL_MISMATCH); + } + + // 만료 μ—¬λΆ€ 확인 + if (qrCode.getExpiredAt().isBefore(LocalDateTime.now())) { + throw new BusinessException(ErrorCode.QR_CODE_EXPIRED); + } + + // 이미 μ‚¬μš©λœ QR μ½”λ“œμΈμ§€ 확인 + if (Boolean.TRUE.equals(qrCode.getUsed())) { + throw new BusinessException(ErrorCode.QR_CODE_ALREADY_USED); + } + + // QR μ½”λ“œμ— μ—°κ²°λœ ν‹°μΌ“κ³Ό νŽ˜μŠ€ν‹°λ²Œ 확인 + if (ticket == null || ticket.getFestival() == null) { + throw new BusinessException(ErrorCode.QR_CODE_INVALID); + } + + // QR μ½”λ“œ μ‚¬μš© 처리 + qrCode.markAsUsed(); + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/service/RedisMessageSubscriber.java b/src/main/java/com/mnms/booking/service/RedisMessageSubscriber.java new file mode 100644 index 0000000..99c608e --- /dev/null +++ b/src/main/java/com/mnms/booking/service/RedisMessageSubscriber.java @@ -0,0 +1,70 @@ +package com.mnms.booking.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mnms.booking.dto.response.WaitingNumberResponseDTO; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.messaging.simp.user.SimpSession; +import org.springframework.messaging.simp.user.SimpUser; +import org.springframework.messaging.simp.user.SimpUserRegistry; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.Map; +import java.util.Queue; +import java.util.concurrent.*; + + +@Service +@Slf4j +@RequiredArgsConstructor +@Transactional +public class RedisMessageSubscriber { + + private final SimpMessagingTemplate messagingTemplate; // WebSocket λ©”μ‹œμ§€ 전솑 + private final ObjectMapper objectMapper; // JSON νŒŒμ‹±μ„ μœ„ν•œ ObjectMapper + private final SimpUserRegistry simpUserRegistry; + + + // μœ μ €λ³„ λŒ€κΈ° λ©”μ‹œμ§€ 큐 + private final Map> pendingMessages = new ConcurrentHashMap<>(); + + + // Redis둜 λ©”μ‹œμ§€ μˆ˜μ‹ ν•  λ•Œ 호좜됨 + public void onMessage(String message, String channel) { + printConnectedUsers(); + + try { + WaitingNumberResponseDTO dto = objectMapper.readValue(message, WaitingNumberResponseDTO.class); + + // μ—°κ²°λœ μœ μ €μΈμ§€ 확인 + if (isUserConnected(dto.getUserId())) { + messagingTemplate.convertAndSendToUser(dto.getUserId(), "/queue/waitingNumber", dto); + } else { + pendingMessages.computeIfAbsent(dto.getUserId(), k -> new ConcurrentLinkedQueue<>()).add(dto); + } + } catch (Exception e) { + log.error("μ˜ˆμ™Έ λ°œμƒ: {}", e.getMessage(), e); + } + } + + private boolean isUserConnected(String userId) { + return simpUserRegistry.getUser(userId) != null; + } + + // λ””λ²„κΉ…μš© : μΆ”ν›„ μ‚­μ œ + private void printConnectedUsers() { + Collection users = simpUserRegistry.getUsers(); + log.info("Connected users count: {}", users.size()); + + for (SimpUser user : users) { + log.info("User name (Principal.name): {}", user.getName()); + for (SimpSession session : user.getSessions()) { + log.info(" Session ID: {}", session.getId()); + log.info(" Session Principal: {}", session.getUser().getName()); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/service/StatisticsQrCodeService.java b/src/main/java/com/mnms/booking/service/StatisticsQrCodeService.java new file mode 100644 index 0000000..33dba6f --- /dev/null +++ b/src/main/java/com/mnms/booking/service/StatisticsQrCodeService.java @@ -0,0 +1,58 @@ +package com.mnms.booking.service; + +import com.mnms.booking.dto.response.StatisticsQrCodeResponseDTO; +import com.mnms.booking.entity.Festival; +import com.mnms.booking.exception.BusinessException; +import com.mnms.booking.exception.ErrorCode; +import com.mnms.booking.repository.FestivalRepository; +import com.mnms.booking.repository.QrCodeRepository; +import com.mnms.booking.repository.TicketRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +public class StatisticsQrCodeService { + + private final QrCodeRepository qrCodeRepository; + private final FestivalRepository festivalRepository; + private final TicketRepository ticketRepository; + + public StatisticsQrCodeResponseDTO getPerformanceEnterStatistics(String festivalId, LocalDateTime performanceDate, String userId, boolean isHost, boolean isAdmin) { + + // 1. ADMIN κΆŒν•œ 확인: ADMIN은 λͺ¨λ“  검증을 κ±΄λ„ˆλ›°κ³  λ°”λ‘œ 톡계 쑰회 + if (!isAdmin) { + Long longUserId; + try { + // userIdκ°€ μœ νš¨ν•œ Long 값인지 확인 + longUserId = Long.valueOf(userId); + } catch (NumberFormatException e) { + // 숫자둜 λ³€ν™˜ν•  수 μ—†λŠ” 경우 μ˜ˆμ™Έ 처리 + throw new BusinessException(ErrorCode.USER_INVALID); + } + + // 2. ADMIN이 μ•„λ‹ˆλ©΄ κΈ°μ‘΄ κΆŒν•œ 검증 둜직 μ‹€ν–‰ + if (isHost) { + Festival festival = festivalRepository.findByFestivalIdAndOrganizer(festivalId, longUserId); + if (festival == null) { + throw new BusinessException(ErrorCode.STATISTICS_ACCESS_DENIED); + } + } else { + boolean hasTicket = ticketRepository.existsByUserIdAndFestivalId(longUserId, festivalId); + if (!hasTicket) { + throw new BusinessException(ErrorCode.STATISTICS_ACCESS_DENIED); + } + } + } + + // 3. 톡계 둜직 + int availableNOP = festivalRepository.findByFestivalId(festivalId) + .map(Festival::getAvailableNOP) + .orElseThrow(() -> new BusinessException(ErrorCode.FESTIVAL_NOT_FOUND)); + + int checkedInCount = qrCodeRepository.countAdmittedAttendees(festivalId, performanceDate); + + return new StatisticsQrCodeResponseDTO(festivalId, performanceDate, availableNOP, checkedInCount); + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/service/StatisticsQueryService.java b/src/main/java/com/mnms/booking/service/StatisticsQueryService.java new file mode 100644 index 0000000..ef6a22a --- /dev/null +++ b/src/main/java/com/mnms/booking/service/StatisticsQueryService.java @@ -0,0 +1,67 @@ +package com.mnms.booking.service; + +import com.mnms.booking.dto.response.StatisticsBookingDTO; +import com.mnms.booking.entity.Festival; +import com.mnms.booking.entity.Ticket; +import com.mnms.booking.enums.ReservationStatus; +import com.mnms.booking.exception.BusinessException; +import com.mnms.booking.exception.ErrorCode; +import com.mnms.booking.repository.FestivalRepository; +import com.mnms.booking.repository.TicketRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class StatisticsQueryService { + + private final TicketRepository ticketRepository; + private final FestivalRepository festivalRepository; + + public void validateHostOrAdminAccess(String festivalId, String userId, boolean isAdmin) { + if (isAdmin) { + return; + } + Long longUserId; + try { + longUserId = Long.valueOf(userId); + } catch (NumberFormatException e) { + throw new BusinessException(ErrorCode.USER_INVALID); + } + if (!festivalRepository.existsByFestivalIdAndOrganizer(festivalId, longUserId)) { + throw new BusinessException(ErrorCode.STATISTICS_ACCESS_DENIED); + } + } + + public List getPerformanceDatesByFestivalId(String festivalId) { + List performanceDates = ticketRepository.findDistinctPerformanceDate(festivalId); + if (performanceDates.isEmpty()) { + throw new BusinessException(ErrorCode.FESTIVAL_NOT_FOUND); + } + return performanceDates; + } + + public List getBookingSummary(String festivalId) { + int availableCapacity = festivalRepository.findByFestivalId(festivalId) + .map(Festival::getAvailableNOP) + .orElseThrow(() -> new BusinessException(ErrorCode.FESTIVAL_NOT_FOUND)); + + // μœ νš¨ν•œ 티켓을 κ°€μ Έμ˜€λŠ” μ˜¬λ°”λ₯Έ 방법 + List bookingSummary = ticketRepository.findBookedSummary(festivalId, ReservationStatus.CONFIRMED); + + if (bookingSummary.isEmpty()) { + return new ArrayList<>(); + } + + bookingSummary.forEach(dto -> dto.setAvailableNOP(availableCapacity)); + + return bookingSummary; + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/service/StatisticsUserService.java b/src/main/java/com/mnms/booking/service/StatisticsUserService.java new file mode 100644 index 0000000..68c5ed2 --- /dev/null +++ b/src/main/java/com/mnms/booking/service/StatisticsUserService.java @@ -0,0 +1,78 @@ +package com.mnms.booking.service; + +import com.mnms.booking.dto.response.StatisticsUserResponseDTO; +import com.mnms.booking.enums.ReservationStatus; +import com.mnms.booking.exception.BusinessException; +import com.mnms.booking.exception.ErrorCode; +import com.mnms.booking.repository.StatisticsRepository; +import com.mnms.booking.util.StatisticsUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientException; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +public class StatisticsUserService { + private static final Logger logger = LoggerFactory.getLogger(StatisticsUserService.class); + private final StatisticsRepository statisticsRepository; + private final WebClient webClient; + private final String userStatsListApi; + + public StatisticsUserService( + StatisticsRepository statisticsRepository, + WebClient.Builder webClientBuilder, + @Value("${base.service.url}") String baseApiUrl, + @Value("${user.service.stats.url}") String userStatsListApi + ) { + this.statisticsRepository = statisticsRepository; + this.webClient = webClientBuilder.baseUrl(baseApiUrl).build(); + this.userStatsListApi = userStatsListApi; + } + + public StatisticsUserResponseDTO getFestivalUserStatistics(String festivalId) { + //List userIds = statisticsRepository.findUserIdsByFestivalId(festivalId); + List userIds = statisticsRepository.findUserIdsByFestivalIdAndReservationStatus( + festivalId, + ReservationStatus.CONFIRMED + ); + + if (userIds.isEmpty()) { + logger.warn("νŽ˜μŠ€ν‹°λ²Œ ID: {}에 λŒ€ν•œ 예맀 내역이 μ—†μŠ΅λ‹ˆλ‹€. 톡계 수치λ₯Ό 0으둜 λ°˜ν™˜ν•©λ‹ˆλ‹€.", festivalId); + return StatisticsUtil.calculateStatistics(List.of()); + } + + logger.info("νŽ˜μŠ€ν‹°λ²Œ ID: {}에 λŒ€ν•œ μ‚¬μš©μž ID {}개λ₯Ό μ°Ύμ•˜μŠ΅λ‹ˆλ‹€.", userIds.size(), festivalId); + logger.debug("μ‚¬μš©μž ID λͺ©λ‘: {}", userIds); + + try { + List> userDemographics = getUserDemographicsFromUserMSA(userIds); + logger.info("User MSAλ‘œλΆ€ν„° μ‚¬μš©μž 톡계 정보λ₯Ό λ°›κΈ° 성곡. 총 건수: {}", userDemographics.size()); + return StatisticsUtil.calculateStatistics(userDemographics); + } catch (WebClientException e) { + logger.error("νŽ˜μŠ€ν‹°λ²Œ {}에 λŒ€ν•œ User MSA 톡계 정보 μ‘°νšŒμ— μ‹€νŒ¨: {}", festivalId, e.getMessage()); + throw new BusinessException(ErrorCode.USER_API_ERROR); + } + } + + private List> getUserDemographicsFromUserMSA(List userIds) { + List longUserIds = userIds.stream() + .map(Long::valueOf) + .collect(Collectors.toList()); + + //logger.info("User MSA API ν˜ΈμΆœμ„ μ‹œλ„ν•©λ‹ˆλ‹€. URL: {}", this.userStatsListApi); + + return webClient.post() + .uri(this.userStatsListApi) + .bodyValue(longUserIds) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>>() {}) + .block(); + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/service/TempReservationService.java b/src/main/java/com/mnms/booking/service/TempReservationService.java new file mode 100644 index 0000000..64d352e --- /dev/null +++ b/src/main/java/com/mnms/booking/service/TempReservationService.java @@ -0,0 +1,48 @@ +package com.mnms.booking.service; + +import com.mnms.booking.entity.Ticket; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +public class TempReservationService { + + private final RedisTemplate redisTemplate; + private static final String PREFIX = "TEMP_RESERVATION:"; + + @Value("${temp-reservation.ttl-minutes:1}") + private long ttlMinutes; + + // 2μ°¨ μ˜ˆλ§€ν•˜κΈ° λˆ„λ₯΄λ©΄ μ‹€ν–‰ + public void createTempReservation(Ticket ticket) { + String key = PREFIX + ticket.getReservationNumber(); + redisTemplate.opsForValue().set(key, ticket, ttlMinutes, TimeUnit.MINUTES); + } + + // κ°±μ‹  - refresh ttl 각자 μ„€μ • + public void refreshTempReservation(String reservationNumber, long ttlMinutes) { + String key = PREFIX + reservationNumber; + Boolean exists = redisTemplate.hasKey(key); + if (Boolean.TRUE.equals(exists)) { + redisTemplate.expire(key, ttlMinutes, TimeUnit.MINUTES); + } + } + + // 쑰회 + public Optional getTempReservation(String reservationNumber) { + String key = PREFIX + reservationNumber; + Ticket ticket = (Ticket) redisTemplate.opsForValue().get(key); + return Optional.ofNullable(ticket); + } + + // μ‚­μ œ + public void deleteTempReservation(String reservationNumber) { + redisTemplate.delete(PREFIX + reservationNumber); + } +} diff --git a/src/main/java/com/mnms/booking/service/TicketService.java b/src/main/java/com/mnms/booking/service/TicketService.java new file mode 100644 index 0000000..9be6ce6 --- /dev/null +++ b/src/main/java/com/mnms/booking/service/TicketService.java @@ -0,0 +1,59 @@ +package com.mnms.booking.service; + +import com.mnms.booking.dto.response.TicketDetailResponseDTO; +import com.mnms.booking.dto.response.TicketResponseDTO; +import com.mnms.booking.entity.Ticket; +import com.mnms.booking.enums.ReservationStatus; +import com.mnms.booking.exception.BusinessException; +import com.mnms.booking.exception.ErrorCode; +import com.mnms.booking.repository.QrCodeRepository; +import com.mnms.booking.repository.TicketRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +@Service +@Slf4j +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TicketService { + + private final TicketRepository ticketRepository; + private final QrCodeRepository qrCodeRepository; + + // 예맀 리슀트 κΈ°λ³Έ 쑰회 + public List getTicketsByUser(Long userId) { + + // statusκ°€ CONFIRMED, CANCELED 일 λ•Œ + List statuses = List.of( + ReservationStatus.CONFIRMED, + ReservationStatus.CANCELED + ); + List tickets = ticketRepository.findByUserIdAndReservationStatusIn(userId, statuses); + + if(tickets.isEmpty()){ + return Collections.emptyList(); + } + return tickets.stream() + .sorted(Comparator.comparing(Ticket::getReservationDate).reversed()) + .map(ticket -> TicketResponseDTO.fromEntity(ticket, ticket.getFestival())) + .toList(); + } + + // 예맀 상세 쑰회 + public TicketDetailResponseDTO getTicketDetailByUser(String reservationNumber, Long userId, String userName) { + Ticket ticket = ticketRepository.findByUserIdAndReservationNumber(userId, reservationNumber) + .orElseThrow(() -> new BusinessException(ErrorCode.TICKET_NOT_FOUND)); + boolean qrUsed = qrCodeRepository.existsByTicket_IdAndUsedTrue(ticket.getId()); + + if (!ticket.getUserId().equals(userId)) { + throw new BusinessException(ErrorCode.USER_UNAUTHORIZED_ACCESS); + } + return TicketDetailResponseDTO.fromEntity(ticket, ticket.getFestival(), userName, qrUsed); + } +} diff --git a/src/main/java/com/mnms/booking/service/TransferCompletionService.java b/src/main/java/com/mnms/booking/service/TransferCompletionService.java new file mode 100644 index 0000000..9863f9f --- /dev/null +++ b/src/main/java/com/mnms/booking/service/TransferCompletionService.java @@ -0,0 +1,210 @@ +package com.mnms.booking.service; + +import com.mnms.booking.dto.request.UpdateTicketRequestDTO; +import com.mnms.booking.dto.response.TransferOthersResponseDTO; +import com.mnms.booking.dto.response.TransferStatusResponseDTO; +import com.mnms.booking.entity.Festival; +import com.mnms.booking.entity.QrCode; +import com.mnms.booking.entity.Ticket; +import com.mnms.booking.entity.Transfer; +import com.mnms.booking.enums.TicketType; +import com.mnms.booking.enums.TransferStatus; +import com.mnms.booking.enums.TransferType; +import com.mnms.booking.exception.BusinessException; +import com.mnms.booking.exception.ErrorCode; +import com.mnms.booking.repository.QrCodeRepository; +import com.mnms.booking.repository.TicketRepository; +import com.mnms.booking.repository.TransferRepository; +import com.mnms.booking.util.CommonUtils; +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +/// 양도 수락 +@Service +@RequiredArgsConstructor +@Transactional +public class TransferCompletionService { + + private final TicketRepository ticketRepository; + private final QrCodeRepository qrCodeRepository; + private final TransferRepository transferRepository; + private final CommonUtils commonUtils; + private final QrCodeService qrCodeService; + private final SimpMessagingTemplate messagingTemplate; + + /// κ°€μ‘± κ°„ 양도 수락 + @Transactional(rollbackFor = BusinessException.class) + public void updateFamilyTicket(UpdateTicketRequestDTO request, Long userId) { + Transfer transfer = getTransferOrThrow(request.getTransferId()); + + validateSenderId(request.getSenderId(), transfer.getTicket().getUserId()); + validateTransferType(transfer, TransferType.FAMILY); + validateReceiver(transfer, userId); + + // CANCELED 처리 + if (handleCancel(transfer, request)) { + return; + } + + // transfer μƒνƒœ μ—…λ°μ΄νŠΈ + transfer.setStatus(TransferStatus.COMPLETED); + applyTicketAndQrUpdate(transfer, request, transfer.getReceiverId(), true); + } + + /// 지인 κ°„ 양도 μš”μ²­ 수락 + @Transactional + public TransferOthersResponseDTO proceedOthersTicket(UpdateTicketRequestDTO request, Long userId) { + + Transfer transfer = getTransferOrThrow(request.getTransferId()); + validateSenderId(request.getSenderId(), transfer.getTicket().getUserId()); + validateTransferType(transfer, TransferType.OTHERS); + + String reservationNumber = transfer.getTicket().getReservationNumber(); + validateReceiver(transfer, userId); + + // CANCELED 처리 + if(handleCancel(transfer, request)){ + return TransferOthersResponseDTO.canceled(reservationNumber, transfer.getSenderId(), userId); + } + + Ticket ticket = getTicketOrThrow(reservationNumber); + validateTicketStatus(ticket); + Festival festival = ticket.getFestival(); + + // transfer μ—…λ°μ΄νŠΈ + transfer.setStatus(TransferStatus.APPROVED); + transfer.setTicketType(request.getTicketType()); + transfer.setAddress(request.getAddress()); + + return TransferOthersResponseDTO.from(transfer, ticket, festival, userId); + } + + + /// 결제 KAFKA ꡬ독 λ©”μ‹œμ§€ λ°›κ³  결제 μ™„λ£Œ μˆ˜ν–‰ + @Transactional + public void updateOthersTicket(String reservationNumber, boolean paymentStatus) { + Ticket ticket = ticketRepository.findByReservationNumber(reservationNumber) + .orElseThrow(() -> new BusinessException(ErrorCode.TICKET_NOT_FOUND)); + + Transfer transfer = transferRepository.findByTicket(ticket); + + TransferStatus newStatus = paymentStatus ? + TransferStatus.COMPLETED : + TransferStatus.APPROVED; + + // 결제 kafka 둜직 λ³€κ²½ μ‹œ μˆ˜μ • μ˜ˆμ • + if (paymentStatus && transfer.getStatus() != TransferStatus.COMPLETED) { + transfer.setStatus(TransferStatus.COMPLETED); + UpdateTicketRequestDTO request = UpdateTicketRequestDTO.builder() + .transferId(transfer.getId()) + .senderId(transfer.getSenderId()) + .transferStatus(transfer.getStatus()) + .ticketType(transfer.getTicketType()) + .address(transfer.getAddress()) + .build(); + applyTicketAndQrUpdate(transfer, request, transfer.getReceiverId(), false); + } + } + + // WebSocket 전솑 -> μ‚¬μš©X (κΈ°μ‘΄ κ²°μ œμ—μ„œ kafka ꡬ독 λ©”μ‹œμ§€ λ°›μ•˜μ„ λ•Œ μ‚¬μš©) + private void notifyTicketStatus(Ticket ticket, TransferStatus status) { + messagingTemplate.convertAndSendToUser( + String.valueOf(ticket.getUserId()), + "/queue/transfer-status", + new TransferStatusResponseDTO(ticket.getReservationNumber(), status) + ); + } + + /// UTIL + private void applyTicketAndQrUpdate(Transfer transfer, UpdateTicketRequestDTO request, + Long receiverId, boolean updateTransferFields) { + Ticket ticket = getTicketOrThrow(transfer.getTicket().getReservationNumber()); + validateTicketStatus(ticket); + + if (updateTransferFields) { + transfer.setTicketType(request.getTicketType()); + transfer.setAddress(request.getAddress()); + } + + updateTicketInfo(ticket, request, receiverId); + updateQrCodes(ticket, request, receiverId); + } + + private boolean handleCancel(Transfer transfer, UpdateTicketRequestDTO request) { + if (request.getTransferStatus() == TransferStatus.CANCELED) { + transferRepository.delete(transfer); + return true; + } + return false; + } + + private void validateTransferType(Transfer transfer, TransferType expectedType) { + if (!transfer.getTransferType().equals(expectedType)) { + throw new BusinessException(ErrorCode.TRANSFER_NOT_MATCH_TYPE); + } + } + + private void validateSenderId(Long senderId, Long ticketUserId) { + if (!senderId.equals(ticketUserId)) { + throw new BusinessException(ErrorCode.TRANSFER_NOT_MATCH_SENDER); + } + } + + private void validateReceiver(Transfer transfer, Long userId){ + // μ–‘μˆ˜μž 확인 + if (!userId.equals(transfer.getReceiverId())){ + throw new BusinessException(ErrorCode.TRANSFER_NOT_MATCH_RECEIVER); + } + } + private Transfer getTransferOrThrow(Long transferId) { + return transferRepository.findById(transferId) + .orElseThrow(() -> new BusinessException(ErrorCode.TRANSFER_NOT_EXIST)); + } + + private Ticket getTicketOrThrow(String reservationNumber) { + return ticketRepository.findByReservationNumber(reservationNumber) + .orElseThrow(() -> new BusinessException(ErrorCode.TICKET_NOT_FOUND)); + } + + private void validateTicketStatus(Ticket ticket) { + if (ticket.isExpired()) { + throw new BusinessException(ErrorCode.TICKET_EXPIRED); + } + if (ticket.isCanceled()) { + throw new BusinessException(ErrorCode.TICKET_CANCELED); + } + } + + /// ticket 정보 μ—…λ°μ΄νŠΈ + private void updateTicketInfo(Ticket ticket, UpdateTicketRequestDTO request, Long receiverId) { + TicketType deliveryMethod = request.getTicketType(); + + ticket.updateTicketInfo( + commonUtils.generateReservationNumber(), + deliveryMethod, + receiverId, + LocalDateTime.now(), + request.getAddress() + ); + } + + /// qr 정보 μ—…λ°μ΄νŠΈ + private void updateQrCodes(Ticket ticket, UpdateTicketRequestDTO request, Long receiverId) { + List existingQrs = qrCodeRepository.findByTicketId(ticket.getId()); + + if (existingQrs.isEmpty()) { + throw new BusinessException(ErrorCode.QR_CODE_NOT_FOUND); + } + + existingQrs.forEach(qr -> { + qr.setQrCodeId(qrCodeService.generateQrCodeId()); + qr.setUserId(receiverId); + qr.setTicket(ticket); + }); + } +} diff --git a/src/main/java/com/mnms/booking/service/TransferService.java b/src/main/java/com/mnms/booking/service/TransferService.java new file mode 100644 index 0000000..66af852 --- /dev/null +++ b/src/main/java/com/mnms/booking/service/TransferService.java @@ -0,0 +1,106 @@ +package com.mnms.booking.service; + +import com.mnms.booking.dto.request.TicketTransferRequestDTO; +import com.mnms.booking.dto.response.TicketResponseDTO; +import com.mnms.booking.dto.response.TicketTransferResponseDTO; +import com.mnms.booking.entity.Festival; +import com.mnms.booking.entity.Ticket; +import com.mnms.booking.entity.Transfer; +import com.mnms.booking.enums.ReservationStatus; +import com.mnms.booking.enums.TransferStatus; +import com.mnms.booking.enums.TransferType; +import com.mnms.booking.exception.BusinessException; +import com.mnms.booking.exception.ErrorCode; +import com.mnms.booking.repository.QrCodeRepository; +import com.mnms.booking.repository.TicketRepository; +import com.mnms.booking.repository.TransferRepository; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TransferService { + + private final TicketRepository ticketRepository; + private final TransferRepository transferRepository; + private final QrCodeRepository qrCodeRepository; + + /// 양도 κ°€λŠ₯ν•œ ν‹°μΌ“ 쑰회 + public List getTicketsByUser(Long userId) { + + List tickets = ticketRepository.findByUserIdAndReservationStatus(userId, ReservationStatus.CONFIRMED); + if(tickets.isEmpty()){ + return Collections.emptyList(); + } + + return tickets.stream() + .filter(ticket -> ticket.getPerformanceDate().isAfter(LocalDateTime.now())) + .filter(ticket -> !transferRepository.existsByTicketId(ticket.getId())) + .filter(ticket -> !qrCodeRepository.existsByTicket_IdAndUsedTrue(ticket.getId())) + .map(ticket -> TicketResponseDTO.fromEntity(ticket, ticket.getFestival())) + .toList(); + } + + + /// 양도 μš”μ²­ + @Transactional + public void requestTransfer(@Valid TicketTransferRequestDTO dto, Long userId) { + Ticket ticket = ticketRepository.findByReservationNumber(dto.getReservationNumber()) + .orElseThrow(() -> new BusinessException(ErrorCode.TICKET_NOT_FOUND)); + + // 예맀 15λΆ„ 확인 ν›„ 지인간 양도 λΆˆκ°€λŠ₯ + if(dto.getTransferType().equals("OTHERS") && Duration.between(ticket.getReservationDate(), LocalDateTime.now()).toMinutes() > 15){ + throw new BusinessException(ErrorCode.TRANSFER_OTHERS_NOT_ALLOWED); + } + + if(transferRepository.existsByTicket_Id(ticket.getId())){ + throw new BusinessException(ErrorCode.TRANSFER_ALREADY_EXIST_REQUEST); + } + + if(!ticket.getUserId().equals(userId)){ + throw new BusinessException(ErrorCode.TICKET_USER_NOT_SAME); + } + + Transfer transfer = Transfer.builder() + .ticket(ticket) + .senderId(userId) + .senderName(dto.getSenderName()) + .receiverId(dto.getRecipientId()) + .transferType("OTHERS".equals(dto.getTransferType()) ? TransferType.OTHERS : TransferType.FAMILY) + .status(TransferStatus.REQUESTED) + .build(); + transferRepository.save(transfer); + } + + /// 양도 μš”μ²­ 쑰회 + public List watchTransfer(Long userId) { + List transfers = transferRepository.findByReceiverIdWithTicketAndFestival( + userId, List.of(TransferStatus.COMPLETED, TransferStatus.CANCELED)); + + if (transfers.isEmpty()) { + throw new BusinessException(ErrorCode.TRANSFER_NOT_EXIST); + } + + return transfers.stream() + .map(transfer -> { + Ticket ticket = transfer.getTicket(); + if(ticket==null) throw new BusinessException(ErrorCode.TICKET_NOT_FOUND); + Festival festival = ticket.getFestival(); + if(festival==null) throw new BusinessException(ErrorCode.FESTIVAL_NOT_FOUND); + return TicketTransferResponseDTO.from(transfer, ticket, festival); + }) + .toList(); + } + + public Boolean checkStatus(Long transferId) { + return transferRepository.findTransferStatusById(transferId).equals(TransferStatus.COMPLETED); + } +} diff --git a/src/main/java/com/mnms/booking/service/WaitingNotificationService.java b/src/main/java/com/mnms/booking/service/WaitingNotificationService.java new file mode 100644 index 0000000..8fb3142 --- /dev/null +++ b/src/main/java/com/mnms/booking/service/WaitingNotificationService.java @@ -0,0 +1,82 @@ +package com.mnms.booking.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mnms.booking.dto.response.WaitingNumberResponseDTO; +import com.mnms.booking.exception.BusinessException; +import com.mnms.booking.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.RedisConnectionFailureException; +import org.springframework.data.redis.RedisSystemException; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; +import java.util.Set; + +@Service +@Slf4j +@RequiredArgsConstructor +public class WaitingNotificationService { + + private final StringRedisTemplate stringRedisTemplate; + private final ObjectMapper objectMapper; + private final WaitingQueueRedisService waitingQueueRedisService; + + /// μ‚¬μš©μž λŒ€κΈ° 순번 쑰회 및 Redis Pub/Sub으둜 λ°œν–‰ + public long getAndPublishWaitingNumber(String waitingQueueKey, String notificationChannelKey, String loginId) { + try { + long waitingNumber = waitingQueueRedisService.getWaitingNumber(waitingQueueKey, loginId); + if (waitingNumber == -1) { + throw new BusinessException(ErrorCode.USER_NOT_FOUND_IN_WAITING); + } + publishWaitingNumber(loginId, waitingNumber, notificationChannelKey); + return waitingNumber; + } catch (RedisConnectionFailureException e) { + throw new BusinessException(ErrorCode.REDIS_CONNECTION_FAILED); + } catch (RedisSystemException e) { + throw new BusinessException(ErrorCode.REDIS_PUBLISH_FAILED); + } + } + + /// Redis Pub/Sub μ±„λ„λ‘œ λŒ€κΈ° 순번 정보 λ°œν–‰ + private void publishWaitingNumber(String userId, long waitingNumber, String notificationChannelKey) { + try { + WaitingNumberResponseDTO waitingNumberDto = new WaitingNumberResponseDTO(userId, waitingNumber, false, null); + String message = objectMapper.writeValueAsString(waitingNumberDto); + + // message λ°œν–‰ + stringRedisTemplate.convertAndSend(notificationChannelKey, message); + + } catch (JsonProcessingException e) { + throw new BusinessException(ErrorCode.JSON_SERIALIZATION_FAILED); + } catch (RedisConnectionFailureException e) { + throw new BusinessException(ErrorCode.REDIS_CONNECTION_FAILED); + } + } + + /// λͺ¨λ“  λŒ€κΈ°μ—΄ μ‚¬μš©μžμ—κ²Œ 순번 μ—…λ°μ΄νŠΈ μ•Œλ¦Ό + public void notifyAllWaitingUsers(String waitingQueueKey, String notificationChannelKey) { + Set allUsersInQueue = waitingQueueRedisService.getAllUsersInQueue(waitingQueueKey); + + if (allUsersInQueue != null) { + for (String userId : allUsersInQueue) { + getAndPublishWaitingNumber(waitingQueueKey, notificationChannelKey, userId); + } + } + } + + public void notifyAffectedWaitingUsers(String waitingQueueKey, String notificationChannelKey, Long removedRank) { + if (removedRank == null) { + return; + } + + // λŒ€κΈ°μ—΄ 퇴μž₯ν•œ μ‚¬λžŒ 뒀에 μžˆλŠ” μ‚¬μš©μžλ§Œ 쑰회 + Set affectedUsers = waitingQueueRedisService.getUsersByRange(waitingQueueKey, removedRank, -1); + if (affectedUsers != null) { + for (String userId : affectedUsers) { + getAndPublishWaitingNumber(waitingQueueKey, notificationChannelKey, userId); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/service/WaitingQueueKeyGenerator.java b/src/main/java/com/mnms/booking/service/WaitingQueueKeyGenerator.java new file mode 100644 index 0000000..1105fab --- /dev/null +++ b/src/main/java/com/mnms/booking/service/WaitingQueueKeyGenerator.java @@ -0,0 +1,29 @@ +package com.mnms.booking.service; + +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +@Component +public class WaitingQueueKeyGenerator { + + private static final String WAITING_QUEUE_KEY = "waiting_queue"; + private static final String BOOKING_USERS_SET_KEY = "booking_users"; + private static final String NOTIFICATION_CHANNEL = "waiting_notification"; + + public String getWaitingQueueKey(String festivalId, LocalDateTime reservationDate) { + String dateStr = reservationDate.format(DateTimeFormatter.ofPattern("yyyyMMddHHmm")); + return WAITING_QUEUE_KEY + ":" + festivalId + ":" + dateStr; + } + + public String getBookingUsersKey(String festivalId, LocalDateTime reservationDate) { + String dateStr = reservationDate.format(DateTimeFormatter.ofPattern("yyyyMMddHHmm")); + return BOOKING_USERS_SET_KEY + ":" + festivalId + ":" + dateStr; + } + + public String getNotificationChannelKey(String festivalId, LocalDateTime reservationDate) { + String dateStr = reservationDate.format(DateTimeFormatter.ofPattern("yyyyMMddHHmm")); + return NOTIFICATION_CHANNEL + "/" + festivalId + "/" + dateStr; + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/service/WaitingQueueRedisService.java b/src/main/java/com/mnms/booking/service/WaitingQueueRedisService.java new file mode 100644 index 0000000..127d52c --- /dev/null +++ b/src/main/java/com/mnms/booking/service/WaitingQueueRedisService.java @@ -0,0 +1,132 @@ +package com.mnms.booking.service; + +import com.mnms.booking.exception.BusinessException; +import com.mnms.booking.exception.ErrorCode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.RedisConnectionFailureException; +import org.springframework.data.redis.RedisSystemException; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.Collections; +import java.util.Optional; +import java.util.Set; + +@Service +@Slf4j +public class WaitingQueueRedisService { + + private final RedisTemplate redisTemplate; + private final ZSetOperations zSetOperations; + + /// Lua 슀크립트 + private static final String ENTER_SCRIPT = + "local current = redis.call('SCARD', KEYS[1]); " + + "if current < tonumber(ARGV[1]) then " + + " redis.call('SADD', KEYS[1], ARGV[2]); " + + " return 1; " + + "else " + + " return 0; " + + "end"; + + private final DefaultRedisScript enterScript; + + { + enterScript = new DefaultRedisScript<>(); + enterScript.setScriptText(ENTER_SCRIPT); + enterScript.setResultType(Long.class); + } + + /** + * Lua 슀크립트둜 μ•ˆμ „ν•˜κ²Œ μœ μ € μΆ”κ°€ μ‹œλ„ + * @return true = μ¦‰μ‹œ μž…μž₯, false = λŒ€κΈ°μ—΄ ν•„μš” + */ + public boolean tryEnterBooking(String bookingUsersKey, long availableNOP, String userId) { + try { + Long result = redisTemplate.execute( + enterScript, + Collections.singletonList(bookingUsersKey), + String.valueOf(availableNOP), + userId + ); + + return Optional.of(result) + .map(r -> r == 1) + .orElseThrow(() -> new BusinessException(ErrorCode.FAILED_TO_ENTER_BOOKING)); + } catch (RedisConnectionFailureException e) { + throw new BusinessException(ErrorCode.REDIS_CONNECTION_FAILED); + } catch (RedisSystemException e) { + throw new BusinessException(ErrorCode.FAILED_TO_EXECUTE_SCRIPT); + } + } + + + public WaitingQueueRedisService(RedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + this.zSetOperations = redisTemplate.opsForZSet(); + } + + public boolean addUserToQueue(String waitingQueueKey, String loginId) { + long timestamp = System.currentTimeMillis(); + Boolean result = zSetOperations.add(waitingQueueKey, loginId, timestamp); + redisTemplate.expire(waitingQueueKey, Duration.ofDays(2)); + return result != null && result; // null λ°©μ–΄ + } + + public boolean removeUserFromQueue(String waitingQueueKey, String userId) { + Long removedCount = zSetOperations.remove(waitingQueueKey, userId); + return removedCount != null && removedCount > 0; + } + + public Set getAllUsersInQueue(String waitingQueueKey) { + return zSetOperations.range(waitingQueueKey, 0, -1); + } + + // μˆ˜μ • ν•„μš” + public Set getUsersByRange(String waitingQueueKey, long start, long end) { + return zSetOperations.range(waitingQueueKey, start, end); + } + + public String getFirstUserInQueue(String waitingQueueKey) { + Set users = zSetOperations.range(waitingQueueKey, 0, 0); + return (users != null && !users.isEmpty()) ? users.iterator().next() : null; + } + + public long getWaitingNumber(String waitingQueueKey, String userId) { + Long rank = zSetOperations.rank(waitingQueueKey, userId); + return (rank != null) ? rank + 1 : -1; + } + + // ν˜„ 예맀 νŽ˜μ΄μ§€μ— μžˆλŠ” μ‚¬μš©μž 수 + public long getBookingUserCount(String bookingUsersKey) { + Long count = redisTemplate.opsForSet().size(bookingUsersKey); + return (count != null) ? count : 0L; + } + + public long getWaitingUserCount(String waitingQueueKey) { + Long count = redisTemplate.opsForZSet().zCard(waitingQueueKey); + return (count != null) ? count : 0L; + } + + public void addBookingUser(String bookingUsersKey, String userId) { + redisTemplate.opsForSet().add(bookingUsersKey, userId); + + // ttl 생성 + redisTemplate.expire(bookingUsersKey, Duration.ofDays(2)); + } + + public void removeBookingUser(String bookingUsersKey, String userId) { + redisTemplate.opsForSet().remove(bookingUsersKey, userId); + } + + + public Long getRank(String waitingQueueKey, String userId){ + return zSetOperations.rank(waitingQueueKey, userId); + } + public void cleanKey(String key){ + redisTemplate.delete(key); + } +} diff --git a/src/main/java/com/mnms/booking/service/WaitingQueueSchedulingService.java b/src/main/java/com/mnms/booking/service/WaitingQueueSchedulingService.java new file mode 100644 index 0000000..25e3a31 --- /dev/null +++ b/src/main/java/com/mnms/booking/service/WaitingQueueSchedulingService.java @@ -0,0 +1,99 @@ +package com.mnms.booking.service; + +import com.mnms.booking.exception.BusinessException; +import com.mnms.booking.exception.ErrorCode; +import jakarta.annotation.PreDestroy; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.RedisConnectionFailureException; +import org.springframework.data.redis.RedisSystemException; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ScheduledFuture; + +@Service +@Slf4j +@RequiredArgsConstructor +public class WaitingQueueSchedulingService { + private final WaitingQueueRedisService waitingQueueRedisService; + private final WaitingNotificationService waitingNotificationService; + private final ThreadPoolTaskScheduler scheduler; + private final Map> scheduledTasks = new ConcurrentHashMap<>(); + + /// μŠ€μΌ€μ€„λŸ¬ μ‹œμž‘ (쀑볡 μ‹œμž‘ λ°©μ§€) + public synchronized void startScheduler(String waitingQueueKey, String bookingUsersKey, String notificationChannelKey, long availableNOP) { + if (scheduledTasks.containsKey(waitingQueueKey) && !scheduledTasks.get(waitingQueueKey).isDone()) { + return; + } + log.info("Starting scheduler for queue: {}", waitingQueueKey); + ScheduledFuture task = scheduler.scheduleWithFixedDelay( + () -> runSchedulerLogic(waitingQueueKey, bookingUsersKey, notificationChannelKey, availableNOP), + Duration.ofSeconds(10) + ); + scheduledTasks.put(waitingQueueKey, task); + } + + /// μŠ€μΌ€μ€„λŸ¬ 쀑지 + public synchronized void stopScheduler(String waitingQueueKey) { + ScheduledFuture task = scheduledTasks.get(waitingQueueKey); + if (task != null && !task.isCancelled()) { + task.cancel(false); + scheduledTasks.remove(waitingQueueKey); + log.info("Stopped scheduler for queue: {}", waitingQueueKey); + } + } + + /// 주기적으둜 λŒ€κΈ°μ—΄ 순번 λ°œν–‰ 및 μž…μž₯ 처리 + private void runSchedulerLogic(String waitingQueueKey, String bookingUsersKey, String notificationChannelKey, long availableNOP) { + try { + Set waitingUsers = waitingQueueRedisService.getAllUsersInQueue(waitingQueueKey); + + if (waitingUsers == null || waitingUsers.isEmpty()) { + stopScheduler(waitingQueueKey); + return; + } + + for (String loginId : waitingUsers) { + try { + waitingNotificationService.getAndPublishWaitingNumber(waitingQueueKey, notificationChannelKey, loginId); + } catch (BusinessException e) { + log.warn("Failed to notify waiting number for user {}: {}", loginId, e.getMessage()); + } + } + + long currentBookingCount = waitingQueueRedisService.getBookingUserCount(bookingUsersKey); + + while (currentBookingCount < availableNOP) { + String nextUser = waitingQueueRedisService.getFirstUserInQueue(waitingQueueKey); + if (nextUser == null) { + break; + } + + boolean removed = waitingQueueRedisService.removeUserFromQueue(waitingQueueKey, nextUser); + if (!removed) { + throw new BusinessException(ErrorCode.FAILED_TO_REMOVE_USER); + } + + waitingQueueRedisService.addBookingUser(bookingUsersKey, nextUser); + log.info("User {} moved from waiting queue to booking users for queue {}", nextUser, waitingQueueKey); + + waitingNotificationService.notifyAllWaitingUsers(waitingQueueKey, notificationChannelKey); + currentBookingCount++; + } + } catch (RedisConnectionFailureException e) { + throw new BusinessException(ErrorCode.REDIS_CONNECTION_FAILED); + } catch (RedisSystemException e) { + throw new BusinessException(ErrorCode.REDIS_OPERATION_FAILED); + } + } + + @PreDestroy + public void cleanup() { + scheduler.shutdown(); + } +} diff --git a/src/main/java/com/mnms/booking/service/WaitingService.java b/src/main/java/com/mnms/booking/service/WaitingService.java new file mode 100644 index 0000000..844938c --- /dev/null +++ b/src/main/java/com/mnms/booking/service/WaitingService.java @@ -0,0 +1,82 @@ +package com.mnms.booking.service; + +import com.mnms.booking.exception.BusinessException; +import com.mnms.booking.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import java.time.LocalDateTime; + +@Service +@Slf4j +@RequiredArgsConstructor +public class WaitingService { + + private final WaitingQueueRedisService waitingQueueRedisService; + private final WaitingQueueSchedulingService waitingQueueSchedulingService; + private final WaitingNotificationService waitingNotificationService; + private final WaitingQueueKeyGenerator waitingQueueKeyGenerator; + + /// μ‚¬μš©μž λŒ€κΈ°μ—΄ μ§„μž… 처리 + public long enterWaitingQueue(String festivalId, LocalDateTime reservationDate, String userId, long availableNOP) { + String bookingUsersKey = waitingQueueKeyGenerator.getBookingUsersKey(festivalId, reservationDate); + String waitingQueueKey = waitingQueueKeyGenerator.getWaitingQueueKey(festivalId, reservationDate); + String notificationChannelKey = waitingQueueKeyGenerator.getNotificationChannelKey(festivalId, reservationDate); + + boolean entered = waitingQueueRedisService.tryEnterBooking(bookingUsersKey, availableNOP, userId); + + if (entered) { + log.info("User {} entered booking page immediately.", userId); + return 0L; // μ¦‰μ‹œ μž…μž₯ + } else { // λŒ€κΈ°μ—΄ μž…μž₯ + boolean added = waitingQueueRedisService.addUserToQueue(waitingQueueKey, userId); + if (!added) { + throw new BusinessException(ErrorCode.FAILED_TO_ENTER_QUEUE); + } + log.info("User {} added to waiting queue {}.", userId, waitingQueueKey); + + waitingQueueSchedulingService.startScheduler(waitingQueueKey, bookingUsersKey, notificationChannelKey, availableNOP); + return waitingNotificationService.getAndPublishWaitingNumber(waitingQueueKey, notificationChannelKey, userId); + } + } + + /// 예맀 νŽ˜μ΄μ§€μ—μ„œ μ‚¬μš©μž 퇴μž₯ 처리 (예맀 μ™„λ£Œ λ˜λŠ” νƒ€μž„μ•„μ›ƒ) + public boolean userExitBookingPage(String festivalId, LocalDateTime reservationDate, String userId) { + String bookingUsersKey = waitingQueueKeyGenerator.getBookingUsersKey(festivalId, reservationDate); + String waitingQueueKey = waitingQueueKeyGenerator.getWaitingQueueKey(festivalId, reservationDate); + waitingQueueRedisService.removeBookingUser(bookingUsersKey, userId); + + log.info("User {} exited booking page and removed from booking user set.", userId); + + // μ˜ˆμ•½μž Set이 λΉ„μ–΄ μžˆλŠ”μ§€ 확인 + long booking_remaining = waitingQueueRedisService.getBookingUserCount(bookingUsersKey); + long waiting_remaining = waitingQueueRedisService.getWaitingUserCount(waitingQueueKey); + + if (booking_remaining == 0) { + // λŒ€κΈ°μ—΄λ„ λΉ„μ–΄ 있으면 ν‚€ μ‚­μ œ + waitingQueueRedisService.cleanKey(bookingUsersKey); + log.info("Cleaned up all Booking Redis keys for festival {}.", bookingUsersKey); + } + if(waiting_remaining == 0) { + waitingQueueRedisService.cleanKey(waitingQueueKey); + log.info("Cleaned up all Waiting Redis keys for festival {}.", waitingQueueKey); + } + return true; + } + + /// νŠΉμ • μ‚¬μš©μžκ°€ λŒ€κΈ°μ—΄μ—μ„œ μ΄νƒˆν–ˆμŒμ„ 처리 + public boolean removeUserFromQueue(String festivalId, LocalDateTime reservationDate, String userId) { + String waitingQueueKey = waitingQueueKeyGenerator.getWaitingQueueKey(festivalId, reservationDate); + String notificationChannelKey = waitingQueueKeyGenerator.getNotificationChannelKey(festivalId, reservationDate); + + Long removedRank = waitingQueueRedisService.getRank(waitingQueueKey, userId); + boolean removed = waitingQueueRedisService.removeUserFromQueue(waitingQueueKey, userId); + + if (!removed) { + throw new BusinessException(ErrorCode.USER_NOT_FOUND_IN_WAITING); + } + log.info("User {} removed from waiting queue (manual removal).", userId); + waitingNotificationService.notifyAffectedWaitingUsers(waitingQueueKey, notificationChannelKey, removedRank); + return true; + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/specification/BookingSpecification.java b/src/main/java/com/mnms/booking/specification/BookingSpecification.java new file mode 100644 index 0000000..14d67be --- /dev/null +++ b/src/main/java/com/mnms/booking/specification/BookingSpecification.java @@ -0,0 +1,449 @@ +package com.mnms.booking.specification; + +import com.mnms.booking.dto.request.BookingRequestDTO; +import com.mnms.booking.dto.request.BookingSelectDeliveryRequestDTO; +import com.mnms.booking.dto.request.BookingSelectRequestDTO; +import com.mnms.booking.dto.response.BookingDetailResponseDTO; +import com.mnms.booking.dto.response.BookingUserResponseDTO; +import com.mnms.booking.dto.response.FestivalDetailResponseDTO; +import com.mnms.booking.enums.ReservationStatus; +import com.mnms.booking.exception.global.ErrorResponse; +import com.mnms.booking.exception.global.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + + +@Tag(name = "예맀 API", description = "예맀 ν‹°μΌ“ 쑰회, ν‹°μΌ“ 선택, 생성") +public interface BookingSpecification { + + /// GET : νŽ˜μŠ€ν‹°λ²Œ 예맀 정보 쑰회 + @PostMapping("/detail/phases/1") + @Operation(summary = "1μ°¨ : 예맀 λ‹¨κ³„μ—μ„œ μ„ νƒν•œ 예맀 상세 쑰회", + description = "festivalId와 performanceDate둜 곡연 상세 정보λ₯Ό μ‘°νšŒν•©λ‹ˆλ‹€. selectedTicketCountλŠ” 0으둜 넣을 것!") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "쑰회 성곡", + content = @Content(schema = @Schema( + implementation = SuccessResponse.class + ))), + @ApiResponse(responseCode = "404", description = "νŽ˜μŠ€ν‹°λ²Œμ„ 찾을 수 μ—†μŒ", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "FESTIVAL_NOT_FOUND", + "message": "μž…λ ₯ ID에 ν•΄λ‹Ήν•˜λŠ” νŽ˜μŠ€ν‹°λ²Œμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€." + } + """ + ))), + @ApiResponse(responseCode = "400", description = "μ„ νƒν•œ λ‚ μ§œ/μ‹œκ°„μ΄ 잘λͺ»λ¨", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "FESTIVAL_INVALID_DATE / FESTIVAL_INVALID_TIME", + "message": "ν•΄λ‹Ή λ‚ μ§œ/μ‹œκ°„μ˜ νŽ˜μŠ€ν‹°λ²Œμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€." + } + """ + ))) + + }) + ResponseEntity> getFestivalDetail( + @Valid @RequestBody BookingSelectRequestDTO request); + + /// POST : 2μ°¨ 예맀 상세 쑰회 + @Operation(summary = "2μ°¨ : 예맀 λ‹¨κ³„μ—μ„œ μ„ νƒν•œ 예맀 상세 쑰회", + description = "festivalId, reservationNumber둜 곡연 및 예맀자 상세 정보λ₯Ό μ‘°νšŒν•©λ‹ˆλ‹€.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "쑰회 성곡", + content = @Content(schema = @Schema( + implementation = SuccessResponse.class + ))), + @ApiResponse(responseCode = "404", description = "νŽ˜μŠ€ν‹°λ²Œμ„ 찾을 수 μ—†μŒ", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "FESTIVAL_NOT_FOUND", + "message": "μž…λ ₯ ID에 ν•΄λ‹Ήν•˜λŠ” νŽ˜μŠ€ν‹°λ²Œμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€." + } + """ + ))), + @ApiResponse(responseCode = "404", description = "예맀 쀑인 티켓을 찾을 수 μ—†μŒ", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "TICKET_NOT_FOUND", + "message": "예맀 정보가 λ§Œλ£Œλ˜μ–΄ μ‘΄μž¬ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€." + } + """ + ))), + @ApiResponse(responseCode = "401", description = "인증 μ‹€νŒ¨", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "UNAUTHORIZED", + "message": "X-User-Id λ˜λŠ” X-User-Role 헀더가 μ—†μŠ΅λ‹ˆλ‹€. OR X-User-Idκ°€ μˆ«μžκ°€ μ•„λ‹™λ‹ˆλ‹€." + } + """ + ))) + }) + ResponseEntity> getFestivalBookingDetail( + @Valid @RequestBody BookingRequestDTO request, + Authentication authentication + ); + + /// POST : λ‚ μ§œ 선택 + @Operation(summary = "νŽ˜μŠ€ν‹°λ²Œ λ‚ μ§œ, μ‹œκ°„, 맀수 선택", + description = "festivalId, performanceDate, selectedTicketCountλ₯Ό μž…λ ₯ν•˜κ³  reservationNumber λ°˜ν™˜") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "μž„μ‹œ μ˜ˆμ•½ 성곡", + content = @Content(schema = @Schema( + implementation = SuccessResponse.class + ))), + @ApiResponse(responseCode = "404", description = "νŽ˜μŠ€ν‹°λ²Œμ„ 찾을 수 μ—†μŒ", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "FESTIVAL_NOT_FOUND", + "message": "μž…λ ₯ ID에 ν•΄λ‹Ήν•˜λŠ” νŽ˜μŠ€ν‹°λ²Œμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€." + } + """ + ))), + @ApiResponse(responseCode = "404", description = "예맀 쀑인 티켓을 찾을 수 μ—†μŒ", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "TICKET_NOT_FOUND", + "message": "예맀 정보가 λ§Œλ£Œλ˜μ–΄ μ‘΄μž¬ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€." + } + """ + ))), + @ApiResponse(responseCode = "400", description = "νŽ˜μŠ€ν‹°λ²Œ ν•΄λ‹Ή λ‚ μ§œ λ˜λŠ” μ‹œκ°„ 뢈일치", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "FESTIVAL_INVALID_DATE OR FESTIVAL_INVALID_TIME", + "message": "ν•΄λ‹Ή λ‚ μ§œ/μ‹œκ°„μ˜ νŽ˜μŠ€ν‹°λ²Œμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€." + } + """ + ))), + @ApiResponse(responseCode = "409", description = "μ˜ˆμ•½ κ°€λŠ₯ν•œ ν‹°μΌ“ 수 초과", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "TICKET_ALREADY_RESERVED", + "message": "μ˜ˆμ•½ κ°€λŠ₯ν•œ ν‹°μΌ“ 수λ₯Ό μ΄ˆκ³Όν•˜μ˜€μŠ΅λ‹ˆλ‹€." + } + """ + ))), + @ApiResponse(responseCode = "401", description = "인증 μ‹€νŒ¨", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "UNAUTHORIZED", + "message": "X-User-Id λ˜λŠ” X-User-Role 헀더가 μ—†μŠ΅λ‹ˆλ‹€. OR X-User-Idκ°€ μˆ«μžκ°€ μ•„λ‹™λ‹ˆλ‹€." + } + """ + ))) + }) + ResponseEntity> selectFestivalDate( + @Valid @RequestBody BookingSelectRequestDTO request, + Authentication authentication + ); + + /// POST : 배솑 선택 + @Operation(summary = "νŽ˜μŠ€ν‹°λ²Œ ν‹°μΌ“ 수령 방법, μ£Όμ†Œ 선택", + description = "festivalId, performanceDate, deliveryMethod(MOBILE or PAPER), address 선택") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "배솑 방법 선택 μ™„λ£Œ", + content = @Content(schema = @Schema( + implementation = SuccessResponse.class + ))), + @ApiResponse(responseCode = "400", description = "배솑 방법 미선택", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "FESTIVAL_DELIVERY_INVALID", + "message": "배솑 방법 μ„ νƒλ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€." + } + """ + ))), + @ApiResponse(responseCode = "400", description = "수령 방법", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "TICKET_INVALID_DELIVERY_METHOD", + "message": "수령 방법이 μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€." + } + """ + ))), + @ApiResponse(responseCode = "404", description = "νŽ˜μŠ€ν‹°λ²Œμ„ 찾을 수 μ—†μŒ", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "FESTIVAL_NOT_FOUND", + "message": "μž…λ ₯ ID에 ν•΄λ‹Ήν•˜λŠ” νŽ˜μŠ€ν‹°λ²Œμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€." + } + """ + ))), + @ApiResponse(responseCode = "404", description = "예맀 쀑인 티켓을 찾을 수 μ—†μŒ", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "TICKET_NOT_FOUND", + "message": "예맀 정보가 λ§Œλ£Œλ˜μ–΄ μ‘΄μž¬ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€." + } + """ + ))), + @ApiResponse(responseCode = "400", description = "νŽ˜μŠ€ν‹°λ²Œ ν•΄λ‹Ή λ‚ μ§œ 뢈일치", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "FESTIVAL_INVALID_DATE", + "message": "ν•΄λ‹Ή λ‚ μ§œ/μ‹œκ°„μ˜ νŽ˜μŠ€ν‹°λ²Œμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€." + } + """ + ))), + @ApiResponse(responseCode = "401", description = "인증 μ‹€νŒ¨", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "UNAUTHORIZED", + "message": "X-User-Id λ˜λŠ” X-User-Role 헀더가 μ—†μŠ΅λ‹ˆλ‹€. OR X-User-Idκ°€ μˆ«μžκ°€ μ•„λ‹™λ‹ˆλ‹€." + } + """ + ))) + }) + ResponseEntity> selectFestivalDelivery( + @Valid @RequestBody BookingSelectDeliveryRequestDTO request, + Authentication authentication + ); + + /// POST : 3μ°¨ 예맀 μ™„λ£Œ (QR 생성) + @Operation(summary = "νŽ˜μŠ€ν‹°λ²Œ 예맀 ν‹°μΌ“ 생성", + description = "μ‚¬μš©μžκ°€ νŠΉμ • νŽ˜μŠ€ν‹°λ²Œ 티켓을 μ˜ˆμ•½ν•˜κΈ° μœ„ν•œ λ§ˆμ§€λ§‰ κ°€μ˜ˆλ§€ μƒνƒœ") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "QR 생성 및 μ˜ˆμ•½ 성곡", + content = @Content(schema = @Schema( + implementation = SuccessResponse.class + ))), + @ApiResponse(responseCode = "404", description = "νŽ˜μŠ€ν‹°λ²Œμ„ 찾을 수 μ—†μŒ", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "FESTIVAL_NOT_FOUND", + "message": "μž…λ ₯ ID에 ν•΄λ‹Ήν•˜λŠ” νŽ˜μŠ€ν‹°λ²Œμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€." + } + """ + ))), + @ApiResponse(responseCode = "404", description = "예맀 쀑인 티켓을 찾을 수 μ—†μŒ", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "TICKET_NOT_FOUND", + "message": "예맀 정보가 λ§Œλ£Œλ˜μ–΄ μ‘΄μž¬ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€." + } + """ + ))), + @ApiResponse(responseCode = "409", description = "νŽ˜μŠ€ν‹°λ²Œ 수용 인원 초과", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "FESTIVAL_LIMIT_AVAILABLE_PEOPLE", + "message": "ν•΄λ‹Ή νŽ˜μŠ€ν‹°λ²Œ 수용 인원이 μ΄ˆκ³Όλ˜μ–΄ 예맀λ₯Ό μ§„ν–‰ν•  수 μ—†μŠ΅λ‹ˆλ‹€." + } + """ + ))), + @ApiResponse(responseCode = "401", description = "인증 μ‹€νŒ¨", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "UNAUTHORIZED", + "message": "X-User-Id λ˜λŠ” X-User-Role 헀더가 μ—†μŠ΅λ‹ˆλ‹€. OR X-User-Idκ°€ μˆ«μžκ°€ μ•„λ‹™λ‹ˆλ‹€." + } + """ + ))) + }) + ResponseEntity> reserveTicket( + @Valid @RequestBody BookingRequestDTO request, + Authentication authentication + ); + + /// GET : WebSocket λ©”μ‹œμ§€ λˆ„λ½ λ°©μ§€ + @Operation(summary = "예맀 μ™„λ£Œ/μ·¨μ†Œ 정보 쑰회", + description = "WebSocket λ©”μ‹œμ§€ λˆ„λ½ μ‹œ μƒνƒœ 확인") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "쑰회 성곡", + content = @Content(schema = @Schema( + implementation = SuccessResponse.class + ))), + @ApiResponse(responseCode = "404", description = "예맀 쀑인 티켓을 찾을 수 μ—†μŒ", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "TICKET_NOT_FOUND", + "message": "예맀 정보가 λ§Œλ£Œλ˜μ–΄ μ‘΄μž¬ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€." + } + """ + ))) + }) + ResponseEntity> checkStatus(@RequestParam String reservationNumber); + + /// GET : 예맀자 정보 쑰회 + @Operation(summary = "예맀자 정보 쑰회", + description = "예맀자 role이 user인 μ‚¬λžŒλ§Œ 쑰회 κ°€λŠ₯") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "쑰회 성곡", + content = @Content(schema = @Schema( + implementation = SuccessResponse.class + ))), + @ApiResponse(responseCode = "401", description = "인증 μ‹€νŒ¨", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "UNAUTHORIZED", + "message": "X-User-Id λ˜λŠ” X-User-Role 헀더가 μ—†μŠ΅λ‹ˆλ‹€. OR X-User-Idκ°€ μˆ«μžκ°€ μ•„λ‹™λ‹ˆλ‹€." + } + """ + ))) + }) + ResponseEntity> getUserInfo(Authentication authentication); + + + /// POST : 이메일 μž„μ‹œ ν…ŒμŠ€νŠΈ + @Operation(summary = "[ν…ŒμŠ€νŠΈ μ§„ν–‰X] 이메일 μž„μ‹œ ν…ŒμŠ€νŠΈ", + description = "예맀 μ™„λ£Œ ν›„ 이메일 전솑 μž„μ‹œ ν…ŒμŠ€νŠΈ") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "메일 λ°œμ†‘ 성곡", + content = @Content(schema = @Schema( + implementation = SuccessResponse.class + ))), + @ApiResponse(responseCode = "404", description = "예맀 쀑인 티켓을 찾을 수 μ—†μŒ", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "TICKET_NOT_FOUND", + "message": "예맀 정보가 λ§Œλ£Œλ˜μ–΄ μ‘΄μž¬ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€." + } + """ + ))), + @ApiResponse(responseCode = "404", description = "예맀 쀑인 티켓을 찾을 수 μ—†μŒ", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "TICKET_EMAIL_TEMPLATE_NOT_FOUND", + "message": "이메일 ν…œν”Œλ¦Ώ 였λ₯˜λ‘œ 이메일 전솑에 μ‹€νŒ¨ν•˜μ˜€μŠ΅λ‹ˆλ‹€." + } + """ + ))) + }) + ResponseEntity confirmTicket( + @RequestParam String reservationNumber, + @RequestParam boolean paymentStatus + ); +} diff --git a/src/main/java/com/mnms/booking/specification/CaptchaSpecification.java b/src/main/java/com/mnms/booking/specification/CaptchaSpecification.java new file mode 100644 index 0000000..4387c65 --- /dev/null +++ b/src/main/java/com/mnms/booking/specification/CaptchaSpecification.java @@ -0,0 +1,118 @@ +package com.mnms.booking.specification; + +import com.mnms.booking.dto.response.CaptchaResponseDTO; +import com.mnms.booking.exception.global.ErrorResponse; +import com.mnms.booking.exception.global.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +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.RequestParam; + +import java.io.IOException; + +@Tag(name = "λ³΄μ•ˆλ¬Έμž API", description = "λ³΄μ•ˆλ¬Έμž 생성 및 인증") +public interface CaptchaSpecification { + + + @Operation( + summary = "λ³΄μ•ˆλ¬Έμž 이미지 μš”μ²­", + description = "μƒˆλ‘œμš΄ λ³΄μ•ˆλ¬Έμž 이미지λ₯Ό μƒμ„±ν•˜μ—¬ λ°˜ν™˜ν•©λ‹ˆλ‹€." + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "λ³΄μ•ˆλ¬Έμž 생성 성곡", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SuccessResponse.class), + examples = @ExampleObject( + name = "성곡 응닡 μ˜ˆμ‹œ", + value = """ + { + "success": true, + "data": null, + "message": "λ³΄μ•ˆλ¬Έμž 이미지가 생성 μ™„λ£Œ" + } + """ + ) + ) + ) + }) + ResponseEntity> getCaptchaImage( + @Parameter(hidden = true) HttpServletRequest request, + @Parameter(hidden = true) HttpServletResponse response + ) throws IOException; + + + @Operation( + summary = "λ³΄μ•ˆλ¬Έμž 검증", + description = "μ‚¬μš©μžκ°€ μž…λ ₯ν•œ λ³΄μ•ˆλ¬Έμž 값이 μ˜¬λ°”λ₯Έμ§€ κ²€μ¦ν•©λ‹ˆλ‹€. " + + "λ³΄μ•ˆλ¬ΈμžλŠ” λ‹€μ„― κΈ€μžμ΄λ©° λŒ€μ†Œλ¬Έμž κ΅¬λΆ„ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€. λ§Œλ£Œμ‹œκ°„μ€ 3뢄이고, 뢈일치둜 μ‹€νŒ¨ν•΄λ„ λ§Œλ£Œμ‹œκ°„ 내에 μž…λ ₯ν•˜λ©΄ 인증 κ°€λŠ₯ν•©λ‹ˆλ‹€." + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "λ³΄μ•ˆλ¬Έμž 인증 성곡", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SuccessResponse.class), + examples = @ExampleObject( + name = "성곡 응닡 μ˜ˆμ‹œ", + value = """ + { + "success": true, + "data": { + "success": true, + "remainingAttempts": 2 + }, + "message": "λ³΄μ•ˆλ¬Έμž 인증 성곡" + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "400", + description = "λ³΄μ•ˆλ¬Έμž 인증 μ‹€νŒ¨", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "μ‹€νŒ¨ 응닡 μ˜ˆμ‹œ", + value = """ + [ + { + "success": false, + "data": null, + "message": "λ³΄μ•ˆλ¬Έμžκ°€ λ§Œλ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€." + }, + { + "success": false, + "data": null, + "message": "λ³΄μ•ˆλ¬Έμž 뢈일치둜 인증이 μ‹€νŒ¨ν•˜μ˜€μŠ΅λ‹ˆλ‹€." + } + ] + """ + ) + ) + ) + }) + ResponseEntity> verifyCaptcha( + @Parameter(description = "μ‚¬μš©μžκ°€ μž…λ ₯ν•œ λ³΄μ•ˆλ¬Έμž κ°’", required = true, example = "aB12c") + @RequestParam("captcha") String captcha, + + @Parameter(hidden = true) + HttpSession session + ); +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/specification/HostSpecification.java b/src/main/java/com/mnms/booking/specification/HostSpecification.java new file mode 100644 index 0000000..12e2364 --- /dev/null +++ b/src/main/java/com/mnms/booking/specification/HostSpecification.java @@ -0,0 +1,187 @@ +package com.mnms.booking.specification; + +import com.mnms.booking.dto.request.HostRequestDTO; +import com.mnms.booking.dto.response.HostResponseDTO; +import com.mnms.booking.exception.global.ErrorResponse; +import com.mnms.booking.exception.global.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +import java.util.List; + +@Tag(name = "주졜자 κ΄€λ ¨ API", description = "주졜자 예맀자 λͺ…단 쑰회, 주졜자 도메인 데이터 제곡 API") +public interface HostSpecification { + + @Operation( + summary = "주졜자 도메인에 예맀자 리슀트 제곡", + description = "μ£Όμ΅œμžκ°€ FestivalId와 PerformanceDateλ₯Ό μ œκ³΅ν•˜λ©΄ ν•΄λ‹Ήν•˜λŠ” 예맀자 userId 리슀트λ₯Ό λ°˜ν™˜ν•©λ‹ˆλ‹€. (ν”„λ‘ νŠΈμ™€ 직접 κ΄€λ ¨ μ—†μŒ)" + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "쑰회 성곡", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SuccessResponse.class), + examples = @ExampleObject( + value = """ + { + "success": true, + "data": [101, 102, 103], + "message": "쑰회 성곡" + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "404", + description = "νŽ˜μŠ€ν‹°λ²Œμ„ 찾을 수 μ—†μŒ", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "FESTIVAL_NOT_FOUND", + "message": "μž…λ ₯ ID에 ν•΄λ‹Ήν•˜λŠ” νŽ˜μŠ€ν‹°λ²Œμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "409", + description = "μ‚¬μš©μž API 호좜 μ‹€νŒ¨", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "USER_API_ERROR", + "message": "예맀자 정보λ₯Ό κ°€μ Έμ˜€λŠ”λ° μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€." + } + """ + ) + ) + ) + }) + ResponseEntity>> getBookingsByOrganizer( + @Parameter(description = "주졜자 μš”μ²­ DTO", required = true) + @RequestBody HostRequestDTO hostRequestDTO + ); + + + + @Operation( + summary = "예맀자 정보 쑰회", + description = "예맀자 정보λ₯Ό μ‘°νšŒν•©λ‹ˆλ‹€. HOST λ˜λŠ” ADMIN κΆŒν•œμ΄ ν•„μš”ν•©λ‹ˆλ‹€.", + security = @SecurityRequirement(name = "bearerAuth") + ) + @PreAuthorize("hasAnyRole('HOST', 'ADMIN')") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "쑰회 성곡", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SuccessResponse.class), + examples = @ExampleObject( + value = """ + { + "success": true, + "data": [ + { + "reservationNumber": "TAEEDA123", + "performanceDate": "2025-09-07T15:00:00", + "userId": 101, + "selectedTicketCount": 2, + "deliveryMethod": "MOBILE", + "address": "μ„œμšΈμ§‘", + "userName": "홍길동", + "userPhone": "010-1234-5678" + } + ], + "message": "쑰회 성곡" + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "404", + description = "νŽ˜μŠ€ν‹°λ²Œμ„ 찾을 수 μ—†μŒ", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "FESTIVAL_NOT_FOUND", + "message": "μž…λ ₯ ID에 ν•΄λ‹Ήν•˜λŠ” νŽ˜μŠ€ν‹°λ²Œμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "403", + description = "κΆŒν•œ μ—†μŒ", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ + { + "timestamp": "2025-09-11T02:22:47.684+00:00", + "status": 403, + "error": "Forbidden", + "path": "/api/host/booking/list" + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "409", + description = "μ‚¬μš©μž API 호좜 μ‹€νŒ¨ λ˜λŠ” μ•Œ 수 μ—†λŠ” 였λ₯˜", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "USER_API_ERROR", + "message": "예맀자 정보λ₯Ό κ°€μ Έμ˜€λŠ”λ° μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€." + } + """ + ) + ) + ) + }) + ResponseEntity>> getBookingInfo( + @Parameter(description = "μ‘°νšŒν•  festivalId", example = "PF123456", required = true) + @RequestParam String festivalId, + + @Parameter(hidden = true) + Authentication authentication + ); +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/specification/QrCodeSpecification.java b/src/main/java/com/mnms/booking/specification/QrCodeSpecification.java new file mode 100644 index 0000000..5d28c73 --- /dev/null +++ b/src/main/java/com/mnms/booking/specification/QrCodeSpecification.java @@ -0,0 +1,122 @@ +package com.mnms.booking.specification; + +import com.mnms.booking.exception.global.ErrorResponse; +import com.mnms.booking.exception.global.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; + +@Tag(name = "QR API", description = "QR 이미지 쑰회, μŠ€μΊ”") +public interface QrCodeSpecification { + + + @Operation(summary = "QR μ½”λ“œ 이미지 쑰회", description = "qrCodeId둜 QR μ½”λ“œ 이미지λ₯Ό PNG ν˜•μ‹μœΌλ‘œ λ°˜ν™˜ν•©λ‹ˆλ‹€.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "쑰회 성곡", + content = @Content(mediaType = "image/png") + ), + @ApiResponse( + responseCode = "409", + description = "QR μ½”λ“œλ₯Ό 찾을 수 μ—†μŒ", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "QR_CODE_ALREADY_USED", + "message": "이미 μ‚¬μš©λœ QR μ½”λ“œμž…λ‹ˆλ‹€." + } + """ + ) + ) + ) + }) + ResponseEntity getQrCodeImage( + @Parameter(description = "μ‘°νšŒν•  QR μ½”λ“œ ID", example = "4b2f23d3019b727a3320f5a79ae98d27") + @PathVariable String qrCodeId + ); + + + + @Operation(summary = "QR μ½”λ“œ μŠ€μΊ” 및 μœ νš¨μ„± 검사", description = "qrCodeId와 μ‚¬μš©μž ID둜 QR μ½”λ“œ μœ νš¨μ„± 검사 ν›„ QR μ‚¬μš© μ²˜λ¦¬ν•©λ‹ˆλ‹€.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "QR μŠ€μΊ” μ™„λ£Œ", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SuccessResponse.class) + ) + ), + @ApiResponse( + responseCode = "410", + description = "QR μ½”λ“œκ°€ μœ νš¨ν•˜μ§€ μ•Šκ±°λ‚˜ 만료됨", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "Expired QR", + value = """ + { + "success": false, + "data": "QR_CODE_EXPIRED", + "message": "QR μ½”λ“œμ˜ 만료일이 μ§€λ‚¬μŠ΅λ‹ˆλ‹€." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "403", + description = "μ£Όμ΅œμžμ™€ QR μ½”λ“œ νŽ˜μŠ€ν‹°λ²Œ 뢈일치", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "FESTIVAL_MISMATCH", + "message": "ν•΄λ‹Ήν•˜λŠ” QR의 νŽ˜μŠ€ν‹°λ²Œ μ£Όμ΅œμžκ°€ μ•„λ‹™λ‹ˆλ‹€." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "409", + description = "이미 μ‚¬μš©λœ QR μ½”λ“œ", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "QR_CODE_ALREADY_USED", + "message": "QR μ½”λ“œκ°€ 이미 μ‚¬μš©λ˜μ—ˆμŠ΅λ‹ˆλ‹€." + } + """ + ) + ) + ) + }) + ResponseEntity> validateAndUseQrCode( + @PathVariable String qrCodeId, + Authentication authentication + ); +} diff --git a/src/main/java/com/mnms/booking/specification/StatisticsSpecification.java b/src/main/java/com/mnms/booking/specification/StatisticsSpecification.java new file mode 100644 index 0000000..07bb6fe --- /dev/null +++ b/src/main/java/com/mnms/booking/specification/StatisticsSpecification.java @@ -0,0 +1,176 @@ +package com.mnms.booking.specification; + +import com.mnms.booking.dto.response.StatisticsBookingDTO; +import com.mnms.booking.dto.response.StatisticsQrCodeResponseDTO; +import com.mnms.booking.dto.response.StatisticsUserResponseDTO; +import com.mnms.booking.exception.global.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.GetMapping; + +import java.time.LocalDateTime; +import java.util.List; + +@Tag(name = "톡계 API", description = "곡연 별 예맀자의 정보λ₯Ό 톡해 성별/λ‚˜μ΄, μž…μž₯ 인원 상황을 확인 κ°€λŠ₯") +public interface StatisticsSpecification { + + @Operation(summary = "festivalId별 예맀자 성별/λ‚˜μ΄ 톡계 쑰회", + description = "νŠΉμ • νŽ˜μŠ€ν‹°λ²Œμ˜ 예맀자 톡계λ₯Ό μ‘°νšŒν•©λ‹ˆλ‹€.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "쑰회 성곡", + content = @Content(schema = @Schema(implementation = StatisticsUserResponseDTO.class))), + @ApiResponse(responseCode = "500", description = "User MSA 쑰회 μ‹€νŒ¨", + content = @Content( + schema = @Schema(implementation = SuccessResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "USER_API_ERROR", + "message": "μ‚¬μš©μž 톡계 정보 μ‘°νšŒμ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€." + } + """ + ) + )) + }) + ResponseEntity> getFestivalUserStatistics(String festivalId); + + + + @Operation(summary = "곡연 λ‚ μ§œ/μ‹œκ°„ λͺ©λ‘ 쑰회", + description = "μ£Όμ΅œμžκ°€ μž…μž₯ 톡계λ₯Ό μ‘°νšŒν•˜κΈ° μ „, ν•΄λ‹Ή νŽ˜μŠ€ν‹°λ²Œμ˜ 유효 곡연 λ‚ μ§œ/μ‹œκ°„ λͺ©λ‘ 쑰회") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "쑰회 성곡", + content = @Content(schema = @Schema(implementation = List.class))), + @ApiResponse(responseCode = "404", description = "νŽ˜μŠ€ν‹°λ²Œ μ—†μŒ", + content = @Content( + schema = @Schema(implementation = SuccessResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "FESTIVAL_NOT_FOUND", + "message": "ν•΄λ‹Ή νŽ˜μŠ€ν‹°λ²Œμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€." + } + """ + ) + )) + }) + ResponseEntity>> getPerformanceDatesForFestival( + String festivalId, + Authentication authentication + ); + + + + @Operation(summary = "곡연 λ‚ μ§œλ³„ μž…μž₯ 톡계", + description = "예맀자 및 μ£Όμ΅œμžκ°€ 곡연 λ‚ μ§œλ³„ ν˜„μž₯ QR μž…μž₯ 톡계λ₯Ό μ‘°νšŒν•©λ‹ˆλ‹€.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "쑰회 성곡", + content = @Content(schema = @Schema(implementation = StatisticsQrCodeResponseDTO.class))), + @ApiResponse(responseCode = "403", description = "κΆŒν•œ μ—†μŒ", + content = @Content( + schema = @Schema(implementation = SuccessResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "STATISTICS_ACCESS_DENIED", + "message": "톡계 쑰회 κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€." + } + """ + ) + )), + @ApiResponse(responseCode = "404", description = "νŽ˜μŠ€ν‹°λ²Œ μ—†μŒ", + content = @Content( + schema = @Schema(implementation = SuccessResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "FESTIVAL_NOT_FOUND", + "message": "ν•΄λ‹Ή νŽ˜μŠ€ν‹°λ²Œμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€." + } + """ + ) + )), + @ApiResponse(responseCode = "400", description = "μœ νš¨ν•˜μ§€ μ•Šμ€ μ‚¬μš©μž ID", + content = @Content( + schema = @Schema(implementation = SuccessResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "USER_INVALID", + "message": "μœ νš¨ν•˜μ§€ μ•Šμ€ μ‚¬μš©μž IDμž…λ‹ˆλ‹€." + } + """ + ) + )) + }) + ResponseEntity> getPerformanceEnterStatistics( + String festivalId, + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime performanceDate, + Authentication authentication + ); + + + + @Operation(summary = "곡연별 예맀자 수 / 수용 인원 μš”μ•½ 쑰회", + description = "μ£Όμ΅œμžκ°€ μžμ‹ μ˜ νŽ˜μŠ€ν‹°λ²Œ 곡연별 예맀 ν˜„ν™©κ³Ό 총 수용 인원을 μš”μ•½ 쑰회") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "쑰회 성곡", + content = @Content(schema = @Schema(implementation = List.class))), + @ApiResponse(responseCode = "403", description = "κΆŒν•œ μ—†μŒ", + content = @Content( + schema = @Schema(implementation = SuccessResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "STATISTICS_ACCESS_DENIED", + "message": "톡계 쑰회 κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€." + } + """ + ) + )), + @ApiResponse(responseCode = "404", description = "νŽ˜μŠ€ν‹°λ²Œ μ—†μŒ", + content = @Content( + schema = @Schema(implementation = SuccessResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "FESTIVAL_NOT_FOUND", + "message": "ν•΄λ‹Ή νŽ˜μŠ€ν‹°λ²Œμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€." + } + """ + ) + )), + @ApiResponse(responseCode = "400", description = "μœ νš¨ν•˜μ§€ μ•Šμ€ μ‚¬μš©μž ID", + content = @Content( + schema = @Schema(implementation = SuccessResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "USER_INVALID", + "message": "μœ νš¨ν•˜μ§€ μ•Šμ€ μ‚¬μš©μž IDμž…λ‹ˆλ‹€." + } + """ + ) + )) + }) + ResponseEntity>> getBookingSummary( + String festivalId, + Authentication authentication + ); +} diff --git a/src/main/java/com/mnms/booking/specification/TicketSpecification.java b/src/main/java/com/mnms/booking/specification/TicketSpecification.java new file mode 100644 index 0000000..f93c1e7 --- /dev/null +++ b/src/main/java/com/mnms/booking/specification/TicketSpecification.java @@ -0,0 +1,135 @@ +package com.mnms.booking.specification; + +import com.mnms.booking.dto.response.TicketDetailResponseDTO; +import com.mnms.booking.dto.response.TicketResponseDTO; +import com.mnms.booking.exception.global.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import java.util.List; + +@Tag(name = "예맀 λ‚΄μ—­ API", description = "예맀 λ‚΄μ—­ 쑰회") +public interface TicketSpecification { + + @Operation(summary = "μ˜ˆλ§€ν•œ ν‹°μΌ“ 정보 쑰회", + description = "μ˜ˆλ§€μžκ°€ 예맀 μ™„λ£Œν•œ 전체 ν‹°μΌ“ 리슀트λ₯Ό μ‘°νšŒν•©λ‹ˆλ‹€. (status : μ™„λ£Œ, μ·¨μ†Œν•œ ν‹°μΌ“ 쑰회 κ°€λŠ₯)" + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "쑰회 성곡", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SuccessResponse.class), + examples = @ExampleObject( + value = """ + { + "success": true, + "data": [ + { + "reservationNumber": "T24CBD629", + "festivalId": "PF272550", + "fname": "κ·Έ κ³³", + "performanceDate": "2025-09-07T15:00:00", + "ticketPrice": 60000, + "deliveryMethod": "MOBILE", + "status": "CONFIRMED" + } + ], + "message": "μš”μ²­μ΄ μ„±κ³΅μ μœΌλ‘œ μ²˜λ¦¬λ˜μ—ˆμŠ΅λ‹ˆλ‹€." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "404", description = "티켓을 찾을 수 μ—†μŒ", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SuccessResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "TICKET_NOT_FOUND", + "message": "ν•΄λ‹Ήν•˜λŠ” 티켓을 찾을 수 μ—†μŠ΅λ‹ˆλ‹€." + } + """ + ) + ) + ) + }) + ResponseEntity>> getUserTickets(Authentication authentication); + + + @Operation(summary = "μ˜ˆλ§€ν•œ ν‹°μΌ“ 정보 λ””ν…ŒμΌ 쑰회", + description = "μ˜ˆλ§€μžκ°€ 예맀 μ™„λ£Œν•œ ν‹°μΌ“ 정보λ₯Ό μ‘°νšŒν•©λ‹ˆλ‹€. ex : /api/ticket/detail?reservationNumber=T24CBD629 쑰회" + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "쑰회 성곡", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SuccessResponse.class), + examples = @ExampleObject( + value = """ + { + "success": true, + "data": { + "reservationNumber": "T24CBD629", + "festivalId": "PF272550", + "fname": "κ·Έ κ³³", + "performanceDate": "2025-09-07T15:00:00", + "ticketPrice": 60000, + "deliveryMethod": "MOBILE", + "status": "CONFIRMED", + "address": "μ„œμšΈμ§‘", + "userName": "삼길동" + }, + "message": "μš”μ²­μ΄ μ„±κ³΅μ μœΌλ‘œ μ²˜λ¦¬λ˜μ—ˆμŠ΅λ‹ˆλ‹€." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "404", description = "티켓을 찾을 수 μ—†μŒ", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SuccessResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "TICKET_NOT_FOUND", + "message": "ν•΄λ‹Ήν•˜λŠ” 티켓을 찾을 수 μ—†μŠ΅λ‹ˆλ‹€." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "401", description = "κΆŒν•œ μ—†μŒ", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SuccessResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "USER_UNAUTHORIZED_ACCESS", + "message": "잘λͺ»λœ μ‚¬μš©μž μ˜ˆλ§€λ‚΄μ—­ μž…λ‹ˆλ‹€." + } + """ + ) + ) + ) + }) + ResponseEntity> getUserTicketDetail( + @RequestParam String reservationNumber, + Authentication authentication + ); +} diff --git a/src/main/java/com/mnms/booking/specification/TransferSpecification.java b/src/main/java/com/mnms/booking/specification/TransferSpecification.java new file mode 100644 index 0000000..8b8bc26 --- /dev/null +++ b/src/main/java/com/mnms/booking/specification/TransferSpecification.java @@ -0,0 +1,485 @@ +package com.mnms.booking.specification; + +import com.mnms.booking.dto.request.TicketTransferRequestDTO; +import com.mnms.booking.dto.request.UpdateTicketRequestDTO; +import com.mnms.booking.dto.response.PersonInfoResponseDTO; +import com.mnms.booking.dto.response.TicketResponseDTO; +import com.mnms.booking.dto.response.TicketTransferResponseDTO; +import com.mnms.booking.dto.response.TransferOthersResponseDTO; +import com.mnms.booking.exception.global.ErrorResponse; +import com.mnms.booking.exception.global.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; +import java.util.List; + +@Tag(name = "양도 API", description = "양도 및 OCR, Ticket μž¬μƒμ„±") +public interface TransferSpecification { + + /// 양도할 수 μžˆλŠ” ν‹°μΌ“ 쑰회 + @Operation(summary = "양도 κ°€λŠ₯ν•œ ν‹°μΌ“ 정보 쑰회", + description = "μ‚¬μš©μžκ°€ 양도 κ°€λŠ₯ν•œ 티켓을 μ‘°νšŒν•  수 μžˆμŠ΅λ‹ˆλ‹€. (status : 양도 받은 ν‹°μΌ“ 양도 λΆˆκ°€λŠ₯)" + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "쑰회 성곡", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SuccessResponse.class), + examples = @ExampleObject( + value = """ + { + "success": true, + "data": [ + { + "reservationNumber": "T24CBD629", + "festivalId": "PF272550", + "fname": "κ·Έ κ³³", + "performanceDate": "2025-09-07T15:00:00", + "ticketPrice": 60000, + "deliveryMethod": "MOBILE", + "status": "CONFIRMED" + } + ], + "message": "μš”μ²­μ΄ μ„±κ³΅μ μœΌλ‘œ μ²˜λ¦¬λ˜μ—ˆμŠ΅λ‹ˆλ‹€." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "404", description = "ν‹°μΌ“ μ—†μŒ", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "TICKET_NOT_FOUND", + "message": "예맀 티켓이 μ‘΄μž¬ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€." + } + """ + ) + ) + ) + }) + ResponseEntity>> getUserTickets(Authentication authentication); + + + + /// 가쑱관계증λͺ…μ„œ 인증 + @Operation(summary = "κ°€μ‘± κ°„ 양도 인증 μ‹œλ„", + description = "가쑱관계증λͺ…μ„œ PDF와 μ–‘λ„μž/μ–‘μˆ˜μž μ •λ³΄λ‘œ 인증을 μ‹œλ„ν•©λ‹ˆλ‹€." + ) + @ApiResponse(responseCode = "406", description = "μœ νš¨ν•˜μ§€ μ•Šμ€ 파일 첨뢀", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "TRANSFER_NOT_VALID_FILE_TYPE", + "message": "μœ νš¨ν•˜μ§€ μ•Šμ€ 파일 ν™•μž₯μžμž…λ‹ˆλ‹€." + } + """ + ) + ) + ) + ResponseEntity> extractPersonAuth( + @RequestPart("file") MultipartFile file, + @RequestPart("targetInfo") String targetInfoJson + ) throws IOException; + + + + @Operation(summary = "κ°€μ‘± κ°„ 양도 인증 κ²°κ³Ό 쑰회", + description = "인증 μ™„λ£Œ ν›„ 양도 λŒ€μƒ 정보와 ν•¨κ»˜ λ°˜ν™˜ν•©λ‹ˆλ‹€." + ) + @ApiResponse(responseCode = "406", description = "μœ νš¨ν•˜μ§€ μ•Šμ€ 파일 첨뢀", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "TRANSFER_NOT_VALID_FILE_TYPE", + "message": "μœ νš¨ν•˜μ§€ μ•Šμ€ 파일 ν™•μž₯μžμž…λ‹ˆλ‹€." + } + """ + ) + ) + ) + ResponseEntity>> extractPersonInfo( + @RequestPart("file") MultipartFile file, + @RequestPart("targetInfo") String targetInfoJson + ) throws IOException; + + + + @Operation(summary = "양도 μš”μ²­", + description = "μ–‘λ„μžκ°€ 양도 μš”μ²­μ„ λ³΄λƒ…λ‹ˆλ‹€." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "μš”μ²­ 성곡", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SuccessResponse.class) + ) + ), + @ApiResponse(responseCode = "404", description = "ν‹°μΌ“ μ—†μŒ", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "TICKET_NOT_FOUND", + "message": "ν•΄λ‹Ήν•˜λŠ” 티켓을 찾을 수 μ—†μŠ΅λ‹ˆλ‹€." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "409", description = "이미 양도 μš”μ²­ 쑴재", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "TRANSFER_ALREADY_EXIST_REQUEST", + "message": "μ§„ν–‰λ˜κ³  μžˆλŠ” 양도 κ±°λž˜κ°€ μ‘΄μž¬ν•˜κ±°λ‚˜, 양도 1회 μ§„ν–‰ν•œ ν‹°μΌ“μž…λ‹ˆλ‹€. μ–‘λ„λŠ” 1회둜 μ œν•œλ©λ‹ˆλ‹€." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "403", description = "κΆŒν•œ μ—†μŒ (ν‹°μΌ“ μ†Œμœ μž 뢈일치)", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "TICKET_USER_NOT_SAME", + "message": "μ‚¬μš©μžκ°€ ν‹°μΌ“ μ†Œμœ μžκ°€ μ•„λ‹™λ‹ˆλ‹€." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "500", description = "μ•Œ 수 μ—†λŠ” 였λ₯˜", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "INTERNAL_SERVER_ERROR", + "message": "μ•Œ 수 μ—†λŠ” 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€." + } + """ + ) + ) + ) + }) + ResponseEntity> requestTransfer( + @RequestBody TicketTransferRequestDTO dto, + Authentication authentication + ); + + + + @Operation(summary = "양도 μš”μ²­ 쑰회", + description = "μ–‘μˆ˜μžκ°€ μžμ‹ μ—κ²Œ 온 양도 μš”μ²­μ„ μ‘°νšŒν•©λ‹ˆλ‹€." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "쑰회 성곡", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SuccessResponse.class) + ) + ), + @ApiResponse(responseCode = "404", description = "양도 μš”μ²­μ΄ μ‘΄μž¬ν•˜μ§€ μ•ŠμŒ", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SuccessResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "TRANSFER_NOT_EXIST", + "message": "양도 μš”μ²­μ΄ μ‘΄μž¬ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "404", description = "ν‹°μΌ“ μ—†μŒ", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SuccessResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "TICKET_NOT_FOUND", + "message": "ν•΄λ‹Ήν•˜λŠ” 티켓을 찾을 수 μ—†μŠ΅λ‹ˆλ‹€." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "404", description = "νŽ˜μŠ€ν‹°λ²Œ μ—†μŒ", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SuccessResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "FESTIVAL_NOT_FOUND", + "message": "μž…λ ₯ ID에 ν•΄λ‹Ήν•˜λŠ” νŽ˜μŠ€ν‹°λ²Œμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "500", description = "μ•Œ 수 μ—†λŠ” 였λ₯˜", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SuccessResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "INTERNAL_SERVER_ERROR", + "message": "μ•Œ 수 μ—†λŠ” 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€." + } + """ + ) + ) + ) + }) + ResponseEntity>> watchTransfer(Authentication authentication); + + + + /// κ°€μ‘± κ°„ 양도 μš”μ²­ 승인 + @Operation( + summary = "κ°€μ‘± κ°„ 양도 μš”μ²­ 수락", + description = "μ–‘μˆ˜μžκ°€ μš”μ²­μ„ μˆ˜λ½ν•˜λ©΄ ν‹°μΌ“κ³Ό QR 정보가 μ—…λ°μ΄νŠΈ λ©λ‹ˆλ‹€." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "μš”μ²­ 성곡", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SuccessResponse.class) + ) + ), + @ApiResponse(responseCode = "400", description = "양도 νƒ€μž… λ˜λŠ” μ–‘μˆ˜μž λ§€μΉ­ μ‹€νŒ¨", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SuccessResponse.class), + examples = { + @ExampleObject( + value = """ + { + "success": false, + "data": "TRANSFER_NOT_MATCH_TYPE", + "message": "양도 νƒ€μž…μ΄ λ§žμ§€ μ•ŠμŠ΅λ‹ˆλ‹€." + } + """ + ), + @ExampleObject( + value = """ + { + "success": false, + "data": "TRANSFER_NOT_MATCH_RECEIVER", + "message": "양도 μŠΉμΈν•˜λŠ” μ–‘μˆ˜μžκ°€ λ§žμ§€ μ•ŠμŠ΅λ‹ˆλ‹€." + } + """ + ) + } + ) + ), + @ApiResponse(responseCode = "404", description = "ν‹°μΌ“ λ˜λŠ” 양도 μš”μ²­ μ—†μŒ", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SuccessResponse.class), + examples = { + @ExampleObject( + value = """ + { + "success": false, + "data": "TRANSFER_NOT_EXIST", + "message": "양도 μš”μ²­μ΄ μ‘΄μž¬ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€." + } + """ + ), + @ExampleObject( + value = """ + { + "success": false, + "data": "TICKET_NOT_FOUND", + "message": "ν•΄λ‹Ήν•˜λŠ” 티켓을 찾을 수 μ—†μŠ΅λ‹ˆλ‹€." + } + """ + ) + } + ) + ), + @ApiResponse(responseCode = "409", description = "ν‹°μΌ“ μƒνƒœ 문제", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SuccessResponse.class), + examples = { + @ExampleObject( + value = """ + { + "success": false, + "data": "TICKET_EXPIRED", + "message": "ν‹°μΌ“μ˜ μœ νš¨κΈ°κ°„μ΄ λ§Œλ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€." + } + """ + ), + @ExampleObject( + value = """ + { + "success": false, + "data": "TICKET_CANCELED", + "message": "μ·¨μ†Œλœ ν‹°μΌ“μž…λ‹ˆλ‹€." + } + """ + ) + } + ) + ), + @ApiResponse(responseCode = "500", description = "μ•Œ 수 μ—†λŠ” 였λ₯˜", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SuccessResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "INTERNAL_SERVER_ERROR", + "message": "μ•Œ 수 μ—†λŠ” 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€." + } + """ + ) + ) + ) + }) + ResponseEntity> responseTicketFamily( + @RequestBody UpdateTicketRequestDTO request, + Authentication authentication + ); + + + /// 지인간 양도 μš”μ²­ 승인 + @Operation(summary = "타인 κ°„ 양도 μš”μ²­ μ™„λ£Œ", + description = "μ–‘μˆ˜μžκ°€ μš”μ²­μ„ μˆ˜λ½ν•˜λ©΄ 결제 μ§„ν–‰ ν›„ 양도 μ™„λ£Œλ©λ‹ˆλ‹€." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "μš”μ²­ 성곡", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SuccessResponse.class) + ) + ), + @ApiResponse(responseCode = "400", description = "양도 νƒ€μž… λ˜λŠ” μ–‘μˆ˜μž λ§€μΉ­ μ‹€νŒ¨", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SuccessResponse.class), + examples = { + @ExampleObject( + value = """ + { + "success": false, + "data": "TRANSFER_NOT_MATCH_TYPE", + "message": "양도 νƒ€μž…μ΄ λ§žμ§€ μ•ŠμŠ΅λ‹ˆλ‹€." + } + """ + ), + @ExampleObject( + value = """ + { + "success": false, + "data": "TRANSFER_NOT_MATCH_RECEIVER", + "message": "양도 μŠΉμΈν•˜λŠ” μ–‘μˆ˜μžκ°€ λ§žμ§€ μ•ŠμŠ΅λ‹ˆλ‹€." + } + """ + ) + } + ) + ), + @ApiResponse(responseCode = "404", description = "ν‹°μΌ“ λ˜λŠ” 양도 μš”μ²­ μ—†μŒ", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SuccessResponse.class), + examples = { + @ExampleObject( + value = """ + { + "success": false, + "data": "TRANSFER_NOT_EXIST", + "message": "양도 μš”μ²­μ΄ μ‘΄μž¬ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€." + } + """ + ), + @ExampleObject( + value = """ + { + "success": false, + "data": "TICKET_NOT_FOUND", + "message": "ν•΄λ‹Ήν•˜λŠ” 티켓을 찾을 수 μ—†μŠ΅λ‹ˆλ‹€." + } + """ + ) + } + ) + ), + @ApiResponse(responseCode = "500", description = "μ•Œ 수 μ—†λŠ” 였λ₯˜", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SuccessResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "INTERNAL_SERVER_ERROR", + "message": "μ•Œ 수 μ—†λŠ” 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€." + } + """ + ) + ) + ) + }) + ResponseEntity> responseTicketOthers( + @RequestBody UpdateTicketRequestDTO request, + Authentication authentication + ); + + + /// Websocket λ©”μ‹œμ§€ λˆ„λ½ λ°©μ§€ apiμš”μ²­ + @Operation(summary = "양도 결제 μ™„λ£Œ 쑰회", + description = "Websocket λ©”μ‹œμ§€ λˆ„λ½ μ‹œ 양도 μ™„λ£Œ μ—¬λΆ€ 확인" + ) + ResponseEntity> checkStatus(@RequestParam Long transferId); +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/specification/WaitingSpecification.java b/src/main/java/com/mnms/booking/specification/WaitingSpecification.java new file mode 100644 index 0000000..df39932 --- /dev/null +++ b/src/main/java/com/mnms/booking/specification/WaitingSpecification.java @@ -0,0 +1,274 @@ +package com.mnms.booking.specification; + +import com.mnms.booking.dto.response.WaitingNumberResponseDTO; +import com.mnms.booking.exception.global.ErrorResponse; +import com.mnms.booking.exception.global.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import java.time.LocalDateTime; + +@Tag(name = "λŒ€κΈ°μ—΄ API", description = "λŒ€κΈ°μ—΄ μž…μž₯, 예맀 ν™”λ©΄ μž…μž₯, λŒ€κΈ°λ²ˆν˜Έ 쑰회") +public interface WaitingSpecification { + + + @Operation( + summary = "예맀 νŽ˜μ΄μ§€ μž…μž₯ μš”μ²­", + description = "μ‚¬μš©μžκ°€ 예맀 νŽ˜μ΄μ§€μ— μž…μž₯ν•˜κ±°λ‚˜ λŒ€κΈ°μ—΄μ— λ“±λ‘λ©λ‹ˆλ‹€. " + + "μ¦‰μ‹œ μž…μž₯이 κ°€λŠ₯ν•˜λ©΄ waitingNumberλŠ” 0, λŒ€κΈ°μ—΄ μž…μž₯ μ‹œ 1 μ΄μƒμ˜ λŒ€κΈ°λ²ˆν˜Έ λ°˜ν™˜." + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "λŒ€κΈ°μ—΄ μž…μž₯ 성곡 λ˜λŠ” 예맀 νŽ˜μ΄μ§€ λ°”λ‘œ μž…μž₯", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SuccessResponse.class) + ) + ), + @ApiResponse( + responseCode = "404", + description = "νŽ˜μŠ€ν‹°λ²Œμ΄ μ‘΄μž¬ν•˜μ§€ μ•Šκ±°λ‚˜ λŒ€κΈ°μ—΄μ—μ„œ μ‚¬μš©μž 쑰회 μ‹€νŒ¨", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "FESTIVAL_NOT_FOUND", + value = """ + { + "success": false, + "data": "FESTIVAL_NOT_FOUND", + "message": "μž…λ ₯ ID에 ν•΄λ‹Ήν•˜λŠ” νŽ˜μŠ€ν‹°λ²Œμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€." + } + """ + ), + @ExampleObject( + name = "USER_NOT_FOUND_IN_WAITING", + value = """ + { + "success": false, + "data": "USER_NOT_FOUND_IN_WAITING", + "message": "λŒ€κΈ°μ—΄μ—μ„œ μ‚¬μš©μžλ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€." + } + """ + ) + } + ) + ), + @ApiResponse( + responseCode = "500", + description = "μ˜ˆμ•½ λ˜λŠ” λŒ€κΈ°μ—΄ μž…μž₯ μ‹€νŒ¨", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "FAILED_TO_ENTER_BOOKING", + value = """ + { + "success": false, + "data": "FAILED_TO_ENTER_BOOKING", + "message": "예맀 μ§„μž… μ²˜λ¦¬μ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€." + } + """ + ), + @ExampleObject( + name = "FAILED_TO_ENTER_QUEUE", + value = """ + { + "success": false, + "data": "FAILED_TO_ENTER_QUEUE", + "message": "λŒ€κΈ°μ—΄ μ§„μž…μ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€." + } + """ + ), + @ExampleObject( + name = "REDIS_CONNECTION_FAILED", + value = """ + { + "success": false, + "data": "REDIS_CONNECTION_FAILED", + "message": "Redis μ„œλ²„ 연결에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€." + } + """ + ), + @ExampleObject( + name = "FAILED_TO_EXECUTE_SCRIPT", + value = """ + { + "success": false, + "data": "FAILED_TO_EXECUTE_SCRIPT", + "message": "Redis 슀크립트 싀행에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€." + } + """ + ), + @ExampleObject( + name = "REDIS_PUBLISH_FAILED", + value = """ + { + "success": false, + "data": "REDIS_PUBLISH_FAILED", + "message": "Redis Pub/Sub λ°œν–‰ μ‹€νŒ¨" + } + """ + ) + } + ) + ) + }) + ResponseEntity> enterBookingPage( + @RequestParam String festivalId, + @RequestParam LocalDateTime reservationDate, + @Parameter(hidden = true) Authentication authentication + ); + + + + @Operation( + summary = "예맀 νŽ˜μ΄μ§€μ—μ„œ μ‚¬μš©μž 퇴μž₯ 처리 (예맀 μ™„λ£Œ λ˜λŠ” νƒ€μž„μ•„μ›ƒ)", + description = "예맀 νŽ˜μ΄μ§€μ— 있던 μ‚¬μš©μžκ°€ 퇴μž₯ν–ˆμ„ λ•Œ μ‹€ν–‰λ©λ‹ˆλ‹€. " + + "λŒ€κΈ°μ—΄μ— 있던 λŒ€κΈ°λ²ˆν˜Έ 1번 μ‚¬μš©μžλŠ” μŠ€μΌ€μ₯΄λŸ¬μ— μ˜ν•΄ 예맀 νŽ˜μ΄μ§€λ‘œ μžλ™ μž…μž₯ν•˜λ©°, " + + "λŒ€κΈ°μ—΄μ— 있던 λͺ¨λ“  λŒ€κΈ°μžμ˜ λŒ€κΈ°λ²ˆν˜Έκ°€ λ³€κ²½λ©λ‹ˆλ‹€." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "μ‚¬μš©μž 퇴μž₯ 성곡", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SuccessResponse.class) + ) + ), + @ApiResponse(responseCode = "404", description = "예맀 μ‚¬μš©μž λͺ©λ‘μ— μ—†μŒ", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SuccessResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "USER_NOT_FOUND_IN_BOOKING", + "message": "μ‚¬μš©μžκ°€ 예맀 νŽ˜μ΄μ§€μ— μ‘΄μž¬ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "500", description = "μ„œλ²„ 였λ₯˜", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SuccessResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "INTERNAL_SERVER_ERROR", + "message": "μ„œλ²„ 였λ₯˜λ‘œ 인해 μ‚¬μš©μžλ₯Ό μ²˜λ¦¬ν•˜μ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€." + } + """ + ) + ) + ) + }) + ResponseEntity> releaseUser( + @RequestParam String festivalId, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime reservationDate, + @Parameter(hidden = true) Authentication authentication + ); + + + + + @Operation( + summary = "λŒ€κΈ°μ—΄ 퇴μž₯", + description = "λŒ€κΈ° 쀑인 μ‚¬μš©μžκ°€ 슀슀둜 λŒ€κΈ°μ—΄μ—μ„œ λ‚˜κ°ˆ λ•Œ ν˜ΈμΆœλ©λ‹ˆλ‹€. " + + "호좜 μ‹œ ν•΄λ‹Ή μ‚¬μš©μžλŠ” λŒ€κΈ°μ—΄μ—μ„œ 제거되고, 남은 λŒ€κΈ°μžμ—κ²Œ μ•Œλ¦Όμ΄ μ „μ†‘λ©λ‹ˆλ‹€." + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "μ‚¬μš©μž 퇴μž₯ 성곡", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SuccessResponse.class) + ) + ), + @ApiResponse( + responseCode = "404", + description = "λŒ€κΈ°μ—΄μ— μ‚¬μš©μž μ—†μŒ", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ + { + "success": false, + "data": "USER_NOT_FOUND_IN_WAITING", + "message": "ν•΄λ‹Ή μ‚¬μš©μžλŠ” λŒ€κΈ°μ—΄ λͺ©λ‘μ— μ—†μŠ΅λ‹ˆλ‹€." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "500", + description = "μ„œλ²„ 였λ₯˜", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "INTERNAL_SERVER_ERROR", + value = """ + { + "success": false, + "data": "INTERNAL_SERVER_ERROR", + "message": "μ„œλ²„ 였λ₯˜λ‘œ 인해 μ‚¬μš©μžλ₯Ό μ²˜λ¦¬ν•˜μ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€." + } + """ + ), + @ExampleObject( + name = "REDIS_CONNECTION_FAILED", + value = """ + { + "success": false, + "data": "REDIS_CONNECTION_FAILED", + "message": "λŒ€κΈ°μ—΄ 정보λ₯Ό μ²˜λ¦¬ν•˜λŠ” 쀑 Redis 연결에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€." + } + """ + ), + @ExampleObject( + name = "REDIS_PUBLISH_FAILED", + value = """ + { + "success": false, + "data": "REDIS_PUBLISH_FAILED", + "message": "Redis Pub/Sub λ°œν–‰ μ‹€νŒ¨" + } + """ + ) + } + ) + ) + }) + ResponseEntity> exitWaitingUser( + @Parameter(description = "νŽ˜μŠ€ν‹°λ²Œ ID", required = true, example = "festival-001") + @RequestParam String festivalId, + + @Parameter(description = "예맀 λ‚ μ§œ 및 μ‹œκ°„", required = true, example = "2025-09-10T18:30:00") + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime reservationDate, + + @Parameter(hidden = true) + Authentication authentication + ); +} diff --git a/src/main/java/com/mnms/booking/util/ApiResponseUtil.java b/src/main/java/com/mnms/booking/util/ApiResponseUtil.java new file mode 100644 index 0000000..f288065 --- /dev/null +++ b/src/main/java/com/mnms/booking/util/ApiResponseUtil.java @@ -0,0 +1,23 @@ +package com.mnms.booking.util; + +import com.mnms.booking.exception.global.SuccessResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +public class ApiResponseUtil { + public static ResponseEntity> success(T data, String message) { + return ResponseEntity.ok(new SuccessResponse<>(true, data, message)); + } + + public static ResponseEntity> success(T data) { + return ResponseEntity.ok(new SuccessResponse<>(true, data, "μš”μ²­μ΄ μ„±κ³΅μ μœΌλ‘œ μ²˜λ¦¬λ˜μ—ˆμŠ΅λ‹ˆλ‹€.")); + } + + public static ResponseEntity> success() { + return ResponseEntity.ok(new SuccessResponse<>(true, null, "μš”μ²­μ΄ μ„±κ³΅μ μœΌλ‘œ μ²˜λ¦¬λ˜μ—ˆμŠ΅λ‹ˆλ‹€.")); + } + + public static ResponseEntity> fail(String message, HttpStatus status) { + return ResponseEntity.status(status).body(new SuccessResponse<>(false, null, message)); + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/util/CommonUtils.java b/src/main/java/com/mnms/booking/util/CommonUtils.java new file mode 100644 index 0000000..c3a592b --- /dev/null +++ b/src/main/java/com/mnms/booking/util/CommonUtils.java @@ -0,0 +1,16 @@ +package com.mnms.booking.util; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +@Component +@RequiredArgsConstructor +public class CommonUtils { + /// reservation number 랜덀 생성 + public String generateReservationNumber() { + String uuidPart = UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase(); + return "T" + uuidPart; + } +} diff --git a/src/main/java/com/mnms/booking/util/SecurityResponseUtil.java b/src/main/java/com/mnms/booking/util/SecurityResponseUtil.java new file mode 100644 index 0000000..8c31a12 --- /dev/null +++ b/src/main/java/com/mnms/booking/util/SecurityResponseUtil.java @@ -0,0 +1,54 @@ +package com.mnms.booking.util; + +import com.mnms.booking.exception.BusinessException; +import com.mnms.booking.exception.ErrorCode; +import com.mnms.booking.security.AuthDetails; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.stream.Collectors; + +@Component +@Slf4j +@RequiredArgsConstructor +public class SecurityResponseUtil { + // Authentication μ—μ„œ userId μΆ”μΆœ + public Long requireUserId(Authentication authentication) { + try { + return Long.parseLong(authentication.getName()); + } catch (NumberFormatException e) { + throw new BusinessException(ErrorCode.USER_INVALID); + } + } + + // Authentication μ—μ„œ name μΆ”μΆœ + public String requireName(Authentication authentication) { + String userName = null; + log.info("authentication : {}", authentication); + Object details = authentication.getDetails(); + if (details instanceof AuthDetails d) { + userName = d.getUserName(); + log.info("authentication name : {}", userName); + } + return userName; + } + + + + // Authenticationμ—μ„œ ROLE 빼였기 + public List requireRole(Authentication authentication) { + if (authentication == null || authentication.getAuthorities() == null) { + return List.of(); // 빈 리슀트 λ°˜ν™˜ + } + + return authentication.getAuthorities() + .stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toList()); + } + +} diff --git a/src/main/java/com/mnms/booking/util/StatisticsUtil.java b/src/main/java/com/mnms/booking/util/StatisticsUtil.java new file mode 100644 index 0000000..4e738db --- /dev/null +++ b/src/main/java/com/mnms/booking/util/StatisticsUtil.java @@ -0,0 +1,81 @@ +package com.mnms.booking.util; + +import com.mnms.booking.dto.response.StatisticsUserResponseDTO; + +import java.text.DecimalFormat; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class StatisticsUtil { + private static final DecimalFormat df = new DecimalFormat("0.00"); + + public static StatisticsUserResponseDTO calculateStatistics(List> demographics) { + int maleCount = 0; + int femaleCount = 0; + Map ageGroupCount = new HashMap<>(); + Map ageGroupPercentage = new HashMap<>(); + + ageGroupCount.put("10λŒ€", 0); + ageGroupCount.put("20λŒ€", 0); + ageGroupCount.put("30λŒ€", 0); + ageGroupCount.put("40λŒ€", 0); + ageGroupCount.put("50λŒ€ 이상", 0); + + int totalPopulation = demographics.size(); + if (totalPopulation == 0) { + Map genderCount = Map.of("male", 0, "female", 0); + Map genderPercentage = Map.of("male", "0.00%", "female", "0.00%"); + ageGroupCount.forEach((key, value) -> ageGroupPercentage.put(key, "0.00%")); + + return new StatisticsUserResponseDTO( + totalPopulation, + genderCount, + genderPercentage, + ageGroupCount, + ageGroupPercentage + ); + } + + for (Map user : demographics) { + String gender = (String) user.get("gender"); + Integer age = (Integer) user.get("age"); + + if ("MALE".equalsIgnoreCase(gender)) { + maleCount++; + } else if ("FEMALE".equalsIgnoreCase(gender)) { + femaleCount++; + } + + if (age != null && age > 0) { + String ageGroup = getAgeGroup(age); + ageGroupCount.put(ageGroup, ageGroupCount.getOrDefault(ageGroup, 0) + 1); + } + } + + String malePercentage = df.format(((double) maleCount / totalPopulation) * 100) + "%"; + String femalePercentage = df.format(((double) femaleCount / totalPopulation) * 100) + "%"; + Map genderPercentage = Map.of("male", malePercentage, "female", femalePercentage); + + ageGroupCount.forEach((key, value) -> { + String percentage = df.format(((double) value / totalPopulation) * 100) + "%"; + ageGroupPercentage.put(key, percentage); + }); + + return new StatisticsUserResponseDTO( + totalPopulation, + Map.of("male", maleCount, "female", femaleCount), + genderPercentage, + ageGroupCount, + ageGroupPercentage + ); + } + + private static String getAgeGroup(int age) { + if (age < 20) return "10λŒ€"; + if (age < 30) return "20λŒ€"; + if (age < 40) return "30λŒ€"; + if (age < 50) return "40λŒ€"; + return "50λŒ€ 이상"; + } +} \ No newline at end of file diff --git a/src/main/java/com/mnms/booking/util/UserApiClient.java b/src/main/java/com/mnms/booking/util/UserApiClient.java new file mode 100644 index 0000000..9c8cf9d --- /dev/null +++ b/src/main/java/com/mnms/booking/util/UserApiClient.java @@ -0,0 +1,52 @@ +package com.mnms.booking.util; + +import com.mnms.booking.dto.response.ApiResponseDTO; +import com.mnms.booking.dto.response.BookingUserInfoResponseDTO; +import com.mnms.booking.dto.response.BookingUserResponseDTO; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + + +import java.util.Collections; +import java.util.List; + + +@Component +@RequiredArgsConstructor +public class UserApiClient { + + private final WebClient webClient; + + @Value("${base.service.url}${user.service.email.url}") + private String userServiceUrl; + + @Value("${base.service.url}${user.service.booking.url}") + private String bookingUserServiceUrl; + + // userId 리슀트 μš”μ²­ + public List getUsersByIds(List userIds) { + ApiResponseDTO> response = webClient.post() + .uri(bookingUserServiceUrl) + .bodyValue(userIds) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>>() {}) + .block(); + return response != null ? response.getData() : Collections.emptyList(); + } + + // userId둜 μš”μ²­ + public BookingUserResponseDTO getUserInfoById(Long userId) { + String url = String.format("%s/%d", userServiceUrl, userId); + ApiResponseDTO response = webClient.get() + .uri(url) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() { + }) + .block(); + + return response != null ? response.getData() : null; + } +} \ No newline at end of file diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties new file mode 100644 index 0000000..c488206 --- /dev/null +++ b/src/main/resources/application-dev.properties @@ -0,0 +1,97 @@ +myboot.name=Dev Env + +# ===== 이름(Γ«Β©Β”Γ­ΒŠΒΈΓ«Β¦Β­ application 라벨) ===== +spring.application.name=booking + +# ===== 둜ΓͺΒ·ΒΈ ===== +logging.level.root=INFO +logging.level.org.springframework.security=DEBUG +logging.level.com.mnms.booking.service=DEBUG + +# ===== DB ===== +spring.datasource.url=${DB_URL} +spring.datasource.username=${DB_USERNAME} +spring.datasource.password=${DB_PASSWORD} +spring.datasource.driverClassName=org.mariadb.jdbc.Driver + +# ===== JPA ===== +spring.jpa.hibernate.ddl-auto=update +spring.jpa.show-sql=true +spring.jpa.database-platform=org.hibernate.dialect.MariaDBDialect + +# ===== Redis ===== +spring.data.redis.host=${SERVER_URL} +spring.data.redis.port=${REDIS_PORT} + +# ===== Kafka ===== +spring.kafka.bootstrap-servers=${KAFKA_SERVERS} +spring.kafka.consumer.auto-offset-reset=earliest +spring.kafka.consumer.properties.spring.json.trusted.packages=* +spring.kafka.consumer.properties.spring.json.use.type.headers=false +spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.StringDeserializer +spring.kafka.consumer.value-deserializer=org.apache.kafka.common.serialization.StringDeserializer + +# κ°€μ˜ˆλ§€ TTL +temp-reservation.ttl-minutes=1 + + +# Topic +app.kafka.topic.festival-event=festival-topic +app.kafka.topic.payment-event=payment-status-events +app.kafka.topic.payment-cancel-event=payment-cancel-events +app.kafka.topic.transfer-payment-event=payment-transfer-status-events + +# ===== JWT ===== +jwt.public-pem-path=classpath:keys/public.pem +jwt.issuer=festival-user-service + +# ===== 외뢀 섀정/URL ===== +spring.config.import=optional:file:.env[.properties] + +# Base API (ΓͺΒ²ΒŒΓ¬ΒΒ΄Γ­ΒŠΒΈΓ¬Β›Β¨Γ¬ΒΒ΄ Γͺ¸°ë³¸Γͺ°’ 제ΓͺΒ³Β΅) +base.service.url=${BASE_API:http://localhost:10000} + +# user API (내뢀 DNS Γͺ¸°ë³¸Γͺ°’ 제ΓͺΒ³Β΅) +user.service.email.url=${USER_EMAIL_INFO_API} +user.service.stats.url=${USER_STATS_LIST_API} +user.service.booking.url=${BOOKING_USER_INFO_API} + +# ===== Mail ===== +spring.mail.host=smtp.gmail.com +spring.mail.port=587 +spring.mail.username=${MAIL_USERNAME} +spring.mail.password=${MAIL_PASSWORD} +spring.mail.properties.mail.smtp.auth=true +spring.mail.properties.mail.smtp.starttls.enable=true +spring.mail.properties.mail.smtp.starttls.required=true +spring.mail.properties.mail.smtp.connectiontimeout=5000 +spring.mail.properties.mail.smtp.timeout=5000 +spring.mail.properties.mail.smtp.writetimeout=5000 +spring.mail.default-encoding=UTF-8 + +# ===== Swagger ===== +springdoc.api-docs.enabled=true +springdoc.swagger-ui.enabled=true +springdoc.swagger-ui.path=/swagger-ui.html +springdoc.override-with-generic-response=false +springdoc.servers[0].url=${BASE_API:http://localhost:10000} +springdoc.servers[0].description=API Gateway + +# ===== Actuator/Prometheus & Γ«Β©Β”Γ­ΒŠΒΈΓ«Β¦Β­ Γ­ΒžΒˆΓ¬ΒŠΒ€Γ­Β†Β Γͺ·¸ëž¨ ===== +management.endpoints.web.exposure.include=health,info,prometheus +management.endpoint.health.show-details=always +management.endpoint.health.probes.enabled=true +management.metrics.tags.application=${spring.application.name} + +# HTTP Γ¬Β„ΒœΓ«Β²Β„ 지연 Γ­ΒžΒˆΓ¬ΒŠΒ€Γ­Β†Β Γͺ·¸ëž¨(p95 ΓͺΒ³Β„Γ¬Β‚Β°Γ¬ΒšΒ©) +management.metrics.distribution.percentiles-histogram.http.server.requests=true + +# Hikari 컀ë„Β₯Γ¬Β…Β˜ Γ­ΒšΒΓ«Β“Β 지연 Γ­ΒžΒˆΓ¬ΒŠΒ€Γ­Β†Β Γͺ·¸ëž¨(p95) +management.metrics.distribution.percentiles-histogram.hikaricp.connections.acquire=true +management.metrics.distribution.sla.hikaricp.connections.acquire=5ms,10ms,20ms,50ms,100ms,200ms,500ms + +# Tomcat Γ«Β©Β”Γ­ΒŠΒΈΓ«Β¦Β­(MBean) Γ­Β™ΒœΓ¬Β„Β±Γ­Β™Β”(Γ¬ΒŠΒ€Γ«Β ΒˆΓ«Β“Βœ, 컀ë„Β₯Γ¬Β…Β˜ 등) +server.tomcat.mbeanregistry.enabled=true + + +spring.jackson.serialization.WRITE_DATES_AS_TIMESTAMPS=false \ No newline at end of file diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties new file mode 100644 index 0000000..ff2cdb7 --- /dev/null +++ b/src/main/resources/application-prod.properties @@ -0,0 +1,87 @@ +myboot.name=Prod Env + +#log level +logging.level.root=INFO +logging.level.com.mnms.booking.service=DEBUG +logging.level.org.springframework.security=DEBUG + +#maria db info +spring.datasource.url=${DB_URL} +spring.datasource.username=${DB_USERNAME} +spring.datasource.password=${DB_PASSWORD} +spring.datasource.driverClassName=org.mariadb.jdbc.Driver + +# hibernate info +spring.jpa.hibernate.ddl-auto=update +spring.jpa.show-sql=true +spring.jpa.database-platform=org.hibernate.dialect.MariaDBDialect + +# redis μ„€μ • - 둜컬 μ„€μ • +#spring.data.redis.host=127.0.0.1 +spring.data.redis.host=${REDIS_SERVER_URL} +spring.data.redis.port=${REDIS_PORT} +#spring.redis.password=your_password + +## kafka +app.kafka.topic.festival-event=festival-topic +app.kafka.topic.payment-event= payment-status-events +app.kafka.topic.payment-cancel-event=payment-cancel-events +app.kafka.topic.transfer-payment-event=payment-transfer-status-events + +spring.kafka.bootstrap-servers=${KAFKA_SERVERS} +spring.kafka.consumer.auto-offset-reset=earliest +spring.kafka.consumer.properties.spring.json.trusted.packages=* +spring.kafka.consumer.properties.spring.json.use.type.headers=false +spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.StringDeserializer +spring.kafka.consumer.value-deserializer=org.apache.kafka.common.serialization.StringDeserializer + +# κ°€μ˜ˆλ§€ TTL +temp-reservation.ttl-minutes=1 + +# jwt +jwt.public-pem-path=classpath:keys/public.pem +jwt.issuer=festival-user-service +spring.config.import=optional:file:.env[.properties] + +# swagger +springdoc.api-docs.enabled=true +springdoc.swagger-ui.enabled=true +springdoc.swagger-ui.path=/swagger-ui.html +springdoc.override-with-generic-response=false + +# user api +base.service.url=${BASE_API} +user.base.service.url=${USER_BASE_API} +user.service.email.url=${USER_EMAIL_INFO_API} +user.service.stats.url=${USER_STATS_LIST_API} +user.service.booking.url=${BOOKING_USER_INFO_API} + +# mail +spring.mail.host=smtp.gmail.com +spring.mail.port=587 +spring.mail.username=${MAIL_USERNAME} +spring.mail.password=${MAIL_PASSWORD} + +spring.mail.properties.mail.smtp.auth=true +spring.mail.properties.mail.smtp.starttls.enable=true +spring.mail.properties.mail.smtp.starttls.required=true +spring.mail.properties.mail.smtp.connectiontimeout=5000 +spring.mail.properties.mail.smtp.timeout=5000 +spring.mail.properties.mail.smtp.writetimeout=5000 +spring.mail.default-encoding=UTF-8 + +# ===== Actuator/Prometheus & λ©”νŠΈλ¦­ νžˆμŠ€ν† κ·Έλž¨ ===== +management.endpoints.web.exposure.include=health,info,prometheus +management.endpoint.health.show-details=always +management.endpoint.health.probes.enabled=true +management.metrics.tags.application=${spring.application.name:booking} + +# HTTP μ„œλ²„ μ§€μ—° νžˆμŠ€ν† κ·Έλž¨(p95 κ³„μ‚°μš©) +management.metrics.distribution.percentiles-histogram.http.server.requests=true + +# Hikari 컀λ„₯μ…˜ νšλ“ μ§€μ—° νžˆμŠ€ν† κ·Έλž¨(p95) +management.metrics.distribution.percentiles-histogram.hikaricp.connections.acquire=true +management.metrics.distribution.sla.hikaricp.connections.acquire=5ms,10ms,20ms,50ms,100ms,200ms,500ms + +# Tomcat λ©”νŠΈλ¦­(MBean) ν™œμ„±ν™”(μŠ€λ ˆλ“œ, 컀λ„₯μ…˜ λ“±) +server.tomcat.mbeanregistry.enabled=true \ No newline at end of file diff --git a/src/main/resources/application-test.properties b/src/main/resources/application-test.properties new file mode 100644 index 0000000..230636a --- /dev/null +++ b/src/main/resources/application-test.properties @@ -0,0 +1,63 @@ +myboot.name=Test Env + +#log level +logging.level.root=INFO +logging.level.c.mnms.booking.config.RedisConfig=INFO +#logging.level.com.basic.myspringboot=debug + +# H2 Database μ„€μ • +spring.datasource.url=jdbc:h2:mem:testdb +spring.datasource.driver-class-name=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= + +# JPA μ„€μ • +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect +spring.jpa.hibernate.ddl-auto=update + +# H2 Console μ‚¬μš© μ„€μ • +spring.h2.console.enabled=true +spring.h2.console.path=/h2-console + +# redis μ„€μ • - 둜컬 μ„€μ • data +spring.redis.host=127.0.0.1 +spring.redis.port=6379 +#spring.redis.password=your_password + +# κ°€μ˜ˆλ§€ TTL +temp-reservation.ttl-minutes=1 + +# kafka +app.kafka.topic.booking-event=booking-events +app.kafka.topic.user-event= user-events +app.kafka.topic.payment-event= payment-status-events + +spring.kafka.bootstrap-servers=${KAFKA_SERVERS} +spring.kafka.consumer.auto-offset-reset=earliest +spring.kafka.consumer.value-deserializer=org.apache.kafka.common.serialization.StringDeserializer +#spring.kafka.consumer.properties.spring.json.value.default.type=com.mnms.booking.kafka.BookingEventListener$UserEventDTO +spring.kafka.consumer.properties.spring.json.trusted.packages=* +spring.kafka.consumer.properties.spring.json.use.type.headers=false + +# kafka μ‚¬μš© X +#spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration + +# jwt +jwt.public-pem-path=classpath:keys/public.pem +jwt.issuer=festival-user-service + +spring.config.import=optional:file:.env[.properties] + +# swagger +springdoc.api-docs.enabled=true +springdoc.swagger-ui.enabled=true +springdoc.swagger-ui.path=/swagger-ui.html +springdoc.override-with-generic-response=false + +# user api +user.service.url=${USER_INFO_API} +booking.user.servicel.url=${BOOKING_USER_INFO_API} + +management.endpoints.web.exposure.include=health,info,prometheus +management.endpoint.health.show-details=always +management.metrics.tags.application=${spring.application.name:booking-service} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 479a627..1c26cbc 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,23 @@ spring.application.name=booking + +# κΈ°λ³Έ true +spring.devtools.restart.enabled=true + +# 포트 μ„€μ • +server.port=8082 + +# λͺ¨λ“  IP μ£Όμ†Œμ—μ„œ μ ‘κ·Ό ν—ˆμš© +server.address=0.0.0.0 + +# ν˜„μž¬ ν™œμ„±ν™”λœ ν™˜κ²½ - 개발 ν™˜κ²½μ— 따라 μˆ˜μ •ν•˜μ„Έμš” +spring.profiles.active=dev + +# log file +logging.file.path=logs + +ocr.key=${OCR_SECRET_KEY} +ocr.invoke_url=${OCR_INVOKE_URL} + +spring.servlet.multipart.max-file-size=100MB +spring.servlet.multipart.max-request-size=100MB +spring.servlet.multipart.enabled=true \ No newline at end of file diff --git a/src/main/resources/keys/public.pem b/src/main/resources/keys/public.pem new file mode 100644 index 0000000..441e127 --- /dev/null +++ b/src/main/resources/keys/public.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA312ubekF6tOrMRzgwkvE +w8Fq2MSJI6gzaBIyKarFMiahThLhivj2pId8FnoOSHGD0bVDP/cOQ4DnIFPpVGjO +gHHIPb28k5+U37Y/8bRzvkjBHK9VTjHls1Ck+lr5NxhET9rtv3NVWN0hdTcF8EL9 +l7xYun00hNg8S0nUUMlwkrgumio+9bntlnE8GRJ3zuMu9WCRtbV+PuRrgjD/CuOv +xtRUtrmlVmmYg2UX2JaY2nx2O6aW3HzWg3pR+2YdTW3vzQGOrXhYO4LUQSWkZQPV +OjfGTzpJj4DkFgGXhymyErtzLrIBwjM2Nt84F5gE/sVg4Hv6ub8v56BqX11IYJxV +uQIDAQAB +-----END PUBLIC KEY----- diff --git a/src/main/resources/templates/email/ticket-confirmation.txt b/src/main/resources/templates/email/ticket-confirmation.txt new file mode 100644 index 0000000..59e75b3 --- /dev/null +++ b/src/main/resources/templates/email/ticket-confirmation.txt @@ -0,0 +1,13 @@ +%s λ‹˜, μ˜ˆλ§€κ°€ μ™„λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€. + +예맀번호: %s +곡연λͺ…: %s +κ³΅μ—°μΌμ‹œ: %s +곡연μž₯: %s +κ²°μ œκΈˆμ•‘: %s원 + +ν‹°μΌ“ 수령: %s (μ•±μ—μ„œ 확인 κ°€λŠ₯) +μž…μž₯ μ‹œ QRμ½”λ“œλ₯Ό μ œμ‹œν•΄ μ£Όμ„Έμš”. + +β€» μ·¨μ†Œ 마감: 곡연 전일 17μ‹œκΉŒμ§€ +문의: 고객센터 1588-0000 \ No newline at end of file diff --git a/src/test/java/com/mnms/booking/BookingApplicationTests.java b/src/test/java/com/mnms/booking/BookingApplicationTests.java deleted file mode 100644 index 723e7d1..0000000 --- a/src/test/java/com/mnms/booking/BookingApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.mnms.booking; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class BookingApplicationTests { - - @Test - void contextLoads() { - } - -}