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 ba3d672..22e4145 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 @@ -1,7 +1,10 @@ package org.mandarin.booking.adapter.security; import org.mandarin.booking.adapter.AuthorizationRequestMatcherConfigurer; +import org.springframework.context.annotation.Bean; import org.springframework.http.HttpMethod; +import org.springframework.security.access.hierarchicalroles.RoleHierarchy; +import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer; import org.springframework.stereotype.Component; @@ -23,4 +26,12 @@ public void authorizeRequests( .requestMatchers(HttpMethod.POST, "/api/hall").hasAuthority("ROLE_ADMIN") .anyRequest().authenticated(); } + + @Bean + static RoleHierarchy roleHierarchy() { + return RoleHierarchyImpl.withDefaultRolePrefix() + .role("ADMIN").implies("DISTRIBUTOR") + .role("DISTRIBUTOR").implies("USER") + .build(); + } } diff --git a/application/src/main/java/org/mandarin/booking/app/JdbcBatchUtils.java b/application/src/main/java/org/mandarin/booking/app/JdbcBatchUtils.java new file mode 100644 index 0000000..5f8da23 --- /dev/null +++ b/application/src/main/java/org/mandarin/booking/app/JdbcBatchUtils.java @@ -0,0 +1,51 @@ +package org.mandarin.booking.app; + +import static java.lang.Math.max; +import static java.lang.Math.min; + +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class JdbcBatchUtils { + private final JdbcTemplate jdbcTemplate; + + public void batchUpdate(String sql, + List items, + SqlParameterBinder binder, + int batchSize) { + if (items.isEmpty()) { + return; + } + int size = items.size(); + int chunk = max(1, batchSize); + for (int start = 0; start < size; start += chunk) {// chunk size만큼 slice + int end = min(size, start + chunk); + List sub = items.subList(start, end); + + jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + binder.bind(ps, sub.get(i)); + } + + @Override + public int getBatchSize() { + return sub.size(); + } + }); + } + } + + @FunctionalInterface + public interface SqlParameterBinder { + void bind(PreparedStatement ps, T item) throws SQLException; + } + +} diff --git a/application/src/main/java/org/mandarin/booking/app/TableAwarePhysicalNamingStrategy.java b/application/src/main/java/org/mandarin/booking/app/TableAwarePhysicalNamingStrategy.java new file mode 100644 index 0000000..494d0f9 --- /dev/null +++ b/application/src/main/java/org/mandarin/booking/app/TableAwarePhysicalNamingStrategy.java @@ -0,0 +1,28 @@ +package org.mandarin.booking.app; + + +import static org.mandarin.booking.StringFormatterUtils.toSnakeCase; + +import org.hibernate.boot.model.naming.Identifier; +import org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl; +import org.hibernate.engine.jdbc.env.spi.JdbcEnvironment; +import org.jspecify.annotations.Nullable; + +public class TableAwarePhysicalNamingStrategy extends PhysicalNamingStrategyStandardImpl { + + private static final ThreadLocal<@Nullable String> CURRENT_TABLE = new ThreadLocal<>(); + + @Override + public Identifier toPhysicalTableName(Identifier name, JdbcEnvironment jdbcEnvironment) { + var table = toSnakeCase(name.getText()); + CURRENT_TABLE.set(table); + return Identifier.toIdentifier(table, name.isQuoted()); + } + + @Override + public Identifier toPhysicalColumnName(Identifier name, JdbcEnvironment jdbcEnvironment) { + var logical = name.getText(); + var physical = toSnakeCase(logical); + return Identifier.toIdentifier(physical, name.isQuoted()); + } +} diff --git a/application/src/main/java/org/mandarin/booking/app/hall/HallCommandRepository.java b/application/src/main/java/org/mandarin/booking/app/hall/HallCommandRepository.java index f9e4685..030676d 100644 --- a/application/src/main/java/org/mandarin/booking/app/hall/HallCommandRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/hall/HallCommandRepository.java @@ -1,15 +1,42 @@ package org.mandarin.booking.app.hall; +import jakarta.validation.constraints.NotEmpty; +import java.util.List; import lombok.RequiredArgsConstructor; +import org.mandarin.booking.app.JdbcBatchUtils; import org.mandarin.booking.domain.hall.Hall; +import org.mandarin.booking.domain.hall.Hall.SeatInsertRow; import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; @Repository +@Transactional @RequiredArgsConstructor class HallCommandRepository { private final HallRepository jpaRepository; + private final JdbcBatchUtils jdbcBatchUtils; Hall insert(Hall hall) { - return jpaRepository.save(hall); + var seatRows = hall.extractSeatRows(); + hall.clearSeats();// jpa가 아닌 jdbc로 일괄 삽입을 위해 clear + + var saved = jpaRepository.save(hall); + + batchInsertSeats(seatRows); + + return saved; + } + + private void batchInsertSeats(@NotEmpty List rows) { + jdbcBatchUtils.batchUpdate( + "INSERT INTO seat (section_id, seat_row, seat_number) VALUES (?, ?, ?)", + rows, + (ps, row) -> { + ps.setLong(1, row.section().getId()); + ps.setString(2, row.rowNumber()); + ps.setString(3, row.seatNumber()); + }, + 1000 + ); } } 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 694b3e8..83785b2 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,5 +1,11 @@ package org.mandarin.booking.app.hall; +import static org.mandarin.booking.domain.hall.QSeat.seat; +import static org.mandarin.booking.domain.hall.QSection.section; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.HashSet; +import java.util.List; import lombok.RequiredArgsConstructor; import org.mandarin.booking.domain.hall.Hall; import org.mandarin.booking.domain.hall.HallException; @@ -11,6 +17,7 @@ @RequiredArgsConstructor class HallQueryRepository { private final HallRepository repository; + private final JPAQueryFactory jpaQueryFactory; boolean existsById(Long hallId) { return repository.existsById(hallId); @@ -24,4 +31,28 @@ Hall findById(Long hallId) { return repository.findById(hallId) .orElseThrow(() -> new HallException("NOT_FOUND", "해당 공연장을 찾을 수 없습니다.")); } + + boolean existsByHallIdAndSectionId(Long hallId, Long sectionId) { + return findById(hallId).hasSectionOf(sectionId); + } + + boolean containsSeatIdsBySectionId(Long sectionId, List seatIds) { + var fetched = jpaQueryFactory + .select(seat.id) + .from(section) + .join(section.seats, seat) + .where(section.id.eq(sectionId)) + .fetch(); + return new HashSet<>(fetched).containsAll(seatIds); + } + + boolean equalsSeatIdsBySectionId(Long sectionId, List seatIds) { + var fetched = jpaQueryFactory + .select(seat.id) + .from(section) + .join(section.seats, seat) + .where(section.id.eq(sectionId)) + .fetch(); + return new HashSet<>(seatIds).equals(new HashSet<>(fetched)); + } } 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 7517baa..8596593 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,5 +1,6 @@ package org.mandarin.booking.app.hall; +import java.util.List; import lombok.RequiredArgsConstructor; import org.mandarin.booking.domain.hall.Hall; import org.mandarin.booking.domain.hall.HallException; @@ -32,11 +33,32 @@ public void checkHallExistByHallName(String hallName) { } } + @Override + public void checkHallExistBySectionId(Long hallId, Long sectionId) { + if (!queryRepository.existsByHallIdAndSectionId(hallId, sectionId)) { + throw new HallException("NOT_FOUND", "해당 공연장에 섹션이 존재하지 않습니다."); + } + } + + @Override + public void checkHallInvalidSeatIds(Long sectionId, List seatIds) { + if (!queryRepository.containsSeatIdsBySectionId(sectionId, seatIds)) { + throw new HallException("BAD_REQUEST", "해당 섹션에 해당하지 않는 좌석이 있습니다."); + } + } + + @Override + public void checkSectionContainsAllOf(Long sectionId, List seatIds) { + if (!queryRepository.equalsSeatIdsBySectionId(sectionId, seatIds)) { + throw new HallException("BAD_REQUEST", "해당 섹션 좌석과 총 좌석이 상이합니다."); + } + } + @Override public HallRegisterResponse register(String userId, HallRegisterRequest request) { checkHallExistByHallName(request.hallName()); - var hall = Hall.create(request.hallName(), userId); + var hall = Hall.create(request.hallName(), request.sections(), userId); var saved = commandRepository.insert(hall); diff --git a/application/src/main/java/org/mandarin/booking/app/hall/HallValidator.java b/application/src/main/java/org/mandarin/booking/app/hall/HallValidator.java index 4d54553..0502f9f 100644 --- a/application/src/main/java/org/mandarin/booking/app/hall/HallValidator.java +++ b/application/src/main/java/org/mandarin/booking/app/hall/HallValidator.java @@ -1,7 +1,15 @@ package org.mandarin.booking.app.hall; +import java.util.List; + public interface HallValidator { void checkHallExistByHallId(Long hallId); void checkHallExistByHallName(String hallName); + + void checkHallExistBySectionId(Long hallId, Long sectionId); + + void checkHallInvalidSeatIds(Long sectionId, List seatIds); + + void checkSectionContainsAllOf(Long sectionId, List seatIds); } diff --git a/application/src/main/java/org/mandarin/booking/app/show/InventoryCommandRepository.java b/application/src/main/java/org/mandarin/booking/app/show/InventoryCommandRepository.java new file mode 100644 index 0000000..3550f21 --- /dev/null +++ b/application/src/main/java/org/mandarin/booking/app/show/InventoryCommandRepository.java @@ -0,0 +1,40 @@ +package org.mandarin.booking.app.show; + +import jakarta.validation.constraints.NotEmpty; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.mandarin.booking.app.JdbcBatchUtils; +import org.mandarin.booking.domain.show.Inventory; +import org.mandarin.booking.domain.show.SeatState.SeatStateRow; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@Repository +@Transactional +@RequiredArgsConstructor +class InventoryCommandRepository { + private final InventoryRepository repository; + private final JdbcBatchUtils jdbcBatchUtils; + + void insert(Inventory inventory) { + List rows = inventory.extractSeatStateRows(); + inventory.clearSeatStates();// jpa가 아닌 jdbc로 일괄 삽입하기 위해 clear + + repository.save(inventory); + + batchInsert(inventory.getId(), rows); + } + + void batchInsert(Long inventoryId, @NotEmpty List rows) { + jdbcBatchUtils.batchUpdate( + "INSERT INTO seat_state (inventory_id, seat_id, grade_id) VALUES (?, ?, ?)", + rows, + (ps, row) -> { + ps.setLong(1, inventoryId); + ps.setLong(2, row.seatId()); + ps.setLong(3, row.gradeId()); + }, + 1000 + ); + } +} diff --git a/application/src/main/java/org/mandarin/booking/app/show/InventoryRepository.java b/application/src/main/java/org/mandarin/booking/app/show/InventoryRepository.java new file mode 100644 index 0000000..44b9940 --- /dev/null +++ b/application/src/main/java/org/mandarin/booking/app/show/InventoryRepository.java @@ -0,0 +1,8 @@ +package org.mandarin.booking.app.show; + +import org.mandarin.booking.domain.show.Inventory; +import org.springframework.data.repository.Repository; + +interface InventoryRepository extends Repository { + void save(Inventory inventory); +} diff --git a/application/src/main/java/org/mandarin/booking/app/show/InventoryService.java b/application/src/main/java/org/mandarin/booking/app/show/InventoryService.java new file mode 100644 index 0000000..07055bd --- /dev/null +++ b/application/src/main/java/org/mandarin/booking/app/show/InventoryService.java @@ -0,0 +1,19 @@ +package org.mandarin.booking.app.show; + +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.mandarin.booking.domain.show.Inventory; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +class InventoryService implements InventoryWriter { + private final InventoryCommandRepository commandRepository; + + @Override + public void createInventory(Long scheduleId, Map> seatAssociations) { + Inventory inventory = Inventory.create(scheduleId, seatAssociations); + commandRepository.insert(inventory); + } +} diff --git a/application/src/main/java/org/mandarin/booking/app/show/InventoryWriter.java b/application/src/main/java/org/mandarin/booking/app/show/InventoryWriter.java new file mode 100644 index 0000000..495dedc --- /dev/null +++ b/application/src/main/java/org/mandarin/booking/app/show/InventoryWriter.java @@ -0,0 +1,8 @@ +package org.mandarin.booking.app.show; + +import java.util.List; +import java.util.Map; + +public interface InventoryWriter { + void createInventory(Long scheduleId, Map> seatAssociations); +} diff --git a/application/src/main/java/org/mandarin/booking/app/show/ShowCommandRepository.java b/application/src/main/java/org/mandarin/booking/app/show/ShowCommandRepository.java index 8c11876..67a80f5 100644 --- a/application/src/main/java/org/mandarin/booking/app/show/ShowCommandRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/show/ShowCommandRepository.java @@ -1,5 +1,6 @@ package org.mandarin.booking.app.show; +import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; import org.mandarin.booking.domain.show.Show; import org.springframework.stereotype.Repository; @@ -9,10 +10,13 @@ @Transactional @RequiredArgsConstructor class ShowCommandRepository { - private final ShowRepository jpaRepository; + private final EntityManager entityManager; Show insert(Show show) { - return jpaRepository.save(show); + entityManager.persist(show); + entityManager.flush(); + entityManager.refresh(show); + return show; } } 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 b331208..0ffc075 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 @@ -19,6 +19,7 @@ import org.mandarin.booking.domain.show.ShowResponse; import org.mandarin.booking.domain.show.ShowScheduleCreateCommand; import org.mandarin.booking.domain.show.ShowScheduleRegisterRequest; +import org.mandarin.booking.domain.show.ShowScheduleRegisterRequest.GradeAssignmentRequest; import org.mandarin.booking.domain.show.ShowScheduleRegisterResponse; import org.springframework.stereotype.Service; @@ -29,6 +30,7 @@ class ShowService implements ShowRegisterer, ShowFetcher { private final ShowQueryRepository queryRepository; private final HallValidator hallValidator; private final HallFetcher hallFetcher; + private final InventoryWriter inventoryWriter; @Override public ShowRegisterResponse register(ShowRegisterRequest request) { @@ -47,13 +49,22 @@ public ShowRegisterResponse register(ShowRegisterRequest request) { @Override public ShowScheduleRegisterResponse registerSchedule(ShowScheduleRegisterRequest request) { var show = queryRepository.findById(request.showId()); + validateSeats(request, show, request.use().sectionId()); + show.validateGradeIds(request.use().gradeAssignments().stream().map(GradeAssignmentRequest::gradeId).toList()); checkConflictSchedule(show.getHallId(), request); var command = new ShowScheduleCreateCommand(request.showId(), request.startAt(), request.endAt()); - show.registerSchedule(command); + var schedule = show.registerSchedule(command); var saved = commandRepository.insert(show); - return new ShowScheduleRegisterResponse(requireNonNull(saved.getId())); + + var hall = hallFetcher.fetch(show.getHallId()); + + var seatsByGradeIds = request.use().seatsByGradeId(saved, hall); + + inventoryWriter.createInventory(schedule.getId(), seatsByGradeIds); + + return new ShowScheduleRegisterResponse(requireNonNull(schedule.getId())); } @Override @@ -95,5 +106,13 @@ private void checkConflictSchedule(Long hallId, ShowScheduleRegisterRequest requ throw new ShowException("해당 회차는 이미 공연 스케줄이 등록되어 있습니다."); } } + + private void validateSeats(ShowScheduleRegisterRequest request, Show show, Long sectionId) { + hallValidator.checkHallExistBySectionId(show.getHallId(), sectionId); + hallValidator.checkHallInvalidSeatIds(sectionId, request.use().excludeSeatIds()); + hallValidator.checkHallInvalidSeatIds(sectionId, request.use().includeSeatIds()); + hallValidator.checkSectionContainsAllOf(sectionId, request.use().allSeatIds()); + } + } diff --git a/application/src/main/resources/application.yml b/application/src/main/resources/application.yml index 25d120f..2cf62dc 100644 --- a/application/src/main/resources/application.yml +++ b/application/src/main/resources/application.yml @@ -3,4 +3,8 @@ spring: name: booking profiles: active: local + jpa: + hibernate: + naming: + physical-strategy: org.mandarin.booking.app.TableAwarePhysicalNamingStrategy diff --git a/application/src/test/java/org/mandarin/booking/app/JdbcBatchUtilsTest.java b/application/src/test/java/org/mandarin/booking/app/JdbcBatchUtilsTest.java new file mode 100644 index 0000000..1b36f77 --- /dev/null +++ b/application/src/test/java/org/mandarin/booking/app/JdbcBatchUtilsTest.java @@ -0,0 +1,99 @@ +package org.mandarin.booking.app; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.sql.PreparedStatement; +import java.util.ArrayList; +import java.util.List; +import org.jspecify.annotations.NonNull; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mandarin.booking.app.JdbcBatchUtils.SqlParameterBinder; +import org.mockito.ArgumentCaptor; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; + +class JdbcBatchUtilsTest { + private final JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); + private final JdbcBatchUtils utils = new JdbcBatchUtils(jdbcTemplate); + + @Test + @DisplayName("빈 목록이면 batchUpdate 호출 안 함") + void noOpOnEmptyItems() { + // Arrange + var emptyItems = List.of(); + + // Act + utils.batchUpdate("SQL", emptyItems, (ps, item) -> { + }, 100); + + // Assert + verify(jdbcTemplate, never()).batchUpdate(eq("SQL"), any(BatchPreparedStatementSetter.class)); + } + + @Test + @DisplayName("batchSize에 맞춰 청크 호출 및 바인더 순서 보장") + void chunkingAndBinderInvocationOrder() throws Exception { + // Arrange + var sql = "INSERT_SQL"; + List items = List.of("A", "B", "C", "D", "E"); + List bound = new ArrayList<>(); + + SqlParameterBinder<@NonNull String> binder = (PreparedStatement ps, String item) -> bound.add(item); + + // Act + utils.batchUpdate(sql, items, binder, 2); // 2,2,1로 분할되어 3회 호출 + + // Assert + ArgumentCaptor setterCaptor = ArgumentCaptor.forClass( + BatchPreparedStatementSetter.class); + verify(jdbcTemplate, times(3)).batchUpdate(eq(sql), setterCaptor.capture()); + + List setters = setterCaptor.getAllValues(); + assertThat(setters).hasSize(3); + + PreparedStatement ps = mock(PreparedStatement.class); + + for (BatchPreparedStatementSetter s : setters) { + int size = s.getBatchSize(); + for (int i = 0; i < size; i++) { + s.setValues(ps, i); + } + } + + // 바인딩된 순서는 입력 순서와 동일해야 한다 + assertThat(bound).containsExactlyElementsOf(items); + } + + @Test + @DisplayName("batchSize <= 0이면 1씩 분할") + void nonPositiveBatchSizeFallsBackToOne() throws Exception { + // Arrange + var sql = "UPSERT_SQL"; + List items = List.of(1, 2, 3); + List bound = new ArrayList<>(); + + // Act + utils.batchUpdate(sql, items, (ps, item) -> bound.add(item), 0); + + // Assert + ArgumentCaptor setterCaptor = ArgumentCaptor.forClass( + BatchPreparedStatementSetter.class); + verify(jdbcTemplate, times(3)).batchUpdate(eq(sql), setterCaptor.capture()); + + PreparedStatement ps = mock(PreparedStatement.class); + for (BatchPreparedStatementSetter s : setterCaptor.getAllValues()) { + assertThat(s.getBatchSize()).isEqualTo(1); + s.setValues(ps, 0); + } + + assertThat(bound).containsExactly(1, 2, 3); + } +} + diff --git a/application/src/test/java/org/mandarin/booking/utils/HallFixture.java b/application/src/test/java/org/mandarin/booking/utils/HallFixture.java index 7d01045..0361519 100644 --- a/application/src/test/java/org/mandarin/booking/utils/HallFixture.java +++ b/application/src/test/java/org/mandarin/booking/utils/HallFixture.java @@ -1,9 +1,27 @@ package org.mandarin.booking.utils; +import java.util.List; import java.util.UUID; +import java.util.stream.IntStream; +import org.mandarin.booking.domain.hall.SeatRegisterRequest; +import org.mandarin.booking.domain.hall.SectionRegisterRequest; public class HallFixture { public static String generateHallName() { return UUID.randomUUID().toString().substring(0, 10); } + + static List generateSectionRegisterRequest(int sectionCount, int seatCount) { + return IntStream.range(0, sectionCount) + .mapToObj(i -> new SectionRegisterRequest( + UUID.randomUUID().toString().substring(0, 10), + IntStream.range(0, seatCount) + .mapToObj(j -> new SeatRegisterRequest( + UUID.randomUUID().toString().substring(0, 8), + UUID.randomUUID().toString().substring(0, 8) + )) + .toList() + )) + .toList(); + } } diff --git a/application/src/test/java/org/mandarin/booking/utils/ShowFixture.java b/application/src/test/java/org/mandarin/booking/utils/ShowFixture.java new file mode 100644 index 0000000..e0d4bb1 --- /dev/null +++ b/application/src/test/java/org/mandarin/booking/utils/ShowFixture.java @@ -0,0 +1,69 @@ +package org.mandarin.booking.utils; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.UUID; +import java.util.stream.IntStream; +import org.mandarin.booking.domain.show.Show; +import org.mandarin.booking.domain.show.ShowRegisterRequest.GradeRequest; +import org.mandarin.booking.domain.show.ShowScheduleCreateCommand; +import org.mandarin.booking.domain.show.ShowScheduleRegisterRequest; +import org.mandarin.booking.domain.show.ShowScheduleRegisterRequest.GradeAssignmentRequest; +import org.mandarin.booking.domain.show.ShowScheduleRegisterRequest.SeatUsageRequest; + +public class ShowFixture { + private static final Random random = new Random(); + + public static ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Long showId, + Long sectionId, + Map> gradeSeatMap) { + return generateShowScheduleRegisterRequest( + showId, + sectionId, + LocalDateTime.of(2025, 9, 10, 19, 0), + LocalDateTime.of(2025, 9, 10, 21, 30), gradeSeatMap); + } + + public static ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Long showId, + Long sectionId, + LocalDateTime startAt, + LocalDateTime endAt, + Map> gradeSeatMap) { + return new ShowScheduleRegisterRequest( + showId, + startAt, + endAt, + getSeatUsageRequest(sectionId, gradeSeatMap) + ); + } + + public static SeatUsageRequest getSeatUsageRequest(long sectionId, Map> gradeSeatMap) { + return new SeatUsageRequest( + sectionId, + List.of(), + gradeSeatMap.entrySet().stream() + .map(entry -> new GradeAssignmentRequest(entry.getKey(), entry.getValue())) + .toList() + ); + } + + static ShowScheduleCreateCommand generateShowScheduleCreateCommand(Show show) { + var startAt = LocalDateTime.now().plusDays(random.nextInt(0, 10)); + return new ShowScheduleCreateCommand(show.getId(), + startAt, + startAt.plusHours(random.nextInt(2, 5)) + ); + } + + static List generateGradeRequest(int count) { + return IntStream.range(0, count) + .mapToObj(i -> new GradeRequest( + UUID.randomUUID().toString().substring(0, 5), + random.nextInt(100) * 1000, + 100 + )) + .toList(); + } +} diff --git a/application/src/test/java/org/mandarin/booking/utils/TestConfig.java b/application/src/test/java/org/mandarin/booking/utils/TestConfig.java index d637335..e19055f 100644 --- a/application/src/test/java/org/mandarin/booking/utils/TestConfig.java +++ b/application/src/test/java/org/mandarin/booking/utils/TestConfig.java @@ -4,6 +4,7 @@ import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; import org.mandarin.booking.adapter.TokenUtils; +import org.mandarin.booking.app.JdbcBatchUtils; import org.mandarin.booking.domain.member.SecurePasswordEncoder; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.TestConfiguration; @@ -24,8 +25,9 @@ public IntegrationTestUtils integrationTestUtils(@Autowired TestFixture testFixt } @Bean - public TestFixture testFixture(@Autowired SecurePasswordEncoder securePasswordEncoder) { - return new TestFixture(entityManager, securePasswordEncoder); + public TestFixture testFixture(@Autowired SecurePasswordEncoder securePasswordEncoder, + @Autowired JdbcBatchUtils jdbcBatchUtils) { + return new TestFixture(entityManager, securePasswordEncoder, jdbcBatchUtils); } @Bean 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 d03eca5..e5457ed 100644 --- a/application/src/test/java/org/mandarin/booking/utils/TestFixture.java +++ b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java @@ -3,23 +3,29 @@ import static org.mandarin.booking.MemberAuthority.ADMIN; import static org.mandarin.booking.utils.EnumFixture.randomEnum; import static org.mandarin.booking.utils.HallFixture.generateHallName; +import static org.mandarin.booking.utils.HallFixture.generateSectionRegisterRequest; import static org.mandarin.booking.utils.MemberFixture.EmailGenerator.generateEmail; import static org.mandarin.booking.utils.MemberFixture.NicknameGenerator.generateNickName; import static org.mandarin.booking.utils.MemberFixture.PasswordGenerator.generatePassword; import static org.mandarin.booking.utils.MemberFixture.UserIdGenerator.generateUserId; +import static org.mandarin.booking.utils.ShowFixture.generateShowScheduleCreateCommand; import jakarta.persistence.EntityManager; import java.time.LocalDate; -import java.time.LocalDateTime; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Random; import java.util.UUID; import java.util.stream.IntStream; import org.mandarin.booking.MemberAuthority; +import org.mandarin.booking.app.JdbcBatchUtils; import org.mandarin.booking.domain.hall.Hall; +import org.mandarin.booking.domain.hall.SectionRegisterRequest; import org.mandarin.booking.domain.member.Member; import org.mandarin.booking.domain.member.Member.MemberCreateCommand; import org.mandarin.booking.domain.member.SecurePasswordEncoder; +import org.mandarin.booking.domain.show.Inventory; import org.mandarin.booking.domain.show.Show; import org.mandarin.booking.domain.show.Show.Rating; import org.mandarin.booking.domain.show.Show.ShowCreateCommand; @@ -27,7 +33,6 @@ import org.mandarin.booking.domain.show.ShowDetailResponse.ShowScheduleResponse; import org.mandarin.booking.domain.show.ShowRegisterRequest; import org.mandarin.booking.domain.show.ShowRegisterRequest.GradeRequest; -import org.mandarin.booking.domain.show.ShowScheduleCreateCommand; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.transaction.annotation.Transactional; @@ -35,11 +40,14 @@ public class TestFixture { private final EntityManager entityManager; private final SecurePasswordEncoder securePasswordEncoder; + private final JdbcBatchUtils jdbcBatchUtils; private volatile Member cachedDefaultMember; - public TestFixture(EntityManager entityManager, SecurePasswordEncoder securePasswordEncoder) { + public TestFixture(EntityManager entityManager, SecurePasswordEncoder securePasswordEncoder, + JdbcBatchUtils jdbcBatchUtils) { this.entityManager = entityManager; this.securePasswordEncoder = securePasswordEncoder; + this.jdbcBatchUtils = jdbcBatchUtils; } public Member getOrCreateDefaultMember() { @@ -107,9 +115,29 @@ public Show insertDummyShow(LocalDate performanceStartDate, LocalDate performanc } public Hall insertDummyHall(String userId) { - var hall = Hall.create(generateHallName(), userId); - entityManager.persist(hall); - return hall; + List sections = generateSectionRegisterRequest(10, 100); + return insertHallGraph(generateHallName(), userId, sections); + } + + public void generateShows(int showCount, Type type) { + var hall = insertDummyHall(generateUserId()); + var hallId = hall.getId(); + var today = LocalDate.now(); + var rows = IntStream.range(0, showCount) + .mapToObj(i -> new ShowRow( + hallId, + UUID.randomUUID().toString().substring(0, 10), + type.name(), + randomEnum(Rating.class).name(), + "공연 줄거리", + "https://example.com/poster.jpg", + today, + today.plusDays(30), + "KRW" + )) + .toList(); + + batchInsertShows(rows); } public Show generateShow(int scheduleCount) { @@ -117,12 +145,7 @@ public Show generateShow(int scheduleCount) { 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)) - ); + var command = generateShowScheduleCreateCommand(show); show.registerSchedule(command); } @@ -136,54 +159,68 @@ public List generateShows(int showCount) { .toList(); } - public void generateShows(int showCount, Type type) { - var hall = insertDummyHall(generateUserId()); - IntStream.range(0, showCount) - .forEach(i -> generateShow(hall.getId(), type)); - } - public void generateShows(int showCount, Rating rating) { var hall = insertDummyHall(generateUserId()); - IntStream.range(0, showCount) - .forEach(i -> generateShow(hall.getId(), rating)); + var hallId = hall.getId(); + var today = LocalDate.now(); + var rows = IntStream.range(0, showCount) + .mapToObj(i -> new ShowRow( + hallId, + UUID.randomUUID().toString().substring(0, 10), + randomEnum(Type.class).name(), + rating.name(), + "공연 줄거리", + "https://example.com/poster.jpg", + today, + today.plusDays(30), + "KRW" + )) + .toList(); + + batchInsertShows(rows); } public void generateShows(int showCount, String titlePart) { Random random = new Random(); var hall = insertDummyHall(generateUserId()); - IntStream.range(0, showCount) - .forEach(i -> { - var request = validShowRegisterRequest(hall.getId(), - randomEnum(Type.class).name(), - randomEnum(Rating.class).name()); - var show = Show.create(hall.getId(), ShowCreateCommand.from(request)); - ReflectionTestUtils.setField(show, "title", - (char) random.nextInt('a', 'z') + titlePart + (char) random.nextInt('a', 'z')); - showInsert(show); - }); + var hallId = hall.getId(); + var today = LocalDate.now(); + var rows = IntStream.range(0, showCount) + .mapToObj(i -> new ShowRow( + hallId, + (char) random.nextInt('a', 'z') + titlePart + (char) random.nextInt('a', 'z'), + randomEnum(Type.class).name(), + randomEnum(Rating.class).name(), + "공연 줄거리", + "https://example.com/poster.jpg", + today, + today.plusDays(30), + "KRW" + )) + .toList(); + + batchInsertShows(rows); } public void generateShows(int showCount, int before, int after) { Random random = new Random(); var hall = insertDummyHall(generateUserId()); var hallId = hall.getId(); - IntStream.range(0, showCount) - .forEach(i -> { - var request = new ShowRegisterRequest( - hallId, - UUID.randomUUID().toString().substring(0, 10), - randomEnum(Type.class).name(), - randomEnum(Rating.class).name(), - "공연 줄거리", - "https://example.com/poster.jpg", - LocalDate.now().minusDays(random.nextInt(before)), - LocalDate.now().plusDays(random.nextInt(after)), - "KRW", - List.of(new ShowRegisterRequest.GradeRequest("VIP", 100000, 100)) - ); - var show = Show.create(hallId, ShowCreateCommand.from(request)); - showInsert(show); - }); + var rows = IntStream.range(0, showCount) + .mapToObj(i -> new ShowRow( + hallId, + UUID.randomUUID().toString().substring(0, 10), + randomEnum(Type.class).name(), + randomEnum(Rating.class).name(), + "공연 줄거리", + "https://example.com/poster.jpg", + LocalDate.now().minusDays(random.nextInt(before)), + LocalDate.now().plusDays(random.nextInt(after)), + "KRW" + )) + .toList(); + + batchInsertShows(rows); } public Show generateShow(List grades) { @@ -221,12 +258,7 @@ public Show generateShowWithNoSynopsis(int scheduleCount) { ))); 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)) - ); + var command = generateShowScheduleCreateCommand(show); show.registerSchedule(command); } @@ -266,7 +298,6 @@ public Show findShowByTitle(String title) { .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) @@ -275,18 +306,111 @@ public boolean isMatchingScheduleInShow(ShowScheduleResponse res, Show show) { .getResultList().isEmpty(); } + public List findSectionIdsByHallId(Long hallId) { + return entityManager.createQuery( + "select s.id from Section s where s.hall.id = :hallId", Long.class) + .setParameter("hallId", hallId) + .getResultList(); + } + + public List findSeatIdsBySectionId(long sectionId) { + return entityManager.createQuery( + "SELECT seat.id FROM Section section " + + "join section.seats as seat WHERE section.id = :sectionId", + Long.class) + .setParameter("sectionId", sectionId) + .getResultList(); + } + + public Map> generateGradeSeatMapByShowIdAndSectionId(Long showId, Long sectionId) { + var gradeIds = findGradeIdsByShowId(showId); + var seatIds = entityManager.createQuery("SELECT seat.id FROM Seat seat WHERE seat.section.id = :sectionId", + Long.class) + .setParameter("sectionId", sectionId) + .getResultList(); + + return gerateGradeSeatMap(gradeIds, seatIds); + } + + public Inventory findInventoryByScheduleId(Long scheduleId) { + return entityManager.createQuery( + "SELECT i FROM Inventory i JOIN FETCH i.states as state WHERE i.showScheduleId = :scheduleId", + Inventory.class) + .setParameter("scheduleId", scheduleId) + .getSingleResult(); + } + + private Hall insertHallGraph(String hallName, String userId, List sections) { + var hall = new Hall(hallName, userId); + entityManager.persist(hall); + entityManager.flush(); + long hallId = hall.getId(); + + if (!sections.isEmpty()) { + jdbcBatchUtils.batchUpdate( + "INSERT INTO section (hall_id, name) VALUES (?, ?)", + sections, + (ps, s) -> { + ps.setLong(1, hallId); + ps.setString(2, s.sectionName()); + }, + 1000 + ); + } + + var seatParams = sections.stream() + .flatMap(sec -> sec.seats().stream() + .map(seat -> new Object[]{ + sec.sectionName(), + seat.rowNumber(), + seat.seatNumber() + })) + .toList(); + if (!seatParams.isEmpty()) { + jdbcBatchUtils.batchUpdate( + "INSERT INTO seat (section_id, seat_row, seat_number) " + + "VALUES ((SELECT s.id FROM section s WHERE s.hall_id = ? AND s.name = ?), ?, ?)", + seatParams, + (ps, arr) -> { + ps.setLong(1, hallId); + ps.setString(2, (String) arr[0]); + ps.setString(3, (String) arr[1]); + ps.setString(4, (String) arr[2]); + }, + 1000 + ); + } + + return hall; + } + + private Map> gerateGradeSeatMap(List gradeIds, List seatIds) { + Map> result = new HashMap<>(); + var gradeCount = gradeIds.size(); + var seatCount = seatIds.size(); + int seatPerGrade = seatCount / gradeCount; + int remainingSeats = seatCount % gradeCount; + + int currentIndex = 0; + for (int i = 0; i < gradeCount; i++) { + int seatsToAdd = seatPerGrade; + if (i < remainingSeats) { + seatsToAdd++; + } + + result.put(gradeIds.get(i), seatIds.subList(currentIndex, currentIndex + seatsToAdd)); + currentIndex += seatsToAdd; + } + + return result; + } + private 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); } - 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)); - showInsert(show); - } - private ShowRegisterRequest validShowRegisterRequest(Long hallId, String type, String rating) { return new ShowRegisterRequest( hallId, @@ -307,12 +431,6 @@ private ShowRegisterRequest validShowRegisterRequest(Long hallId, String type, S ); } - private void generateShow(Long hallId, Rating rating) { - var request = validShowRegisterRequest(hallId, randomEnum(Type.class).name(), rating.name()); - var show = Show.create(hallId, ShowCreateCommand.from(request)); - showInsert(show); - } - private Show showInsert(Show show) { entityManager.persist(show); return show; @@ -322,4 +440,45 @@ private Member memberInsert(Member member) { entityManager.persist(member); return member; } + + private List findGradeIdsByShowId(Long showId) { + return entityManager.createQuery("select g.id from Grade g where g.show.id = :showId ", Long.class) + .setParameter("showId", showId) + .getResultList(); + } + + private void batchInsertShows(List rows) { + if (rows == null || rows.isEmpty()) { + return; + } + jdbcBatchUtils.batchUpdate( + "INSERT INTO shows (hall_id, title, type, rating, synopsis, poster_url, performance_start_date, performance_end_date, currency) " + + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + rows, + (ps, row) -> { + ps.setLong(1, row.hallId()); + ps.setString(2, row.title()); + ps.setString(3, row.type()); + ps.setString(4, row.rating()); + ps.setString(5, row.synopsis()); + ps.setString(6, row.posterUrl()); + ps.setObject(7, row.performanceStartDate()); + ps.setObject(8, row.performanceEndDate()); + ps.setString(9, row.currency()); + }, + 1000 + ); + } + + public record ShowRow(Long hallId, + String title, + String type, + String rating, + String synopsis, + String posterUrl, + LocalDate performanceStartDate, + LocalDate performanceEndDate, + String currency) { + } } diff --git a/application/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java index 0b3bd03..2831379 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java @@ -2,6 +2,7 @@ import static java.util.Objects.requireNonNull; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatStream; import static org.mandarin.booking.MemberAuthority.ADMIN; import static org.mandarin.booking.MemberAuthority.DISTRIBUTOR; import static org.mandarin.booking.MemberAuthority.USER; @@ -10,18 +11,23 @@ import static org.mandarin.booking.adapter.ApiStatus.INTERNAL_SERVER_ERROR; import static org.mandarin.booking.adapter.ApiStatus.NOT_FOUND; import static org.mandarin.booking.adapter.ApiStatus.SUCCESS; +import static org.mandarin.booking.utils.ShowFixture.generateShowScheduleRegisterRequest; +import static org.mandarin.booking.utils.ShowFixture.getSeatUsageRequest; import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.mandarin.booking.domain.show.Show; import org.mandarin.booking.domain.show.ShowScheduleRegisterRequest; +import org.mandarin.booking.domain.show.ShowScheduleRegisterRequest.GradeAssignmentRequest; +import org.mandarin.booking.domain.show.ShowScheduleRegisterRequest.SeatUsageRequest; import org.mandarin.booking.domain.show.ShowScheduleRegisterResponse; 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; +import org.springframework.test.util.ReflectionTestUtils; @IntegrationTest @DisplayName("POST /api/show/schedule") @@ -34,10 +40,16 @@ public class POST_specs { ) { // Arrange var show = testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); + var hallId = show.getHallId(); + var sectionId = testFixture.findSectionIdsByHallId(hallId).stream().findFirst().get(); + var gradeSeatMap = testFixture.generateGradeSeatMapByShowIdAndSectionId(show.getId(), sectionId); var request = generateShowScheduleRegisterRequest( - show, + show.getId(), + sectionId, LocalDateTime.of(2025, 9, 10, 19, 0), - LocalDateTime.of(2025, 9, 10, 21, 30)); + LocalDateTime.of(2025, 9, 10, 21, 30), + gradeSeatMap + ); // Act var response = testUtils.post("/api/show/schedule", request) @@ -55,10 +67,14 @@ public class POST_specs { ) { // Arrange var show = testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); + var hallId = show.getHallId(); + var sectionId = testFixture.findSectionIdsByHallId(hallId).stream().findFirst().get(); + var gradeSeatMap = testFixture.generateGradeSeatMapByShowIdAndSectionId(show.getId(), sectionId); var request = generateShowScheduleRegisterRequest( - show, + show.getId(), + sectionId, LocalDateTime.of(2025, 9, 10, 19, 0), - LocalDateTime.of(2025, 9, 10, 21, 30)); + LocalDateTime.of(2025, 9, 10, 21, 30), gradeSeatMap); // Act var response = testUtils.post("/api/show/schedule", request) @@ -76,10 +92,14 @@ public class POST_specs { ) { // Arrange var show = testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); + var hallId = show.getHallId(); + var sectionId = testFixture.findSectionIdsByHallId(hallId).stream().findFirst().get(); + var gradeSeatMap = testFixture.generateGradeSeatMapByShowIdAndSectionId(show.getId(), sectionId); var request = generateShowScheduleRegisterRequest( - show, + show.getId(), + sectionId, LocalDateTime.of(2025, 9, 10, 19, 0), - LocalDateTime.of(2025, 9, 10, 21, 30)); + LocalDateTime.of(2025, 9, 10, 21, 30), gradeSeatMap); // Act var response = testUtils.post("/api/show/schedule", request) @@ -97,7 +117,10 @@ public class POST_specs { ) { // Arrange var show = testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); - var request = generateShowScheduleRegisterRequest(show); + var hallId = show.getHallId(); + var sectionId = testFixture.findSectionIdsByHallId(hallId).stream().findFirst().get(); + var gradeSeatMap = testFixture.generateGradeSeatMapByShowIdAndSectionId(show.getId(), sectionId); + var request = generateShowScheduleRegisterRequest(show.getId(), sectionId, gradeSeatMap); // Act var response = testUtils.post("/api/show/schedule", request) @@ -115,8 +138,16 @@ public class POST_specs { ) { // Arrange var show = testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); + var hallId = show.getHallId(); + var sectionId = testFixture.findSectionIdsByHallId(hallId).stream().findFirst().get(); + var gradeSeatMap = testFixture.generateGradeSeatMapByShowIdAndSectionId(show.getId(), sectionId); var now = LocalDateTime.now(); - var request = generateShowScheduleRegisterRequest(show, now, now.minusMinutes(1)); + var request = generateShowScheduleRegisterRequest( + show.getId(), + sectionId, + now, + now.minusMinutes(1), + gradeSeatMap); // Act var response = testUtils.post("/api/show/schedule", request) @@ -136,10 +167,15 @@ public class POST_specs { ) { // Arrange var show = testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); - var request = generateShowScheduleRegisterRequest(show, + var hallId = show.getHallId(); + var sectionId = testFixture.findSectionIdsByHallId(hallId).stream().findFirst().get(); + var gradeSeatMap = testFixture.generateGradeSeatMapByShowIdAndSectionId(show.getId(), sectionId); + var request = generateShowScheduleRegisterRequest( + show.getId(), + sectionId, LocalDateTime.of(2025, 9, 10, 21, 30), - LocalDateTime.of(2025, 9, 10, 19, 0) - ); + LocalDateTime.of(2025, 9, 10, 19, 0), + gradeSeatMap); // Act var response = testUtils.post("/api/show/schedule", request) @@ -157,11 +193,16 @@ public class POST_specs { @Autowired TestFixture testFixture ) { // Arrange - testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); - var request = new ShowScheduleRegisterRequest( + var show = testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); + var hallId = show.getHallId(); + var sectionId = testFixture.findSectionIdsByHallId(hallId).stream().findFirst().get(); + var gradeSeatMap = testFixture.generateGradeSeatMapByShowIdAndSectionId(show.getId(), sectionId); + var request = generateShowScheduleRegisterRequest( 9999L,// 존재하지 않는 showId + show.getId(), LocalDateTime.of(2025, 9, 10, 19, 0), - LocalDateTime.of(2025, 9, 10, 21, 30) + LocalDateTime.of(2025, 9, 10, 21, 30), + gradeSeatMap ); // Act @@ -181,10 +222,14 @@ public class POST_specs { ) { // Arrange var show = testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 9, 11)); + var hallId = show.getHallId(); + var sectionId = testFixture.findSectionIdsByHallId(hallId).stream().findFirst().get(); + var gradeSeatMap = testFixture.generateGradeSeatMapByShowIdAndSectionId(show.getId(), sectionId); var request = new ShowScheduleRegisterRequest( requireNonNull(show.getId()), LocalDateTime.of(2023, 9, 10, 19, 0), - LocalDateTime.of(2023, 9, 10, 21, 30) + LocalDateTime.of(2023, 9, 10, 21, 30), + getSeatUsageRequest(sectionId, gradeSeatMap) ); // Act @@ -207,17 +252,22 @@ public class POST_specs { LocalDate.now().minusDays(1), LocalDate.now().plusDays(10) ); + var hallId = show.getHallId(); + var sectionId = testFixture.findSectionIdsByHallId(hallId).stream().findFirst().get(); + var gradeSeatMap = testFixture.generateGradeSeatMapByShowIdAndSectionId(show.getId(), sectionId); var request = generateShowScheduleRegisterRequest( - show, + show.getId(), + sectionId, LocalDateTime.now(), - LocalDateTime.now().plusHours(2) - ); + LocalDateTime.now().plusHours(2), + gradeSeatMap); var nextRequest = generateShowScheduleRegisterRequest( - show, + show.getId(), + sectionId, LocalDateTime.now().plusHours(1), - LocalDateTime.now().plusHours(3) - ); + LocalDateTime.now().plusHours(3), + gradeSeatMap); testUtils.post( "/api/show/schedule", @@ -239,19 +289,328 @@ public class POST_specs { assertThat(response.getData()).contains("해당 회차는 이미 공연 스케줄이 등록되어 있습니다."); } - private ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show) { - return generateShowScheduleRegisterRequest(show, LocalDateTime.of(2025, 9, 10, 19, 0), - LocalDateTime.of(2025, 9, 10, 21, 30)); + @Test + void showId에_해당하는_hall에_해당하는_sectionId를_찾을_수_없으면_NOT_FOUND를_반환한다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + var show = testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); + + var gradeSeatMap = testFixture.generateGradeSeatMapByShowIdAndSectionId(show.getId(), + testFixture.findSectionIdsByHallId(show.getHallId()).stream().findFirst().get()); + var sectionId = 9999L;// 존재하지 않는 sectionId + var request = generateShowScheduleRegisterRequest( + show.getId(), + sectionId, + LocalDateTime.of(2025, 9, 10, 19, 0), + LocalDateTime.of(2025, 9, 10, 21, 30), + gradeSeatMap + ); + + // Act + var response = testUtils.post("/api/show/schedule", request) + .withAuthorization(testUtils.getAuthToken(DISTRIBUTOR)) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(NOT_FOUND); + } + + @Test + void excludeSeatIds에_해당_section의_id가_아닌_좌석_id가_포함되면_NOT_FOUND를_반환한다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + var show = testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); + long sectionId = testFixture.findSectionIdsByHallId(show.getHallId()).stream().findFirst().get(); + var gradeSeatMap = testFixture.generateGradeSeatMapByShowIdAndSectionId(show.getId(), sectionId); + var invalidSeatId = 9999L; + gradeSeatMap.entrySet().stream().findFirst() + .ifPresent(entry -> entry.getValue().add(invalidSeatId)); + var request = generateShowScheduleRegisterRequest( + show.getId(), + sectionId, + LocalDateTime.of(2025, 9, 10, 19, 0), + LocalDateTime.of(2025, 9, 10, 21, 30), + gradeSeatMap + ); + + // Act + var response = testUtils.post("/api/show/schedule", request) + .withAuthorization(testUtils.getAuthToken(DISTRIBUTOR)) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } + + @Test + void excludeSeatIds에_중복된_좌석이_있는_경우_BAD_REQUEST를_반환한다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + var show = testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); + long sectionId = testFixture.findSectionIdsByHallId(show.getHallId()).stream().findFirst().get(); + var request = new ShowScheduleRegisterRequest( + show.getId(), + LocalDateTime.of(2025, 9, 10, 19, 0), + LocalDateTime.of(2025, 9, 10, 21, 30), + new SeatUsageRequest( + sectionId, + List.of(1L, 1L), + List.of(new GradeAssignmentRequest(1L, List.of(1L, 2L))) + ) + ); + + // Act + var response = testUtils.post("/api/show/schedule", request) + .withAuthorization(testUtils.getAuthToken(DISTRIBUTOR)) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + assertThat(response.getData()).isEqualTo("excludeSeatIds must not contain duplicates"); + } + + @Test + void gradeAssignments의_gradeId가_해당_show에_존재하지_않으면_NOT_FOUND를_반환한다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + var show = testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); + long sectionId = testFixture.findSectionIdsByHallId(show.getHallId()).stream().findFirst().get(); + List seatIds = testFixture.findSeatIdsBySectionId(sectionId); + var invalidGradeId = 9999L; + var request = new ShowScheduleRegisterRequest( + show.getId(), + LocalDateTime.of(2025, 9, 10, 19, 0), + LocalDateTime.of(2025, 9, 10, 21, 30), + new SeatUsageRequest( + sectionId, + List.of(), + List.of(new GradeAssignmentRequest(invalidGradeId, seatIds)) + ) + ); + + // Act + var response = testUtils.post("/api/show/schedule", request) + .withAuthorization(testUtils.getAuthToken(DISTRIBUTOR)) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(NOT_FOUND); + } + + @Test + void gradeAssignments의_gradeId가_중복된_경우_BAD_REQUEST를_반환한다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + var show = testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); + long sectionId = testFixture.findSectionIdsByHallId(show.getHallId()).stream().findFirst().get(); + List seatIds = testFixture.findSeatIdsBySectionId(sectionId); + int midPoint = seatIds.size() / 2; + List firstHalf = seatIds.subList(0, midPoint); + List secondHalf = seatIds.subList(midPoint, seatIds.size()); + + var request = new ShowScheduleRegisterRequest( + show.getId(), + LocalDateTime.of(2025, 9, 10, 19, 0), + LocalDateTime.of(2025, 9, 10, 21, 30), + new SeatUsageRequest( + sectionId, + List.of(), + List.of(new GradeAssignmentRequest(1L, firstHalf), + new GradeAssignmentRequest(1L, secondHalf)) + ) + ); + + // Act + var response = testUtils.post("/api/show/schedule", request) + .withAuthorization(testUtils.getAuthToken(DISTRIBUTOR)) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + assertThat(response.getData()).isEqualTo("gradeAssignments gradeIds must not contain duplicates"); + } + + @Test + void gradeAssignments의_seatIds에_해당_hall의_seat_id가_존재하지_않는_경우_BAD_REQUEST를_반환한다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + var show = testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); + long sectionId = testFixture.findSectionIdsByHallId(show.getHallId()).stream().findFirst().get(); + var gradeSeatMap = testFixture.generateGradeSeatMapByShowIdAndSectionId(show.getId(), sectionId); + var invalidSeatId = 9999L; + gradeSeatMap.entrySet().stream() + .findFirst() + .ifPresent(entry -> entry.getValue().add(invalidSeatId)); + + var request = generateShowScheduleRegisterRequest( + show.getId(), + sectionId, + LocalDateTime.of(2025, 9, 10, 19, 0), + LocalDateTime.of(2025, 9, 10, 21, 30), + gradeSeatMap + ); + + // Act + var response = testUtils.post("/api/show/schedule", request) + .withAuthorization(testUtils.getAuthToken(DISTRIBUTOR)) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); } + @Test + void gradeAssignments의_seatIds에_중복된_좌석이_존재하는_경우_BAD_REQUEST를_반환한다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + var show = testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); + long sectionId = testFixture.findSectionIdsByHallId(show.getHallId()).stream().findFirst().get(); + var gradeSeatMap = testFixture.generateGradeSeatMapByShowIdAndSectionId(show.getId(), sectionId); + gradeSeatMap.entrySet().stream() + .findFirst() + .ifPresent(entry -> { + var firstSeatId = entry.getValue().getFirst(); + entry.getValue().add(firstSeatId); + }); + var request = generateShowScheduleRegisterRequest( + show.getId(), + sectionId, + LocalDateTime.of(2025, 9, 10, 19, 0), + LocalDateTime.of(2025, 9, 10, 21, 30), + gradeSeatMap + ); + + // Act + var response = testUtils.post("/api/show/schedule", request) + .withAuthorization(testUtils.getAuthToken(DISTRIBUTOR)) + .assertFailure(); - private ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show, - LocalDateTime startAt, - LocalDateTime endAt) { - return new ShowScheduleRegisterRequest( + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + assertThat(response.getData()).isEqualTo( + "gradeAssignments seatIds must not contain duplicates across all assignments"); + } + + @Test + void 제외_좌석과_등록_좌석_전체가_section의_모든_좌석과_다른_경우_BAD_REQUEST를_반환한다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + var show = testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); + long sectionId = testFixture.findSectionIdsByHallId(show.getHallId()).stream().findFirst().get(); + var gradeSeatMap = testFixture.generateGradeSeatMapByShowIdAndSectionId(show.getId(), sectionId); + var includeSeatIds = testFixture.findSeatIdsBySectionId(sectionId); + includeSeatIds.removeFirst();// 한자리 빠짐 + + var request = new ShowScheduleRegisterRequest( show.getId(), - startAt, - endAt + LocalDateTime.of(2025, 9, 10, 19, 0), + LocalDateTime.of(2025, 9, 10, 21, 30), + new SeatUsageRequest( + sectionId, + List.of(), // 제외 좌석은 없음 + List.of(new GradeAssignmentRequest( + gradeSeatMap.keySet().stream().findFirst().get(), + includeSeatIds + )) + ) ); + // Act + var response = testUtils.post("/api/show/schedule", request) + .withAuthorization(testUtils.getAuthToken(DISTRIBUTOR)) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + assertThat(response.getData()).isEqualTo("해당 섹션 좌석과 총 좌석이 상이합니다."); + } + + @Test + void 일정이_정상적으로_등록된_경우_inventory에_해당_회차의_좌석이_모두_생성된다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + var show = testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); + long sectionId = testFixture.findSectionIdsByHallId(show.getHallId()).stream().findFirst().get(); + var gradeSeatMap = testFixture.generateGradeSeatMapByShowIdAndSectionId(show.getId(), sectionId); + var request = generateShowScheduleRegisterRequest( + show.getId(), + sectionId, + LocalDateTime.of(2025, 9, 10, 19, 0), + LocalDateTime.of(2025, 9, 10, 21, 30), + gradeSeatMap + ); + + // Act + var response = testUtils.post("/api/show/schedule", request) + .withAuthorization(testUtils.getAuthToken(DISTRIBUTOR)) + .assertSuccess(ShowScheduleRegisterResponse.class); + + // Assert + var scheduleId = response.getData().scheduleId(); + var inventory = testFixture.findInventoryByScheduleId(scheduleId); + assertThatStream(inventory.getStates().stream()) + .allSatisfy(input -> { + var gradeId = (Long) ReflectionTestUtils.getField(input, "gradeId"); + var seatId = (Long) ReflectionTestUtils.getField(input, "seatId"); + assertThat(gradeSeatMap.keySet()).contains(gradeId); + assertThat(gradeSeatMap.get(gradeId)).contains(seatId); + } + ); + } + + @Test + void excludeSeatIds에_중복이_없으면_TRUE_로_검증되고_SUCCESS를_반환한다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + var show = testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); + long sectionId = testFixture.findSectionIdsByHallId(show.getHallId()).stream().findFirst().get(); + var gradeSeatMap = testFixture.generateGradeSeatMapByShowIdAndSectionId(show.getId(), sectionId); + var allSeats = testFixture.findSeatIdsBySectionId(sectionId); + + var includeSeatIds = new java.util.ArrayList<>(allSeats); + var excludeSeatId1 = includeSeatIds.get(0); + var excludeSeatId2 = includeSeatIds.get(1); + var excludeSeatIds = List.of(excludeSeatId1, excludeSeatId2); + includeSeatIds.remove(excludeSeatId1); + includeSeatIds.remove(excludeSeatId2); + + var gradeId = gradeSeatMap.keySet().stream().findFirst().get(); + var request = new ShowScheduleRegisterRequest( + show.getId(), + LocalDateTime.of(2025, 9, 10, 19, 0), + LocalDateTime.of(2025, 9, 10, 21, 30), + new SeatUsageRequest( + sectionId, + excludeSeatIds, + List.of(new GradeAssignmentRequest(gradeId, includeSeatIds)) + ) + ); + + // Act + var response = testUtils.post("/api/show/schedule", request) + .withAuthorization(testUtils.getAuthToken(DISTRIBUTOR)) + .assertSuccess(ShowScheduleRegisterResponse.class); + + // Assert + assertThat(response.getStatus()).isEqualTo(SUCCESS); } } diff --git a/build.gradle b/build.gradle index 8ae3705..4b0ae50 100644 --- a/build.gradle +++ b/build.gradle @@ -40,6 +40,7 @@ subprojects { repositories { mavenCentral() + maven { url 'https://jitpack.io' } } dependencies { diff --git a/common/src/main/java/org/mandarin/booking/StringFormatterUtils.java b/common/src/main/java/org/mandarin/booking/StringFormatterUtils.java new file mode 100644 index 0000000..77ed1bd --- /dev/null +++ b/common/src/main/java/org/mandarin/booking/StringFormatterUtils.java @@ -0,0 +1,12 @@ +package org.mandarin.booking; + +public final class StringFormatterUtils { + private StringFormatterUtils() { + } + + public static String toSnakeCase(String s) { + return s.replaceAll("([a-z])([A-Z])", "$1_$2") // camelCase → camel_Case + .replaceAll("([A-Z]+)([A-Z][a-z])", "$1_$2") // HTTPServer → HTTP_Server + .toLowerCase(); + } +} diff --git a/docs/devlog/251111.md b/docs/devlog/251111.md new file mode 100644 index 0000000..1a4930e --- /dev/null +++ b/docs/devlog/251111.md @@ -0,0 +1,13 @@ +## 예찬 + +공연 좌석 목록 조회 기능을 구현하려고 하다보니 공연 좌석 등록 기능이 필요하다는 사실을 깨달았다. 그래서 공연 좌석 등록 기능을 구현했다. 기능을 구현하더라도 선행되는 조건이 있는것을 사전에 인지할 수 있었다면 +불필요한 시간 소모가 없었을거 같은데 조금은 아쉬운 부분이 있다. + +기본적으로 공연 일정을 등록할때 해당 공연장의 좌석과 매핑이 가능한 형태로 API를 확장시켰다. 기존에는 일정 정보만 저장하는 형태였다면 현재 방식은 해당 일정을 어떤 공연장의 어떤 구역에서 진행하는지, 등급별 +좌석은 어떤 좌석들을 해당시킬지 등을 한번에 영속한다. 과정에서 조회 목적을 위한 데이터 모델로 `Inventory`라는 개념을 도입했다. 현재는 RDB에 사용할 모델로 관리하고 있지만 NoSQL 모델로 사용되는것도 +충분히 가능해 보인다고 생각된다. + +현재까지 작업을 진행하며 있었던 몇가지 고질적인 문제도 함께 개선했다. 여러개의 속성을 영속화 할때 batch insert를 하는 방법이다. sequence를 사용하여 bulk insert 하는 방법도 있긴 하지만 +현재 사용중인 데이터베이스인 MYSQL 기준으로 식별자 생성 전력이 IDENTITY가 가장 적합하기도 할 뿐더러 SEQUENCE 세팅 전략을 생각하는것도 사실 쉬운일은 아닌거 같다. 그래서 일단 JDBC +Template를 사용해 한번에 insert할 네이티브 쿼리를 작성하는 방식을 채택했다. 좀 간단하게 해결할 방법이 있으면 좋겠단 생각이 있어서 별도의 라이브러리 형태의 도구도 만들어봤고 +QueryDSL-EntityQL을 적용할까 생각도 해봤는데 둘다 만족스러운 형태로는 잘 되지 않아서 일단은 현재 형태로 만족하려고 한다. diff --git a/docs/specs/api/show_schedule_register.md b/docs/specs/api/show_schedule_register.md index 792a2c8..c33a13f 100644 --- a/docs/specs/api/show_schedule_register.md +++ b/docs/specs/api/show_schedule_register.md @@ -15,7 +15,15 @@ { "showId": 1, "startAt": "2025-10-10T19:00:00", - "endAt": "2025-10-10T21:30:00" + "endAt": "2025-10-10T21:30:00", + "use": { + "sectionId": 10, + "excludeSeatIds": [1003, 1007], + "gradeAssignments": [ + { "gradeId": 1, "seatIds": [1001, 1002, 1004] }, + { "gradeId": 2, "seatIds": [1005, 1006, 1008] } + ] + } } ``` @@ -28,7 +36,15 @@ -d '{ "showId": 1, "startAt": "2025-10-10T19:00:00", - "endAt": "2025-10-10T21:30:00" + "endAt": "2025-10-10T21:30:00", + "use": { + "sectionId": 10, + "excludeSeatIds": [1003, 1007], + "gradeAssignments": [ + { "gradeId": 1, "seatIds": [1001, 1002, 1004] }, + { "gradeId": 2, "seatIds": [1005, 1006, 1008] } + ] + } }' ``` @@ -58,6 +74,16 @@ - [x] 존재하지 않는 showId를 보내면 NOT_FOUND 상태코드를 반환한다 - [x] 공연 기간 범위를 벗어나는 startAt 또는 endAt을 보낼 경우 BAD_REQUEST를 반환한다 - [x] 동일한 hallId와 시간이 겹치는 회차를 등록하려 하면 INTERNAL_SERVER_ERROR를 반환한다 +- [x] showId에 해당하는 hall에 해당하는 sectionId를 찾을 수 없으면 NOT_FOUND를 반환한다 +- [x] excludeSeatIds에 해당 section의 id가 아닌 좌석 id가 포함되면 NOT_FOUND를 반환한다 +- [x] excludeSeatIds에 중복된 좌석이 있는 경우 BAD_REQUEST를 반환한다 +- [x] gradeAssignments의 gradeId가 해당 show에 존재하지 않으면 NOT_FOUND를 반환한다 +- [x] gradeAssignments의 gradeId가 중복된 경우 BAD_REQUEST를 반환한다 +- [x] gradeAssignments의 seatIds에 해당 hall의 seat id가 존재하지 않는 경우 BAD_REQUEST 반환한다 +- [x] gradeAssignments의 seatIds에 중복된 좌석이 존재하는 경우 BAD_REQUEST를 반환한다 +- [x] 제외 좌석과 등록 좌석 전체가 section의 모든 좌석과 다른 경우 BAD_REQUEST를 반환한다 +- [x] 일정이 정상적으로 등록된 경우 inventory에 해당 회차의 좌석이 모두 생성된다 +- [x] excludeSeatIds에_중복이_없으면_TRUE_로_검증되고_SUCCESS를_반환한다 비고 diff --git a/domain/.gitignore b/domain/.gitignore index e69de29..8c4b929 100644 --- a/domain/.gitignore +++ b/domain/.gitignore @@ -0,0 +1 @@ +src/main/generated/ diff --git a/domain/build.gradle b/domain/build.gradle index 83e1f51..a71beda 100644 --- a/domain/build.gradle +++ b/domain/build.gradle @@ -1,3 +1,7 @@ +plugins { + id 'java' +} + bootJar { enabled = false } @@ -11,15 +15,25 @@ dependencies { api 'org.springframework.boot:spring-boot-starter-data-jpa' api 'org.springframework.boot:spring-boot-starter-validation' - // ---- Querydsl ---- + api 'com.querydsl:querydsl-sql:5.1.0' api 'com.querydsl:querydsl-jpa:5.1.0:jakarta' + // entityql 제거: 직접 JDBC 사용으로 대체 + annotationProcessor 'com.querydsl:querydsl-apt:5.1.0:jakarta' annotationProcessor 'jakarta.persistence:jakarta.persistence-api:3.1.0' annotationProcessor 'jakarta.annotation:jakarta.annotation-api:2.1.1' } -dependencyManagement { - imports { - mavenBom 'org.springframework.modulith:spring-modulith-bom:1.4.3' - } +def generated = file("src/main/generated") + +tasks.withType(JavaCompile).configureEach { + options.annotationProcessorPath = configurations.annotationProcessor +} + +tasks.clean { + delete 'src/main/generated' +} + +sourceSets { + main.java.srcDirs += generated } diff --git a/domain/src/main/java/org/mandarin/booking/domain/hall/Hall.java b/domain/src/main/java/org/mandarin/booking/domain/hall/Hall.java index 7b6df17..8d82ca8 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/hall/Hall.java +++ b/domain/src/main/java/org/mandarin/booking/domain/hall/Hall.java @@ -6,6 +6,8 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.OneToMany; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Size; import java.util.ArrayList; import java.util.List; import lombok.AccessLevel; @@ -30,7 +32,42 @@ public Hall(String hallName, String registantId) { this.registantId = registantId; } - public static Hall create(String name, String registantId) { - return new Hall(name, registantId); + public List getSeatsBySectionIdAndSeatIds(Long sectionId, List seatIds) { + return sections.stream() + .filter(section -> section.getId().equals(sectionId)) + .flatMap(section -> section.getSeats().stream()) + .map(AbstractEntity::getId) + .filter(seatIds::contains) + .toList(); + } + + public boolean hasSectionOf(Long sectionId) { + return sections.stream().anyMatch(section -> section.getId().equals(sectionId)); + } + + public List extractSeatRows() { + List rows = new ArrayList<>(); + for (Section section : getSections()) { + for (Seat seat : section.getSeats()) { + rows.add(new SeatInsertRow(section, seat.getRowNumber(), seat.getSeatNumber())); + } + } + return rows; + } + + public void clearSeats() { + for (Section section : getSections()) { + section.getSeats().clear(); + } + } + + public static Hall create(String name, @Size @Valid List sections, String registantId) { + var hall = new Hall(name, registantId); + sections.forEach(req + -> hall.sections.add(Section.create(req, hall))); + return hall; + } + + public record SeatInsertRow(Section section, String rowNumber, String seatNumber) { } } diff --git a/domain/src/main/java/org/mandarin/booking/domain/hall/Seat.java b/domain/src/main/java/org/mandarin/booking/domain/hall/Seat.java index 31db7d2..a223480 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/hall/Seat.java +++ b/domain/src/main/java/org/mandarin/booking/domain/hall/Seat.java @@ -5,6 +5,7 @@ import static lombok.AccessLevel.PRIVATE; import static lombok.AccessLevel.PROTECTED; +import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; @@ -17,12 +18,18 @@ @Entity @NoArgsConstructor(access = PROTECTED) @AllArgsConstructor(access = PRIVATE) -class Seat extends AbstractEntity { +public class Seat extends AbstractEntity { @ManyToOne(fetch = LAZY) @JoinColumn(name = "section_id", nullable = false) private Section section; + @Column(name = "seat_row") private String rowNumber; + @Column(name = "seat_number") private String seatNumber; + + static Seat create(Section section, String rowNumber, String seatNumber) { + return new Seat(section, rowNumber, seatNumber); + } } diff --git a/domain/src/main/java/org/mandarin/booking/domain/hall/Section.java b/domain/src/main/java/org/mandarin/booking/domain/hall/Section.java index e2591e9..5b9539b 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/hall/Section.java +++ b/domain/src/main/java/org/mandarin/booking/domain/hall/Section.java @@ -5,6 +5,7 @@ import static lombok.AccessLevel.PACKAGE; import static lombok.AccessLevel.PRIVATE; import static lombok.AccessLevel.PROTECTED; +import static lombok.AccessLevel.PUBLIC; import jakarta.persistence.Entity; import jakarta.persistence.JoinColumn; @@ -21,13 +22,28 @@ @Entity @NoArgsConstructor(access = PROTECTED) @AllArgsConstructor(access = PRIVATE) -class Section extends AbstractEntity { +public class Section extends AbstractEntity { @ManyToOne(fetch = LAZY, optional = false) @JoinColumn(name = "hall_id") private Hall hall; @OneToMany(mappedBy = "section", cascade = ALL, fetch = LAZY) + @Getter(value = PUBLIC) private List seats = new ArrayList<>(); private String name; + + Section(Hall hall, String name) { + this.hall = hall; + this.name = name; + } + + static Section create(SectionRegisterRequest request, Hall hall) { + var section = new Section(hall, request.sectionName()); + var createdSeats = request.seats().stream() + .map(r -> Seat.create(section, r.rowNumber(), r.seatNumber())) + .toList(); + section.seats.addAll(createdSeats); + return section; + } } diff --git a/domain/src/main/java/org/mandarin/booking/domain/show/Grade.java b/domain/src/main/java/org/mandarin/booking/domain/show/Grade.java index 8e6bfab..b382c55 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/show/Grade.java +++ b/domain/src/main/java/org/mandarin/booking/domain/show/Grade.java @@ -20,7 +20,7 @@ @NoArgsConstructor(access = PROTECTED) @AllArgsConstructor(access = PACKAGE) @Table(uniqueConstraints = @UniqueConstraint(name = "uk_grade_show_name", columnNames = {"show_id", "name"})) -class Grade extends AbstractEntity { +public class Grade extends AbstractEntity { @ManyToOne(fetch = LAZY) @JoinColumn(name = "show_id", nullable = false) @@ -32,11 +32,11 @@ class Grade extends AbstractEntity { private Integer quantity; - GradeResponse toResponse() { - return new GradeResponse(getId(), getName(), getBasePrice(), getQuantity()); - } - static Grade of(Show show, GradeRequest gradeRequest) { return new Grade(show, gradeRequest.name(), gradeRequest.basePrice(), gradeRequest.quantity()); } + + GradeResponse toResponse() { + return new GradeResponse(getId(), getName(), getBasePrice(), getQuantity()); + } } diff --git a/domain/src/main/java/org/mandarin/booking/domain/show/Inventory.java b/domain/src/main/java/org/mandarin/booking/domain/show/Inventory.java new file mode 100644 index 0000000..5da1679 --- /dev/null +++ b/domain/src/main/java/org/mandarin/booking/domain/show/Inventory.java @@ -0,0 +1,54 @@ +package org.mandarin.booking.domain.show; + +import static jakarta.persistence.FetchType.LAZY; +import static lombok.AccessLevel.PROTECTED; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.mandarin.booking.domain.AbstractEntity; +import org.mandarin.booking.domain.show.SeatState.SeatStateRow; + + +@Entity +@Table(indexes = { + @Index(name = "idx_inventory_show_schedule_id", columnList = "show_schedule_id") +}) +@Getter +@NoArgsConstructor(access = PROTECTED) +public class Inventory extends AbstractEntity { + @OneToMany(mappedBy = "inventory", fetch = LAZY, cascade = CascadeType.ALL, orphanRemoval = true) + private List states = new ArrayList<>(); + + private Long showScheduleId; + + public List extractSeatStateRows() { + return states.stream() + .map(SeatState::extractRow) + .toList(); + } + + public void clearSeatStates() { + this.states.clear(); + } + + public static Inventory create(Long showScheduleId, Map> seatAssociations) { + var inventory = new Inventory(); + inventory.showScheduleId = showScheduleId; + + var seatStates = seatAssociations.entrySet().stream() + .flatMap(entry -> entry.getValue().stream() + .map(seat -> SeatState.create(inventory, seat, entry.getKey()))) + .toList(); + + inventory.states.addAll(seatStates); + return inventory; + } +} diff --git a/domain/src/main/java/org/mandarin/booking/domain/show/SeatState.java b/domain/src/main/java/org/mandarin/booking/domain/show/SeatState.java new file mode 100644 index 0000000..f32297c --- /dev/null +++ b/domain/src/main/java/org/mandarin/booking/domain/show/SeatState.java @@ -0,0 +1,43 @@ +package org.mandarin.booking.domain.show; + +import static lombok.AccessLevel.PACKAGE; +import static lombok.AccessLevel.PROTECTED; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.mandarin.booking.domain.AbstractEntity; + +@Entity +@NoArgsConstructor(access = PROTECTED) +@Getter(value = PACKAGE) +public class SeatState extends AbstractEntity { + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "inventory_id", nullable = false) + private Inventory inventory; + + @Column(nullable = false) + private Long seatId; + + @Column(nullable = false) + private Long gradeId; + + static SeatState create(Inventory inventory, Long seatId, Long gradeId) { + var state = new SeatState(); + state.inventory = inventory; + state.seatId = seatId; + state.gradeId = gradeId; + return state; + } + + SeatStateRow extractRow() { + return new SeatStateRow(seatId, gradeId); + } + + public record SeatStateRow(Long seatId, Long gradeId) { + } +} 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 c8c8812..4940346 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 @@ -12,6 +12,7 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Comparator; +import java.util.HashSet; import java.util.List; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -19,6 +20,7 @@ import lombok.NoArgsConstructor; import org.mandarin.booking.Currency; import org.mandarin.booking.domain.AbstractEntity; +import org.mandarin.booking.domain.hall.HallException; import org.mandarin.booking.domain.show.ShowDetailResponse.ShowScheduleResponse; import org.mandarin.booking.domain.show.ShowRegisterRequest.GradeRequest; @@ -30,6 +32,9 @@ public class Show extends AbstractEntity { @OneToMany(mappedBy = "show", fetch = LAZY, cascade = ALL) private final List schedules = new ArrayList<>(); + @OneToMany(mappedBy = "show", fetch = LAZY, cascade = ALL) + private List grades = new ArrayList<>(); + private Long hallId; private String title; @@ -51,8 +56,6 @@ public class Show extends AbstractEntity { @Enumerated(EnumType.STRING) private Currency currency; - @OneToMany(mappedBy = "show", fetch = LAZY, cascade = ALL) - private List grades = new ArrayList<>(); private Show(Long hallId, String title, Type type, Rating rating, String synopsis, String posterUrl, LocalDate performanceStartDate, @@ -69,40 +72,14 @@ private Show(Long hallId, String title, Type type, Rating rating, String synopsi this.currency = currency; } - public static Show create(Long hallId, ShowCreateCommand command) { - var startDate = command.getPerformanceStartDate(); - var endDate = command.getPerformanceEndDate(); - - if (startDate.isAfter(endDate)) { - throw new ShowException("공연 시작 날짜는 종료 날짜 이후에 있을 수 없습니다."); - } - - var show = new Show( - hallId, - command.getTitle(), - command.getType(), - command.getRating(), - command.getSynopsis(), - command.getPosterUrl(), - startDate, - endDate, - command.getCurrency() - ); - - var grades = command.getTicketGrades().stream() - .map(gradeReq -> Grade.of(show, gradeReq)) - .toList(); - show.addGrades(grades); - return show; - } - - public void registerSchedule(ShowScheduleCreateCommand command) { + public ShowSchedule registerSchedule(ShowScheduleCreateCommand command) { if (!isInSchedule(command.startAt(), command.endAt())) { throw new ShowException("BAD_REQUEST", "공연 기간 범위를 벗어나는 일정입니다."); } var schedule = ShowSchedule.create(this, command); this.schedules.add(schedule); + return schedule; } public List getScheduleResponses() { @@ -127,6 +104,48 @@ public List getGradeResponses() { .toList(); } + public void validateGradeIds(List gradeIds) { + var fetchedGradeIds = this.grades.stream() + .map(AbstractEntity::getId).toList(); + if (!new HashSet<>(fetchedGradeIds).containsAll(gradeIds)) { + throw new HallException("NOT_FOUND", "해당하는 등급이 존재하지 않습니다."); + } + } + + public Grade getGradeById(Long gradeId) { + return this.grades.stream() + .filter(grade -> grade.getId().equals(gradeId)) + .findFirst() + .orElseThrow(() -> new ShowException("존재하지 않는 등급입니다.")); + } + + public static Show create(Long hallId, ShowCreateCommand command) { + var startDate = command.getPerformanceStartDate(); + var endDate = command.getPerformanceEndDate(); + + if (startDate.isAfter(endDate)) { + throw new ShowException("공연 시작 날짜는 종료 날짜 이후에 있을 수 없습니다."); + } + + var show = new Show( + hallId, + command.getTitle(), + command.getType(), + command.getRating(), + command.getSynopsis(), + command.getPosterUrl(), + startDate, + endDate, + command.getCurrency() + ); + + var grades = command.getTicketGrades().stream() + .map(gradeReq -> Grade.of(show, gradeReq)) + .toList(); + show.addGrades(grades); + return show; + } + private void addGrades(List grades) { this.grades.addAll(grades); } @@ -136,8 +155,10 @@ private boolean isInSchedule(LocalDateTime scheduleStartAt, LocalDateTime schedu && scheduleEndAt.isBefore(performanceEndDate.atStartOfDay()); } + public enum Type { MUSICAL, PLAY, CONCERT, OPERA, DANCE, CLASSICAL, ETC + } public enum Rating { @@ -147,6 +168,7 @@ public enum Rating { @Getter @AllArgsConstructor(access = AccessLevel.PRIVATE) public static class ShowCreateCommand { + private final String title; private final Type type; private final Rating rating; diff --git a/domain/src/main/java/org/mandarin/booking/domain/show/ShowSchedule.java b/domain/src/main/java/org/mandarin/booking/domain/show/ShowSchedule.java index 9dfeea4..361f23f 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/show/ShowSchedule.java +++ b/domain/src/main/java/org/mandarin/booking/domain/show/ShowSchedule.java @@ -14,7 +14,7 @@ @Entity @Getter @NoArgsConstructor(access = PROTECTED) -class ShowSchedule extends AbstractEntity { +public class ShowSchedule extends AbstractEntity { @ManyToOne(fetch = LAZY, optional = false) @JoinColumn(name = "show_id", nullable = false) diff --git a/domain/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterRequest.java b/domain/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterRequest.java index 7b7ba25..b9fa629 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterRequest.java +++ b/domain/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterRequest.java @@ -1,15 +1,101 @@ package org.mandarin.booking.domain.show; +import jakarta.validation.Valid; import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.mandarin.booking.domain.hall.Hall; public record ShowScheduleRegisterRequest( + @NotNull(message = "showId is required") Long showId, + + @NotNull(message = "startAt is required") LocalDateTime startAt, - LocalDateTime endAt + + @NotNull(message = "endAt is required") + LocalDateTime endAt, + + @NotNull(message = "use is required") + @Valid + SeatUsageRequest use ) { @AssertTrue(message = "The end time must be after the start time") private boolean isEndAfterStart() { return endAt.isAfter(startAt); } + + public record SeatUsageRequest( + @NotNull(message = "sectionId is required") + Long sectionId, + + List excludeSeatIds, + + @NotNull(message = "gradeAssignments are required") + @NotEmpty(message = "gradeAssignments must not be empty") + List<@Valid GradeAssignmentRequest> gradeAssignments + ) { + @AssertTrue(message = "excludeSeatIds must not contain duplicates") + public boolean hasUniqueExcludeSeatIds() { + if (excludeSeatIds.isEmpty()) { + return true; + } + Set uniqueIds = new HashSet<>(excludeSeatIds); + return uniqueIds.size() == excludeSeatIds.size(); + } + + @AssertTrue(message = "gradeAssignments gradeIds must not contain duplicates") + public boolean hasUniqueGradeIds() { + Set gradeIds = new HashSet<>(); + return gradeAssignments.stream() + .map(GradeAssignmentRequest::gradeId) + .allMatch(gradeIds::add); + } + + @AssertTrue(message = "gradeAssignments seatIds must not contain duplicates across all assignments") + public boolean hasUniqueSeatIdsInGradeAssignments() { + Set allSeatIds = new HashSet<>(); + return gradeAssignments.stream() + .flatMap(assignment -> assignment.seatIds().stream()) + .allMatch(allSeatIds::add); + } + + public List includeSeatIds() { + return gradeAssignments.stream() + .flatMap(assignment -> assignment.seatIds().stream()) + .toList(); + } + + public List allSeatIds() { + List ids = new ArrayList<>(); + ids.addAll(excludeSeatIds); + ids.addAll(includeSeatIds()); + return ids; + } + + public Map> seatsByGradeId(Show saved, Hall hall) { + return this.gradeAssignments.stream() + .collect(Collectors.toMap( + ga -> saved.getGradeById(ga.gradeId()).getId(), + ga -> hall.getSeatsBySectionIdAndSeatIds(this.sectionId(), ga.seatIds()) + )); + } + } + + public record GradeAssignmentRequest( + @NotNull(message = "gradeId is required") + Long gradeId, + + @NotNull(message = "seatIds are required") + @NotEmpty(message = "seatIds must not be empty") + List seatIds + ) { + } } 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 f6cd9a5..130fb47 100644 --- a/internal/src/main/java/org/mandarin/booking/adapter/SecurityConfig.java +++ b/internal/src/main/java/org/mandarin/booking/adapter/SecurityConfig.java @@ -7,7 +7,6 @@ import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler; import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; import org.springframework.security.access.hierarchicalroles.RoleHierarchy; -import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; @@ -60,13 +59,6 @@ public HttpFirewall allowUrlEncodedPercentHttpFirewall() { return firewall; } - @Bean - static RoleHierarchy roleHierarchy() { - return RoleHierarchyImpl.withDefaultRolePrefix() - .role("ADMIN").implies("DISTRIBUTOR") - .role("DISTRIBUTOR").implies("USER") - .build(); - } @Bean static MethodSecurityExpressionHandler methodSecurityExpressionHandler(RoleHierarchy roleHierarchy) {