diff --git a/application/src/main/java/org/mandarin/booking/adapter/security/ApplicationAuthorizationRequestMatcherConfigurer.java b/application/src/main/java/org/mandarin/booking/adapter/security/ApplicationAuthorizationRequestMatcherConfigurer.java index 9e0c056..2b12f8f 100644 --- a/application/src/main/java/org/mandarin/booking/adapter/security/ApplicationAuthorizationRequestMatcherConfigurer.java +++ b/application/src/main/java/org/mandarin/booking/adapter/security/ApplicationAuthorizationRequestMatcherConfigurer.java @@ -18,6 +18,7 @@ public void authorizeRequests( .requestMatchers("/api/auth/reissue").permitAll() .requestMatchers(HttpMethod.POST, "/api/show/schedule").hasAuthority("ROLE_DISTRIBUTOR") .requestMatchers(HttpMethod.GET, "/api/show").permitAll() + .requestMatchers(HttpMethod.GET, "/api/show/*").permitAll()// 인증이 필요한 GET /show/* 엔드포인트 추가시 설정을 이 줄 아래에 .requestMatchers(HttpMethod.POST, "/api/show").hasAuthority("ROLE_ADMIN") .anyRequest().authenticated(); } diff --git a/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java b/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java index a1df9aa..a56d74d 100644 --- a/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java +++ b/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java @@ -1,9 +1,11 @@ package org.mandarin.booking.adapter.webapi; import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; import org.mandarin.booking.adapter.SliceView; import org.mandarin.booking.app.show.ShowFetcher; import org.mandarin.booking.app.show.ShowRegisterer; +import org.mandarin.booking.domain.show.ShowDetailResponse; import org.mandarin.booking.domain.show.ShowInquiryRequest; import org.mandarin.booking.domain.show.ShowRegisterRequest; import org.mandarin.booking.domain.show.ShowRegisterResponse; @@ -11,6 +13,7 @@ import org.mandarin.booking.domain.show.ShowScheduleRegisterRequest; import org.mandarin.booking.domain.show.ShowScheduleRegisterResponse; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -25,6 +28,13 @@ SliceView inquire(@Valid ShowInquiryRequest req) { return showFetcher.fetchShows(req.page(), req.size(), req.type(), req.rating(), req.q(), req.from(), req.to()); } + @GetMapping("/{showId}") + ShowDetailResponse inquireDetail(@PathVariable + @Positive(message = "show Id는 음수일 수 없습니다.") + Long showId) { + return showFetcher.fetchShowDetail(showId); + } + @PostMapping ShowRegisterResponse register(@RequestBody @Valid ShowRegisterRequest request) { return showRegisterer.register(request); diff --git a/application/src/main/java/org/mandarin/booking/app/hall/HallFetcher.java b/application/src/main/java/org/mandarin/booking/app/hall/HallFetcher.java new file mode 100644 index 0000000..7527b13 --- /dev/null +++ b/application/src/main/java/org/mandarin/booking/app/hall/HallFetcher.java @@ -0,0 +1,7 @@ +package org.mandarin.booking.app.hall; + +import org.mandarin.booking.domain.hall.Hall; + +public interface HallFetcher { + Hall fetch(Long hallId); +} diff --git a/application/src/main/java/org/mandarin/booking/app/hall/HallQueryRepository.java b/application/src/main/java/org/mandarin/booking/app/hall/HallQueryRepository.java index 7eedc66..382defd 100644 --- a/application/src/main/java/org/mandarin/booking/app/hall/HallQueryRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/hall/HallQueryRepository.java @@ -1,6 +1,8 @@ package org.mandarin.booking.app.hall; import lombok.RequiredArgsConstructor; +import org.mandarin.booking.domain.hall.Hall; +import org.mandarin.booking.domain.hall.HallException; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; @@ -13,4 +15,9 @@ class HallQueryRepository { boolean existsById(Long hallId) { return repository.existsById(hallId); } + + public Hall findById(Long hallId) { + return repository.findById(hallId) + .orElseThrow(() -> new HallException("NOT_FOUND", "해당 공연장을 찾을 수 없습니다.")); + } } diff --git a/application/src/main/java/org/mandarin/booking/app/hall/HallRepository.java b/application/src/main/java/org/mandarin/booking/app/hall/HallRepository.java index 66a25c5..49f6f53 100644 --- a/application/src/main/java/org/mandarin/booking/app/hall/HallRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/hall/HallRepository.java @@ -1,10 +1,11 @@ package org.mandarin.booking.app.hall; +import java.util.Optional; import org.mandarin.booking.domain.hall.Hall; import org.springframework.data.repository.Repository; interface HallRepository extends Repository { - Hall save(Hall hall); - boolean existsById(Long id); + + Optional findById(Long id); } diff --git a/application/src/main/java/org/mandarin/booking/app/hall/HallService.java b/application/src/main/java/org/mandarin/booking/app/hall/HallService.java index ce32723..9c0063a 100644 --- a/application/src/main/java/org/mandarin/booking/app/hall/HallService.java +++ b/application/src/main/java/org/mandarin/booking/app/hall/HallService.java @@ -1,12 +1,13 @@ package org.mandarin.booking.app.hall; import lombok.RequiredArgsConstructor; +import org.mandarin.booking.domain.hall.Hall; import org.mandarin.booking.domain.hall.HallException; import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor -class HallService implements HallValidator { +class HallService implements HallValidator, HallFetcher { private final HallQueryRepository queryRepository; @Override @@ -15,4 +16,9 @@ public void checkHallExist(Long hallId) { throw new HallException("NOT_FOUND", "해당 공연장을 찾을 수 없습니다."); } } + + @Override + public Hall fetch(Long hallId) { + return queryRepository.findById(hallId); + } } diff --git a/application/src/main/java/org/mandarin/booking/app/show/ShowFetcher.java b/application/src/main/java/org/mandarin/booking/app/show/ShowFetcher.java index 89da51c..3d48798 100644 --- a/application/src/main/java/org/mandarin/booking/app/show/ShowFetcher.java +++ b/application/src/main/java/org/mandarin/booking/app/show/ShowFetcher.java @@ -2,9 +2,12 @@ import java.time.LocalDate; import org.mandarin.booking.adapter.SliceView; +import org.mandarin.booking.domain.show.ShowDetailResponse; import org.mandarin.booking.domain.show.ShowResponse; public interface ShowFetcher { SliceView fetchShows(Integer page, Integer size, String type, String rating, String q, LocalDate from, LocalDate to); + + ShowDetailResponse fetchShowDetail(Long showId); } diff --git a/application/src/main/java/org/mandarin/booking/app/show/ShowService.java b/application/src/main/java/org/mandarin/booking/app/show/ShowService.java index 6113b80..6b0aebc 100644 --- a/application/src/main/java/org/mandarin/booking/app/show/ShowService.java +++ b/application/src/main/java/org/mandarin/booking/app/show/ShowService.java @@ -6,11 +6,13 @@ import java.time.LocalDate; import lombok.RequiredArgsConstructor; import org.mandarin.booking.adapter.SliceView; +import org.mandarin.booking.app.hall.HallFetcher; import org.mandarin.booking.app.hall.HallValidator; import org.mandarin.booking.domain.show.Show; import org.mandarin.booking.domain.show.Show.Rating; import org.mandarin.booking.domain.show.Show.ShowCreateCommand; import org.mandarin.booking.domain.show.Show.Type; +import org.mandarin.booking.domain.show.ShowDetailResponse; import org.mandarin.booking.domain.show.ShowException; import org.mandarin.booking.domain.show.ShowRegisterRequest; import org.mandarin.booking.domain.show.ShowRegisterResponse; @@ -26,6 +28,7 @@ class ShowService implements ShowRegisterer, ShowFetcher { private final ShowCommandRepository commandRepository; private final ShowQueryRepository queryRepository; private final HallValidator hallValidator; + private final HallFetcher hallFetcher; @Override public ShowRegisterResponse register(ShowRegisterRequest request) { @@ -61,6 +64,25 @@ public SliceView fetchShows(Integer page, Integer size, String typ q, from, to); } + @Override + public ShowDetailResponse fetchShowDetail(Long showId) { + var show = queryRepository.findById(showId); + var hall = hallFetcher.fetch(show.getHallId()); + return new ShowDetailResponse( + show.getId(), + show.getTitle(), + show.getType(), + show.getRating(), + show.getSynopsis(), + show.getPosterUrl(), + show.getPerformanceStartDate(), + show.getPerformanceEndDate(), + hall.getId(), + hall.getName(), + show.getScheduleResponses() + ); + } + private void checkDuplicateTitle(String title) { if (queryRepository.existsByName(title)) { throw new ShowException("이미 존재하는 공연 이름입니다:" + title); diff --git a/application/src/test/java/org/mandarin/booking/utils/TestFixture.java b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java index b23c7f2..23620d2 100644 --- a/application/src/test/java/org/mandarin/booking/utils/TestFixture.java +++ b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java @@ -8,6 +8,7 @@ import jakarta.persistence.EntityManager; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import java.util.Random; import java.util.UUID; @@ -21,7 +22,9 @@ import org.mandarin.booking.domain.show.Show.Rating; import org.mandarin.booking.domain.show.Show.ShowCreateCommand; import org.mandarin.booking.domain.show.Show.Type; +import org.mandarin.booking.domain.show.ShowDetailResponse.ShowScheduleResponse; import org.mandarin.booking.domain.show.ShowRegisterRequest; +import org.mandarin.booking.domain.show.ShowScheduleCreateCommand; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.transaction.annotation.Transactional; @@ -89,6 +92,23 @@ public Hall insertDummyHall() { return hall; } + public Show generateShow(int scheduleCount) { + var hall = insertDummyHall(); + var show = generateShow(hall.getId()); + + for (int i = 0; i < scheduleCount; i++) { + Random random = new Random(); + var startAt = LocalDateTime.now().plusDays(random.nextInt(0, 10)); + var command = new ShowScheduleCreateCommand(show.getId(), + startAt, + startAt.plusHours(random.nextInt(2, 5)) + ); + show.registerSchedule(command); + } + + return showInsert(show); + } + public List generateShows(int showCount) { var hall = insertDummyHall(); return IntStream.range(0, showCount) @@ -144,6 +164,33 @@ public void generateShows(int showCount, int before, int after) { }); } + public Show generateShowWithNoSynopsis(int scheduleCount) { + var hall = insertDummyHall(); + var show = Show.create(hall.getId(), ShowCreateCommand.from(new ShowRegisterRequest( + hall.getId(), + UUID.randomUUID().toString().substring(0, 10), + randomEnum(Type.class).name(), + randomEnum(Rating.class).name(), + null, + "https://example.com/poster.jpg", + LocalDate.now(), + LocalDate.now().plusDays(30) + ))); + + for (int i = 0; i < scheduleCount; i++) { + Random random = new Random(); + var startAt = LocalDateTime.now().plusDays(random.nextInt(0, 10)); + var command = new ShowScheduleCreateCommand(show.getId(), + startAt, + startAt.plusHours(random.nextInt(2, 5)) + ); + show.registerSchedule(command); + } + + ReflectionTestUtils.setField(show, "synopsis", ""); + return showInsert(show); + } + public boolean existsHallName(String name) { return (entityManager.createQuery("SELECT COUNT(h) FROM Hall h WHERE h.name = :name") .setParameter("name", name) @@ -160,6 +207,20 @@ public Member findMemberByUserId(String userId) { .getSingleResult(); } + public Hall findHallById(Long hallId) { + return entityManager.createQuery("SELECT h FROM Hall h WHERE h.id = :hallId", Hall.class) + .setParameter("hallId", hallId) + .getSingleResult(); + } + + public boolean isMatchingScheduleInShow(ShowScheduleResponse res, Show show) { + return !entityManager.createQuery( + "SELECT s FROM ShowSchedule s WHERE s.id = :scheduleId AND s.show.id = :showId", Object.class) + .setParameter("scheduleId", res.getScheduleId()) + .setParameter("showId", show.getId()) + .getResultList().isEmpty(); + } + private void generateShow(Long hallId, Type type) { var request = validShowRegisterRequest(hallId, type.name(), randomEnum(Rating.class).name()); var show = Show.create(hallId, ShowCreateCommand.from(request)); @@ -185,7 +246,7 @@ private void generateShow(Long hallId, Rating rating) { showInsert(show); } - private Show generateShow(Long hallId) { + public Show generateShow(Long hallId) { var request = validShowRegisterRequest(hallId, randomEnum(Type.class).name(), randomEnum(Rating.class).name()); var show = Show.create(hallId, ShowCreateCommand.from(request)); return showInsert(show); diff --git a/application/src/test/java/org/mandarin/booking/webapi/show/showId/GET_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/showId/GET_specs.java new file mode 100644 index 0000000..216954b --- /dev/null +++ b/application/src/test/java/org/mandarin/booking/webapi/show/showId/GET_specs.java @@ -0,0 +1,206 @@ +package org.mandarin.booking.webapi.show.showId; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatStream; +import static org.mandarin.booking.adapter.ApiStatus.BAD_REQUEST; +import static org.mandarin.booking.adapter.ApiStatus.NOT_FOUND; +import static org.mandarin.booking.adapter.ApiStatus.SUCCESS; + +import java.time.Duration; +import java.util.Comparator; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mandarin.booking.domain.show.ShowDetailResponse; +import org.mandarin.booking.domain.show.ShowDetailResponse.ShowScheduleResponse; +import org.mandarin.booking.domain.show.ShowResponse; +import org.mandarin.booking.utils.IntegrationTest; +import org.mandarin.booking.utils.IntegrationTestUtils; +import org.mandarin.booking.utils.TestFixture; +import org.springframework.beans.factory.annotation.Autowired; + +@IntegrationTest +@DisplayName("GET /api/show/{showId}") +class GET_specs { + @Test + void 존재하는_showId를_요청하면_200과_함께_공연_상세_정보가_반환된다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + var show = testFixture.generateShow(5); + + // Act + var response = testUtils.get("/api/show/" + show.getId()) + .assertSuccess(ShowResponse.class); + + // Assert + assertThat(response.getStatus()).isEqualTo(SUCCESS); + } + + @Test + void 존재하지_않는_showId_요청_시_NOT_FOUND를_반환한다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + var show = testFixture.generateShow(5); + var invalidShowId = show.getId() + 9999; + + // Act + var response = testUtils.get("/api/show/" + invalidShowId) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(NOT_FOUND); + } + + @ParameterizedTest + @ValueSource(strings = {"0", "abc", "1.5", "@#$%", "-10"}) + void 양의_정수가_아닌_showId_요청_시_BAD_REQUEST을_반환한다( + String invalidShowId, + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + testFixture.generateShow(5); + + // Act + var response = testUtils.get("/api/show/" + invalidShowId) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } + + @Test + void 존재하는_공연장_ID가_조회된다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + var show = testFixture.generateShow(5); + + // Act + var response = testUtils.get("/api/show/" + show.getId()) + .assertSuccess(ShowDetailResponse.class); + + // Assert + var hallId = response.getData().hallId(); + var hallName = response.getData().hallName(); + var fetched = testFixture.findHallById(hallId); + + assertThat(fetched).isNotNull(); + assertThat(fetched.getName()).isEqualTo(hallName); + assertThat(fetched.getId()).isEqualTo(hallId); + } + + @Test + void 공연_일정은_마감_이전의_일정만_조회된다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + var show = testFixture.generateShow(5); + + // Act + var response = testUtils.get("/api/show/" + show.getId()) + .assertSuccess(ShowDetailResponse.class); + + // Assert + var schedules = response.getData().schedules(); + assertThat(schedules) + .allMatch(schedule -> schedule.getEndAt() + .isBefore(response.getData().performanceEndDate().atStartOfDay())); + + } + + @Test + void 공연_일정의_런타임은_시작_시간과_종료_시간의_차이와_일치한다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + var show = testFixture.generateShow(5); + + // Act + var response = testUtils.get("/api/show/" + show.getId()) + .assertSuccess(ShowDetailResponse.class); + + // Assert + var schedules = response.getData().schedules(); + + assertThat(schedules) + .allMatch(schedule -> schedule.getRuntimeMinutes() == Duration.between(schedule.getStartAt(), + schedule.getEndAt()).toMinutes()); + } + + @Test + void schedules는_endAt_ASC_순으로_정렬되어_반환된다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + var show = testFixture.generateShow(5); + + // Act + var response = testUtils.get("/api/show/" + show.getId()) + .assertSuccess(ShowDetailResponse.class); + + // Assert + var schedules = response.getData().schedules(); + assertThat(schedules).isNotEmpty(); + assertThat(schedules).isSortedAccordingTo(Comparator.comparing(ShowScheduleResponse::getEndAt)); + } + + @Test + void 영속화된_정보가_조회된다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + var show = testFixture.generateShow(5); + + // Act + var response = testUtils.get("/api/show/" + show.getId()) + .assertSuccess(ShowDetailResponse.class); + + // Assert + var data = response.getData(); + assertThat(data.showId()).isEqualTo(show.getId()); + assertThat(data.title()).isEqualTo(show.getTitle()); + assertThat(data.type()).isEqualTo(show.getType()); + assertThat(data.rating()).isEqualTo(show.getRating()); + assertThat(data.synopsis()).isEqualTo(show.getSynopsis()); + assertThat(data.posterUrl()).isEqualTo(show.getPosterUrl()); + assertThat(data.performanceStartDate()).isEqualTo(show.getPerformanceStartDate()); + assertThat(data.performanceEndDate()).isEqualTo(show.getPerformanceEndDate()); + + var schedules = data.schedules(); + assertThat(schedules).hasSize(show.getSchedules().size()); + assertThatStream(schedules.stream()) + .allMatch(res -> + testFixture.isMatchingScheduleInShow(res, show) + ); + } + + @Test + void synopsis가_없는_경우_빈_문자열로_반환된다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + var show = testFixture.generateShowWithNoSynopsis(5); + + // Act + var response = testUtils.get("/api/show/" + show.getId()) + .assertSuccess(ShowDetailResponse.class); + + // Assert + var data = response.getData(); + + assertThat(data.synopsis()).isNotNull(); + assertThat(data.synopsis()).isEmpty(); + } +} diff --git a/docs/devlog/250927.md b/docs/devlog/250927.md new file mode 100644 index 0000000..6dcf83f --- /dev/null +++ b/docs/devlog/250927.md @@ -0,0 +1,6 @@ +## 예찬 + +공연 상세 목록 조회 기능을 완료했다. 기능 자체가 복잡하지는 않은데 리팩터링 과정에서 알게된 사실이 있다. AR을 기준으로 연관을 전부 쪼개놔서 그런가 생각보다 공연 상세를 조회하는 과정이 빠른 성능을 보이지는 +않는다. + +QueryDSL로 성능을 빠르게 뽑아보려고 시도를 해보기도 했지만 그리 빠르지가 않다. 역시 찍어보지 않고서는 모른는건가? 물론 인덱스 처리를 하지 않은 상황이라 튜닝후 결과를 보기 전에는 모르 diff --git a/docs/specs/api/show_detail.md b/docs/specs/api/show_detail.md new file mode 100644 index 0000000..b548edc --- /dev/null +++ b/docs/specs/api/show_detail.md @@ -0,0 +1,84 @@ +# 기능 명세서: 공연 상세 조회 + +## 개요 + +공연의 상세 정보를 조회하는 기능이다. +공연 기본 정보(제목, 유형, 등급, 기간, 시놉시스, 포스터, 공연장)와 함께 등록된 회차 정보를 확인할 수 있다. +공연 목록 조회 후 특정 공연을 선택했을 때 상세 페이지로 진입하기 위한 핵심 엔드포인트이다. + +## Endpoint + +- Method: `GET` +- URL: `/api/show/{showId}` + +## 요청 파라미터 + +- Path Variable + - `showId` (Long, required): 조회할 공연의 고유 식별자 + +## 요청 예시 + +GET /api/show/1 + +## 응답 본문 + +```json + +{ + "status": "SUCCESS", + "data": { + "showId": 1, + "title": "라라랜드", + "type": "MUSICAL", + "rating": "ALL", + "synopsis": "꿈을 좇는 두 청춘의 사랑과 음악 이야기", + "posterUrl": "https://cdn.example.com/posters/la_la_land.jpg", + "performanceStartDate": "2025-10-05", + "performanceEndDate": "2025-11-05", + "hall": { + "hallId": 3, + "hallName": "샤롯데씨어터" + }, + "schedules": [ + { + "scheduleId": 10, + "startAt": "2025-10-10T19:00:00", + "endAt": "2025-10-10T21:30:00", + "runtimeMinutes": 150 + }, + { + "scheduleId": 11, + "startAt": "2025-10-11T14:00:00", + "endAt": "2025-10-11T16:30:00", + "runtimeMinutes": 150 + } + ] + }, + "timestamp": "2025-09-25T00:00:00Z" +} + +``` + +응답 코드 + +- 200 OK: 정상적으로 조회된 경우 +- 400 BAD_REQUEST: 잘못된 showId 값이 전달된 경우 +- 404 NOT_FOUND: 존재하지 않는 공연을 조회한 경우 + +## Policy + +- 공연 기간(performanceStartDate ≤ performanceEndDate)은 등록 시점에 검증되므로 조회 시 항상 유효한 값을 반환한다. +- schedules는 공연에 연결된 회차가 없으면 빈 배열을 반환한다. + +테스트 시나리오 + +- [x] 존재하는 showId를 요청하면 200과 함께 공연 상세 정보가 반환된다 +- [x] 존재하지 않는 showId 요청 시 NOT_FOUND를 반환한다 +- [x] 양의 정수가 아닌 showId 요청 시 BAD_REQUEST을 반환한다 +- [x] 공연에 회차가 없는 경우 schedules는 빈 배열이다 +- [x] 존재하는 공연장 ID가 조회된다 +- [x] 공연 일정은 마감 이전의 일정만 조회된다 +- [x] 공연 일정의 런타임은 시작 시간과 종료 시간의 차이와 일치한다 +- [x] schedules는 endAt ASC 순으로 정렬되어 반환된다 +- [x] 영속화된 정보가 조회된다 +- [x] synopsis가 없는 경우 빈 문자열로 반환된다 diff --git a/domain/src/main/java/org/mandarin/booking/domain/show/Show.java b/domain/src/main/java/org/mandarin/booking/domain/show/Show.java index e35291a..af5d098 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/show/Show.java +++ b/domain/src/main/java/org/mandarin/booking/domain/show/Show.java @@ -1,6 +1,6 @@ package org.mandarin.booking.domain.show; -import static jakarta.persistence.CascadeType.MERGE; +import static jakarta.persistence.CascadeType.ALL; import static jakarta.persistence.FetchType.LAZY; import jakarta.persistence.Entity; @@ -11,18 +11,20 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; import org.mandarin.booking.domain.AbstractEntity; +import org.mandarin.booking.domain.show.ShowDetailResponse.ShowScheduleResponse; @Entity @Table(name = "shows") @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Show extends AbstractEntity { - @OneToMany(mappedBy = "show", fetch = LAZY, cascade = MERGE) + @OneToMany(mappedBy = "show", fetch = LAZY, cascade = ALL) private final List schedules = new ArrayList<>(); private Long hallId; @@ -65,6 +67,19 @@ public void registerSchedule(ShowScheduleCreateCommand command) { this.schedules.add(schedule); } + public List getScheduleResponses() { + return this.schedules.stream() + .sorted(Comparator.comparing(ShowSchedule::getEndAt)) + .map( + schedule -> new ShowScheduleResponse( + schedule.getId(), + schedule.getStartAt(), + schedule.getEndAt() + ) + ) + .toList(); + } + public static Show create(Long hallId, ShowCreateCommand command) { var startDate = command.getPerformanceStartDate(); var endDate = command.getPerformanceEndDate(); diff --git a/domain/src/main/java/org/mandarin/booking/domain/show/ShowDetailResponse.java b/domain/src/main/java/org/mandarin/booking/domain/show/ShowDetailResponse.java new file mode 100644 index 0000000..939a698 --- /dev/null +++ b/domain/src/main/java/org/mandarin/booking/domain/show/ShowDetailResponse.java @@ -0,0 +1,34 @@ +package org.mandarin.booking.domain.show; + +import com.querydsl.core.annotations.QueryProjection; +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import lombok.Getter; +import org.mandarin.booking.domain.show.Show.Rating; +import org.mandarin.booking.domain.show.Show.Type; + +public record ShowDetailResponse(Long showId, String title, Type type, Rating rating, String synopsis, String posterUrl, + LocalDate performanceStartDate, LocalDate performanceEndDate, Long hallId, + String hallName, List schedules) { + @QueryProjection + public ShowDetailResponse { + } + + @Getter + public static class ShowScheduleResponse { + private final Long scheduleId; + private final LocalDateTime startAt; + private final LocalDateTime endAt; + private final long runtimeMinutes; + + @QueryProjection + public ShowScheduleResponse(Long scheduleId, LocalDateTime startAt, LocalDateTime endAt) { + this.scheduleId = scheduleId; + this.startAt = startAt; + this.endAt = endAt; + this.runtimeMinutes = Duration.between(startAt, endAt).toMinutes(); + } + } +} diff --git a/internal/src/main/java/org/mandarin/booking/adapter/GlobalExceptionHandler.java b/internal/src/main/java/org/mandarin/booking/adapter/GlobalExceptionHandler.java index 52f4860..d632e25 100644 --- a/internal/src/main/java/org/mandarin/booking/adapter/GlobalExceptionHandler.java +++ b/internal/src/main/java/org/mandarin/booking/adapter/GlobalExceptionHandler.java @@ -11,6 +11,8 @@ import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.HandlerMethodValidationException; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.servlet.NoHandlerFoundException; @Slf4j @@ -39,4 +41,14 @@ public ErrorResponse handleValidationException(MethodArgumentNotValidException e public ErrorResponse handleNoHandlerFoundException(NoHandlerFoundException ex) { return new ErrorResponse(NOT_FOUND, ex.getMessage()); } + + @ExceptionHandler(HandlerMethodValidationException.class) + public ErrorResponse handleHandlerMethodValidationException(HandlerMethodValidationException ex) { + return new ErrorResponse(BAD_REQUEST, ex.getMessage()); + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ErrorResponse handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException ex) { + return new ErrorResponse(BAD_REQUEST, ex.getMessage()); + } } diff --git a/internal/src/main/java/org/mandarin/booking/adapter/SecurityConfig.java b/internal/src/main/java/org/mandarin/booking/adapter/SecurityConfig.java index cbaf9ea..f6cd9a5 100644 --- a/internal/src/main/java/org/mandarin/booking/adapter/SecurityConfig.java +++ b/internal/src/main/java/org/mandarin/booking/adapter/SecurityConfig.java @@ -17,6 +17,8 @@ import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.firewall.HttpFirewall; +import org.springframework.security.web.firewall.StrictHttpFirewall; @Configuration @RequiredArgsConstructor @@ -51,6 +53,13 @@ public SecurityFilterChain apiChain(HttpSecurity http, return http.build(); } + @Bean + public HttpFirewall allowUrlEncodedPercentHttpFirewall() { + StrictHttpFirewall firewall = new StrictHttpFirewall(); + firewall.setAllowUrlEncodedPercent(true); + return firewall; + } + @Bean static RoleHierarchy roleHierarchy() { return RoleHierarchyImpl.withDefaultRolePrefix()